Files
KoalaSync/scripts/release-local-gate.mjs
T
2026-08-25 03:01:28 +02:00

170 lines
6.4 KiB
JavaScript

#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { versionFromTag } from './release-artifact-checks.mjs';
import {
parseCheckRuns,
validateReleaseSourceVersion,
validateRequiredChecks
} from './release-preflight.mjs';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
function capture(command, args) {
return execFileSync(command, args, {
cwd: repoRoot,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']
}).trim();
}
function run(command, args) {
execFileSync(command, args, {
cwd: repoRoot,
stdio: 'inherit'
});
}
export function parseGateArgs(args) {
const values = Array.from(args);
const candidate = values.includes('--candidate');
const positional = values.filter(value => value !== '--candidate');
if (positional.length !== 1) {
throw new Error('Usage: npm run release:gate -- MAJOR.MINOR.PATCH [--candidate]');
}
const version = versionFromTag(`v${positional[0]}`);
return { version, candidate };
}
export function playwrightImageFromLock(lock) {
const version = lock?.packages?.['node_modules/@playwright/test']?.version;
if (!/^\d+\.\d+\.\d+$/u.test(version || '')) {
throw new Error('package-lock.json must pin node_modules/@playwright/test to an exact version');
}
return `mcr.microsoft.com/playwright:v${version}-noble`;
}
export function linuxGateCommand() {
return [
'git clone --no-local /src /work',
'cd /work',
'npm ci',
'npm ci --prefix server',
'npm run verify',
'npm run test:e2e'
].join(' && ');
}
export function parseRemoteMain(text) {
const match = /^([a-f0-9]{40})\trefs\/heads\/main\s*$/u.exec(String(text));
if (!match) throw new Error(`could not resolve origin main from: ${String(text).trim() || '<empty>'}`);
return match[1];
}
export function validateReleaseWorkflowContract(text) {
const workflow = String(text);
const image = 'ghcr.io/shik3i/koalasync';
if (!workflow.includes(`IMAGE: ${image}`)) {
throw new Error(`release workflow must define the lowercase canonical image ${image}`);
}
for (const reference of ['images: ${{ env.IMAGE }}', 'subject-name: ${{ env.IMAGE }}']) {
if (!workflow.includes(reference)) throw new Error(`release workflow must use ${reference}`);
}
if (/ghcr\.io\/\$\{\{\s*github\.repository\s*\}\}/u.test(workflow)) {
throw new Error('release workflow must not derive a Docker image from case-preserving github.repository');
}
return image;
}
function assertCleanTree() {
const status = capture('git', ['status', '--porcelain=v1']);
if (status) throw new Error(`release gate requires a clean working tree:\n${status}`);
}
function assertFinalMainChecks() {
const branch = capture('git', ['branch', '--show-current']);
if (branch !== 'main') throw new Error(`final release gate requires branch main, found ${branch || '<detached>'}`);
const head = capture('git', ['rev-parse', 'HEAD']);
const remoteMain = parseRemoteMain(capture('git', [
'ls-remote', '--exit-code', 'origin', 'refs/heads/main'
]));
if (head !== remoteMain) throw new Error(`HEAD ${head} does not match origin/main ${remoteMain}`);
const checksText = capture('gh', [
'api', `repos/Shik3i/KoalaSync/commits/${head}/check-runs`,
'--jq', '.check_runs[] | [.name, .conclusion, .html_url] | @tsv'
]);
// Model the release workflow querying this commit while its own preflight
// check is still running. This exact state broke the first v3.1.5 attempt.
const checks = parseCheckRuns(`${checksText}\npreflight\t\tlocal://self-check`);
validateRequiredChecks(checks);
}
async function smokeRelayImage(image) {
const containerId = capture('docker', [
'run', '--detach', '--platform', 'linux/amd64', '--publish', '127.0.0.1::3000',
'--env', 'SERVER_SALT=release-local-gate-salt-with-more-than-thirty-two-chars',
image
]);
try {
const portOutput = capture('docker', ['port', containerId, '3000/tcp']);
const port = /:(\d+)$/u.exec(portOutput)?.[1];
if (!port) throw new Error(`could not resolve relay host port: ${portOutput}`);
const deadline = Date.now() + 30000;
while (Date.now() < deadline) {
try {
const response = await fetch(`http://127.0.0.1:${port}/health`, {
signal: globalThis.AbortSignal.timeout(1000)
});
if (response.ok) return;
} catch (_error) {
// Container is still starting.
}
await new Promise(resolve => setTimeout(resolve, 250));
}
run('docker', ['logs', containerId]);
throw new Error('relay container did not become healthy within 30 seconds');
} finally {
run('docker', ['rm', '--force', containerId]);
}
}
export async function runReleaseGate({ version, candidate }) {
assertCleanTree();
validateReleaseSourceVersion(version, repoRoot);
validateReleaseWorkflowContract(fs.readFileSync(
path.join(repoRoot, '.github/workflows/release.yml'), 'utf8'
));
if (!candidate) assertFinalMainChecks();
const lock = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package-lock.json'), 'utf8'));
const playwrightImage = playwrightImageFromLock(lock);
run('docker', ['pull', '--platform', 'linux/amd64', playwrightImage]);
run('docker', [
'run', '--rm', '--platform', 'linux/amd64', '--ipc=host', '--env', 'CI=1',
'--volume', `${repoRoot}:/src:ro`, playwrightImage,
'bash', '-lc', linuxGateCommand()
]);
const relayImage = `koalasync:${version}-release-gate`;
run('docker', [
'build', '--platform', 'linux/amd64',
'--file', 'server/Dockerfile', '--tag', relayImage, '.'
]);
await smokeRelayImage(relayImage);
console.log(`Local ${candidate ? 'candidate' : 'final'} release gate passed for v${version}`);
}
const isMainModule = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isMainModule) {
try {
await runReleaseGate(parseGateArgs(process.argv.slice(2)));
} catch (error) {
console.error(`Local release gate failed: ${error.message}`);
process.exitCode = 1;
}
}