Commit Graph

3 Commits

Author SHA1 Message Date
Conor Bronsdon 27e86fef12 Use an OIDC-capable npm runtime for publishing 2026-09-05 12:34:58 -07:00
Conor Bronsdon 025d69a942 release: npm publish with provenance (closes the 3.22.1 freeze gap) (#113)
* release: publish to npm with provenance after the GitHub release

The package sat frozen at 3.22.1 while the repo walked to 3.23.0 —
this workflow only ever created GitHub releases; no publish step
existed anywhere. The new job is independently idempotent (skips when
the version is already on npm), refuses to publish when package.json
disagrees with the CHANGELOG heading (the drift the release job already
warns about must block a publish, not ship), runs the test suite, and
publishes via npm Trusted Publishing with --provenance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* release.yml: guard the publish-npm changelog parse

The publish-npm version check derived CHANGELOG_VERSION from a bare
grep | sed pipeline. Today that still reaches the explicit mismatch
error (GitHub's default run shell is `bash -e {0}` — no pipefail — so
the failing grep yields an empty var rather than killing the step), but
the resulting message reads "!= CHANGELOG.md ()  fix the drift" when the
real problem is a missing or malformed heading, and the behavior depends
entirely on pipefail staying off. Adding `shell: bash` to the step, or a
repo-level defaults.run.shell, would turn it into a silent exit 1 with no
output at all.

Use the same guard the release job already uses a few lines up: capture
with `|| true`, check for empty, emit the specific error. Correct in both
shell modes and self-diagnosing either way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* release: serialize the workflow so publish can't race itself

Both jobs are check-then-act against shared state — `gh release view`
before `gh release create`, `npm view` before `npm publish` — and the
workflow had no concurrency control. Two overlapping runs can both read
"not published yet": a same-version pair turns one run red, and a
different-version pair can finish out of order and drag npm's `latest`
back to the older version.

Adding the publish job is what makes this reachable. Release runs were
9-18s wall time, far too short to overlap; a run that now also sets up
node, tests, and publishes with provenance is minutes long, and main has
already taken two CHANGELOG.md merges 2m11s apart (8042712, ae1ecbe).
The likelier collision is a workflow_dispatch or re-run racing a push,
which is exactly the recovery path this job exists to serve.

Workflow-level rather than job-level so the release job's own
check-then-act is covered too. The group omits `github.ref` because the
registry and release list are repo-global. cancel-in-progress stays
false: a cancelled run can die between its check and its publish.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* release: refuse to publish a commit that is not the release tag

publish-npm proves package.json and CHANGELOG.md agree on which version
it is publishing, then publishes whatever commit the run checked out. It
never checks that commit against the tag the GitHub release for that
version points at. The two come apart on the exact recovery this job
exists for: npm is at 3.22.1, main is at 3.23.0, and main has taken eight
commits since v3.23.0 (f666087) — among them e9471a5, which changes
detector/patterns.js, the file that with validate.js is the whole `files`
array. A dispatch from main today would publish 3.23.0 to npm carrying a
patterns.js the v3.23.0 release does not contain, and the version guard
passes throughout because both trees say 3.23.0.

Fail closed rather than check out the tag. npm builds the provenance
attestation from the run's github.sha, not from the worktree, so checking
out the tag would correct the tarball and leave the attestation naming a
commit whose contents were not published — signed in, and unfixable,
since a published version is immutable. Dispatching from the tag ref is
not a way out either: workflow_dispatch runs the workflow file from the
ref you pick, and release.yml at v3.23.0 has no publish job at all.

Gated on the same condition as the publish itself, because a CHANGELOG.md
edit that does not bump the version legitimately runs off the tag with
nothing to publish, and that must not turn the workflow red. The default
checkout is depth-1 with no tags (checkout@v6 defaults fetch-depth 1,
fetch-tags false), so the guard fetches the one tag it resolves and peels
with ^{commit} so an annotated tag compares as its commit. A missing tag
gets its own error: the release job creates it a job earlier, so its
absence means the release failed, was deleted, or is still a draft.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 12:21:50 -07:00
Conor Bronsdon 0a347d5be9 Add CHANGELOG-driven release automation workflow (#50)
Cuts a GitHub release from the topmost CHANGELOG.md entry on push to
main (CHANGELOG.md changes) or manual dispatch. Skips cleanly if the
tag already exists; warns (non-blocking) if package.json version
drifts from the changelog.
2026-07-07 21:02:11 -07:00