Skip to content

feat(release): release from CI with one command - #259

Merged
Moltenship merged 9 commits into
mainfrom
release/local-one-command
Oct 15, 2026
Merged

Moltenship merged 9 commits into
mainfrom
release/local-one-command

Conversation

@Moltenship

@Moltenship Moltenship commented Oct 15, 2026 •

Copy link
Copy Markdown
Owner

One command releases the nine @moltenship packages, and nobody confirms anything: asking for the release is the approval.

vp run release [<version>] [--dry-run]     # or /release [version] [--dry-run] in Claude Code

The laptop only triggers and watches. CI does the release, publishing through npm trusted publishing (OIDC) with provenance; no npm token exists, and there's no 2FA prompt, environment reviewer, npm login or version prompt.

A release, from the owner's side

  1. vp run release (or /release).
  2. The command checks the laptop side:
    • the checkout is main, clean and in sync with origin;
    • CI is green on HEAD (it waits while CI runs);
    • main's rules would let the workflow push.
  3. It picks the version: the argument, or the suggestion from the Conventional Commits since the last tag. It never prompts.
  4. It starts .github/workflows/release.yml on main and watches it (gh run watch). Then it prints the GitHub release's link, or the failed jobs and the end of their logs.
  5. /release then rewrites the generated notes with gh release edit.

The workflow

One release at a time (concurrency: release, nothing cancelled).

Job Permissions What it does
check id-token: write Fails fast:
- the version is above the packages';
- v<version> isn't on GitHub and npm doesn't have it;
- the run is on main, on the commit the command checked;
- no release landed on main while the run waited;
- every package trusts this workflow: it trades the OIDC token for a publish token, as npm publish does, and throws the token away.
gate read In the runner only: bumps the nine manifests, commits chore(release): v<version> and tags it. Then runs release:gate on that commit: the full tier, Firefox included, with a strict dry run. Writes the notes with changelogithub and bundles the commit and tag for the next jobs.
publish id-token: write, no install scripts npm publish --provenance on the tarballs the gate checked, through trusted publishing, skipping any npm already serves.
finish contents: write, the only writer Waits for npm to serve every package (integrity, dist-tag, trusted publisher and provenance). Then pushes the tag first, then main, merging like a PR if main moved, and writes the GitHub release, marked as a pre-release for an RC.
install read Installs from npm into clean Vite and Next.js apps (registry:e2e).

Dry runs and hardening:

  • A dry run skips publish and finish. It runs publish-dry-run and finish-dry-run instead, which have no OIDC token and no write access, and queues apart from real releases.
  • The jobs that hold credentials pin their actions to commits, use no cache, install without scripts, and run tools/release/src/ci.ts with node rather than through Vite Task.

Why workflow_dispatch and not antfu's tag push: with a tag push, the laptop pushes the bump and the tag before anything checks them, so a red gate leaves both behind. Here nothing leaves the runners until the gate has passed, and main gets the version only once npm serves it. That matters because the docs site's registry follows main.

Resuming:

  • Rerunning vp run release <version> after a run failed after publishing started runs that run's failed jobs again (gh run rerun --failed). That reuses the same commit and tarballs, kept 30 days, so npm never gets two builds of one version. This needs no checkout.
  • A run that failed before publishing is left alone, and a new run starts from main.
  • A running release is watched; a finished one is just reported.
  • A bare vp run release finishes the packages' own version while its release isn't done. When the only commits since the last tag are the release's own, it stops with "Nothing to release" instead of publishing an empty version.
  • It won't start a release while another one is running.

Trust and the zero-confirmation trade-off

  • What's given up: no person looks at a release before it ships.
  • What stands in:
    • only the owner can push to or dispatch on main;
    • each package's trusted publisher is pinned to Moltenship/time and release.yml;
    • optionally, "Require two-factor authentication and disallow tokens" means no token can publish at all. Trusted publishing isn't a token, so it keeps working.
  • No downgrade from rc.1: rc.1 went out with a token plus --provenance (no trusted publisher). pnpm's trustPolicy: no-downgrade ranks trusted publisher + provenance above provenance alone (trustChecks.ts), so releases from this workflow aren't a downgrade. npm has no such check.

Details and sources: docs/internal/research/release-flow.md, which has a new section on this decision.

The owner's one-time setup (also in docs/internal/releasing.md)

