Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 34 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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' &&
Expand Down Expand Up @@ -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/<tag> 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:
Expand Down
3 changes: 2 additions & 1 deletion docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 14 additions & 2 deletions scripts/releaseCommit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")], {
Expand Down Expand Up @@ -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/<tag> 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);
Expand Down
9 changes: 6 additions & 3 deletions scripts/releaseCommit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<tag> only, so a branch of the same name is never taken
* for it.
*/
import { execFileSync } from "node:child_process";
import { resolve } from "node:path";
Expand Down Expand Up @@ -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;
};
Expand Down
37 changes: 33 additions & 4 deletions scripts/releaseWorkflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -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/<tag>, 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(
Expand All @@ -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);
Expand Down
Loading