ci: publish a prerelease on every merge into main - #23
Merged
Conversation
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.
NejcS
approved these changes
Aug 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
mainto thenextdist-tag.latestis never touched by this path, sonpx plain-forgeusers continue to see only cut releases. Consumers opt in:Why it lives in
publish.ymlnpm's trusted publisher is bound to the exact workflow filename, which
RELEASING.mdalready calls out as part of the trust contract. A newprerelease.ymlwould 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 anNPM_TOKENsecret, abandoning the token-less OIDC model.So
publish.ymlnow owns both paths, and the release job is gated ongithub.event_name == 'release'so the two cannot cross:release: publishedpublishlatest(ornextif flagged pre-release)push: branches: [main]prereleasenextVersioning
package.jsononmainis stale by design, so the publishedlatestis the base. Patch-bump it, append the commit sha: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. Oncev1.0.21ships, the next merge derives1.0.22-next.<sha>unassisted.Sha identifiers do not order chronologically (semver compares them alphanumerically). That's harmless here because
--tag nextis repointed by every publish, so@nextalways resolves to the newest one. It does mean a semver range must never be used to pick "the newest prerelease" — documented inRELEASING.md.Details
npmenvironment, 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.latestis ever not a plainX.Y.Z.Testing
actionlintclean. YAML parses with all three jobs and both triggers. Version-derivation shell logic and the semver ordering claim verified locally against1.0.20,1.9.9,2.0.0,10.20.30.The publish path itself cannot be tested before merge — it only runs on
pushtomain. The first merge is the real test. If it fails,latestis 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.mjsandforge/ship). I implemented "every merge" as asked; if that churn isn't wanted, addingpaths-ignoreto thepushtrigger is a one-line change.