diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 449d04f..46f08a8 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -1,6 +1,5 @@ -# Publishes to npm when a GitHub release is created. -# `npm publish` gates itself: prepack runs the build, prepublishOnly runs lint + tests. -# For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages +# Builds and verifies the release without npm credentials, then gives the +# publish command the token only after it receives the finished tarball. name: Node.js Package @@ -9,20 +8,82 @@ on: types: [created] jobs: - publish-npm: + build: runs-on: ubuntu-latest permissions: contents: read - id-token: write + outputs: + package-sha: ${{ steps.package.outputs.sha }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + persist-credentials: false + ref: main + - name: Verify release source + env: + RELEASE_BODY: ${{ github.event.release.body }} + RELEASE_REF: ${{ github.ref }} + RELEASE_SHA: ${{ github.sha }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + node tools/publish-check.mjs + release_commit=$(git rev-parse "refs/tags/${RELEASE_TAG}^{commit}") + git switch --detach "$release_commit" - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 24 cache: pnpm - registry-url: https://registry.npmjs.org/ - run: pnpm install --frozen-lockfile - - run: npm publish --access public --provenance + - name: Test and pack + run: | + pnpm exec safe-publish-latest --force-in-publish + pnpm run lint + pnpm run format + pnpm run typecheck + pnpm run test + pnpm run build + mkdir release-artifact + npm pack --ignore-scripts --pack-destination release-artifact + - name: Record package digest + id: package + run: | + shopt -s nullglob + packages=(release-artifact/*.tgz) + test "${#packages[@]}" -eq 1 + echo "sha=$(sha256sum "${packages[0]}" | cut -d ' ' -f 1)" >> "$GITHUB_OUTPUT" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: npm-package + path: release-artifact/*.tgz + if-no-files-found: error + retention-days: 1 + + publish-npm: + needs: build + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: npm-package + path: release-artifact + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24 + registry-url: https://registry.npmjs.org/ + - name: Verify package digest + env: + EXPECTED_SHA: ${{ needs.build.outputs.package-sha }} + run: | + shopt -s nullglob + packages=(release-artifact/*.tgz) + test "${#packages[@]}" -eq 1 + echo "$EXPECTED_SHA ${packages[0]}" | sha256sum --check --strict + - name: Publish to npm + run: npm publish ./release-artifact/*.tgz --access public --provenance --ignore-scripts env: NODE_AUTH_TOKEN: ${{ secrets.npm_token }} diff --git a/tools/publish-check.mjs b/tools/publish-check.mjs new file mode 100644 index 0000000..698799e --- /dev/null +++ b/tools/publish-check.mjs @@ -0,0 +1,113 @@ +// Run this from main before checking out the release. It inspects the tag as +// data, so a bad tag cannot replace the checks that decide whether it ships. +import { execFileSync, spawnSync } from "node:child_process"; +import { env } from "node:process"; + +/** + * @param {string} message + * @returns {never} + */ +const fail = (message) => { + console.error(message); + process.exit(1); +}; + +/** @param {...string} args */ +const git = (...args) => + execFileSync("git", args, { + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + +const releaseTag = env.RELEASE_TAG; +if (!releaseTag) fail("RELEASE_TAG is missing, refusing to publish"); + +const tagRef = `refs/tags/${releaseTag}`; +if (env.RELEASE_REF !== tagRef) { + fail(`release ref is ${env.RELEASE_REF ?? "missing"} instead of ${tagRef}`); +} + +let tagType; +try { + tagType = git("cat-file", "-t", tagRef); +} catch { + fail(`${releaseTag} is missing from the checkout`); +} +if (tagType !== "tag") { + fail(`${releaseTag} is not annotated, refusing to publish`); +} + +const tagTarget = git("rev-parse", `${tagRef}^{commit}`); +if (!env.RELEASE_SHA) fail("RELEASE_SHA is missing, refusing to publish"); +if (tagTarget !== env.RELEASE_SHA) { + fail(`${releaseTag} moved after the release event, refusing to publish`); +} + +let packageJson; +let changelog; +try { + packageJson = JSON.parse(git("show", `${tagTarget}:package.json`)); + changelog = git("show", `${tagTarget}:CHANGELOG.md`); +} catch { + fail(`${releaseTag} has invalid release files`); +} + +const { version } = packageJson; +if (typeof version !== "string" || version.length === 0) { + fail(`${releaseTag} has no package version, refusing to publish`); +} + +const expectedTag = `v${version}`; +if (releaseTag !== expectedTag) { + fail(`release tag is ${releaseTag} instead of ${expectedTag}`); +} + +const releaseHeading = `## [${version}]`; +const releaseStart = changelog.search(/^## \[/mu); +const nextRelease = changelog.indexOf("\n## [", releaseStart + 1); +if ( + releaseStart === -1 || + !changelog.startsWith(releaseHeading, releaseStart) +) { + fail(`the newest CHANGELOG.md release is not ${version}`); +} +const releaseNotes = `${changelog + .slice(releaseStart, nextRelease === -1 ? undefined : nextRelease) + .trim()}\n`; + +const tagObject = git("cat-file", "-p", tagRef); +const annotationStart = tagObject.indexOf("\n\n"); +const annotation = + annotationStart === -1 + ? "" + : `${tagObject.slice(annotationStart + 2).trim()}\n`; +if (annotation !== releaseNotes) { + fail(`${releaseTag} annotation does not match CHANGELOG.md`); +} + +const releaseBody = env.RELEASE_BODY; +if (releaseBody === undefined) { + fail("RELEASE_BODY is missing, refusing to publish"); +} +if (`${releaseBody.trimEnd()}\n` !== releaseNotes) { + fail("GitHub release notes do not match CHANGELOG.md"); +} + +const mainRef = env.RELEASE_MAIN_REF ?? "origin/main"; +const ancestry = spawnSync( + "git", + ["merge-base", "--is-ancestor", tagTarget, mainRef], + { encoding: "utf8" }, +); +if (ancestry.error) throw ancestry.error; +if (ancestry.status !== 0 && ancestry.status !== 1) { + process.stderr.write(ancestry.stderr); + fail(`could not verify ${mainRef}`); +} +if (ancestry.status === 1) { + fail(`${releaseTag} is not reachable from ${mainRef}`); +} + +console.log( + `${releaseTag} is annotated, documented, and reachable from ${mainRef}`, +); diff --git a/tools/release-check.mjs b/tools/release-check.mjs index 2cc24da..d197939 100644 --- a/tools/release-check.mjs +++ b/tools/release-check.mjs @@ -19,6 +19,7 @@ const here = import.meta.dirname; const root = dirname(here); const changelogPath = join(here, "changelog.mjs"); const changelogPresetPath = join(here, "changelog-preset.mjs"); +const publishCheckPath = join(here, "publish-check.mjs"); const tagReleasePath = join(here, "tag-release.mjs"); /** @@ -55,6 +56,39 @@ const runTagRelease = (repo) => timeout: 30_000, }); +/** + * @param {string} repo + * @param {string} releaseTag + * @param {string} [mainRef] + * @param {string} [releaseBody] + * @param {string} [releaseSha] + */ +const runPublishCheck = ( + repo, + releaseTag, + mainRef = "refs/heads/main", + releaseBody = "## [1.3.9] (2026-08-17)\n\n* secure the release tools\n", + releaseSha = git( + repo, + "rev-parse", + `refs/tags/${releaseTag}^{commit}`, + ).trim(), +) => { + return spawnSync(process.execPath, [publishCheckPath], { + cwd: repo, + encoding: "utf8", + env: { + ...env, + RELEASE_BODY: releaseBody, + RELEASE_MAIN_REF: mainRef, + RELEASE_REF: `refs/tags/${releaseTag}`, + RELEASE_SHA: releaseSha, + RELEASE_TAG: releaseTag, + }, + timeout: 30_000, + }); +}; + /** * @param {string} repo * @param {string} tagName @@ -257,6 +291,119 @@ const checks = [ assert.equal(tagObject(repo, "v1.3.9"), originalObject); }), ], + [ + "publish accepts the matching annotated tag on main", + () => + inTempDir((directory) => { + const repo = initReleaseRepo(directory); + const tagResult = runTagRelease(repo); + assert.equal(tagResult.status, 0, tagResult.stderr); + + const result = runPublishCheck(repo, "v1.3.9"); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /annotated, documented/u); + }), + ], + [ + "publish rejects a tag that does not match package.json", + () => + inTempDir((directory) => { + const repo = initReleaseRepo(directory); + git(repo, "tag", "-a", "v1.3.8", "-m", "old name, new files"); + const result = runPublishCheck(repo, "v1.3.8"); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /instead of v1\.3\.9/u); + }), + ], + [ + "publish rejects a tag moved after the release event", + () => + inTempDir((directory) => { + const repo = initReleaseRepo(directory); + const tagResult = runTagRelease(repo); + assert.equal(tagResult.status, 0, tagResult.stderr); + const releaseSha = tagTarget(repo, "v1.3.9"); + + git(repo, "tag", "--delete", "v1.3.9"); + git(repo, "commit", "--quiet", "--allow-empty", "-m", "fix: later"); + git( + repo, + "tag", + "-a", + "v1.3.9", + "-m", + "## [1.3.9] (2026-08-17)\n\n* secure the release tools", + ); + + const result = runPublishCheck( + repo, + "v1.3.9", + "refs/heads/main", + undefined, + releaseSha, + ); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /moved after the release event/u); + }), + ], + [ + "publish rejects release notes that drift from the changelog", + () => + inTempDir((directory) => { + const repo = initReleaseRepo(directory); + const tagResult = runTagRelease(repo); + assert.equal(tagResult.status, 0, tagResult.stderr); + + const result = runPublishCheck( + repo, + "v1.3.9", + "refs/heads/main", + "## [1.3.9] (2026-08-17)\n\n* different notes\n", + ); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /release notes do not match/u); + }), + ], + [ + "publish rejects a lightweight release tag", + () => + inTempDir((directory) => { + const repo = initReleaseRepo(directory); + git(repo, "tag", "v1.3.9"); + + const result = runPublishCheck(repo, "v1.3.9"); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /not annotated/u); + }), + ], + [ + "publish rejects a release tag outside main", + () => + inTempDir((directory) => { + const repo = initReleaseRepo(directory); + const tagResult = runTagRelease(repo); + assert.equal(tagResult.status, 0, tagResult.stderr); + git(repo, "checkout", "--quiet", "--detach"); + git(repo, "branch", "--force", "main", "HEAD^"); + + const result = runPublishCheck(repo, "v1.3.9"); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /not reachable/u); + }), + ], + [ + "publish accepts a main checkout past the release tag", + () => + inTempDir((directory) => { + const repo = initReleaseRepo(directory); + const tagResult = runTagRelease(repo); + assert.equal(tagResult.status, 0, tagResult.stderr); + git(repo, "commit", "--quiet", "--allow-empty", "-m", "fix: later"); + + const result = runPublishCheck(repo, "v1.3.9"); + assert.equal(result.status, 0, result.stderr); + }), + ], ]; for (const [name, check] of checks) {