diff --git a/.github/scripts/pages-production-config.mjs b/.github/scripts/pages-production-config.mjs index 0458a2b74..7491d2d0b 100644 --- a/.github/scripts/pages-production-config.mjs +++ b/.github/scripts/pages-production-config.mjs @@ -1,29 +1,12 @@ import { randomUUID } from "node:crypto"; import { appendFileSync, readFileSync } from "node:fs"; - -const runtimeVariables = [ - "WARMBLY_API_URL", - "WARMBLY_APP_URL", - "WARMBLY_TURNSTILE_KEY", - "WARMBLY_BETA_NOTICE", - "WARMBLY_SENTRY_DSN", - "WARMBLY_SENTRY_ENVIRONMENT", - "WARMBLY_POSTHOG_KEY", - "WARMBLY_POSTHOG_HOST", - "WARMBLY_POSTHOG_UI_HOST", - "WARMBLY_POSTHOG_ERROR_TRACKING", - "WARMBLY_POSTHOG_SESSION_REPLAY", - "WARMBLY_COMPANY_LOGOS", -]; -const requiredVariables = new Set([ - "WARMBLY_API_URL", - "WARMBLY_APP_URL", - "WARMBLY_TURNSTILE_KEY", -]); +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)) { @@ -31,6 +14,17 @@ try { } 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")) { diff --git a/.github/scripts/pages-production-config.test.mjs b/.github/scripts/pages-production-config.test.mjs index d6fa74db8..e049d4b23 100644 --- a/.github/scripts/pages-production-config.test.mjs +++ b/.github/scripts/pages-production-config.test.mjs @@ -27,14 +27,14 @@ function fixture() { }; } -function run(project) { +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, GITHUB_ENV: envFile, GITHUB_OUTPUT: outputFile }, + env: { ...process.env, PAGES_APP: app, GITHUB_ENV: envFile, GITHUB_OUTPUT: outputFile }, encoding: "utf8", }); const read = (file) => { @@ -147,3 +147,53 @@ test("rejects malformed API responses, missing production config, and runner out 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 cb49581f4..5ec909460 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,10 +99,16 @@ jobs: - 'scripts/check-cli-installer.sh' - 'scripts/build-cli.sh' pages: - - '.github/scripts/pages-production-config*' + - '.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 @@ -115,7 +121,7 @@ jobs: with: node-version: "22" - name: Check production configuration import - run: node --test .github/scripts/pages-production-config.test.mjs + run: node --test .github/scripts/pages-*.test.mjs migrations-ci: name: Migrations diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a16f87871..df2fd1a29 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -505,18 +505,45 @@ jobs: draft: false prerelease: ${{ contains(github.ref_name, '-') }} - deploy-pages: - name: Deploy dashboard to Cloudflare Pages + 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: dashboard-production + environment: ${{ matrix.environment }} env: CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} - CLOUDFLARE_PAGES_PROJECT_NAME: ${{ vars.CLOUDFLARE_PAGES_PROJECT_NAME }} + CLOUDFLARE_PAGES_PROJECT_NAME: ${{ matrix.project }} + PAGES_APP: ${{ matrix.app }} steps: - uses: actions/checkout@v4 with: @@ -550,32 +577,34 @@ jobs: with: node-version: "22" cache: pnpm - cache-dependency-path: web/pnpm-lock.yaml + cache-dependency-path: ${{ matrix.directory }}/pnpm-lock.yaml - - name: Install dashboard dependencies - working-directory: web + - name: Install frontend dependencies + working-directory: ${{ matrix.directory }} run: pnpm install --frozen-lockfile - - name: Build production dashboard - working-directory: web + - name: Build production frontend + working-directory: ${{ matrix.directory }} env: VITE_SENTRY_RELEASE: ${{ github.ref_name }} SENTRY_ORG: ${{ vars.SENTRY_ORG }} - SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT_WEB }} + 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 }} - run: pnpm build:pages + BUILD_SCRIPT: ${{ matrix.build_script }} + run: pnpm run "$BUILD_SCRIPT" - - name: Deploy tagged dashboard to production + - 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 web/dist \ + 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" \ diff --git a/docs/content/docs/development/split-deployment.mdx b/docs/content/docs/development/split-deployment.mdx index ec96c353f..f16325667 100644 --- a/docs/content/docs/development/split-deployment.mdx +++ b/docs/content/docs/development/split-deployment.mdx @@ -203,38 +203,55 @@ 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 the dashboard only on releases +### Deploy static frontends only on releases -For Cloudflare Pages, keep the existing project and custom domain. The repository's `Release` workflow can upload the dashboard after it successfully publishes a stable GitHub Release. Ordinary merges and prerelease tags do not run this deployment job. The job is opt-in, so forks and self-hosts do not need Cloudflare credentials. +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. -The build checks out the release commit, reads the existing project's production branch and public production variables from the Cloudflare API, runs `pnpm build:pages` in `web`, and uploads `web/dist` with Wrangler. It explicitly uses the project's production branch rather than the release tag as its branch name, so the upload updates production instead of creating a preview. Cloudflare remains the source of truth for dashboard configuration; you do not need duplicate API URLs, Turnstile keys, or analytics settings in GitHub. +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` | -The job reads only the dashboard's known public `WARMBLY_*` settings from **Pages > Settings > Environment variables > Production**. Keep `WARMBLY_API_URL`, `WARMBLY_APP_URL`, and `WARMBLY_TURNSTILE_KEY` set there, along with any existing analytics settings. These values are shipped to browsers and must be plaintext Pages variables, not encrypted secrets. The job fails before building if required configuration is missing or a frontend value is encrypted. Unrelated variables and secrets are not imported. +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, create the repository environment **dashboard-production** under **Settings > Environments**. Add any approval requirements there if production deployments should need a human approval. If you restrict deployment branches and tags, allow release tags such as `v*`, not only `main`. -2. In that environment, add the secret `CLOUDFLARE_API_TOKEN`. Create a Cloudflare custom API token with **Account > Cloudflare Pages > Edit**, restricted to the account containing this Pages project. -3. Add these environment variables in **dashboard-production**: +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 project | - | `CLOUDFLARE_PAGES_PROJECT_NAME` | The existing Pages project name, not its custom domain | + | `CLOUDFLARE_ACCOUNT_ID` | The Cloudflare account ID containing the existing projects | + | `CLOUDFLARE_PAGES_PROJECTS` | A JSON array of app/project mappings, shown below | - Only these two environment variables and the API token are required in GitHub. The production branch and all dashboard runtime variables are read from Pages automatically. Repository-level variables and secrets also work; environment-level values take precedence. + ```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" } + ] + ``` - Optional source-map uploads use the existing repository variables `SENTRY_ORG`, `SENTRY_PROJECT_WEB`, `POSTHOG_CLI_PROJECT_ID`, and `POSTHOG_CLI_HOST`, with the secrets `SENTRY_AUTH_TOKEN` and `POSTHOG_CLI_API_KEY`. The Sentry release identity is the release tag. See [source maps](/development/configuration/#source-maps). + 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. -4. In Cloudflare, go to **Workers & Pages > your dashboard 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 the project as a Direct Upload project: Git-integrated projects also accept Wrangler uploads. + 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 **Deploy dashboard to Cloudflare Pages** succeeds and the production deployment in Pages shows the release commit. +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. This job deliberately builds 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. +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. -If a build or upload fails, the existing Pages deployment stays live. 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 release jobs. Do not retry an older release after a newer one has deployed unless you intend to roll the dashboard back. To pause release uploads, set `CLOUDFLARE_PAGES_DEPLOY_ENABLED` to `false`; this leaves the current deployment live and does not re-enable Cloudflare's automatic deployments. This job changes only the dashboard; it does not deploy the API, admin panel, or docs. +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