From 01b40d097b233ec57d5036c3a0743a40d80c5481 Mon Sep 17 00:00:00 2001 From: w1ne <14119286+w1ne@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:12:06 +0200 Subject: [PATCH] chore(release): enforce GitHub release hygiene Signed-off-by: w1ne <14119286+w1ne@users.noreply.github.com> --- CONTRIBUTING.md | 11 +++ docs/release-hygiene.md | 39 ++++++++++ package.json | 1 + scripts/packageReleaseAudit.test.ts | 58 +++++++++++++++ scripts/packageReleaseAudit.ts | 108 +++++++++++++++++++++++++++- scripts/release.ts | 12 ++++ 6 files changed, 226 insertions(+), 3 deletions(-) create mode 100644 docs/release-hygiene.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 67cf547fa..6f096d1a0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,6 +32,17 @@ commits without a sign-off. To fix existing commits: git rebase --signoff develop && git push --force-with-lease ``` +## Release Hygiene + +See [docs/release-hygiene.md](docs/release-hygiene.md) before publishing a +release. A pushed tag is not enough; `package.json`, the local tag, the remote +tag, the GitHub Release, and GitHub's `Latest` marker must all agree. The final +verification command is: + +```bash +npm run test:release-hygiene +``` + ## Trademark The MIT license covers the code only. Use of the kernelCAD name and logo is diff --git a/docs/release-hygiene.md b/docs/release-hygiene.md new file mode 100644 index 000000000..56fbc0a63 --- /dev/null +++ b/docs/release-hygiene.md @@ -0,0 +1,39 @@ +# Release Hygiene + +kernelCAD releases have two public surfaces: + +- the git tag, for source checkouts and package provenance +- the GitHub Release, for the release page and the `Latest` marker users see + +A release is not complete until both surfaces agree with `package.json`. + +## Required Final Check + +After merging the release PR and pushing the tag, run: + +```bash +npm run test:release-hygiene +``` + +That command fails if: + +- `package.json` is ahead of the highest local stable tag +- `package.json` is ahead of the highest remote stable tag on `origin` +- the matching GitHub Release is missing +- the matching GitHub Release is a draft or prerelease +- GitHub's latest release tag is not the package version tag + +## Manual Release Checklist + +Use this checklist when publishing outside `npm run release`: + +```bash +git fetch origin --tags +git tag vX.Y.Z +git push origin vX.Y.Z +gh release create vX.Y.Z --title "vX.Y.Z — " --notes-file RELEASE_NOTES.md --latest +npm run test:release-hygiene +``` + +Do not report a release as published after only pushing `vX.Y.Z`; that leaves +GitHub Releases stale even though the tag exists. diff --git a/package.json b/package.json index 7411647ed..b21d7b565 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "test:audit": "npx tsx scripts/testQualityAudit.ts", "test:hygiene": "npm test -- scripts/repoHygieneAudit.test.ts", "test:package": "npx tsx scripts/packageReleaseAudit.ts", + "test:release-hygiene": "KERNELCAD_RELEASE_AUDIT_TAGS=1 KERNELCAD_RELEASE_AUDIT_REMOTE=1 KERNELCAD_RELEASE_AUDIT_GITHUB=1 npm run test:package", "proof:foundation": "npm run typecheck && npm run test:audit && npm run test:hygiene && npm run test:package && npm test -- src/modeling/constraints/solver.test.ts src/modeling/constraints/advanced_constraints.test.ts tests/integration/diagnostics/hint-mandatory.test.ts tests/integration/diagnostics/next-action-mandatory.test.ts scripts/lib/whatsNewTemplate.test.ts tests/integration/mcp/listApi.driftSentinel.test.ts tests/unit/skill/skillShapeMethodsDrift.test.ts tests/unit/patterns/patternCapture.test.ts tests/unit/backends/occt/patternLowerer.test.ts tests/integration/examples/patternExamples.test.ts && npm run proof:dist-skills", "proof:dist-skills": "npm test -- tests/unit/cli/walkSkillTree.test.ts tests/unit/cli/skillInstallRecursion.test.ts tests/unit/cli/skillInstallDeprecation.test.ts tests/unit/scripts/distGenerate.test.ts tests/unit/scripts/distSkillManifest.test.ts tests/unit/scripts/distHarnessAuthor.test.ts tests/unit/scripts/distGrepGate.test.ts tests/unit/scripts/distToolNameGate.test.ts tests/unit/scripts/distFsDiscoverySentinel.test.ts tests/unit/scripts/distReadme.test.ts tests/unit/scripts/distPostinstallScript.test.ts", "proof:assembly-contract": "npm run proof:foundation && npm test -- tests/unit/assemblies/assemblyCapture.test.ts tests/unit/backends/occt/assemblyLowerer.test.ts tests/integration/examples/assemblyExamples.test.ts tests/unit/skill/skillGlobalsDrift.test.ts", diff --git a/scripts/packageReleaseAudit.test.ts b/scripts/packageReleaseAudit.test.ts index b6e9b5052..afe32c880 100644 --- a/scripts/packageReleaseAudit.test.ts +++ b/scripts/packageReleaseAudit.test.ts @@ -48,4 +48,62 @@ describe('packageReleaseAudit', () => { message: 'package.json version 0.11.0 is ahead of highest local stable tag v0.8.0', }); }); + + it('blocks package versions that outrun the remote stable release tags', () => { + const result = auditPackageReleaseState({ + packageJsonText: JSON.stringify({ + version: '0.15.0', + bin: { kernelcad: 'dist/cli/index.js' }, + files: ['dist/cli', 'README.md', 'LICENSE'], + scripts: { prepack: 'npm run build:cli && npm run build:player' }, + }), + tagNames: ['v0.15.0'], + remoteTagNames: ['v0.12.0'], + }); + + expect(result.blockers).toContainEqual({ + kind: 'package-version-ahead-of-remote-tag', + message: 'package.json version 0.15.0 is ahead of highest remote stable tag v0.12.0', + }); + }); + + it('blocks a missing GitHub release for the package version', () => { + const result = auditPackageReleaseState({ + packageJsonText: JSON.stringify({ + version: '0.15.0', + bin: { kernelcad: 'dist/cli/index.js' }, + files: ['dist/cli', 'README.md', 'LICENSE'], + scripts: { prepack: 'npm run build:cli && npm run build:player' }, + }), + tagNames: ['v0.15.0'], + remoteTagNames: ['v0.15.0'], + githubRelease: null, + latestGithubReleaseTag: 'v0.12.0', + }); + + expect(result.blockers).toContainEqual({ + kind: 'github-release-missing', + message: 'GitHub Release v0.15.0 must exist before the release is complete', + }); + }); + + it('blocks when GitHub latest release does not match the package version', () => { + const result = auditPackageReleaseState({ + packageJsonText: JSON.stringify({ + version: '0.15.0', + bin: { kernelcad: 'dist/cli/index.js' }, + files: ['dist/cli', 'README.md', 'LICENSE'], + scripts: { prepack: 'npm run build:cli && npm run build:player' }, + }), + tagNames: ['v0.15.0'], + remoteTagNames: ['v0.15.0'], + githubRelease: { tagName: 'v0.15.0', isDraft: false, isPrerelease: false }, + latestGithubReleaseTag: 'v0.12.0', + }); + + expect(result.blockers).toContainEqual({ + kind: 'github-release-not-latest', + message: 'GitHub latest release is v0.12.0, expected v0.15.0', + }); + }); }); diff --git a/scripts/packageReleaseAudit.ts b/scripts/packageReleaseAudit.ts index 80bda05c3..4bfead496 100644 --- a/scripts/packageReleaseAudit.ts +++ b/scripts/packageReleaseAudit.ts @@ -7,7 +7,12 @@ import { fileURLToPath } from 'node:url'; export type PackageAuditKind = | 'missing-prepack-build' | 'missing-bin-file-entry' - | 'package-version-ahead-of-local-tag'; + | 'package-version-ahead-of-local-tag' + | 'package-version-ahead-of-remote-tag' + | 'github-release-missing' + | 'github-release-draft' + | 'github-release-prerelease' + | 'github-release-not-latest'; export interface PackageAuditFinding { kind: PackageAuditKind; @@ -28,6 +33,15 @@ interface PackageJsonShape { interface PackageAuditInput { packageJsonText: string; tagNames?: string[]; + remoteTagNames?: string[]; + githubRelease?: GithubReleaseState | null; + latestGithubReleaseTag?: string | null; +} + +interface GithubReleaseState { + tagName: string; + isDraft: boolean; + isPrerelease: boolean; } function binPaths(pkg: PackageJsonShape): string[] { @@ -76,6 +90,8 @@ function formatSemver(version: [number, number, number]): string { export function auditPackageReleaseState(input: PackageAuditInput): PackageAuditResult { const pkg = JSON.parse(input.packageJsonText) as PackageJsonShape; const blockers: PackageAuditFinding[] = []; + const expectedTag = pkg.version ? `v${pkg.version}` : null; + const packageVersion = expectedTag ? parseStableSemverTag(expectedTag) : null; // The package ships the CLI bundle (dist/cli) AND the static headless // player (dist/headless-player, consumed by render_preview) — prepack must @@ -101,7 +117,6 @@ export function auditPackageReleaseState(input: PackageAuditInput): PackageAudit } if (input.tagNames) { - const packageVersion = pkg.version ? parseStableSemverTag(`v${pkg.version}`) : null; const highestTag = highestStableTag(input.tagNames); if (packageVersion && highestTag && compareSemver(packageVersion, highestTag) > 0) { blockers.push({ @@ -111,6 +126,49 @@ export function auditPackageReleaseState(input: PackageAuditInput): PackageAudit } } + if (input.remoteTagNames) { + const highestRemoteTag = highestStableTag(input.remoteTagNames); + if (packageVersion && highestRemoteTag && compareSemver(packageVersion, highestRemoteTag) > 0) { + blockers.push({ + kind: 'package-version-ahead-of-remote-tag', + message: `package.json version ${pkg.version} is ahead of highest remote stable tag v${formatSemver(highestRemoteTag)}`, + }); + } + } + + if (expectedTag && input.githubRelease === null) { + blockers.push({ + kind: 'github-release-missing', + message: `GitHub Release ${expectedTag} must exist before the release is complete`, + }); + } else if (expectedTag && input.githubRelease) { + if (input.githubRelease.tagName !== expectedTag) { + blockers.push({ + kind: 'github-release-missing', + message: `GitHub Release ${expectedTag} must exist before the release is complete`, + }); + } + if (input.githubRelease.isDraft) { + blockers.push({ + kind: 'github-release-draft', + message: `GitHub Release ${expectedTag} must not be a draft`, + }); + } + if (input.githubRelease.isPrerelease) { + blockers.push({ + kind: 'github-release-prerelease', + message: `GitHub Release ${expectedTag} must not be a prerelease`, + }); + } + } + + if (expectedTag && input.latestGithubReleaseTag && input.latestGithubReleaseTag !== expectedTag) { + blockers.push({ + kind: 'github-release-not-latest', + message: `GitHub latest release is ${input.latestGithubReleaseTag}, expected ${expectedTag}`, + }); + } + return { blockers }; } @@ -120,14 +178,58 @@ export function formatPackageAuditReport(result: PackageAuditResult): string { function main(): void { const strictTags = process.env.KERNELCAD_RELEASE_AUDIT_TAGS === '1'; + const strictRemote = process.env.KERNELCAD_RELEASE_AUDIT_REMOTE === '1'; + const strictGithub = process.env.KERNELCAD_RELEASE_AUDIT_GITHUB === '1'; const tagNames = strictTags ? execFileSync('git', ['tag', '--list'], { encoding: 'utf8' }) .split('\n') .filter(Boolean) : undefined; + const remoteTagNames = strictRemote + ? execFileSync('git', ['ls-remote', '--tags', 'origin'], { encoding: 'utf8' }) + .split('\n') + .map(line => line.split('\t')[1]?.replace(/^refs\/tags\//, '').replace(/\^\{\}$/, '')) + .filter((tagName): tagName is string => Boolean(tagName)) + : undefined; + + const packageJsonText = readFileSync('package.json', 'utf8'); + const packageVersion = (JSON.parse(packageJsonText) as PackageJsonShape).version; + const expectedTag = packageVersion ? `v${packageVersion}` : null; + let githubRelease: GithubReleaseState | null | undefined; + let latestGithubReleaseTag: string | null | undefined; + if (strictGithub && expectedTag) { + try { + githubRelease = JSON.parse(execFileSync('gh', [ + 'release', + 'view', + expectedTag, + '--json', + 'tagName,isDraft,isPrerelease', + ], { encoding: 'utf8' })) as GithubReleaseState; + } catch { + githubRelease = null; + } + + try { + latestGithubReleaseTag = JSON.parse(execFileSync('gh', [ + 'release', + 'list', + '--limit', + '1', + '--json', + 'tagName', + ], { encoding: 'utf8' }))[0]?.tagName ?? null; + } catch { + latestGithubReleaseTag = null; + } + } + const result = auditPackageReleaseState({ - packageJsonText: readFileSync('package.json', 'utf8'), + packageJsonText, tagNames, + remoteTagNames, + githubRelease, + latestGithubReleaseTag, }); const report = formatPackageAuditReport(result); if (report) console.error(report); diff --git a/scripts/release.ts b/scripts/release.ts index e26a7680b..c1671212b 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -208,4 +208,16 @@ const currentBranch = getOutput('git branch --show-current'); run(`git push origin ${currentBranch}`, 'Git push branch failed'); run(`git push origin v${newVersion}`, 'Git push tag failed'); +// 8. Publish and verify the GitHub Release. A pushed git tag is not a +// completed release: GitHub's Latest release marker is what users see. +console.log('\nšŸ“£ Publishing GitHub Release...'); +run( + `gh release create v${newVersion} --title "v${newVersion} — kernelCAD release" --notes-file ${RELEASE_NOTES_PATH} --latest`, + 'GitHub Release creation failed.', +); +run( + 'KERNELCAD_RELEASE_AUDIT_TAGS=1 KERNELCAD_RELEASE_AUDIT_REMOTE=1 KERNELCAD_RELEASE_AUDIT_GITHUB=1 npm run test:package', + 'Release hygiene audit failed.', +); + console.log(`\nšŸŽ‰ Release v${newVersion} Completed Successfully!`);