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
75 changes: 68 additions & 7 deletions .github/workflows/npm-publish.yml
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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 }}
113 changes: 113 additions & 0 deletions tools/publish-check.mjs
Original file line number Diff line number Diff line change
@@ -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}`,
);
147 changes: 147 additions & 0 deletions tools/release-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
Loading