From 68df534c39aec1530b95f6e863f2b3df62b1efce Mon Sep 17 00:00:00 2001 From: Anton Wiklund Date: Sun, 8 Nov 2026 05:26:01 +0100 Subject: [PATCH] fix(release): check out the docs job's tag by its release commit The release workflow's docs job checked out the version's tag by its bare name. For a bare name, the checkout action takes a branch of the same name before the tag, so the job could render, attest as made on main, and upload docs from a commit that is not the release. The job now reads the tag as refs/tags/ only, checks with main's own release:commit script that it names that version's release commit on main, as the deploy does, and checks out that commit by its hash. The deploy's lookup of the tag's commit is qualified the same way. Tests cover the docs job's steps and a branch named like a tag beside the real one. The releasing and deployment docs say how the tag is read. Refs: GHSA-c67r-w75r-rjqx --- .github/workflows/deploy.yml | 2 +- .github/workflows/release.yml | 42 ++++++++++++++++++++++++++------- docs/deployment.md | 3 ++- docs/releasing.md | 8 +++++-- scripts/releaseCommit.test.ts | 16 +++++++++++-- scripts/releaseCommit.ts | 9 ++++--- scripts/releaseWorkflow.test.ts | 37 +++++++++++++++++++++++++---- 7 files changed, 96 insertions(+), 21 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a808f307a..46e86bfc8 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -137,7 +137,7 @@ jobs: exit 0 fi RELEASE_MAIN=FETCH_HEAD npm run release:commit -- tag "$TAG" - sha=$(git rev-list -n 1 "$TAG") + sha=$(git rev-parse --verify "refs/tags/$TAG^{commit}") for _ in 1 2 3 4 5 6; do draft=$(gh release view "$TAG" --json isDraft --jq .isDraft 2>/dev/null || true) [ "$draft" = false ] && break diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5e51d00ec..d56e84021 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -301,11 +301,13 @@ jobs: # A version's docs, attached to its GitHub release when the release job # could not (docs/releasing.md#when-a-release-fails): the release was # made as a draft, and the upload failed, or the archive was not - # attested. Run by hand from main, with the version's tag. It renders - # the docs from the tag, as the release would have, attests them as - # this workflow's on main, and uploads them, then publishes the draft, - # which the deploy refuses; it needs no deploy key, and has none. A release that already - # carries an attested archive keeps it: an archive is rendered once. + # attested. Run by hand from main, with the version's tag. It checks + # that the tag (read as refs/tags/ only) names its version's release + # commit on main, renders the docs from that commit, as the release + # would have, attests them as this workflow's on main, and uploads + # them, then publishes the draft, which the deploy refuses; it needs + # no deploy key, and has none. A release that already carries an + # attested archive keeps it: an archive is rendered once. docs: if: >- github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && @@ -371,11 +373,35 @@ jobs: echo "attach=$attach" echo "latest=$([ "$TAG" = "$newest" ] && echo true || echo false)" } >> "$GITHUB_OUTPUT" - - if: steps.release.outputs.attach == 'true' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # The tag is read as refs/tags/ only, never as a bare name, + # which a branch of the same name would win; and the commit it + # names must be its version's release commit on main, as the + # deploy checks (npm run release:commit, scripts/releaseCommit.ts). + # main is this run's commit, as the job runs only from main; the + # check is main's script, under main's Node, before anything of + # the tag's is checked out. The tag's commit is then checked out + # by its SHA. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ inputs.tag }} + fetch-depth: 0 persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: .nvmrc + package-manager-cache: false + - name: The tag names its release commit on main + id: commit + env: + TAG: ${{ inputs.tag }} + run: | + git fetch --force --no-tags --quiet origin "+refs/tags/$TAG:refs/tags/$TAG" + RELEASE_MAIN=HEAD npm run release:commit -- tag "$TAG" + echo "sha=$(git rev-parse --verify "refs/tags/$TAG^{commit}")" >> "$GITHUB_OUTPUT" + - if: steps.release.outputs.attach == 'true' + name: Check out the tag's commit + env: + SHA: ${{ steps.commit.outputs.sha }} + run: git checkout --quiet --detach "$SHA" - name: The version can archive its docs if: steps.release.outputs.attach == 'true' env: diff --git a/docs/deployment.md b/docs/deployment.md index 6de6ba6f5..ea023db5e 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -43,7 +43,8 @@ Only `main` deploys, and only from this repository: - **Only the newest release.** The deploy checks that the tag is the newest version on `main` (the highest `vX.Y.Z` tag), that it names that version's release commit, and that the version has a GitHub - release. A release commit is one semantic-release made + release. The tag is read as `refs/tags/vX.Y.Z`, never as a branch of + the same name. A release commit is one semantic-release made (`npm run release:commit`, [releaseCommit.ts](../scripts/releaseCommit.ts)): on `main`'s first-parent chain, titled `chore(release): X.Y.Z`, by diff --git a/docs/releasing.md b/docs/releasing.md index 68b6a0045..cae16693f 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -651,8 +651,12 @@ that passes CI, with the merge queue empty, tries again. A run whose waiting. That run's `docs` job has its own concurrency group, so it doesn't wait for a Release run. It marks the version latest when it's the newest tag, and a release made while it runs would leave - that wrong. The job renders - the docs from the tag, draws them, attests the archive, attaches it + that wrong. The job reads the tag as `refs/tags/vX.Y.Z` only, never + as a bare name, which a branch of the same name would win. It fails + unless the tag names that version's release commit on `main`, as the + deploy checks (`npm run release:commit`), and it checks out that + commit by its hash. It then renders + the docs from it, draws them, attests the archive, attaches it with the run's token, and publishes the release if it's still a draft, without the deploy key. It keeps an attested archive already on the release, and renders and replaces one that isn't. diff --git a/scripts/releaseCommit.test.ts b/scripts/releaseCommit.test.ts index 4b8e093a3..5d1258b71 100644 --- a/scripts/releaseCommit.test.ts +++ b/scripts/releaseCommit.test.ts @@ -80,7 +80,7 @@ describe("main last", () => { describe("main tag", () => { const refusal = (tag: string, why: string) => - `::error::${tag} names no release commit of semantic-release's on main (${why}), so the site is not deployed (docs/releasing.md#when-a-release-fails).`; + `::error::${tag} names no release commit of semantic-release's on main (${why}), so it is refused (docs/releasing.md#when-a-release-fails).`; it("passes a tag on its version's release commit on main", () => { const { io, messages } = fake([commit("a", "chore(release): 1.0.1")], { @@ -218,12 +218,24 @@ describe("defaultIo, on a repository with a pull request's commit titled as a re git(["tag", "v1.0.1", fake]); expect(main(["node", "tag", "v1.0.1"], io())).toBe(1); expect(messages).toEqual([ - `::error::v1.0.1 names no release commit of semantic-release's on main (${fake} is not on main's first-parent chain), so the site is not deployed (docs/releasing.md#when-a-release-fails).`, + `::error::v1.0.1 names no release commit of semantic-release's on main (${fake} is not on main's first-parent chain), so it is refused (docs/releasing.md#when-a-release-fails).`, ]); expect(main(["node", "tag", "v1.0.0"], io())).toBe(0); expect(main(["node", "tag", "v9.9.9"], io())).toBe(1); }); + // A branch named like a version's tag is not that tag: the tag is read + // as refs/tags/ only, whichever commit the branch is on. + it("reads a version's tag, not a branch of the same name", () => { + const release = git(["rev-parse", "v1.0.0^{commit}"]); + git(["branch", "v1.0.0", "feature^"]); + expect(main(["node", "tag", "v1.0.0"], io())).toBe(0); + expect(messages).toEqual([`v1.0.0 names ${release}, its release commit.`]); + git(["branch", "v1.0.1", "main^"]); + expect(main(["node", "tag", "v1.0.1"], io())).toBe(1); + expect(messages[1]).toContain("(it names no commit)"); + }); + it("stops on semantic-release's commit when its tag is missing", () => { git(["tag", "-d", "v1.0.0"]); expect(main(["node", "last"], io())).toBe(1); diff --git a/scripts/releaseCommit.ts b/scripts/releaseCommit.ts index b8c0bdb3c..1642b2aad 100644 --- a/scripts/releaseCommit.ts +++ b/scripts/releaseCommit.ts @@ -26,8 +26,11 @@ * tag, or every release stops (a tag's push failed). A commit that is * only titled as one is passed over, with a notice. * - * `tag`, in deploy.yml: the commit a version's tag names must be that - * version's release commit, or the site is not deployed. + * `tag`, in deploy.yml and in release.yml's docs job: the commit a + * version's tag names must be that version's release commit, or the + * site is not deployed and the docs are not rendered. The tag is read + * as refs/tags/ only, so a branch of the same name is never taken + * for it. */ import { execFileSync } from "node:child_process"; import { resolve } from "node:path"; @@ -101,7 +104,7 @@ function last(io: ReleaseCommitIo): number { function tag(io: ReleaseCommitIo, name: string): number { const refuse = (why: string) => { io.log( - `::error::${name} names no release commit of semantic-release's on main (${why}), so the site is not deployed (docs/releasing.md#when-a-release-fails).`, + `::error::${name} names no release commit of semantic-release's on main (${why}), so it is refused (docs/releasing.md#when-a-release-fails).`, ); return 1; }; diff --git a/scripts/releaseWorkflow.test.ts b/scripts/releaseWorkflow.test.ts index 5f76a0c56..d9c142113 100644 --- a/scripts/releaseWorkflow.test.ts +++ b/scripts/releaseWorkflow.test.ts @@ -75,7 +75,7 @@ describe("release.yml", () => { it("sets up Node without npm's cache", () => { const setups = release.filter((step) => step.includes("uses: actions/setup-node@")); - expect(setups).toHaveLength(2); + expect(setups).toHaveLength(3); for (const step of setups) { expect(step).not.toMatch(/^\s+cache:/m); expect(step).toMatch(/^\s+package-manager-cache: false$/m); @@ -93,10 +93,10 @@ describe("release.yml", () => { }); // A local action is the checkout's, and the docs job checks out the - // tag, whose copy of an action may restore from the cache. - it("runs no local action in the docs job, which checks out the tag", () => { + // tag's commit, whose copy of an action may restore from the cache. + it("runs no local action in the docs job, which checks out the tag's commit", () => { const docs = steps(workflow.get("docs") ?? "", 6); - expect(docs.some((step) => step.includes("ref: ${{ inputs.tag }}"))).toBe(true); + expect(docs.some((step) => step.includes('git checkout --quiet --detach "$SHA"'))).toBe(true); expect(docs.filter((step) => /uses:\s*\.\//.test(step))).toEqual([]); // The shell from its own download, checked against the pinned // SHA-256 (npm run check:shell), and only the libraries from @@ -151,6 +151,34 @@ describe("the release commit's checks", () => { ); }); + // A bare name is a branch's before it is a tag's for actions/checkout, + // so the docs job reads the tag as refs/tags/, checks its commit + // with main's script before anything of the tag's is checked out, and + // checks out that commit by its SHA. + it("check the tag's commit in release.yml's docs job, then check out its SHA", () => { + const docs = steps(workflow.get("docs") ?? "", 6); + expect(docs.filter((step) => step.includes("inputs.tag") && /\bref:/.test(step))).toEqual([]); + const checkouts = docs.filter((step) => step.includes("uses: actions/checkout@")); + expect(checkouts).toHaveLength(1); + expect(checkouts[0]).not.toMatch(/^\s+ref:/m); + expect(checkouts[0]).toMatch(/^\s+fetch-depth: 0$/m); + const checked = at(docs, "name: The tag names its release commit on main"); + expect(docs[checked]).toContain( + 'git fetch --force --no-tags --quiet origin "+refs/tags/$TAG:refs/tags/$TAG"', + ); + expect(docs[checked]).toMatch( + /^ {10}RELEASE_MAIN=HEAD npm run release:commit -- tag "\$TAG"$/m, + ); + expect(docs[checked]).toContain('git rev-parse --verify "refs/tags/$TAG^{commit}"'); + expect(docs[checked]).not.toMatch(/^\s+if:/m); + expect(at(docs, "uses: actions/checkout@")).toBeLessThan(checked); + expect(at(docs, "uses: actions/setup-node@")).toBeLessThan(checked); + const switched = at(docs, "name: Check out the tag's commit"); + expect(switched).toBe(checked + 1); + expect(docs[switched]).toContain("SHA: ${{ steps.commit.outputs.sha }}"); + expect(docs[switched + 1]).toContain("name: The version can archive its docs"); + }); + it("check the tag's commit in deploy.yml with the script, against origin/main", () => { const checked = at(version, "name: The tag is the newest release"); expect(version[checked]).toMatch( @@ -163,6 +191,7 @@ describe("the release commit's checks", () => { expect(version[checked].indexOf("npm run release:commit")).toBeLessThan( version[checked].indexOf('echo "sha=$sha"'), ); + expect(version[checked]).toContain('sha=$(git rev-parse --verify "refs/tags/$TAG^{commit}")'); const setup = at(version, "uses: actions/setup-node@"); expect(setup).toBeGreaterThan(-1); expect(setup).toBeLessThan(checked);