veritas-kanban/scripts/validate-release.mjs
Brad Groux d3976f1d74 chore: harden audit findings and release QA
Add release validation and scheduled QA workflows.

Harden webhook URL handling, API helper edge cases, and runtime version reporting.

Split heavy web bundles, centralize view metadata, and stabilize full-suite tests.
2026-05-16 18:59:40 -05:00

377 lines
9.5 KiB
JavaScript

#!/usr/bin/env node
import { access, readFile } from 'node:fs/promises';
import { constants } from 'node:fs';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const packageFiles = [
{ label: 'root', file: 'package.json' },
{ label: 'shared', file: 'shared/package.json' },
{ label: 'server', file: 'server/package.json' },
{ label: 'web', file: 'web/package.json' },
{ label: 'cli', file: 'cli/package.json' },
{ label: 'mcp', file: 'mcp/package.json' },
];
const requiredFiles = [
'CHANGELOG.md',
'Dockerfile',
'README.md',
'package.json',
'pnpm-lock.yaml',
'pnpm-workspace.yaml',
];
const requiredScripts = [
'audit',
'build',
'lint',
'lint:budget',
'test:e2e',
'test:load',
'test:load:smoke',
'test:unit',
'typecheck',
];
const buildOutputs = [
{ label: 'shared build output', file: 'shared/dist/index.js' },
{ label: 'server build output', file: 'server/dist/index.js' },
{ label: 'web build output', file: 'web/dist/index.html' },
{ label: 'CLI build output', file: 'cli/dist/index.js' },
{ label: 'MCP build output', file: 'mcp/dist/index.js' },
];
const checks = [];
function usage() {
console.log(`Usage: pnpm validate:release -- [options]
Options:
--version <version> Validate a specific version. Defaults to package.json version.
--github Validate v<version> tag and GitHub release.
--repo <owner/repo> GitHub repository for --github. Defaults to package.json repository.
--skip-build-output Skip local dist artifact checks.
--docker-build Build the production Docker image as part of validation.
--help Show this help text.
`);
}
function parseArgs(argv) {
const options = {
dockerBuild: false,
github: false,
repo: undefined,
skipBuildOutput: false,
version: undefined,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--') {
continue;
}
if (arg === '--help' || arg === '-h') {
usage();
process.exit(0);
}
if (arg === '--github') {
options.github = true;
continue;
}
if (arg === '--skip-build-output') {
options.skipBuildOutput = true;
continue;
}
if (arg === '--docker-build') {
options.dockerBuild = true;
continue;
}
if (arg === '--version') {
options.version = argv[index + 1];
index += 1;
continue;
}
if (arg.startsWith('--version=')) {
options.version = arg.slice('--version='.length);
continue;
}
if (arg === '--repo') {
options.repo = argv[index + 1];
index += 1;
continue;
}
if (arg.startsWith('--repo=')) {
options.repo = arg.slice('--repo='.length);
continue;
}
fail('CLI options', `Unknown option: ${arg}`);
}
return options;
}
function record(status, name, detail = '') {
checks.push({ status, name, detail });
}
function pass(name, detail = '') {
record('pass', name, detail);
}
function fail(name, detail = '') {
record('fail', name, detail);
}
function skip(name, detail = '') {
record('skip', name, detail);
}
function check(name, condition, detail = '') {
if (condition) {
pass(name, detail);
} else {
fail(name, detail);
}
}
function relativePath(file) {
return path.join(rootDir, file);
}
async function readText(file) {
return readFile(relativePath(file), 'utf8');
}
async function readJson(file) {
return JSON.parse(await readText(file));
}
async function exists(file) {
try {
await access(relativePath(file), constants.F_OK);
return true;
} catch {
return false;
}
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: rootDir,
encoding: 'utf8',
stdio: options.stdio ?? 'pipe',
});
if (result.error) {
return {
ok: false,
status: 1,
stdout: '',
stderr: result.error.message,
};
}
return {
ok: result.status === 0,
status: result.status,
stdout: typeof result.stdout === 'string' ? result.stdout.trim() : '',
stderr: typeof result.stderr === 'string' ? result.stderr.trim() : '',
};
}
function escapeRegex(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function parseGithubRepo(repositoryUrl) {
if (!repositoryUrl) return undefined;
const match = repositoryUrl.match(/github\.com[:/]([^/]+\/[^/.]+)(?:\.git)?$/);
return match?.[1];
}
function printableDetail(detail) {
return detail ? ` - ${detail}` : '';
}
async function main() {
const options = parseArgs(process.argv.slice(2));
const packages = [];
for (const packageFile of packageFiles) {
packages.push({
...packageFile,
json: await readJson(packageFile.file),
});
}
const rootPackage = packages.find((pkg) => pkg.label === 'root').json;
const expectedVersion = options.version ?? rootPackage.version;
check(
'Release version is valid semver',
/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(expectedVersion),
expectedVersion
);
for (const packageFile of requiredFiles) {
check(`Required file exists: ${packageFile}`, await exists(packageFile));
}
for (const pkg of packages) {
check(
`${pkg.label} package version matches ${expectedVersion}`,
pkg.json.version === expectedVersion,
`found ${pkg.json.version}`
);
}
check(
'packageManager pins pnpm',
/^pnpm@\d+\.\d+\.\d+$/.test(rootPackage.packageManager ?? ''),
rootPackage.packageManager ?? 'not declared'
);
check(
'Node engine targets Node 22 or newer',
/^>=22\b/.test(rootPackage.engines?.node ?? ''),
rootPackage.engines?.node ?? 'not declared'
);
for (const scriptName of requiredScripts) {
check(
`Required package script exists: ${scriptName}`,
typeof rootPackage.scripts?.[scriptName] === 'string',
rootPackage.scripts?.[scriptName] ?? 'missing'
);
}
const readme = await readText('README.md');
check(
'README version badge matches release version',
new RegExp(`version-${escapeRegex(expectedVersion)}-blue\\.svg`).test(readme),
`expected badge version ${expectedVersion}`
);
const changelog = await readText('CHANGELOG.md');
check(
'CHANGELOG has a release heading',
new RegExp(
`^## \\[${escapeRegex(expectedVersion)}\\](?:\\s+-\\s+\\d{4}-\\d{2}-\\d{2})?$`,
'm'
).test(changelog),
`expected ## [${expectedVersion}]`
);
if (options.skipBuildOutput) {
skip('Local build output validation', 'skipped by --skip-build-output');
} else {
for (const artifact of buildOutputs) {
check(`${artifact.label} exists`, await exists(artifact.file), artifact.file);
}
}
if (options.github) {
const tagName = `v${expectedVersion}`;
const repo = options.repo ?? parseGithubRepo(rootPackage.repository?.url);
check(
'GitHub repository resolved',
typeof repo === 'string' && repo.length > 0,
repo ?? 'missing'
);
const localTag = run('git', ['tag', '--list', tagName]);
check(
`Local git tag exists: ${tagName}`,
localTag.ok && localTag.stdout.split('\n').includes(tagName),
localTag.ok && localTag.stdout ? tagName : localTag.stderr || 'not found'
);
const remoteTag = run('git', ['ls-remote', '--tags', 'origin', `refs/tags/${tagName}`]);
check(
`Origin git tag exists: ${tagName}`,
remoteTag.ok && remoteTag.stdout.includes(`refs/tags/${tagName}`),
remoteTag.ok && remoteTag.stdout ? 'origin' : remoteTag.stderr || 'not found'
);
if (repo) {
const release = run('gh', [
'release',
'view',
tagName,
'--repo',
repo,
'--json',
'isDraft,isPrerelease,name,tagName,url',
]);
if (release.ok) {
const releaseJson = JSON.parse(release.stdout);
check(
`GitHub release exists: ${tagName}`,
releaseJson.tagName === tagName,
releaseJson.url ?? releaseJson.name ?? ''
);
check(
`GitHub release is published: ${tagName}`,
releaseJson.isDraft === false,
releaseJson.isDraft ? 'draft release' : 'published'
);
} else {
fail(`GitHub release exists: ${tagName}`, release.stderr || 'gh release view failed');
}
}
} else {
skip('Git tag and GitHub release validation', 'pass --github to verify remote release state');
}
if (options.dockerBuild) {
const dockerTag = `veritas-kanban:validate-${expectedVersion.replace(/[^0-9A-Za-z_.-]/g, '-')}`;
const result = run('docker', ['build', '--target', 'production', '-t', dockerTag, '.'], {
stdio: 'inherit',
});
check('Production Docker image builds', result.ok, dockerTag);
} else {
skip('Production Docker image build', 'pass --docker-build to verify the image');
}
const labels = {
fail: 'FAIL',
pass: 'PASS',
skip: 'SKIP',
};
console.log(`\nRelease validation for ${expectedVersion}\n`);
for (const item of checks) {
console.log(`${labels[item.status]} ${item.name}${printableDetail(item.detail)}`);
}
const failures = checks.filter((item) => item.status === 'fail');
if (failures.length > 0) {
console.error(`\nRelease validation failed: ${failures.length} check(s) failed.`);
process.exit(1);
}
console.log('\nRelease validation passed.');
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});