From e7b6877ba4c449113d8dc4300ab76a50d366c0f3 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Mon, 5 Oct 2026 09:00:34 +0000 Subject: [PATCH 1/3] feat: deploy the dashboard to the existing Cloudflare Pages production project only after stable releases with opt-in configuration and cutover docs Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 87 +++++++++++++++++++ .../docs/development/split-deployment.mdx | 35 ++++++++ 2 files changed, 122 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 038d3cf49..c725513b9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -504,3 +504,90 @@ jobs: /tmp/cli/* draft: false prerelease: ${{ contains(github.ref_name, '-') }} + + deploy-pages: + name: Deploy dashboard to Cloudflare Pages + needs: create-release + if: ${{ vars.CLOUDFLARE_PAGES_DEPLOY_ENABLED == 'true' && !contains(github.ref_name, '-') }} + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + environment: dashboard-production + env: + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_PAGES_PROJECT_NAME: ${{ vars.CLOUDFLARE_PAGES_PROJECT_NAME }} + CLOUDFLARE_PAGES_PRODUCTION_BRANCH: ${{ vars.CLOUDFLARE_PAGES_PRODUCTION_BRANCH }} + WARMBLY_API_URL: ${{ vars.WARMBLY_API_URL }} + WARMBLY_APP_URL: ${{ vars.WARMBLY_APP_URL }} + WARMBLY_TURNSTILE_KEY: ${{ vars.WARMBLY_TURNSTILE_KEY }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + + - name: Validate production deployment configuration + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + run: | + for name in CLOUDFLARE_API_TOKEN CLOUDFLARE_ACCOUNT_ID CLOUDFLARE_PAGES_PROJECT_NAME CLOUDFLARE_PAGES_PRODUCTION_BRANCH WARMBLY_API_URL WARMBLY_APP_URL WARMBLY_TURNSTILE_KEY; do + if [ -z "${!name}" ]; then + echo "::error::Set $name before enabling Cloudflare Pages release deployments." + exit 1 + fi + done + project=$(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") + if ! jq --exit-status --arg branch "$CLOUDFLARE_PAGES_PRODUCTION_BRANCH" \ + '.success == true and .result.production_branch == $branch' <<< "$project" > /dev/null; then + echo "::error::CLOUDFLARE_PAGES_PRODUCTION_BRANCH must match the existing Pages project's production branch." + exit 1 + fi + + - uses: pnpm/action-setup@v4 + with: + version: "11.9.0" + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: pnpm + cache-dependency-path: web/pnpm-lock.yaml + + - name: Install dashboard dependencies + working-directory: web + run: pnpm install --frozen-lockfile + + - name: Build production dashboard + working-directory: web + env: + VITE_SENTRY_RELEASE: ${{ github.ref_name }} + WARMBLY_BETA_NOTICE: ${{ vars.WARMBLY_BETA_NOTICE }} + WARMBLY_SENTRY_DSN: ${{ vars.WARMBLY_SENTRY_DSN }} + WARMBLY_SENTRY_ENVIRONMENT: ${{ vars.WARMBLY_SENTRY_ENVIRONMENT }} + WARMBLY_POSTHOG_KEY: ${{ vars.WARMBLY_POSTHOG_KEY }} + WARMBLY_POSTHOG_HOST: ${{ vars.WARMBLY_POSTHOG_HOST }} + WARMBLY_POSTHOG_UI_HOST: ${{ vars.WARMBLY_POSTHOG_UI_HOST }} + WARMBLY_POSTHOG_ERROR_TRACKING: ${{ vars.WARMBLY_POSTHOG_ERROR_TRACKING }} + WARMBLY_POSTHOG_SESSION_REPLAY: ${{ vars.WARMBLY_POSTHOG_SESSION_REPLAY }} + WARMBLY_COMPANY_LOGOS: ${{ vars.WARMBLY_COMPANY_LOGOS }} + SENTRY_ORG: ${{ vars.SENTRY_ORG }} + SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT_WEB }} + 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 + + - name: Deploy tagged dashboard to production + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + RELEASE_TAG: ${{ github.ref_name }} + WRANGLER_SEND_METRICS: "false" + run: | + pnpm dlx wrangler@4.45.0 pages deploy web/dist \ + --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..d3b0ab9fd 100644 --- a/docs/content/docs/development/split-deployment.mdx +++ b/docs/content/docs/development/split-deployment.mdx @@ -203,6 +203,41 @@ 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 + +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. + +The build checks out the release commit, 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. Before building, it checks that the configured branch matches the existing Pages project's production branch. + + +GitHub Actions builds do not inherit environment variables from the Pages dashboard. Copy the production frontend configuration to GitHub first. The Cloudflare API token and source-map upload credentials are secrets; the `WARMBLY_*` values below are public frontend configuration and are shipped to browsers. + + +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. +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**: + + | 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_PAGES_PRODUCTION_BRANCH` | The project's configured production branch, usually `main` | + | `WARMBLY_API_URL` | The production API origin, such as `https://api.example.com` | + | `WARMBLY_APP_URL` | The production dashboard origin, such as `https://app.example.com` | + | `WARMBLY_TURNSTILE_KEY` | The production Turnstile site key, not its secret key | + + These values are required when deployment is enabled. Copy any configured `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`, and `WARMBLY_COMPANY_LOGOS` variables too. Repository-level variables and secrets also work; environment-level values take precedence. + + 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). + +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. +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. + +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. + +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. + ## 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. From 94cd8b1692d5011032bc527b574602fc5355c618 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Mon, 5 Oct 2026 09:14:37 +0000 Subject: [PATCH 2/3] feat: reuse Cloudflare Pages production configuration for exact-release dashboard builds and cover safe runtime imports in CI Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/scripts/pages-production-config.mjs | 58 +++++++ .../scripts/pages-production-config.test.mjs | 149 ++++++++++++++++++ .github/workflows/ci.yml | 21 ++- .github/workflows/release.yml | 33 ++-- .../docs/development/split-deployment.mdx | 14 +- 5 files changed, 244 insertions(+), 31 deletions(-) create mode 100644 .github/scripts/pages-production-config.mjs create mode 100644 .github/scripts/pages-production-config.test.mjs diff --git a/.github/scripts/pages-production-config.mjs b/.github/scripts/pages-production-config.mjs new file mode 100644 index 000000000..0458a2b74 --- /dev/null +++ b/.github/scripts/pages-production-config.mjs @@ -0,0 +1,58 @@ +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", +]); + +class ConfigurationError extends Error {} + +try { + 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 ?? {}; + 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..d6fa74db8 --- /dev/null +++ b/.github/scripts/pages-production-config.test.mjs @@ -0,0 +1,149 @@ +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) { + 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 }, + 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")); + } +}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 438288c36..cb49581f4 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,24 @@ jobs: - 'site/public/cli.ps1' - 'scripts/check-cli-installer.sh' - 'scripts/build-cli.sh' + pages: + - '.github/scripts/pages-production-config*' + - '.github/workflows/release.yml' + - '.github/workflows/ci.yml' + - 'web/docker-entrypoint.sh' + + 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-production-config.test.mjs migrations-ci: name: Migrations @@ -519,7 +538,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 c725513b9..a16f87871 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -517,33 +517,30 @@ jobs: env: CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} CLOUDFLARE_PAGES_PROJECT_NAME: ${{ vars.CLOUDFLARE_PAGES_PROJECT_NAME }} - CLOUDFLARE_PAGES_PRODUCTION_BRANCH: ${{ vars.CLOUDFLARE_PAGES_PRODUCTION_BRANCH }} - WARMBLY_API_URL: ${{ vars.WARMBLY_API_URL }} - WARMBLY_APP_URL: ${{ vars.WARMBLY_APP_URL }} - WARMBLY_TURNSTILE_KEY: ${{ vars.WARMBLY_TURNSTILE_KEY }} steps: - uses: actions/checkout@v4 with: ref: ${{ github.sha }} - - name: Validate production deployment configuration + - 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 CLOUDFLARE_PAGES_PRODUCTION_BRANCH WARMBLY_API_URL WARMBLY_APP_URL WARMBLY_TURNSTILE_KEY; do + 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 - project=$(curl --fail --silent --show-error \ + 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") - if ! jq --exit-status --arg branch "$CLOUDFLARE_PAGES_PRODUCTION_BRANCH" \ - '.success == true and .result.production_branch == $branch' <<< "$project" > /dev/null; then - echo "::error::CLOUDFLARE_PAGES_PRODUCTION_BRANCH must match the existing Pages project's production branch." - exit 1 - fi + "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: @@ -563,15 +560,6 @@ jobs: working-directory: web env: VITE_SENTRY_RELEASE: ${{ github.ref_name }} - WARMBLY_BETA_NOTICE: ${{ vars.WARMBLY_BETA_NOTICE }} - WARMBLY_SENTRY_DSN: ${{ vars.WARMBLY_SENTRY_DSN }} - WARMBLY_SENTRY_ENVIRONMENT: ${{ vars.WARMBLY_SENTRY_ENVIRONMENT }} - WARMBLY_POSTHOG_KEY: ${{ vars.WARMBLY_POSTHOG_KEY }} - WARMBLY_POSTHOG_HOST: ${{ vars.WARMBLY_POSTHOG_HOST }} - WARMBLY_POSTHOG_UI_HOST: ${{ vars.WARMBLY_POSTHOG_UI_HOST }} - WARMBLY_POSTHOG_ERROR_TRACKING: ${{ vars.WARMBLY_POSTHOG_ERROR_TRACKING }} - WARMBLY_POSTHOG_SESSION_REPLAY: ${{ vars.WARMBLY_POSTHOG_SESSION_REPLAY }} - WARMBLY_COMPANY_LOGOS: ${{ vars.WARMBLY_COMPANY_LOGOS }} SENTRY_ORG: ${{ vars.SENTRY_ORG }} SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT_WEB }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} @@ -583,6 +571,7 @@ jobs: - name: Deploy tagged dashboard 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" run: | diff --git a/docs/content/docs/development/split-deployment.mdx b/docs/content/docs/development/split-deployment.mdx index d3b0ab9fd..ec96c353f 100644 --- a/docs/content/docs/development/split-deployment.mdx +++ b/docs/content/docs/development/split-deployment.mdx @@ -207,13 +207,13 @@ Whichever origin you serve them from has to be in the backend's `CORS_ALLOW_ORIG 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. -The build checks out the release commit, 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. Before building, it checks that the configured branch matches the existing Pages project's production branch. +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. -GitHub Actions builds do not inherit environment variables from the Pages dashboard. Copy the production frontend configuration to GitHub first. The Cloudflare API token and source-map upload credentials are secrets; the `WARMBLY_*` values below are public frontend configuration and are shipped to browsers. +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. -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. +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**: @@ -221,12 +221,8 @@ GitHub Actions builds do not inherit environment variables from the Pages dashbo |---|---| | `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_PAGES_PRODUCTION_BRANCH` | The project's configured production branch, usually `main` | - | `WARMBLY_API_URL` | The production API origin, such as `https://api.example.com` | - | `WARMBLY_APP_URL` | The production dashboard origin, such as `https://app.example.com` | - | `WARMBLY_TURNSTILE_KEY` | The production Turnstile site key, not its secret key | - These values are required when deployment is enabled. Copy any configured `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`, and `WARMBLY_COMPANY_LOGOS` variables too. Repository-level variables and secrets also work; environment-level values take precedence. + 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. 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). @@ -236,6 +232,8 @@ GitHub Actions builds do not inherit environment variables from the Pages dashbo 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. + 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. ## Anything the control plane cannot know From a865db3386ac3e839e4ea69a07dc0344b952dae9 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Mon, 5 Oct 2026 09:48:40 +0000 Subject: [PATCH 3/3] feat: deploy configurable Cloudflare Pages project lists from exact releases with isolated dashboard, admin, site and docs builds Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/scripts/pages-production-config.mjs | 34 ++++------ .../scripts/pages-production-config.test.mjs | 54 +++++++++++++++- .github/scripts/pages-projects.mjs | 40 ++++++++++++ .github/scripts/pages-projects.test.mjs | 64 +++++++++++++++++++ .github/workflows/ci.yml | 10 ++- .github/workflows/release.yml | 55 ++++++++++++---- .../docs/development/split-deployment.mdx | 47 +++++++++----- 7 files changed, 252 insertions(+), 52 deletions(-) create mode 100644 .github/scripts/pages-projects.mjs create mode 100644 .github/scripts/pages-projects.test.mjs 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