mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 10:54:59 +00:00
378 lines
9.5 KiB
JavaScript
378 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',
|
|
'qa:mantine',
|
|
'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);
|
|
});
|