From 6b1935639981e1f4af6b28f53e32e99d5e49555f Mon Sep 17 00:00:00 2001 From: Altay Date: Sat, 5 Sep 2026 20:59:58 +0300 Subject: [PATCH] ci: bind release writeback to verified source --- .github/workflows/ci.yml | 4 +- .releaserc.json | 8 +- CONTRIBUTING.md | 15 ++ .../scripts/release-commit.ts | 123 ++++++++++++ .../tests/release-commit.test.ts | 176 ++++++++++++++++++ packages/react-json-logic/tsconfig.json | 2 +- 6 files changed, 318 insertions(+), 10 deletions(-) create mode 100644 packages/react-json-logic/scripts/release-commit.ts create mode 100644 packages/react-json-logic/tests/release-commit.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07ebf4f..4cb9696 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ permissions: {} concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: verify: @@ -62,6 +62,7 @@ jobs: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.sha }} fetch-depth: 0 persist-credentials: false @@ -106,7 +107,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 4230862..843b916 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -22,13 +22,7 @@ "npmPublish": true } ], - [ - "@jno21/semantic-release-github-commit", - { - "files": ["packages/react-json-logic/package.json"], - "commitMessage": "chore(release): ${nextRelease.version} [skip ci]" - } - ], + "./packages/react-json-logic/scripts/release-commit.ts", "@semantic-release/github" ] } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7e7d7f7..42e1930 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,3 +47,18 @@ pnpm exec vp run verify - Publishing uses npm Trusted Publishing (OpenID Connect): the release job grants `id-token: write` and uses no `NPM_TOKEN` secret - GitHub Releases and version push-back commits are authored by `uinaf-releaser[bot]` via a short-lived App installation token from the `release` Environment - The demo app deploy is configured through the repository host dashboard + +Release preparation uses `packages/react-json-logic/scripts/release-commit.ts` +after npm prepares the package version. The checkout and GitHub's atomic +`expectedHeadOid` are bound to the verified workflow event SHA. GitHub signs +the commit and rejects writeback if `main` has advanced. The plugin fetches the +returned immutable SHA and checks its parent, unchanged source, and exact +prepared manifest before semantic-release tags it. Only the package version +may change. Keep `.github/workflows/ci.yml` and the `release` Environment names: +these identify the npm trusted publisher. + +If GitHub accepts writeback 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. diff --git a/packages/react-json-logic/scripts/release-commit.ts b/packages/react-json-logic/scripts/release-commit.ts new file mode 100644 index 0000000..06eb69c --- /dev/null +++ b/packages/react-json-logic/scripts/release-commit.ts @@ -0,0 +1,123 @@ +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/react-json-logic"); + 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)packages/react-json-logic/package.json", + ) + ) + throw new Error( + "release preparation changed files other than packages/react-json-logic/package.json", + ); + const before = record( + JSON.parse(git("show", `${expected}:packages/react-json-logic/package.json`)), + ); + const content = fs.readFileSync(path.join(cwd, "packages/react-json-logic/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: "packages/react-json-logic/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)packages/react-json-logic/package.json", + ) + ) + throw new Error( + "release writeback changed source outside packages/react-json-logic/package.json", + ); + if ( + !execFileSync("git", ["show", `${committed}:packages/react-json-logic/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/packages/react-json-logic/tests/release-commit.test.ts b/packages/react-json-logic/tests/release-commit.test.ts new file mode 100644 index 0000000..938d3a8 --- /dev/null +++ b/packages/react-json-logic/tests/release-commit.test.ts @@ -0,0 +1,176 @@ +// @vitest-environment node +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(), "react-json-logic-release-")); + const remote = path.join(root, "remote"); + const cwd = path.join(root, "checkout"); + fs.mkdirSync(path.join(remote, "packages/react-json-logic"), { recursive: true }); + git(remote, "init", "-b", "main"); + fs.writeFileSync(path.join(remote, "source.txt"), "verified source\n"); + fs.writeFileSync( + path.join(remote, "packages/react-json-logic/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, "packages/react-json-logic/package.json"), content); + const context = { + cwd, + env: { + GITHUB_SHA: expected, + GITHUB_REPOSITORY: "uinaf/react-json-logic", + 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, "packages/react-json-logic/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, "packages/react-json-logic/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/react-json-logic", + branchName: "main", + }); + assert.deepEqual(input.fileChanges.additions, [ + { + path: "packages/react-json-logic/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, "packages/react-json-logic/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, "packages/react-json-logic/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, "packages/react-json-logic/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, "packages/react-json-logic/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.deepEqual(config.plugins[2], [ + "@semantic-release/npm", + { pkgRoot: "packages/react-json-logic", npmPublish: true }, + ]); + assert.equal(config.plugins[3], "./packages/react-json-logic/scripts/release-commit.ts"); +}); diff --git a/packages/react-json-logic/tsconfig.json b/packages/react-json-logic/tsconfig.json index d89516f..656564f 100644 --- a/packages/react-json-logic/tsconfig.json +++ b/packages/react-json-logic/tsconfig.json @@ -20,5 +20,5 @@ "skipLibCheck": true, "jsx": "react-jsx" }, - "include": ["src", "tests", "vite.config.ts"] + "include": ["src", "scripts", "tests", "vite.config.ts"] }