109 lines
4.9 KiB
TypeScript
109 lines
4.9 KiB
TypeScript
import { spawnSync } from 'node:child_process';
|
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
import { parseCapabilityAllowlist, releaseIdIsImmutable } from '../src/lib/server/release-gate';
|
|
|
|
const RELEASE_ENVIRONMENTS = new Set(['fixture', 'development', 'staging', 'production']);
|
|
const DIGEST_IMAGE = /@sha256:[a-f0-9]{64}$/i;
|
|
|
|
type ReleaseCheckInput = {
|
|
mode: string;
|
|
releaseId: string;
|
|
releaseEnvironment: string;
|
|
dashboardImage: string;
|
|
convexImage: string;
|
|
dockerfile: string;
|
|
capabilityAllowlist: string | undefined;
|
|
trackedFiles: readonly string[];
|
|
browserBundleFiles?: readonly string[];
|
|
browserBundlePresent?: boolean;
|
|
};
|
|
|
|
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'); }
|
|
if (!RELEASE_ENVIRONMENTS.has(input.releaseEnvironment)) {
|
|
errors.push('RELEASE_ENV must be explicitly set to fixture, development, staging, or production');
|
|
}
|
|
|
|
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 && !DIGEST_IMAGE.test(input.dashboardImage)) {
|
|
errors.push('CONVEX_DASHBOARD_IMAGE must be digest-pinned for staging/production');
|
|
}
|
|
if (strictEnvironment && !DIGEST_IMAGE.test(input.convexImage)) {
|
|
errors.push('CONVEX_IMAGE must be digest-pinned for staging/production');
|
|
}
|
|
if (strictEnvironment && input.browserBundlePresent === false) {
|
|
errors.push('built browser client artifacts are required for staging/production release checks');
|
|
}
|
|
if (strictEnvironment) {
|
|
const baseImages = [...input.dockerfile.matchAll(/^FROM\s+([^\s]+)(?:\s+AS\s+[^\s]+)?$/gim)].map((match) => match[1]);
|
|
if (baseImages.length === 0 || baseImages.some((image) => !DIGEST_IMAGE.test(image))) {
|
|
errors.push('Dockerfile base images 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|p12|pfx)$|(?:credentials|secrets)(?:\.(?:json|ya?ml|env|txt)|$)|\.npmrc$)/i.test(file) && file !== '.env.example') {
|
|
errors.push(`secret-like file is tracked: ${file}`);
|
|
}
|
|
}
|
|
if ((input.browserBundleFiles ?? []).length > 0) {
|
|
errors.push('browser client artifacts contain managed secret names');
|
|
}
|
|
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);
|
|
}
|
|
|
|
const MANAGED_SECRET_NAMES = ['TOKEN_ENCRYPTION_KEY', 'CONVEX_BRIDGE_SECRET', 'APP_AUTH_PRIVATE_KEY', 'TYPESAFE_API_KEY', 'refresh_token'];
|
|
function browserBundleFiles(directory = 'build/client'): string[] {
|
|
try { statSync(directory); } catch { return []; }
|
|
const files: string[] = [];
|
|
const visit = (path: string) => {
|
|
for (const entry of readdirSync(path, { withFileTypes: true })) {
|
|
const child = `${path}/${entry.name}`;
|
|
if (entry.isDirectory()) visit(child);
|
|
else if (entry.isFile()) {
|
|
const source = readFileSync(child, 'utf8');
|
|
if (MANAGED_SECRET_NAMES.some((name) => source.includes(name))) files.push(child);
|
|
}
|
|
}
|
|
};
|
|
visit(directory);
|
|
return files;
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
const releaseEnvironment = process.env.RELEASE_ENV ?? (process.env.APP_MODE === 'demo' ? 'fixture' : '');
|
|
const errors = releaseSafetyErrors({
|
|
mode: process.env.APP_MODE ?? 'live',
|
|
releaseId: process.env.APP_RELEASE_ID ?? 'local',
|
|
releaseEnvironment,
|
|
dashboardImage: process.env.CONVEX_DASHBOARD_IMAGE ?? '',
|
|
convexImage: process.env.CONVEX_IMAGE ?? '',
|
|
dockerfile: readFileSync(new URL('../Dockerfile', import.meta.url), 'utf8'),
|
|
capabilityAllowlist: process.env.APP_CAPABILITY_ALLOWLIST,
|
|
trackedFiles: trackedFiles(),
|
|
browserBundleFiles: browserBundleFiles(),
|
|
browserBundlePresent: existsSync('build/client'),
|
|
});
|
|
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}`);
|
|
}
|
|
}
|