Skip to content

ci: publish a prerelease on every merge into main - #23

Merged
zanjonke merged 1 commit into
mainfrom
ci/prerelease-on-merge
Aug 28, 2026
Merged

ci: publish a prerelease on every merge into main#23
zanjonke merged 1 commit into
mainfrom
ci/prerelease-on-merge

Conversation

@zanjonke

Copy link
Copy Markdown
Collaborator

Nothing reaches npm between releases today, so there's no way to try a merged change without waiting for a release to be cut. This publishes every merge into main to the next dist-tag.

latest is never touched by this path, so npx plain-forge users continue to see only cut releases. Consumers opt in:

npx plain-forge@next install         # newest prerelease
npx plain-forge@1.0.21-next.a3f9c21  # one exact commit

Why it lives in publish.yml

npm's trusted publisher is bound to the exact workflow filename, which RELEASING.md already calls out as part of the trust contract. A new prerelease.yml would not be trusted — it would need a second publisher entry on npmjs.com (and I could not confirm npm supports more than one per package), or an NPM_TOKEN secret, abandoning the token-less OIDC model.

So publish.yml now owns both paths, and the release job is gated on github.event_name == 'release' so the two cannot cross:

Trigger Job dist-tag
release: published publish latest (or next if flagged pre-release)
push: branches: [main] prerelease next

Versioning

package.json on main is stale by design, so the published latest is the base. Patch-bump it, append the commit sha:

latest = 1.0.20   ->   1.0.21-next.a3f9c21

This puts each prerelease below the release it anticipates — 1.0.20 < 1.0.21-next.a3f9c21 < 1.0.21 (verified) — and names the commit it came from. Once v1.0.21 ships, the next merge derives 1.0.22-next.<sha> unassisted.

Sha identifiers do not order chronologically (semver compares them alphanumerically). That's harmless here because --tag next is repointed by every publish, so @next always resolves to the newest one. It does mean a semver range must never be used to pick "the newest prerelease" — documented in RELEASING.md.

Details

  • Re-running a merge's workflow is a no-op, not a failure. The version derives from the sha, so the job detects it's already on npm and skips.
  • Reuses the existing npm environment, so OIDC sees identical claims whether or not the trusted publisher pins one. I confirmed via the API that it carries no protection rules, so the job never waits for an approval.
  • Slack on failure only. A ping per merge would be noise; the run summary records what was published.
  • A guard refuses to guess if latest is ever not a plain X.Y.Z.

Testing

actionlint clean. YAML parses with all three jobs and both triggers. Version-derivation shell logic and the semver ordering claim verified locally against 1.0.20, 1.9.9, 2.0.0, 10.20.30.

The publish path itself cannot be tested before merge — it only runs on push to main. The first merge is the real test. If it fails, latest is unaffected and Slack will say so.

One thing to decide

Every merge permanently adds a version to npm. Unpublishing is only possible within 72 hours, so the registry accumulates one prerelease per merge — including for docs-only and test-only merges, which change nothing in the tarball (only bin/cli.mjs and forge/ ship). I implemented "every merge" as asked; if that churn isn't wanted, adding paths-ignore to the push trigger is a one-line change.

Nothing reaches npm between releases today, so there is no way to try a merged
change without waiting for a release to be cut. This publishes every merge into
main to the `next` dist-tag.

`latest` is never touched by this path, so `npx plain-forge` users continue to
see only cut releases. Consumers opt in with `npx plain-forge@next`.

The job lives in publish.yml rather than a prerelease.yml of its own because
npm's trusted publisher is bound to this exact workflow filename — a new file
would not be trusted and would need a second publisher entry on npmjs.com, or
an NPM_TOKEN secret, abandoning the token-less OIDC model. The release path is
now gated on `github.event_name == 'release'` so the two cannot cross.

Versioning reads the published `latest` and bumps its patch, because
package.json on main is stale by design and cannot be the source. Appending the
commit sha puts each prerelease below the release it anticipates
(1.0.20 < 1.0.21-next.a3f9c21 < 1.0.21) and names the commit it came from. Sha
identifiers do not sort chronologically, which is harmless here: `--tag next` is
repointed by every publish, so `@next` always resolves to the newest one.

Re-running a merge's workflow derives the same version, which npm would refuse.
The job detects that and skips instead of failing red.

It reuses the existing `npm` environment so OIDC sees the same claims whether or
not the trusted publisher pins one; that environment carries no protection
rules, so the job never waits for approval. Slack is notified on failure only —
a ping per merge would be noise, and the run summary is the record.
@zanjonke
zanjonke requested a review from NejcS August 28, 2026 12:25
@zanjonke
zanjonke merged commit 4f1def5 into main Aug 28, 2026
3 checks passed
@zanjonke
zanjonke deleted the ci/prerelease-on-merge branch August 28, 2026 12:29
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.

2 participants