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