diff --git a/.github/scripts/pages-production-config.mjs b/.github/scripts/pages-production-config.mjs new file mode 100644 index 000000000..7491d2d0b --- /dev/null +++ b/.github/scripts/pages-production-config.mjs @@ -0,0 +1,52 @@ +import { randomUUID } from "node:crypto"; +import { appendFileSync, readFileSync } from "node:fs"; +import { apps } from "./pages-projects.mjs"; + +class ConfigurationError extends Error {} + +try { + const app = process.env.PAGES_APP || "web"; + if (!Object.hasOwn(apps, app)) throw new ConfigurationError("Unsupported Pages app. Use web, admin, site, or docs."); + const project = JSON.parse(readFileSync(0, "utf8")); + const branch = project.result?.production_branch; + if (project.success !== true || typeof branch !== "string" || !branch.trim() || /[\r\n]/.test(branch)) { + throw new ConfigurationError("Cloudflare did not return a valid Pages production branch."); + } + + const variables = project.result.deployment_configs?.production?.env_vars ?? {}; + let runtimeVariables; + let requiredVariables = new Set(); + if (app === "web" || app === "admin") { + const entrypoint = readFileSync(new URL(`../../${app}/docker-entrypoint.sh`, import.meta.url), "utf8"); + runtimeVariables = [...new Set([...entrypoint.matchAll(/\$\{(WARMBLY_[A-Z_]+)/g)].map((match) => match[1]))] + .filter((name) => name !== "WARMBLY_CONFIG_OUT"); + requiredVariables = new Set(["WARMBLY_API_URL", app === "web" ? "WARMBLY_APP_URL" : "WARMBLY_DASHBOARD_URL", "WARMBLY_TURNSTILE_KEY"]); + } else { + const prefix = app === "site" ? /^PUBLIC_[A-Z0-9_]+$/ : /^NEXT_PUBLIC_[A-Z0-9_]+$/; + runtimeVariables = Object.keys(variables).filter((name) => prefix.test(name)); + } + const entries = runtimeVariables.map((name) => { + const variable = variables[name]; + if (variable != null && (variable.type !== "plain_text" || typeof variable.value !== "string")) { + throw new ConfigurationError(`Set ${name} as a plaintext production variable in Pages; it is public browser configuration.`); + } + const value = variable?.value ?? ""; + if (requiredVariables.has(name) && !value.trim()) { + throw new ConfigurationError(`Set ${name} in the Pages project's production environment before enabling release deployments.`); + } + let delimiter; + do { + delimiter = randomUUID(); + } while (value.includes(delimiter)); + return `${name}<<${delimiter}\n${value}\n${delimiter}\n`; + }); + + appendFileSync(process.env.GITHUB_ENV, entries.join("")); + appendFileSync(process.env.GITHUB_OUTPUT, `production_branch=${branch}\n`); +} catch (error) { + const message = error instanceof ConfigurationError + ? error.message + : "Failed to read Cloudflare Pages production configuration."; + console.error(`::error::${message}`); + process.exitCode = 1; +} diff --git a/.github/scripts/pages-production-config.test.mjs b/.github/scripts/pages-production-config.test.mjs new file mode 100644 index 000000000..e049d4b23 --- /dev/null +++ b/.github/scripts/pages-production-config.test.mjs @@ -0,0 +1,199 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { runInNewContext } from "node:vm"; + +const script = fileURLToPath(new URL("./pages-production-config.mjs", import.meta.url)); +const entrypoint = readFileSync(new URL("../../web/docker-entrypoint.sh", import.meta.url), "utf8"); +const names = [...new Set([...entrypoint.matchAll(/\$\{(WARMBLY_[A-Z_]+)/g)].map((match) => match[1]))] + .filter((name) => name !== "WARMBLY_CONFIG_OUT"); + +function fixture() { + return { + success: true, + result: { + production_branch: "production", + deployment_configs: { + production: { + env_vars: Object.fromEntries(names.map((name) => [name, { type: "plain_text", value: `production-${name}` }])), + }, + preview: { env_vars: { WARMBLY_API_URL: { type: "plain_text", value: "preview-only" } } }, + }, + }, + }; +} + +function run(project, app = "web") { + const work = mkdtempSync(join(tmpdir(), "pages-config-")); + try { + const envFile = join(work, "env"); + const outputFile = join(work, "output"); + const result = spawnSync(process.execPath, [script], { + input: typeof project === "string" ? project : JSON.stringify(project), + env: { ...process.env, PAGES_APP: app, GITHUB_ENV: envFile, GITHUB_OUTPUT: outputFile }, + encoding: "utf8", + }); + const read = (file) => { + try { return readFileSync(file, "utf8"); } catch { return ""; } + }; + return { ...result, env: read(envFile), output: read(outputFile) }; + } finally { + rmSync(work, { recursive: true, force: true }); + } +} + +function decode(text) { + const lines = text.split("\n"); + const values = {}; + while (lines[0]) { + const [name, delimiter] = lines.shift().split("<<"); + const end = lines.indexOf(delimiter); + assert.ok(end >= 0); + values[name] = lines.splice(0, end).join("\n"); + lines.shift(); + } + return values; +} + +test("imports every runtime key from production only and discovers the production branch", () => { + const project = fixture(); + project.result.deployment_configs.production.env_vars.NODE_OPTIONS = { type: "plain_text", value: "untrusted" }; + project.result.deployment_configs.production.env_vars.SENTRY_AUTH_TOKEN = { type: "secret_text", value: "private-value" }; + const result = run(project); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.output, "production_branch=production\n"); + const values = decode(result.env); + assert.deepEqual(Object.keys(values).sort(), names.sort()); + for (const name of names) assert.equal(values[name], `production-${name}`); + assert.equal(result.stdout + result.stderr, ""); +}); + +test("preserves multiline values without injecting additional runner variables", () => { + const project = fixture(); + const value = 'logos "quoted"\\path\r\nNODE_OPTIONS=untrusted\n::error::not-a-command'; + project.result.deployment_configs.production.env_vars.WARMBLY_COMPANY_LOGOS.value = value; + const result = run(project); + assert.equal(result.status, 0, result.stderr); + assert.equal(decode(result.env).WARMBLY_COMPANY_LOGOS, value); + assert.equal(result.stdout + result.stderr, ""); +}); + +test("defaults absent optional runtime settings to empty strings", () => { + const project = fixture(); + delete project.result.deployment_configs.production.env_vars.WARMBLY_POSTHOG_KEY; + const result = run(project); + assert.equal(result.status, 0, result.stderr); + assert.equal(decode(result.env).WARMBLY_POSTHOG_KEY, ""); +}); + +test("renders the imported production settings with the real dashboard entrypoint", () => { + const project = fixture(); + project.result.deployment_configs.production.env_vars.WARMBLY_COMPANY_LOGOS.value = 'logos "quoted"\\path'; + const imported = run(project); + assert.equal(imported.status, 0, imported.stderr); + const work = mkdtempSync(join(tmpdir(), "pages-render-")); + try { + const output = join(work, "config.js"); + const rendered = spawnSync("sh", [fileURLToPath(new URL("../../web/docker-entrypoint.sh", import.meta.url))], { + env: { ...process.env, ...decode(imported.env), WARMBLY_CONFIG_OUT: output }, + encoding: "utf8", + }); + assert.equal(rendered.status, 0, rendered.stderr); + const window = {}; + runInNewContext(readFileSync(output, "utf8"), { window }); + assert.equal(window.__WARMBLY_ENV__.API_URL, "production-WARMBLY_API_URL"); + assert.equal(window.__WARMBLY_ENV__.TURNSTILE_KEY, "production-WARMBLY_TURNSTILE_KEY"); + assert.equal(window.__WARMBLY_ENV__.COMPANY_LOGOS, 'logos "quoted"\\path'); + } finally { + rmSync(work, { recursive: true, force: true }); + } +}); + +test("rejects missing required variables and encrypted runtime settings without writing config", () => { + for (const name of ["WARMBLY_API_URL", "WARMBLY_APP_URL", "WARMBLY_TURNSTILE_KEY"]) { + for (const setting of [undefined, { type: "plain_text", value: " " }, { type: "secret_text", value: "hidden" }]) { + const project = fixture(); + project.result.deployment_configs.production.env_vars[name] = setting; + const result = run(project); + assert.equal(result.status, 1); + assert.ok(result.stderr.includes(name)); + assert.ok(!result.stderr.includes("hidden")); + assert.equal(result.env + result.output, ""); + } + } + const project = fixture(); + project.result.deployment_configs.production.env_vars.WARMBLY_POSTHOG_KEY.type = "secret_text"; + assert.equal(run(project).status, 1); +}); + +test("rejects malformed API responses, missing production config, and runner output injection", () => { + const inputs = ["invalid-private-response", { success: false }, { success: true, result: {} }, null]; + for (const branch of ["", "main\nother=value", "main\rother=value"]) { + const project = fixture(); + project.result.production_branch = branch; + inputs.push(project); + } + const noProduction = fixture(); + delete noProduction.result.deployment_configs.production; + inputs.push(noProduction); + for (const input of inputs) { + const result = run(input); + assert.equal(result.status, 1); + assert.equal(result.env + result.output, ""); + assert.ok(!result.stderr.includes("invalid-private-response")); + } +}); + +test("imports and renders the admin's distinct runtime configuration", () => { + const entry = new URL("../../admin/docker-entrypoint.sh", import.meta.url); + const adminNames = [...new Set([...readFileSync(entry, "utf8").matchAll(/\$\{(WARMBLY_[A-Z_]+)/g)].map((match) => match[1]))] + .filter((name) => name !== "WARMBLY_CONFIG_OUT"); + const project = fixture(); + project.result.production_branch = "admin-release"; + project.result.deployment_configs.production.env_vars = Object.fromEntries(adminNames.map((name) => [name, { type: "plain_text", value: `admin-${name}` }])); + const imported = run(project, "admin"); + assert.equal(imported.status, 0, imported.stderr); + assert.equal(imported.output, "production_branch=admin-release\n"); + assert.deepEqual(Object.keys(decode(imported.env)).sort(), adminNames.sort()); + const work = mkdtempSync(join(tmpdir(), "pages-admin-")); + try { + const output = join(work, "config.js"); + const rendered = spawnSync("sh", [fileURLToPath(entry)], { + env: { ...process.env, ...decode(imported.env), WARMBLY_CONFIG_OUT: output }, + encoding: "utf8", + }); + assert.equal(rendered.status, 0, rendered.stderr); + const window = {}; + runInNewContext(readFileSync(output, "utf8"), { window }); + assert.equal(window.__WARMBLY_ENV__.DASHBOARD_URL, "admin-WARMBLY_DASHBOARD_URL"); + assert.equal(window.__WARMBLY_ENV__.ENV_LABEL, "admin-WARMBLY_ENV_LABEL"); + } finally { + rmSync(work, { recursive: true, force: true }); + } + delete project.result.deployment_configs.production.env_vars.WARMBLY_DASHBOARD_URL; + assert.equal(run(project, "admin").status, 1); +}); + +test("site and docs import only their public build variables, without dashboard requirements", () => { + for (const [app, name] of [["site", "PUBLIC_POSTHOG_KEY"], ["docs", "NEXT_PUBLIC_ANALYTICS_KEY"]]) { + const project = fixture(); + project.result.deployment_configs.production.env_vars = { + [name]: { type: "plain_text", value: `${app}-public` }, + API_KEY: { type: "secret_text", value: "private" }, + NODE_OPTIONS: { type: "plain_text", value: "untrusted" }, + WARMBLY_API_URL: { type: "plain_text", value: "not-this-app" }, + }; + const imported = run(project, app); + assert.equal(imported.status, 0, imported.stderr); + assert.deepEqual(decode(imported.env), { [name]: `${app}-public` }); + project.result.deployment_configs.production.env_vars[name].type = "secret_text"; + assert.equal(run(project, app).status, 1); + project.result.deployment_configs.production.env_vars = {}; + assert.equal(run(project, app).status, 0); + } + assert.equal(run(fixture(), "unsupported").status, 1); +}); diff --git a/.github/scripts/pages-projects.mjs b/.github/scripts/pages-projects.mjs new file mode 100644 index 000000000..223d4d416 --- /dev/null +++ b/.github/scripts/pages-projects.mjs @@ -0,0 +1,40 @@ +import { appendFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +export const apps = { + web: { directory: "web", build_script: "build:pages", output_dir: "web/dist", environment: "dashboard-production", sentry_project_var: "SENTRY_PROJECT_WEB" }, + admin: { directory: "admin", build_script: "build:pages", output_dir: "admin/dist", environment: "admin-production", sentry_project_var: "SENTRY_PROJECT_ADMIN" }, + site: { directory: "site", build_script: "build", output_dir: "site/dist", environment: "site-production", sentry_project_var: "SENTRY_PROJECT_SITE" }, + docs: { directory: "docs", build_script: "build", output_dir: "docs/out", environment: "docs-production", sentry_project_var: "SENTRY_PROJECT_DOCS" }, +}; + +export function deploymentMatrix(projects, legacyProject) { + const targets = projects?.trim() + ? JSON.parse(projects) + : legacyProject?.trim() ? [{ app: "web", project: legacyProject }] : []; + if (!Array.isArray(targets) || targets.length === 0 || targets.length > 256) { + throw new Error("Set CLOUDFLARE_PAGES_PROJECTS to a JSON array with 1 to 256 app/project entries."); + } + const seen = new Set(); + return { include: targets.map((target) => { + if (!target || typeof target.app !== "string" || !Object.hasOwn(apps, target.app) || typeof target.project !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(target.project)) { + throw new Error("Each Pages target needs an app (web, admin, site, docs) and a valid Pages project name."); + } + if (Object.keys(target).some((key) => !["app", "project"].includes(key))) { + throw new Error("Pages targets accept only app and project fields."); + } + if (seen.has(target.project)) throw new Error("Each Pages project must appear only once in the deployment list."); + seen.add(target.project); + return { ...target, ...apps[target.app] }; + }) }; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + try { + const matrix = deploymentMatrix(process.env.CLOUDFLARE_PAGES_PROJECTS, process.env.CLOUDFLARE_PAGES_PROJECT_NAME); + appendFileSync(process.env.GITHUB_OUTPUT, `matrix=${JSON.stringify(matrix)}\n`); + } catch { + console.error("::error::Invalid Pages targets. Set CLOUDFLARE_PAGES_PROJECTS to a JSON array of unique app/project entries (web, admin, site, docs; maximum 256). Single-dashboard setups may use CLOUDFLARE_PAGES_PROJECT_NAME instead."); + process.exitCode = 1; + } +} diff --git a/.github/scripts/pages-projects.test.mjs b/.github/scripts/pages-projects.test.mjs new file mode 100644 index 000000000..edf42ae66 --- /dev/null +++ b/.github/scripts/pages-projects.test.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { apps, deploymentMatrix } from "./pages-projects.mjs"; + +test("one list expands every supported app with its actual build script and output", () => { + const targets = Object.keys(apps).map((app) => ({ app, project: `warmbly-${app}` })); + const matrix = deploymentMatrix(JSON.stringify(targets)); + assert.equal(matrix.include.length, 4); + for (const entry of matrix.include) { + assert.deepEqual(entry, { ...targets.find((target) => target.app === entry.app), ...apps[entry.app] }); + const pkg = JSON.parse(readFileSync(new URL(`../../${entry.directory}/package.json`, import.meta.url), "utf8")); + assert.equal(typeof pkg.scripts[entry.build_script], "string"); + if (entry.app === "web" || entry.app === "admin") assert.ok(pkg.scripts[entry.build_script].includes("WARMBLY_CONFIG_OUT=dist/config.js")); + } + assert.equal(apps.docs.output_dir, "docs/out"); + assert.match(readFileSync(new URL("../../docs/next.config.mjs", import.meta.url), "utf8"), /output:\s*['"]export['"]/); +}); + +test("supports more than two projects, including multiple deployments of the same app", () => { + const targets = Array.from({ length: 6 }, (_, i) => ({ app: "web", project: `dashboard-${i}` })); + assert.equal(deploymentMatrix(JSON.stringify(targets)).include.length, 6); + assert.equal(deploymentMatrix(JSON.stringify(targets.slice(0, 2))).include.length, 2); +}); + +test("keeps the existing single-dashboard variable compatible and gives the list precedence", () => { + assert.equal(deploymentMatrix("", "existing-dashboard").include[0].project, "existing-dashboard"); + assert.equal(deploymentMatrix('[{"app":"docs","project":"docs-project"}]', "old-dashboard").include[0].app, "docs"); +}); + +test("rejects invalid config, unknown apps, duplicate projects and build/path overrides", () => { + for (const input of ["not json", "{}", "[]", "null", "[null]", '[{"app":"worker","project":"test"}]', '[{"app":"__proto__","project":"test"}]', '[{"app":"web","project":"bad/name"}]', '[{"app":"web","project":"bad\nname"}]', '[{"app":"web","project":"ok","directory":"../"}]', '[{"app":"web","project":"same"},{"app":"admin","project":"same"}]']) { + assert.throws(() => deploymentMatrix(input)); + } + assert.throws(() => deploymentMatrix("", "")); + assert.throws(() => deploymentMatrix('[{"app":["web"],"project":"ok"}]')); + assert.throws(() => deploymentMatrix(JSON.stringify(Array.from({ length: 257 }, (_, i) => ({ app: "web", project: `p-${i}` }))))); + assert.equal(deploymentMatrix(JSON.stringify(Array.from({ length: 256 }, (_, i) => ({ app: "web", project: `p-${i}` })))).include.length, 256); +}); + +test("CLI emits a safe matrix for the workflow and does not expose invalid input", () => { + const work = mkdtempSync(join(tmpdir(), "pages-matrix-")); + try { + const output = join(work, "output"); + const env = { ...process.env, GITHUB_OUTPUT: output, CLOUDFLARE_PAGES_PROJECTS: '[{"app":"admin","project":"admin-project"}]' }; + const script = fileURLToPath(new URL("./pages-projects.mjs", import.meta.url)); + const result = spawnSync(process.execPath, [script], { env, encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + const content = readFileSync(output, "utf8"); + assert.equal(content.split("\n").length, 2); + assert.equal(JSON.parse(content.slice("matrix=".length)).include[0].directory, "admin"); + env.CLOUDFLARE_PAGES_PROJECTS = "private-invalid-input"; + const invalid = spawnSync(process.execPath, [script], { env, encoding: "utf8" }); + assert.equal(invalid.status, 1); + assert.ok(!invalid.stderr.includes("private-invalid-input")); + assert.equal(readFileSync(output, "utf8"), content); + } finally { + rmSync(work, { recursive: true, force: true }); + } +}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 438288c36..5ec909460 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,7 @@ jobs: ios: ${{ steps.filter.outputs.ios }} installer: ${{ steps.filter.outputs.installer }} cli-installer: ${{ steps.filter.outputs.cli-installer }} + pages: ${{ steps.filter.outputs.pages }} steps: - uses: actions/checkout@v4 - uses: dorny/paths-filter@v3 @@ -97,6 +98,30 @@ jobs: - 'site/public/cli.ps1' - 'scripts/check-cli-installer.sh' - 'scripts/build-cli.sh' + pages: + - '.github/scripts/pages-*' + - '.github/workflows/release.yml' + - '.github/workflows/ci.yml' + - 'web/docker-entrypoint.sh' + - 'admin/docker-entrypoint.sh' + - 'web/package.json' + - 'admin/package.json' + - 'site/package.json' + - 'docs/package.json' + - 'docs/next.config.mjs' + + pages-ci: + name: Pages release configuration + needs: changes + if: needs.changes.outputs.pages == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Check production configuration import + run: node --test .github/scripts/pages-*.test.mjs migrations-ci: name: Migrations @@ -519,7 +544,7 @@ jobs: ci-status: name: CI Status runs-on: ubuntu-latest - needs: [changes, migrations-ci, go-ci, web-ci, qa-ci, admin-ci, site-ci, forms-ci, installer-ci, cli-installer-ci, make-ci, rust-ci, elixir-ci, ios-ci, frontend-images] + needs: [changes, migrations-ci, go-ci, web-ci, qa-ci, admin-ci, site-ci, forms-ci, installer-ci, cli-installer-ci, make-ci, rust-ci, elixir-ci, ios-ci, frontend-images, pages-ci] if: always() steps: - name: Check CI status diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 038d3cf49..df2fd1a29 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -504,3 +504,108 @@ jobs: /tmp/cli/* draft: false prerelease: ${{ contains(github.ref_name, '-') }} + + pages-targets: + name: Configure Pages release deployments + needs: create-release + if: ${{ vars.CLOUDFLARE_PAGES_DEPLOY_ENABLED == 'true' && !contains(github.ref_name, '-') }} + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + matrix: ${{ steps.targets.outputs.matrix }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + - uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Validate deployment targets + id: targets + env: + CLOUDFLARE_PAGES_PROJECTS: ${{ vars.CLOUDFLARE_PAGES_PROJECTS }} + CLOUDFLARE_PAGES_PROJECT_NAME: ${{ vars.CLOUDFLARE_PAGES_PROJECT_NAME }} + run: node .github/scripts/pages-projects.mjs + + deploy-pages: + name: Deploy ${{ matrix.app }} to Pages (${{ matrix.project }}) + needs: pages-targets + if: ${{ needs.pages-targets.result == 'success' }} + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.pages-targets.outputs.matrix) }} + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + environment: ${{ matrix.environment }} + env: + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_PAGES_PROJECT_NAME: ${{ matrix.project }} + PAGES_APP: ${{ matrix.app }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + + - uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Load existing Pages production configuration + id: pages-config + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + run: | + for name in CLOUDFLARE_API_TOKEN CLOUDFLARE_ACCOUNT_ID CLOUDFLARE_PAGES_PROJECT_NAME; do + if [ -z "${!name}" ]; then + echo "::error::Set $name before enabling Cloudflare Pages release deployments." + exit 1 + fi + done + curl --fail --silent --show-error \ + --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ + "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/pages/projects/$CLOUDFLARE_PAGES_PROJECT_NAME" \ + | node .github/scripts/pages-production-config.mjs + + - uses: pnpm/action-setup@v4 + with: + version: "11.9.0" + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: pnpm + cache-dependency-path: ${{ matrix.directory }}/pnpm-lock.yaml + + - name: Install frontend dependencies + working-directory: ${{ matrix.directory }} + run: pnpm install --frozen-lockfile + + - name: Build production frontend + working-directory: ${{ matrix.directory }} + env: + VITE_SENTRY_RELEASE: ${{ github.ref_name }} + SENTRY_ORG: ${{ vars.SENTRY_ORG }} + SENTRY_PROJECT: ${{ vars[matrix.sentry_project_var] }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + POSTHOG_CLI_PROJECT_ID: ${{ vars.POSTHOG_CLI_PROJECT_ID }} + POSTHOG_CLI_HOST: ${{ vars.POSTHOG_CLI_HOST }} + POSTHOG_CLI_API_KEY: ${{ secrets.POSTHOG_CLI_API_KEY }} + BUILD_SCRIPT: ${{ matrix.build_script }} + run: pnpm run "$BUILD_SCRIPT" + + - name: Deploy tagged frontend to production + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_PAGES_PRODUCTION_BRANCH: ${{ steps.pages-config.outputs.production_branch }} + RELEASE_TAG: ${{ github.ref_name }} + WRANGLER_SEND_METRICS: "false" + PAGES_OUTPUT_DIR: ${{ matrix.output_dir }} + run: | + pnpm dlx wrangler@4.45.0 pages deploy "$PAGES_OUTPUT_DIR" \ + --project-name "$CLOUDFLARE_PAGES_PROJECT_NAME" \ + --branch "$CLOUDFLARE_PAGES_PRODUCTION_BRANCH" \ + --commit-hash "$GITHUB_SHA" \ + --commit-message "$RELEASE_TAG" diff --git a/docs/content/docs/development/split-deployment.mdx b/docs/content/docs/development/split-deployment.mdx index 056a742ed..f16325667 100644 --- a/docs/content/docs/development/split-deployment.mdx +++ b/docs/content/docs/development/split-deployment.mdx @@ -203,6 +203,56 @@ Set these as build environment variables on the host: Whichever origin you serve them from has to be in the backend's `CORS_ALLOW_ORIGINS`, or the dashboard loads and every API call fails preflight. +### Deploy static frontends only on releases + +For Cloudflare Pages, keep your existing projects and custom domains. The repository's `Release` workflow can deploy any configured list of the dashboard, admin panel, marketing site, and docs after it successfully publishes a stable GitHub Release. Ordinary merges and prerelease tags do not run these deployment jobs. The workflow is opt-in, so forks and self-hosts do not need Cloudflare credentials. + +Each project gets its own build job, which checks out the exact release commit, reads that project's production branch and public production variables from the Cloudflare API, builds the selected app, and uploads its static output with Wrangler. The upload uses the project's production branch rather than the release tag as its branch name, so it updates production instead of creating a preview. Cloudflare remains the source of truth for frontend configuration; you do not need duplicate API URLs, Turnstile keys, or analytics settings in GitHub. + +| App | Build | Output | GitHub deployment environment | +|---|---|---|---| +| `web` | `pnpm build:pages` | `web/dist` | `dashboard-production` | +| `admin` | `pnpm build:pages` | `admin/dist` | `admin-production` | +| `site` | `pnpm build` | `site/dist` | `site-production` | +| `docs` | `pnpm build` | `docs/out` | `docs-production` | + + +Each job reads public settings from its own **Pages > Settings > Environment variables > Production**. The dashboard requires `WARMBLY_API_URL`, `WARMBLY_APP_URL`, and `WARMBLY_TURNSTILE_KEY`; the admin panel requires `WARMBLY_API_URL`, `WARMBLY_DASHBOARD_URL`, and `WARMBLY_TURNSTILE_KEY`. Their remaining runtime keys, including analytics settings, are read from each app's entrypoint. The marketing site imports `PUBLIC_*` variables, and docs imports `NEXT_PUBLIC_*` variables. These values are shipped to browsers and must be plaintext Pages variables, not encrypted secrets. Missing required settings or encrypted public values fail before building. Unrelated variables and secrets are not imported. + + +1. In GitHub, open **Settings > Secrets and variables > Actions**. Under **Secrets**, add `CLOUDFLARE_API_TOKEN`. Create a Cloudflare custom API token with **Pages Read and Pages Write** (also called **Account > Cloudflare Pages > Edit**), restricted to the account containing your projects. No DNS or Workers permissions are needed. +2. Under **Variables**, add: + + | Variable | Value | + |---|---| + | `CLOUDFLARE_ACCOUNT_ID` | The Cloudflare account ID containing the existing projects | + | `CLOUDFLARE_PAGES_PROJECTS` | A JSON array of app/project mappings, shown below | + + ```json + [ + { "app": "web", "project": "your-dashboard-project" }, + { "app": "admin", "project": "your-admin-project" }, + { "app": "site", "project": "your-marketing-project" }, + { "app": "docs", "project": "your-docs-project" } + ] + ``` + + Use actual Pages project names, not custom domains. Add only the projects you want deployed. Multiple entries can build the same app for different projects, each using its own production settings. A project must appear only once. GitHub permits up to 256 matrix entries. The list must be a repository-level variable because the target list is resolved before deployment environments are loaded. + + Existing single-dashboard setups can keep the repository variable `CLOUDFLARE_PAGES_PROJECT_NAME` instead. When `CLOUDFLARE_PAGES_PROJECTS` is set, it takes precedence. + +3. Optionally configure the GitHub deployment environments in the table above under **Settings > Environments**, with approvals if needed. Allow release tags such as `v*` if restricting deployment branches and tags. Account variables and the token can be environment-scoped instead; otherwise repository-level values are shared. Optional source-map uploads use `SENTRY_ORG`, the app-specific `SENTRY_PROJECT_WEB` or `SENTRY_PROJECT_ADMIN`, `POSTHOG_CLI_PROJECT_ID`, and `POSTHOG_CLI_HOST`, with the secrets `SENTRY_AUTH_TOKEN` and `POSTHOG_CLI_API_KEY`. Environment-scoped values allow different upload projects for each app. The Sentry release identity is the release tag. See [source maps](/development/configuration/#source-maps). + +4. For every listed Cloudflare project, go to **Workers & Pages > your project > Settings > Builds & deployments > Configure Production deployments**, uncheck **Enable automatic production branch deployments**, and save. Keep preview branch deployments enabled if you want PR previews, with their existing Pages build configuration. Do not switch or recreate projects as Direct Upload projects: Git-integrated projects also accept Wrangler uploads. +5. In GitHub's **Settings > Secrets and variables > Actions > Variables**, set the **repository-level** variable `CLOUDFLARE_PAGES_DEPLOY_ENABLED` to `true`. This toggle must be repository-level because GitHub evaluates the job condition before loading environment-level variables. +6. Publish the next stable release using the normal version-tag flow. In **Actions > Release**, confirm each **Deploy ... to Pages** job succeeds and every production deployment in Pages shows the release commit. + +The deployment is part of the existing release workflow, not a separate `release: published` workflow. Releases created with `GITHUB_TOKEN`, as this workflow does, do not trigger another release-event workflow. + +A Pages deploy hook would reuse Cloudflare's build configuration too, but it builds the latest commit on its configured branch. These jobs deliberately build the tagged release commit instead, so changes merged while a release is building cannot slip into production. Retries use the same release commit with the current Pages production configuration. Builds use the repository's scripts and output paths in the table, not arbitrary build commands stored in Cloudflare. + +Projects deploy independently, not atomically: one failed build or upload leaves that project's previous deployment live and does not cancel the other projects. After fixing the configuration, use **Re-run failed jobs** on that release run to retry the same release commit without creating another tag or rebuilding successful jobs. Do not retry an older release after a newer one has deployed unless you intend a rollback. To pause release uploads, set `CLOUDFLARE_PAGES_DEPLOY_ENABLED` to `false`; this leaves current deployments live and does not re-enable Cloudflare's automatic deployments. This workflow deploys only the listed static frontends; it does not deploy backend services. + ## Anything the control plane cannot know `warmbly join` writes `/etc/warmbly/node.env` from the control plane's answer and rewrites it on every join. Next to it, `/etc/warmbly/node.local.env` is created once and never written again, and the container reads it second, so a name repeated there wins.