Do it within 48 hours before the first release. Since 2 Oct 2026, npm drops a trusted publisher that hasn't published within 48 hours of being created.

  1. A trusted publisher on each of the nine packages. Two ways:
    • Run vp run release:trust. It runs npm trust github <pkg> --file release.yml --repo Moltenship/time --allow-publish --yes for each package; it needs npm 11.15 or later. The first call asks for 2FA; tick "skip 2FA for the next 5 minutes" and the other eight go through.
    • Or, on npmjs.com, open each package's Settings, then Trusted publishing, then GitHub Actions, and enter:
      • organization or user: Moltenship;
      • repository: time;
      • workflow filename: release.yml;
      • environment: blank.
  2. Remove the token. Delete the repository's npm environment and its NPM_TOKEN secret, and revoke the token on npmjs.com.
  3. Optional: set each package to "Require two-factor authentication and disallow tokens". vp run release:trust --disallow-tokens does it with npm access set mfa=publish.

Removed from the first version of this PR

  • staging and approval;
  • the npm login, terminal and version-prompt preflight;
  • the local gate's state file, undo and signal handling;
  • the macOS 27 Firefox wait, now unneeded because the gate runs Firefox on a fresh runner;
  • terminal.sh;
  • the CI concurrency change;
  • the release:version, release:publish, release:published and release:github tasks, plus the old release.yml's token-based jobs.

Kept

  • changelogithub notes and the frozen CHANGELOG;
  • the /release skill in the repo with disable-model-invocation: true (rewritten for the CI flow);
  • the research note;
  • CI's on-demand release-gate job.

Tests

  • Unit tests: 150 pass, covering:
    • nextStep, publishStarted and the version choice's rules;
    • the check job's problems;
    • npm's exchange answers;
    • the trusted-publisher setup;
    • the workflow contract: jobs, permissions, pins, the dry-run jobs and CI parity;
    • the publish guard and the npm check.
  • vp run ready:full passed locally at e90d41c1. vp run ready passed at the final commit, 5354a5bb.
  • CI's release-gate is green on e90d41c1 (job) and on the final commit, 5354a5bb (job).
  • Dry runs of the release workflow, each started by vp run release --dry-run from this branch:
    • 38705122632 on 974733f9, the first design;
    • 38708797626 on c147cee0, with the jobs that have no credentials;
    • 38718528054 on the final commit, 5354a5bb, with the version picked by the command (1.0.0-rc.2).
  • What the dry runs did:
    • check: npm answered 404 OIDC token exchange error - package not found for all nine, as expected before the setup, and the run reported it.
    • gate: committed and tagged in the runner, and the gate passed.
    • publish dry run: npm 11.20.0 and the nine tarballs, with provenance.
    • finish dry run: the fast-forward plan and the notes.

Review

Claude Opus 5.5 (xhigh) reviewed this redesign in rounds 5 to 7, after four rounds on the first one. It found 1 blocker, 5 should-fix and 11 nits, all fixed. The final round confirmed nothing open. One nit is left by choice: a dry run with no version, started while a real release is unfinished, picks that version and stops at check with a clear message.

Untested live

  • A real publish through trusted publishing: no trusted publisher exists yet, and the dry run doesn't publish. The check job's exchange did reach npm, which answered 404 OIDC token exchange error - package not found for all nine packages, as expected before the setup.
  • A real push to main and a GitHub release from the workflow: the dry run only proves the job can push the tag (git push --dry-run).
  • A rerun of failed jobs after a partial publish.

Refs #207, #146, #47

🤖 Generated with Claude Code

Moltenship and others added 2 commits October 15, 2026 09:50
`vp run release [<version>] [--dry-run]` checks the checkout, the npm and GitHub logins, main's
rules and CI; bumps the nine packages in a release commit; runs the release gate on it; tags it;
stages the tarballs the gate checked and approves them with one 2FA; pushes; checks what npm
serves; writes the GitHub release from the Conventional Commits with changelogithub; and installs
the release from npm into clean apps. Before publishing, a failure undoes the local commit and
tag; after, a rerun picks up where it stopped.

The release workflow is gone: nothing publishes from CI.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…skill

`docs/internal/releasing.md` describes `vp run release` step by step: what stops it, what a rerun
picks up, why it publishes before pushing, and npm's 2FA. `docs/internal/research/release-flow.md`
compares how TanStack, antfu, unjs, changesets and release-it release, with npm's and pnpm's rules
in October 2026, and recommends keeping the local flow.

`/release` (`.claude/skills/release/SKILL.md`) is a project skill only the owner starts: it runs
the command in a pseudo-terminal (`terminal.sh`), relays npm's 2FA link, then rewrites the
generated notes from `notes-template.md` with `gh release edit`.

AGENTS.md, IMPLEMENTATION_PLAN.md, architecture.md, quality-gates.md, release-audit.md, the README
and CHANGELOG.md follow: release notes come from the Conventional Commits, so PRs add no changelog
line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Moltenship and others added 7 commits October 15, 2026 10:29
…arts

From the review of #259:

