diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a808f307..46e86bfc 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 5e51d00e..d56e8402 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 6de6ba6f..ea023db5 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 68b6a004..cae16693 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 4b8e093a..5d1258b7 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 b8c0bdb3..1642b2aa 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 5f76a0c5..d9c14211 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);