Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions docs/release-hygiene.md
Original file line number Diff line number Diff line change
@@ -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 <merged-develop-commit>
git push origin vX.Y.Z
gh release create vX.Y.Z --title "vX.Y.Z — <release title>" --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.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
58 changes: 58 additions & 0 deletions scripts/packageReleaseAudit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
});
});
108 changes: 105 additions & 3 deletions scripts/packageReleaseAudit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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[] {
Expand Down Expand Up @@ -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
Expand All @@ -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({
Expand All @@ -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 };
}

Expand All @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions scripts/release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!`);
Loading