feat: add isolated release gates and CI checks
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const port = Number(process.env.SMOKE_WEB_PORT ?? 15173);
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
const root = await mkdtemp(join(tmpdir(), 'eden-fixture-'));
|
||||
let server: Bun.Subprocess | undefined;
|
||||
|
||||
async function compileFixture(directory: string): Promise<void> {
|
||||
const child = Bun.spawn(['bun', 'workers/reference.ts', '--fixture', '--directory', directory], {
|
||||
stdout: 'pipe', stderr: 'pipe', stdin: 'ignore',
|
||||
env: { ...process.env, APP_MODE: 'demo', REFERENCE_DIR: directory },
|
||||
});
|
||||
const code = await child.exited;
|
||||
if (code !== 0) throw new Error('Fixture reference compilation failed');
|
||||
}
|
||||
|
||||
async function waitForServer(): Promise<void> {
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(base);
|
||||
if (response.status === 200) return;
|
||||
} catch { /* startup race */ }
|
||||
await Bun.sleep(250);
|
||||
}
|
||||
throw new Error('Fixture web server did not become ready');
|
||||
}
|
||||
|
||||
try {
|
||||
await compileFixture(root);
|
||||
server = Bun.spawn(['bun', 'build/index.js'], {
|
||||
stdout: 'pipe', stderr: 'pipe', stdin: 'ignore',
|
||||
env: {
|
||||
...process.env,
|
||||
APP_MODE: 'demo', APP_ORIGIN: base, ORIGIN: base,
|
||||
HOST: '127.0.0.1', PORT: String(port), REFERENCE_DIR: root,
|
||||
APP_RELEASE_ID: 'fixture',
|
||||
},
|
||||
});
|
||||
await waitForServer();
|
||||
|
||||
const rootResponse = await fetch(base);
|
||||
if (!(await rootResponse.text()).includes('FIXTURE DEMO')) throw new Error('Fixture label missing');
|
||||
if (rootResponse.headers.get('x-application-release') !== 'fixture') throw new Error('Release identifier header missing');
|
||||
const assets = await fetch(`${base}/assets`);
|
||||
if (assets.status !== 200 || !(await assets.text()).includes('2,086,000.00')) throw new Error('Fixture asset report failed');
|
||||
|
||||
for (const message of ['/value', '/assets']) {
|
||||
const response = await fetch(`${base}/api/chat`, {
|
||||
method: 'POST', headers: { origin: base, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ message, requestId: crypto.randomUUID() }),
|
||||
});
|
||||
if (response.status !== 200) throw new Error(`Fixture assistant failed for ${message}`);
|
||||
const result = await response.json() as { status?: string; operation?: { status?: string; report?: { total?: string } } };
|
||||
if (result.status !== 'ready' || result.operation?.status !== 'ready' || result.operation.report?.total !== '208600000') {
|
||||
throw new Error(`Fixture assistant returned an unexpected result for ${message}`);
|
||||
}
|
||||
}
|
||||
console.log(`Isolated fixture smoke passed: ${base}; synthetic reference/backend only`);
|
||||
} finally {
|
||||
if (server) {
|
||||
server.kill('SIGTERM');
|
||||
await server.exited;
|
||||
}
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -152,10 +152,10 @@ export function cleanEnvironment(base: Environment): Environment {
|
||||
}
|
||||
|
||||
const PUBLIC_KEYS = new Set([
|
||||
'APP_MODE', 'APP_ORIGIN', 'APP_AUTH_ISSUER', 'EVE_CALLBACK_URL', 'PUBLIC_CONVEX_URL',
|
||||
'APP_MODE', 'APP_RELEASE_ID', 'APP_CAPABILITY_ALLOWLIST', 'APP_ORIGIN', 'APP_AUTH_ISSUER', 'EVE_CALLBACK_URL', 'PUBLIC_CONVEX_URL',
|
||||
'CONVEX_URL', 'CONVEX_SELF_HOSTED_URL', 'CONVEX_SITE_ORIGIN', 'REFERENCE_DIR', 'ESI_USER_AGENT',
|
||||
'WEB_BIND_ADDRESS', 'CONVEX_BIND_ADDRESS', 'WEB_PORT', 'CONVEX_PORT', 'CONVEX_SITE_PORT',
|
||||
'CONVEX_IMAGE', 'CONVEX_DASHBOARD_IMAGE',
|
||||
'CONVEX_IMAGE', 'CONVEX_DASHBOARD_IMAGE', 'RELEASE_ENV',
|
||||
]);
|
||||
export function applicationEnvironment(base: Environment, secrets: Record<string, string>, publicConfig: Environment, deployment = false): Environment {
|
||||
const env = cleanEnvironment(base);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { parseCapabilityAllowlist, releaseIdIsImmutable } from '../src/lib/server/release-gate';
|
||||
|
||||
type ReleaseCheckInput = {
|
||||
mode: string;
|
||||
releaseId: string;
|
||||
releaseEnvironment: string;
|
||||
dashboardImage: string;
|
||||
capabilityAllowlist: string | undefined;
|
||||
trackedFiles: readonly string[];
|
||||
};
|
||||
|
||||
export function releaseSafetyErrors(input: ReleaseCheckInput): string[] {
|
||||
const errors: string[] = [];
|
||||
try { parseCapabilityAllowlist(input.capabilityAllowlist); }
|
||||
catch (error) { errors.push(error instanceof Error ? error.message : 'Invalid capability allowlist'); }
|
||||
|
||||
const strictEnvironment = input.releaseEnvironment === 'production' || input.releaseEnvironment === 'staging';
|
||||
if (!releaseIdIsImmutable(input.releaseId, strictEnvironment ? 'production' : 'development')) {
|
||||
errors.push('APP_RELEASE_ID must be a non-placeholder immutable identifier for this release environment');
|
||||
}
|
||||
if (strictEnvironment && !input.dashboardImage.includes('@sha256:')) {
|
||||
errors.push('CONVEX_DASHBOARD_IMAGE must be digest-pinned for staging/production');
|
||||
}
|
||||
if (input.mode === 'demo' && strictEnvironment) {
|
||||
errors.push('APP_MODE=demo is not permitted for a staging/production release');
|
||||
}
|
||||
for (const file of input.trackedFiles) {
|
||||
if (/(^|\/)(?:\.env(?:\.|$)|.*\.pem$|.*\.key$)/i.test(file) && file !== '.env.example') {
|
||||
errors.push(`secret-like file is tracked: ${file}`);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function trackedFiles(): string[] {
|
||||
const result = spawnSync('git', ['ls-files'], { encoding: 'utf8' });
|
||||
if (result.status !== 0) throw new Error('Unable to inspect tracked files');
|
||||
return result.stdout.split('\n').filter(Boolean);
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const releaseEnvironment = process.env.RELEASE_ENV ?? (process.env.APP_MODE === 'demo' ? 'fixture' : 'development');
|
||||
const errors = releaseSafetyErrors({
|
||||
mode: process.env.APP_MODE ?? 'live',
|
||||
releaseId: process.env.APP_RELEASE_ID ?? 'local',
|
||||
releaseEnvironment,
|
||||
dashboardImage: process.env.CONVEX_DASHBOARD_IMAGE ?? '',
|
||||
capabilityAllowlist: process.env.APP_CAPABILITY_ALLOWLIST,
|
||||
trackedFiles: trackedFiles(),
|
||||
});
|
||||
if (errors.length) {
|
||||
console.error(`Release safety check failed (${errors.length} issue${errors.length === 1 ? '' : 's'})`);
|
||||
for (const message of errors) console.error(`- ${message}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
const capabilities = parseCapabilityAllowlist(process.env.APP_CAPABILITY_ALLOWLIST).join(',') || 'none';
|
||||
console.log(`Release safety check passed: environment=${releaseEnvironment}, mode=${process.env.APP_MODE ?? 'live'}, capabilities=${capabilities}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user