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>
This commit is contained in:
Matthew Meszaros
2026-10-05 09:48:40 +00:00
co-authored by Devin AI
parent 94cd8b1692
commit a865db3386
7 changed files with 252 additions and 52 deletions
+14 -20
View File
@@ -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")) {
@@ -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);
});
+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 });
}
});