- A rerun once publishing has started (the tag on GitHub, any package on npm, or
  .release/release.json saying so) skips the CI checks and the gate, which passed before, instead
  of getting stuck on them. The gate's state records the manifest's hash, so a later dry run's
  tarballs never pass for the gate's.
- The undo covers everything before the first upload, Ctrl-C and SIGTERM included; the
  confirmation moves to the start, and the terminal check to the checks. A dry run commits on a
  detached HEAD, so the branch never moves.
- git never prompts for credentials, and the checks try a dry-run push. When main moves, the
  release merges into it as a PR would. A failed bump restores the manifests.
- A commit titled as the release must change only the nine versions. pnpm's JSON is read past
  reporter lines, and a staged version the listing doesn't describe is looked up before it counts
  as a conflict.
- CI's dispatched runs get a concurrency group of their own, so a push to main doesn't cancel the
  Firefox job a release waits for; no run starts when CI already failed.
- The skill sends one-time-password accounts to their own terminal, and allows the run two hours.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
From the second review of #259:

- A bump to a version npm already serves, from a release this checkout has no commit or tag for,
  stops before it commits.
- SIGHUP restores too, as when terminal.sh's terminal goes away; a signal stops the gate first and
  writes nothing to a closed terminal. The undo covers the bump itself: a detached HEAD and
  manifests changed but not yet committed go back.
- A started release whose .release/ a later dry run rewrote runs the gate again on the release
  commit instead of stopping; a checkout publishing one version refuses another until it's done.
- npm's login is needed only while something is left to publish. Release tags are fetched without
  failing the run on one that differs. The push merges only when main moved, and says when main
  needs a linear history.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…next version once released

From the third review of #259: a rerun from the tagged release commit checked out on its own, as
the gate message advises after main moved on, no longer stops for not being on main; and a
release counts as done once its GitHub release is out, so an install test that keeps failing
doesn't hold up the next version.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The owner confirms nothing: `vp run release [<version>]` (or /release) checks the checkout and CI,
picks the version, starts the release workflow on main and watches it. The workflow checks the
version and npm's trusted publisher on each package, bumps and gates the release commit on a fresh
runner (Firefox included), publishes the gate's tarballs with npm trusted publishing and
provenance, then pushes main and the tag and writes the GitHub release, and installs from npm.
Nothing leaves the runners before the gate passes, and main gets the version once npm serves it.
A run that fails after it starts publishing finishes when its failed jobs run again, which the
command does.

Gone with it: staging and approval, npm's login and 2FA on the laptop, the version prompt, the
local gate's undo and state, the Firefox wait and the terminal helper. `vp run release:trust` sets
up the trusted publishers once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…cision behind it

releasing.md walks through a release from the owner's side and the workflow's, what a rerun picks
up, and the owner's one-time setup: a trusted publisher on each package (vp run release:trust or
npmjs.com, within 48 hours of the first release), the old token's removal, and optionally
disallowing tokens. The research note adds the zero-confirmation decision, why workflow_dispatch
beats a tag push, and what trusted publishing changes for pnpm's trustPolicy. The skill, AGENTS.md
and the other notes follow; CI's concurrency goes back to what it was, since nothing waits for its
Firefox job any more.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
… dry runs credential-free

From the review of the CI release in #259:

- vp run release looks at the version's runs before anything else, so watching, reporting and
  rerunning need only gh; only starting a run checks the checkout and CI, whose verdict on a
  release commit the workflow pushed comes from its parent. It won't start a release while
  another runs, and reruns the newest run that reached npm.
- The finish job pushes the tag before main, so a conflict with main can be merged by hand; the
  runbook says how to finish by hand when only a tooling fix would help.
- The check job stops a run that builds on another commit than the one checked, or on main from
  before a release that landed while it waited. Dry runs queue apart, and publish and finish in
  jobs of their own with no OIDC token and no write access.
- The jobs with credentials pin their actions to commits, use no cache, and run the release's
  steps with node instead of Vite Task; ci.ts refuses to run outside GitHub Actions.
- npm's 429 and other answers no longer read as a missing trusted publisher, and release:trust's
  dry run only prints what it would run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…e bot's release commits

From the review of #259: a bare vp run release finishes the packages' own version while its run
isn't done, and otherwise stops when the only commits since the last tag are the release's own,
so it can't publish an empty version. A commit counts as the release workflow's own only with
the bot's address as well as the subject. The gate's upload is pinned to a commit like the
privileged jobs' actions, and the runbook's by-hand finish fast-forwards main when it can.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@Moltenship Moltenship changed the title feat(release): release with one local command feat(release): release from CI with one command Oct 15, 2026
@Moltenship
Moltenship merged commit d91e31f into main Oct 15, 2026
15 checks passed
@Moltenship
Moltenship deleted the release/local-one-command branch October 15, 2026 10:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant