From 94cd8b1692d5011032bc527b574602fc5355c618 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Mon, 5 Oct 2026 09:14:37 +0000 Subject: [PATCH] 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