From c590416a224786853bf04ee78116d7b693f95057 Mon Sep 17 00:00:00 2001 From: Altay Date: Sat, 5 Sep 2026 20:53:25 +0300 Subject: [PATCH] ci: bind release writeback to verified source --- .github/workflows/release.yml | 3 +- .releaserc.json | 8 +- docs/releasing.md | 17 +++- scripts/release-commit.ts | 89 +++++++++++++++++++ test/release-commit.test.ts | 159 ++++++++++++++++++++++++++++++++++ tsconfig.json | 2 +- vite.config.ts | 10 ++- 7 files changed, 271 insertions(+), 17 deletions(-) create mode 100644 scripts/release-commit.ts create mode 100644 test/release-commit.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ecca01b..e2d15c6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,7 +56,7 @@ jobs: # semantic-release reads the whole tag history to pick the next # version. v0.1.3 is the floor it continues from. fetch-depth: 0 - ref: main + ref: ${{ github.sha }} - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 with: standalone: true @@ -89,7 +89,6 @@ jobs: @semantic-release/commit-analyzer@13.0.1 @semantic-release/release-notes-generator@14.1.1 @semantic-release/npm@13.1.5 - @jno21/semantic-release-github-commit@1.0.1 @semantic-release/github@12.0.8 conventional-changelog-conventionalcommits@9.3.1 env: diff --git a/.releaserc.json b/.releaserc.json index b19c02f..430bf7d 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -5,13 +5,7 @@ "@semantic-release/commit-analyzer", "@semantic-release/release-notes-generator", "@semantic-release/npm", - [ - "@jno21/semantic-release-github-commit", - { - "files": ["package.json"], - "commitMessage": "chore(release): ${nextRelease.version} [skip ci]" - } - ], + "./scripts/release-commit.ts", [ "@semantic-release/github", { diff --git a/docs/releasing.md b/docs/releasing.md index a7c50ce..59502a3 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -48,11 +48,20 @@ history continues from `v0.1.3`; `v0.1.0`–`v0.1.3` are the legacy git-install tags and are never deleted or moved. During preparation, `@semantic-release/npm` stages the released `package.json` -version and `@jno21/semantic-release-github-commit` commits it to `main` through -GitHub's API as the authenticated App. GitHub signs that commit, and the release -tag points to it. The `[skip ci]` marker on that commit is what stops a release +version and `scripts/release-commit.ts` commits it through GitHub's signed +`createCommitOnBranch` API as the authenticated App. The checkout and expected +branch head are the verified workflow event SHA. GitHub atomically rejects the +write if `main` has advanced; a successful response is fetched by its immutable +commit SHA. The plugin checks its parent, unchanged source, and exact prepared +manifest before semantic-release tags it. Only the package version may change. The `[skip ci]` marker on that commit is what stops a release from releasing itself. +If GitHub accepts the commit but fetching or validating it fails, preparation +stops before tagging or publishing. Inspect that commit's parent, tree, version +and verified signature, plus existing tags, npm versions and GitHub Releases, +before recovery. Reconcile only missing publication steps from the validated +commit; do not create another version or move an existing tag to hide failure. + Check what the next version would be without publishing anything: ```sh @@ -61,7 +70,7 @@ pnpm dlx semantic-release --dry-run --no-ci ## The artifact -`dist/` is generated and untracked. `prepublishOnly` runs `pnpm run verify`, +`dist/` is generated and untracked. `prepublishOnly` runs `pnpm run verify:full`, which builds it, so the tarball is always packed from a tree that just passed the gate. `files` is `dist`, `docs`, `README.md`, `LICENSE`. diff --git a/scripts/release-commit.ts b/scripts/release-commit.ts new file mode 100644 index 0000000..fc60a11 --- /dev/null +++ b/scripts/release-commit.ts @@ -0,0 +1,89 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { isDeepStrictEqual } from "node:util"; +import assert from "node:assert/strict"; + +interface Context { + cwd: string; + env: NodeJS.ProcessEnv; + nextRelease: { version: string; gitHead: string }; +} + +function record(value: unknown): Record { + assert(value !== null && typeof value === "object" && !Array.isArray(value), "Expected object"); + return Object.fromEntries(Object.entries(value)); +} +function oid(value: unknown): string { + assert(typeof value === "string" && /^[a-f0-9]{40}$/.test(value), "Expected commit SHA"); + return value; +} + +// npm's prepare runs first. Only its version edit may enter the signed commit. +export async function prepare(_config: unknown, context: Context): Promise { + const { cwd, env, nextRelease } = context; + const expected = oid(env.GITHUB_SHA); + const repository = env.GITHUB_REPOSITORY; + assert.equal(repository, "uinaf/skillcheck"); + const token = env.GITHUB_TOKEN ?? env.GH_TOKEN; + assert(token, "Release token required"); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + if (git("rev-parse", "HEAD") !== expected || nextRelease.gitHead !== expected) + throw new Error("release checkout must match the verified event commit"); + if (git("diff", "--name-only", expected, "--", ".", ":(exclude)package.json")) + throw new Error("release preparation changed files other than package.json"); + const before = record(JSON.parse(git("show", `${expected}:package.json`))); + const content = fs.readFileSync(path.join(cwd, "package.json")); + const prepared = record(JSON.parse(content.toString("utf8"))); + if (!isDeepStrictEqual(prepared, { ...before, version: nextRelease.version })) + throw new Error("release preparation must change only the package version"); + + // GitHub signs this commit and rejects a moved branch atomically. A preflight + // against main would leave a race between reading its head and writing it. + const response = await fetch("https://api.github.com/graphql", { + method: "POST", + signal: AbortSignal.timeout(30_000), + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + query: `mutation($input: CreateCommitOnBranchInput!) { + createCommitOnBranch(input: $input) { commit { oid } } + }`, + variables: { + input: { + branch: { repositoryNameWithOwner: repository, branchName: "main" }, + expectedHeadOid: expected, + message: { headline: `chore(release): ${nextRelease.version} [skip ci]` }, + fileChanges: { + additions: [{ path: "package.json", contents: content.toString("base64") }], + }, + }, + }, + }), + }); + if (!response.ok) throw new Error(`release writeback failed: GitHub HTTP ${response.status}`); + const result = record(await response.json()); + if (result.errors !== undefined) { + assert(Array.isArray(result.errors), "Expected GraphQL errors array"); + if (result.errors.length) { + const messages = result.errors.map((error) => { + const message = record(error).message; + assert.equal(typeof message, "string"); + return message; + }); + throw new Error(`release writeback rejected: ${messages.join("; ")}`); + } + } + const committed = oid(record(record(record(result.data).createCommitOnBranch).commit).oid); + + // main may advance after the mutation. Fetch and tag the returned commit, + // never the moving branch. A failure here leaves writeback without a release. + git("fetch", "origin", committed); + if (git("show", "-s", "--format=%P", committed) !== expected) + throw new Error("release writeback has an unexpected parent"); + if (git("diff", "--name-only", expected, committed, "--", ".", ":(exclude)package.json")) + throw new Error("release writeback changed source outside package.json"); + if (!execFileSync("git", ["show", `${committed}:package.json`], { cwd }).equals(content)) + throw new Error("release writeback does not match the prepared package"); + git("reset", "--hard", committed); + nextRelease.gitHead = committed; +} diff --git a/test/release-commit.test.ts b/test/release-commit.test.ts new file mode 100644 index 0000000..c6c8031 --- /dev/null +++ b/test/release-commit.test.ts @@ -0,0 +1,159 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { URL } from "node:url"; +import { test, vi } from "vite-plus/test"; +import { prepare } from "../scripts/release-commit.ts"; + +const git = (cwd: string, ...args: string[]) => + execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); +const commit = (cwd: string) => { + git(cwd, "add", "."); + git( + cwd, + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "fixture", + ); + return git(cwd, "rev-parse", "HEAD"); +}; +for (const mode of [ + "normal", + "advanced-before", + "advanced-after", + "source-edit", + "manifest-edit", + "http-error", + "wrong-parent", + "fetch-failure", + "returned-source-edit", + "returned-manifest-edit", + "wrong-checkout", + "wrong-release-head", +] as const) { + test(`signed release writeback: ${mode}`, async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "skillcheck-release-")); + const remote = path.join(root, "remote"); + const cwd = path.join(root, "checkout"); + fs.mkdirSync(remote, { recursive: true }); + git(remote, "init", "-b", "main"); + fs.writeFileSync(path.join(remote, "source.txt"), "verified source\n"); + fs.writeFileSync( + path.join(remote, "package.json"), + JSON.stringify({ name: "fixture", version: "1.0.0" }), + ); + const expected = commit(remote); + git(root, "clone", remote, cwd); + const content = JSON.stringify({ name: "fixture", version: "1.0.1" }); + fs.writeFileSync(path.join(cwd, "package.json"), content); + const context = { + cwd, + env: { GITHUB_SHA: expected, GITHUB_REPOSITORY: "uinaf/skillcheck", GITHUB_TOKEN: "fixture" }, + nextRelease: { version: "1.0.1", gitHead: expected }, + }; + if (mode === "wrong-checkout") context.env.GITHUB_SHA = "a".repeat(40); + if (mode === "wrong-release-head") context.nextRelease.gitHead = "a".repeat(40); + let created = ""; + if (mode === "advanced-before") { + fs.writeFileSync( + path.join(remote, "package.json"), + JSON.stringify({ name: "fixture", version: "1.0.0", newDependency: true }), + ); + commit(remote); + } + if (mode === "source-edit") fs.writeFileSync(path.join(cwd, "source.txt"), "unverified\n"); + if (mode === "manifest-edit") + fs.writeFileSync( + path.join(cwd, "package.json"), + JSON.stringify({ name: "other", version: "1.0.1" }), + ); + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, options) => { + assert.equal(url, "https://api.github.com/graphql"); + assert(typeof options?.body === "string"); + const { + variables: { input }, + } = JSON.parse(options.body); + assert.equal(input.expectedHeadOid, expected); + assert.deepEqual(input.branch, { + repositoryNameWithOwner: "uinaf/skillcheck", + branchName: "main", + }); + assert.deepEqual(input.fileChanges.additions, [ + { path: "package.json", contents: Buffer.from(content).toString("base64") }, + ]); + if (mode === "http-error") return new Response("denied", { status: 403 }); + if (mode === "advanced-before") + return Response.json({ + data: null, + errors: [{ message: "expectedHeadOid does not match branch head" }], + }); + if (mode === "wrong-parent") { + fs.writeFileSync(path.join(remote, "source.txt"), "raced\n"); + commit(remote); + } + fs.writeFileSync(path.join(remote, "package.json"), content); + if (mode === "returned-source-edit") + fs.writeFileSync(path.join(remote, "source.txt"), "unverified\n"); + if (mode === "returned-manifest-edit") + fs.appendFileSync(path.join(remote, "package.json"), "\n"); + created = commit(remote); + if (mode === "advanced-after") { + fs.writeFileSync(path.join(remote, "source.txt"), "newer source\n"); + commit(remote); + } + return Response.json({ + data: { + createCommitOnBranch: { + commit: { oid: mode === "fetch-failure" ? "a".repeat(40) : created }, + }, + }, + }); + }); + try { + if (mode === "normal" || mode === "advanced-after") { + await prepare({}, context); + assert.equal(git(cwd, "rev-parse", "HEAD"), created); + assert.equal(context.nextRelease.gitHead, created); + assert.equal(git(cwd, "show", "-s", "--format=%P", "HEAD"), expected); + assert.equal(fs.readFileSync(path.join(cwd, "source.txt"), "utf8"), "verified source\n"); + assert.equal(fs.readFileSync(path.join(cwd, "package.json"), "utf8"), content); + if (mode === "advanced-after") assert.notEqual(git(remote, "rev-parse", "HEAD"), created); + } else { + await assert.rejects( + prepare({}, context), + /changed files|only the package version|expectedHeadOid|HTTP 403|unexpected parent|changed source|does not match|verified event commit|Command failed: git fetch/, + ); + assert.equal( + context.nextRelease.gitHead, + mode === "wrong-release-head" ? "a".repeat(40) : expected, + ); + assert.equal(git(cwd, "rev-parse", "HEAD"), expected); + if (mode === "fetch-failure") assert.equal(git(remote, "rev-parse", "HEAD"), created); + if (mode === "advanced-before") + assert.match(fs.readFileSync(path.join(remote, "package.json"), "utf8"), /newDependency/); + if (mode === "source-edit" || mode === "manifest-edit") + assert.equal(fetchMock.mock.calls.length, 0); + } + assert.equal(git(remote, "tag", "--list"), ""); + } finally { + fetchMock.mockRestore(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); +} + +test("release prepares npm before signed writeback, then publishes GitHub", () => { + const config = JSON.parse( + fs.readFileSync(new URL("../.releaserc.json", import.meta.url), "utf8"), + ); + assert.equal(config.plugins[2], "@semantic-release/npm"); + assert.equal(config.plugins[3], "./scripts/release-commit.ts"); +}); diff --git a/tsconfig.json b/tsconfig.json index 96ad766..d93a881 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,5 +11,5 @@ "rewriteRelativeImportExtensions": true, "noEmit": true }, - "include": ["src", "test", "vite.config.ts"] + "include": ["src", "scripts", "test", "vite.config.ts"] } diff --git a/vite.config.ts b/vite.config.ts index 27ce9ef..35aa1b3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,6 +7,7 @@ const stableShell = { const graphInputs = [ ".node-version", + ".releaserc.json", "package.json", "pnpm-lock.yaml", "tsconfig.json", @@ -20,13 +21,13 @@ export default defineConfig({ ...stableShell, cache: true, command: "vp fmt --check", - input: [...graphInputs, ".github/**", "docs/**", "src/**", "test/**", "*.md"], + input: [...graphInputs, ".github/**", "docs/**", "scripts/**", "src/**", "test/**", "*.md"], }, lint: { ...stableShell, cache: true, command: "vp lint", - input: [...graphInputs, "src/**", "test/**/*.ts"], + input: [...graphInputs, "scripts/**", "src/**", "test/**/*.ts"], }, pack: { ...stableShell, @@ -38,12 +39,15 @@ export default defineConfig({ test: { ...stableShell, cache: true, - command: "vp test run test/cli.test.ts test/cursor-provider.test.ts", + command: + "vp test run test/cli.test.ts test/cursor-provider.test.ts test/release-commit.test.ts", dependsOn: ["pack"], input: [ ...graphInputs, "dist/**", "src/**", + "scripts/**", + "test/release-commit.test.ts", "test/cli.test.ts", "test/cursor-provider.test.ts", "test/fixtures/**",