Merge pull request #838 from warmbly/devin/1791190573-release-pages-dashboard

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