Skip to content

publish: a release you can resume, and announce last - #98

Merged
ralyodio merged 2 commits into
mainfrom
worktree-publish-saga
Sep 24, 2026
Merged

ralyodio merged 2 commits into
mainfrom
worktree-publish-saga

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Fixes #93.

What went wrong

Run 33854645746 published @profullstack/hqtui@0.1.10, failed in the demo's build, and left the v0.1.10 release public for a version nobody could install in full. Retrying the workflow reached the already-published library first, which npm refuses, so recovery burned 0.1.11.

publish.yml still ran two unconditional npm publish steps after a release: published trigger, so any later registry, network or auth failure reproduces the same split state.

What this does

scripts/publish.mjs splits the release into pack and publish, following the three steps the issue asked for.

1. Build and pack both packages before either upload, and keep the artifacts. Both tarballs are packed first, so a build failure happens while nothing is public, and they are uploaded as a run artifact (release-<tag>) before the first npm publish so a failed run can be resumed from the exact bytes.

pack also refuses a tarball that is missing its build. It checks every path package.json promises, then follows the relative imports inside the tarball — apps/demo's entry point is one line, import "../dist/main.js", so the declared entry point can be present in a tarball that installs nothing runnable. Removing the dist and packing reproduces the 0.1.10 failure shape:

@profullstack/hqtui-demo packed with imports it does not contain:
  bin/hqtui-demo.mjs imports ../dist/main.js

2. Distinguish a verified match from an absent version. Every package is looked up before anything is uploaded, and the answer is one of three states, never a guess: absent, present with a shasum, or indeterminate. Absent is published, a verified match is skipped, and a conflict or an unreadable registry fails the run before any upload. An unreachable registry read as "absent" is how you publish twice; read as "present" is how you skip a package that never shipped.

Sameness is decided on contents rather than gzip bytes: if dist.shasum differs, the published tarball is downloaded and compared file by file, because two packs of one commit can differ in packing without differing in what they install. Only a real content difference is a conflict.

3. Verify both packages before finalizing the release. publish waits for both versions to be readable, and the GitHub release is announced last.

A draft cannot start the workflow — GitHub does not trigger workflows for the created activity type on draft releases — so the tag push is the way in: write the notes as a draft, push the tag, and the workflow flips that draft to public once npm has both packages. With no draft it creates the release from the commit log. Publishing a release by hand still works and still publishes both packages; it just announces first, which is the ordering that made this possible.

Also: the dist-tag now comes from the version, so a prerelease publishes as next and its release is marked as one, instead of failing the guard on the release path.

Tests

packages/hqtui/test/publish.test.ts, 21 tests under both bun and node --test, with the registry and npm as functions. Including the issue's regression scenario:

  • library published, demo not, recovery uploads only the demo, no version bump;
  • the same version published with different contents stops the run with nothing uploaded;
  • a byte-different but content-identical repack is skipped rather than fought over;
  • an unreadable registry fails instead of guessing.

Verified against the real tree and the live registry: pack produces both tarballs with no false positives from the new guards, and a dry-run publish correctly classifies @profullstack/hqtui-demo@0.6.3 as an identical skip while flagging the library as changed since it was published (#94 touched ui.ts after that publish).

Full suite: 426 tests pass on bun and Node, bun run typecheck clean.

docs/RELEASING.md writes down the flow and the recovery, which is now "re-run the failed run".

Unrelated observation while testing: 0.6.3 is on npm but has no v0.6.3 tag or GitHub release.

🤖 Generated with Claude Code

ralyodio and others added 2 commits September 24, 2026 07:59
Run 33854645746 published the library, failed on the demo's build, and left
a public v0.1.10 release for a version nobody could install in full. Retrying
the whole workflow hit the already-published library, which npm refuses, so
0.1.10 was abandoned for 0.1.11.

Two irreversible uploads cannot be made atomic, so they are resumable instead.
scripts/publish.mjs splits the release into pack and publish:

  * pack builds both tarballs before either is uploaded and records their
    bytes, so a build failure happens while nothing is public. It refuses a
    tarball missing its build, checking every path package.json promises and
    then following the relative imports inside the tarball, which is what
    catches a demo packed without a dist.
  * publish asks the registry about each version first. A verified match is
    skipped, an absent version is uploaded, and a different artifact under the
    same version or an unreadable registry fails before anything is uploaded.
    Sameness is decided on contents, not gzip bytes, so a repack of the same
    commit is recognised rather than fought over.
  * publish then waits for both versions to be readable before exiting zero.

The workflow announces last. A tag push does the registry work and turns a
waiting draft into the release, because a draft cannot start it: GitHub does
not trigger workflows for the created activity type on drafts. Publishing a
release by hand still works and still publishes both packages.

The dist-tag now comes from the version, so a prerelease goes out as next and
its release is marked as one. The packed tarballs are kept as a run artifact
to resume a failed run from by hand.

allowJs lets the library's tsconfig type the test's import of the script.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`npm pack --json` needs its stdout captured; `npm publish` needs its stdout
in the log, where a person can read what was uploaded. One helper was doing
both, so the publish output was swallowed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ralyodio
ralyodio merged commit 1308441 into main Sep 24, 2026
1 check passed
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.

Publish workflow cannot resume a partial two-package release after the library succeeds

1 participant