mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-07 00:02:07 +00:00
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:
co-authored by
Devin AI
parent
94cd8b1692
commit
a865db3386
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user