mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
648 lines
18 KiB
JavaScript
648 lines
18 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' },
|
|
{ label: 'desktop', file: 'desktop/package.json' },
|
|
];
|
|
|
|
const requiredFiles = [
|
|
'CHANGELOG.md',
|
|
'Dockerfile',
|
|
'README.md',
|
|
'package.json',
|
|
'pnpm-lock.yaml',
|
|
'pnpm-workspace.yaml',
|
|
];
|
|
|
|
const requiredScripts = [
|
|
'audit',
|
|
'build',
|
|
'desktop:test:readiness',
|
|
'desktop:wait:ready',
|
|
'lint',
|
|
'lint:budget',
|
|
'qa:mantine',
|
|
'smoke:cli-mcp',
|
|
'test:e2e',
|
|
'test:load',
|
|
'test:load:smoke',
|
|
'test:release-format',
|
|
'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' },
|
|
{ label: 'desktop build output', file: 'desktop/out/main/index.js' },
|
|
];
|
|
|
|
function releaseDocsForVersion(version) {
|
|
const major = Number.parseInt(version.split('.')[0] ?? '', 10);
|
|
|
|
if (major === 6) {
|
|
return [
|
|
{
|
|
label: 'v6 compatibility and release policy',
|
|
file: 'docs/V6-COMPATIBILITY-AND-RELEASE-POLICY.md',
|
|
terms: [
|
|
'Harness Support Tiers',
|
|
'Compatibility Matrix',
|
|
'Release Channels',
|
|
'Rollback Policy',
|
|
],
|
|
},
|
|
{
|
|
label: 'v6 upgrade install admin guide',
|
|
file: 'docs/V6-UPGRADE-INSTALL-ADMIN-GUIDE.md',
|
|
terms: [
|
|
'Fresh Mac Desktop Install',
|
|
'v5 To v6 Upgrade',
|
|
'Harness Installation And Authentication',
|
|
'Backup And Recovery',
|
|
],
|
|
},
|
|
{
|
|
label: 'v6 release notes',
|
|
file: 'docs/V6-RELEASE-NOTES.md',
|
|
terms: [
|
|
'Breaking Changes And Migration Warnings',
|
|
'Known Limitations',
|
|
'Release Artifacts',
|
|
],
|
|
},
|
|
{
|
|
label: 'v6 GA checklist',
|
|
file: 'docs/V6-GA-CHECKLIST.md',
|
|
terms: [
|
|
'Provider Certification',
|
|
'Final Release Validation Commands',
|
|
'Distribution And Post-Publication',
|
|
],
|
|
},
|
|
{
|
|
label: 'v6 release candidate evidence packet',
|
|
file: 'docs/V6-RC-EVIDENCE-PACKET.md',
|
|
terms: [
|
|
'Issue And Pull Request Traceability',
|
|
'Verification Matrix',
|
|
'Publication Evidence',
|
|
],
|
|
},
|
|
{
|
|
label: 'v6 visual tour',
|
|
file: 'docs/V6-VISUAL-TOUR.md',
|
|
terms: ['Provider Support', 'Buzz Integration', 'Approval And Run Evidence'],
|
|
},
|
|
{
|
|
label: 'v6 agent runtime architecture',
|
|
file: 'docs/architecture/V6-AGENT-RUNTIME-CONTROL-PLANE.md',
|
|
terms: ['Authority Model', 'Adapter Boundaries', 'Run Lifecycle', 'Security Boundaries'],
|
|
},
|
|
];
|
|
}
|
|
|
|
if (major === 5) {
|
|
return [
|
|
{
|
|
label: 'v5 compatibility and release policy',
|
|
file: 'docs/V5-COMPATIBILITY-AND-RELEASE-POLICY.md',
|
|
terms: ['Compatibility Matrix', 'Release Channels', 'Rollback Policy'],
|
|
},
|
|
{
|
|
label: 'v5 upgrade install admin guide',
|
|
file: 'docs/V5-UPGRADE-INSTALL-ADMIN-GUIDE.md',
|
|
terms: ['Fresh Mac Desktop Install', 'v4 To v5 Upgrade', 'Multi-User Admin'],
|
|
},
|
|
{
|
|
label: 'v5 release notes',
|
|
file: 'docs/V5-RELEASE-NOTES.md',
|
|
terms: ['Breaking Changes And Migration Warnings', 'Release Artifacts'],
|
|
},
|
|
{
|
|
label: 'v5 GA checklist',
|
|
file: 'docs/V5-GA-CHECKLIST.md',
|
|
terms: ['Final Release Validation Commands', 'Post-GA backlog'],
|
|
},
|
|
];
|
|
}
|
|
|
|
throw new Error(`Release document validation is not defined for major version ${major}.`);
|
|
}
|
|
|
|
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 normalizeReleaseBody(value) {
|
|
return value.replace(/\r\n?/g, '\n').trim();
|
|
}
|
|
|
|
export function releaseBodyFormattingIssues(value, options = {}) {
|
|
const { compactLayout = true } = options;
|
|
const issues = [];
|
|
const shortProseBlocks = [];
|
|
|
|
if (value.includes('\r')) {
|
|
issues.push('contains carriage-return characters');
|
|
}
|
|
|
|
const lines = value.replace(/\r\n?/g, '\n').split('\n');
|
|
let inFence = false;
|
|
let previousBlockLine;
|
|
|
|
for (const [index, line] of lines.entries()) {
|
|
const lineNumber = index + 1;
|
|
const trimmed = line.trim();
|
|
const trimmedStart = line.trimStart();
|
|
|
|
if (/^```/.test(trimmedStart)) {
|
|
inFence = !inFence;
|
|
previousBlockLine = undefined;
|
|
shortProseBlocks.length = 0;
|
|
continue;
|
|
}
|
|
|
|
if (inFence) continue;
|
|
|
|
if (trimmed.length === 0) {
|
|
previousBlockLine = undefined;
|
|
continue;
|
|
}
|
|
|
|
if (/\s{2}$/.test(line)) {
|
|
issues.push(`line ${lineNumber} uses a Markdown hard break`);
|
|
}
|
|
|
|
if (/<br\s*\/?>/i.test(line)) {
|
|
issues.push(`line ${lineNumber} contains an explicit HTML line break`);
|
|
}
|
|
|
|
if (/\\[rn](?:\\[rn])?/.test(line)) {
|
|
issues.push(`line ${lineNumber} contains a literal escaped line break`);
|
|
}
|
|
|
|
if (/^\s*>/.test(line)) {
|
|
issues.push(`line ${lineNumber} uses a blockquote instead of a full-width block`);
|
|
}
|
|
|
|
const blockLine = {
|
|
lineNumber,
|
|
heading: /^\s*#{1,6}\s+/.test(line),
|
|
nestedHeading: /^\s*#{3,6}\s+/.test(line),
|
|
listItem: /^\s*(?:[-*+]|\d+\.)\s+/.test(line),
|
|
tableRow: /^\s*\|.*\|\s*$/.test(line),
|
|
};
|
|
|
|
if (compactLayout && blockLine.nestedHeading) {
|
|
issues.push(
|
|
`line ${lineNumber} uses a nested heading that fragments the GitHub release layout`
|
|
);
|
|
}
|
|
|
|
if (compactLayout && !blockLine.listItem && /^\*\*[^*]+\*\*\s+\S/.test(trimmed)) {
|
|
issues.push(
|
|
`line ${lineNumber} uses a bold-led prose block; use a Markdown list for parallel release items`
|
|
);
|
|
}
|
|
|
|
if (compactLayout && blockLine.listItem && trimmed.length > 160) {
|
|
issues.push(
|
|
`line ${lineNumber} uses a long list item that will render with ragged hanging indentation; shorten it or use a full-width prose paragraph`
|
|
);
|
|
}
|
|
|
|
const proseBlock =
|
|
!blockLine.heading && !blockLine.listItem && !blockLine.tableRow && trimmed.length < 160;
|
|
|
|
if (compactLayout && proseBlock) {
|
|
shortProseBlocks.push(lineNumber);
|
|
if (shortProseBlocks.length === 3) {
|
|
issues.push(
|
|
`lines ${shortProseBlocks.join(
|
|
', '
|
|
)} form consecutive short prose blocks; combine them into full-width paragraphs`
|
|
);
|
|
}
|
|
} else {
|
|
shortProseBlocks.length = 0;
|
|
}
|
|
|
|
if (
|
|
previousBlockLine &&
|
|
!(previousBlockLine.listItem && blockLine.listItem) &&
|
|
!(previousBlockLine.tableRow && blockLine.tableRow)
|
|
) {
|
|
issues.push(
|
|
`lines ${previousBlockLine.lineNumber}-${lineNumber} are not separated by a blank line`
|
|
);
|
|
}
|
|
|
|
previousBlockLine = blockLine;
|
|
}
|
|
|
|
if (inFence) {
|
|
issues.push('contains an unclosed fenced code block');
|
|
}
|
|
|
|
return issues;
|
|
}
|
|
|
|
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;
|
|
const requiredReleaseDocs = releaseDocsForVersion(expectedVersion);
|
|
const releaseBodyFile = `docs/releases/v${expectedVersion}.md`;
|
|
const releaseBodyExists = await exists(releaseBodyFile);
|
|
const releaseBody = releaseBodyExists ? await readText(releaseBodyFile) : '';
|
|
const releaseVersionParts = expectedVersion
|
|
.split(/[+-]/, 1)[0]
|
|
.split('.')
|
|
.map((part) => Number.parseInt(part, 10));
|
|
const compactReleaseLayout =
|
|
releaseVersionParts[0] > 6 ||
|
|
(releaseVersionParts[0] === 6 &&
|
|
(releaseVersionParts[1] > 0 ||
|
|
(releaseVersionParts[1] === 0 && releaseVersionParts[2] >= 2)));
|
|
const releaseBodyIssues = releaseBodyExists
|
|
? releaseBodyFormattingIssues(releaseBody, { compactLayout: compactReleaseLayout })
|
|
: [];
|
|
|
|
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));
|
|
}
|
|
|
|
check('Reviewed GitHub release body exists', releaseBodyExists, releaseBodyFile);
|
|
check(
|
|
'GitHub release body uses full-width Markdown blocks',
|
|
releaseBodyExists && releaseBodyIssues.length === 0,
|
|
releaseBodyIssues.slice(0, 3).join('; ') || releaseBodyFile
|
|
);
|
|
|
|
for (const pkg of packages) {
|
|
check(
|
|
`${pkg.label} package version matches ${expectedVersion}`,
|
|
pkg.json.version === expectedVersion,
|
|
`found ${pkg.json.version}`
|
|
);
|
|
}
|
|
|
|
const desktopPackage = packages.find((pkg) => pkg.label === 'desktop')?.json;
|
|
if (desktopPackage) {
|
|
for (const scriptName of ['package:mac:unsigned', 'release:mac']) {
|
|
check(
|
|
`Desktop release script exists: ${scriptName}`,
|
|
typeof desktopPackage.scripts?.[scriptName] === 'string',
|
|
desktopPackage.scripts?.[scriptName] ?? 'missing'
|
|
);
|
|
}
|
|
}
|
|
|
|
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}]`
|
|
);
|
|
|
|
for (const doc of requiredReleaseDocs) {
|
|
const docExists = await exists(doc.file);
|
|
check(`Required release doc exists: ${doc.label}`, docExists, doc.file);
|
|
if (!docExists) continue;
|
|
|
|
const content = await readText(doc.file);
|
|
for (const term of doc.terms) {
|
|
check(
|
|
`Required release doc section exists: ${doc.label} -> ${term}`,
|
|
content.includes(term),
|
|
doc.file
|
|
);
|
|
}
|
|
}
|
|
|
|
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',
|
|
'body,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'
|
|
);
|
|
check(
|
|
`GitHub release body matches ${releaseBodyFile}`,
|
|
releaseBodyExists &&
|
|
normalizeReleaseBody(releaseJson.body ?? '') === normalizeReleaseBody(releaseBody),
|
|
releaseBodyFile
|
|
);
|
|
} 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.');
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
main().catch((error) => {
|
|
console.error(error instanceof Error ? error.message : error);
|
|
process.exit(1);
|
|
});
|
|
}
|