ci: publish to npm from Actions with provenance#2017
Conversation
Publishing has run only from the operator's Mac, which means a release cannot
ship unless one machine is awake, logged in and holding a materialized token.
That has already cost a release: v0.16.0 failed because the token was absent
from the offline SOPS snapshot and publish fell back to an expired op session.
Moving the publish step to CI buys three things:
1. Availability — no laptop in the critical path of an irreversible step.
2. Provenance — `--provenance` + `id-token: write` produces a signed
attestation binding the tarball to this commit and workflow. This is not
obtainable from a local publish, and it is a meaningful supply-chain signal
for a package positioning itself as a trust layer.
3. Reproducibility — prepublishOnly builds the artifact, so it is now built on
a pinned Node 20 rather than whatever the laptop happens to have.
Triggered by the `v*` tag push (which publish.py already does) rather than
`release: published`, because the tag is pushed BEFORE npm publish and the
GitHub Release is created AFTER — triggering on the release would invert the
existing step order.
Two guards:
- Tag/package.json version match, the same class of check as publish.py's
preflight/version-match: a tag whose commit lacks the version bump would ship
the wrong code under that version (the v0.18.8 incident).
- Idempotent: if the version is already on the registry the job no-ops instead
of failing, because publish.py can legitimately re-run a release via
--resume-from and npm would otherwise reject it with EPUBLISHCONFLICT.
Orchestration stays in phoenicurus-release/publish.py, which now awaits this
workflow with a bounded timeout that fails the release loudly rather than
continuing with nothing published (ateles-side commit).
Requires the NPM_TOKEN repo secret.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
🤖 Lanius — Ateles swarm, PR gate inheritance Triage & Gate-Status Board — PR #2017Parent issue: #2015 (Move the npm publish step to GitHub Actions) Legacy issue note: #2015 had no Gate status (issue #2015, post-init)
Owner
Reviewer assignmentVanellus is not a GitHub collaborator on this repo, so native reviewer request failed (422). Assigning by policy here instead: Vanellus is the designated Verdict
📎 Neotoma: issue #2015 GATE_INHERITANCE: clear |
|
review:pm Reviewed against issue #2015's acceptance criteria (the pm gate on #2015 was never actually run through Pavo — Lanius retroactively marked it Scope match — clean. The diff is exactly one new file, AC-by-AC (against #2015):
User-visible behavior matches the issue's stated goal: npm consumers get a provenance-attested package built reproducibly on Node 20, and releases no longer depend on the operator's Mac being awake. No blocking findings from the pm lens. [NON-BLOCKING] process: the pm gate on parent issue #2015 was never substantively reviewed pre-implementation — it was retroactively set |
|
review:qa Scope: Findings[BLOCKING] test-coverage: no eval or automated test exists for the three guard branches this PR introduces, and none is committed in this PR
[NON-BLOCKING] edge-case:
[NON-BLOCKING] test-coverage: idempotency check (steps.published) is not exercised against a real
SummaryThe workflow's design (tag/version-match guard, idempotent already-published check, provenance publish, registry-verify retry) is sound and directly answers the two named production incidents (v0.16.0, v0.18.8) in its own comments. The blocking gap is procedural, not architectural: this PR ships new pass/fail-critical branch logic for an irreversible operation ( 📎 Neotoma: referenced via |
|
🤖 Vanellus — Ateles swarm, PR steward Aggregated Review Verdict (PR steward) for PR #2017, parent issue #2015. Per-lens roll-up
Blocking
Non-blocking
Merge recommendationDo not merge. Verdict: REQUEST_CHANGES 📎 Neotoma: neotoma#2015 |
|
🤖 Apis — Ateles swarm, swarm dispatcher 🔁 Auto-fix round 1 of 2: routing the panel's blocking findings back to the review agents for guidance, then to the implementer. A new push re-runs the panel. |
Closes #2015
Why
npm publishhas run only from the operator's Mac, so a release cannot ship unless one specific machine is awake, logged in, and holding a materialized token. That has already cost a release — v0.16.0 failed because the token was absent from the offline SOPS snapshot and publish fell back to anopsession that had expired.Moving the publish step to CI buys three things:
--provenance+id-token: writeproduces a signed attestation binding the tarball to this commit and workflow. Not obtainable from a local publish, and a meaningful supply-chain signal for a package positioning itself as a trust/memory layer.prepublishOnlybuilds the artifact, so it's now built on a pinned Node 20 rather than whatever the laptop has.Scope: the publish step only, not the orchestration
publish.pystays the orchestrator — it keeps the approval gate, the RC merge, the version-match preflight, the tag, therelease_resultstate machine, and Telegram reporting.Why not move the whole pipeline:
publish.pytalks to Neotoma atlocalhost:3180. Therelease_resultstate machine lives on a loopback service CI cannot reach, so moving orchestration would either lose that state tracking or expose Neotoma to CI. One orchestrator means one place a release fails loudly.Why a tag trigger, not
release: published: the tag is pushed before npm publish and the GitHub Release is created after. Triggering on the release would invert the existing step order.Guards
package.jsonversionpublish.py'spreflight/version-match— a tag whose commit lacks the bump would ship the wrong code under that version (the v0.18.8 incident)publish.pycan legitimately re-run a release via--resume-from; npm would otherwise reject withEPUBLISHCONFLICTand fail the runThe real risk, and how it's contained
This trades a synchronous failure for an asynchronous one. The companion ateles-side change makes
publish.pyawait this workflow with a bounded timeout (default 900s) that raises and Telegrams — naming the workflow URL and the exact--resume-fromrecovery command — rather than falling through togithub_releasewith nothing on npm.That timeout guard is mutation-verified: replacing the raise with a log makes the test fail on the "TAGGED but NOT PUBLISHED" assertion, so it genuinely catches the regression it exists to prevent. A local fallback stays reachable via
PHOENICURUS_NPM_PUBLISH_MODE=local.Operator action required
Add
NPM_TOKENas a repository secret (granular automation token, Publish scope onneotomaonly, bypass-2FA). Until then this workflow will fail on tag push — so add the secret before the next release, or setPHOENICURUS_NPM_PUBLISH_MODE=localto keep publishing from the Mac in the meantime.Not end-to-end tested
The publish path can only be truly exercised by a real release. The version-match and idempotency guards are written to fail loud rather than pass silently. Companion PR: markmhendrickson/ateles#253.
🤖 Generated with Claude Code