Skip to content

Commit 9a5a926

Browse files
committed
ci(release): add local release sanity checks
Validate package version, changelog coverage, dist packaging, and release tag parity before publishing so release failures surface before tag workflows.
1 parent da215f5 commit 9a5a926

4 files changed

Lines changed: 119 additions & 10 deletions

File tree

.github/workflows/release.yml

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,15 +41,8 @@ jobs:
4141
- name: Unit tests
4242
run: bun run test
4343

44-
- name: Verify git tag matches package.json version
45-
run: |
46-
set -euo pipefail
47-
TAG="${GITHUB_REF#refs/tags/v}"
48-
PKG=$(node -p "require('./package.json').version")
49-
if [ "$TAG" != "$PKG" ]; then
50-
echo "::error::Git tag v$TAG must match package.json version $PKG (publish aborted)."
51-
exit 1
52-
fi
44+
- name: Release sanity checks
45+
run: bun run release:check
5346

5447
- name: Create tarball for GitHub Release
5548
id: pack

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@
3232
"check:fix": "biome check --write .",
3333
"test": "bun test src/",
3434
"test:coverage": "bun src/coverage-threshold.ts",
35-
"prepublishOnly": "bun run build && bun run check && bun run test",
35+
"release:check": "bun src/release-sanity.ts",
36+
"prepublishOnly": "bun run release:check && bun run build && bun run check && bun run test",
3637
"setup-hooks": "git config core.hooksPath .githooks"
3738
},
3839
"keywords": [

src/release-sanity.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
import { checkReleaseSanity } from "./release-sanity.js";
4+
5+
const BASE_PACKAGE = {
6+
name: "@rethunk/github-mcp",
7+
version: "1.2.3",
8+
files: ["dist", "README.md"],
9+
};
10+
11+
describe("checkReleaseSanity", () => {
12+
test("accepts a package version with matching changelog entry and tag ref", () => {
13+
expect(
14+
checkReleaseSanity({
15+
packageJson: BASE_PACKAGE,
16+
changelog: "## [1.2.3] — 2026-04-26\n\n### Changed\n",
17+
githubRef: "refs/tags/v1.2.3",
18+
}),
19+
).toEqual([]);
20+
});
21+
22+
test("reports mismatched tags and missing changelog entries", () => {
23+
expect(
24+
checkReleaseSanity({
25+
packageJson: BASE_PACKAGE,
26+
changelog: "## [1.2.2] — 2026-04-26\n",
27+
githubRef: "refs/tags/v1.2.4",
28+
}),
29+
).toEqual([
30+
"CHANGELOG.md must contain a ## [1.2.3] release entry.",
31+
"Git tag v1.2.4 must match package.json version 1.2.3.",
32+
]);
33+
});
34+
35+
test("requires dist to be included in package files", () => {
36+
expect(
37+
checkReleaseSanity({
38+
packageJson: { ...BASE_PACKAGE, files: ["README.md"] },
39+
changelog: "## [1.2.3] — 2026-04-26\n",
40+
}),
41+
).toEqual(['package.json "files" must include "dist".']);
42+
});
43+
});

src/release-sanity.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { readFileSync } from "node:fs";
2+
3+
interface PackageJson {
4+
version?: unknown;
5+
files?: unknown;
6+
}
7+
8+
interface ReleaseSanityInput {
9+
packageJson: PackageJson;
10+
changelog: string;
11+
githubRef?: string;
12+
}
13+
14+
export function checkReleaseSanity(input: ReleaseSanityInput): string[] {
15+
const errors: string[] = [];
16+
const version = input.packageJson.version;
17+
18+
if (typeof version !== "string" || !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) {
19+
errors.push("package.json version must be a valid semver string.");
20+
return errors;
21+
}
22+
23+
const releaseHeading = new RegExp(`^## \\[${escapeRegExp(version)}\\](?:\\s|$)`, "m");
24+
if (!releaseHeading.test(input.changelog)) {
25+
errors.push(`CHANGELOG.md must contain a ## [${version}] release entry.`);
26+
}
27+
28+
if (
29+
!Array.isArray(input.packageJson.files) ||
30+
!input.packageJson.files.some((entry) => entry === "dist")
31+
) {
32+
errors.push('package.json "files" must include "dist".');
33+
}
34+
35+
if (input.githubRef?.startsWith("refs/tags/")) {
36+
const tagVersion = input.githubRef.slice("refs/tags/v".length);
37+
if (tagVersion !== version) {
38+
errors.push(`Git tag v${tagVersion} must match package.json version ${version}.`);
39+
}
40+
}
41+
42+
return errors;
43+
}
44+
45+
function escapeRegExp(value: string): string {
46+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
47+
}
48+
49+
function readPackageJson(): PackageJson {
50+
return JSON.parse(readFileSync("package.json", "utf8")) as PackageJson;
51+
}
52+
53+
function runReleaseSanityCli(): void {
54+
const errors = checkReleaseSanity({
55+
packageJson: readPackageJson(),
56+
changelog: readFileSync("CHANGELOG.md", "utf8"),
57+
githubRef: process.env.GITHUB_REF,
58+
});
59+
60+
if (errors.length > 0) {
61+
for (const error of errors) {
62+
process.stderr.write(`Release sanity check failed: ${error}\n`);
63+
}
64+
process.exit(1);
65+
}
66+
67+
console.log("Release sanity OK");
68+
}
69+
70+
if (import.meta.main) {
71+
runReleaseSanityCli();
72+
}

0 commit comments

Comments
 (0)