helpers4 release
Releases a TypeScript, Rust or mixed repository in one step: version from Conventional Commits,
changelog, release commit on main, tag, GitHub Release, and publication to npm,
crates.io and JSR with each registry’s own tool and OIDC trusted publishing.
Nothing is written before every build and every registry dry-run has passed. Running the release again finishes what is missing.
With dry-run: true the release does everything but write: the job summary (and the log) tells the
version and why, what each registry’s dry-run checked, the files the release commit would write and
the Release notes.
Quick start
Section titled “Quick start”One-time setup
Section titled “One-time setup”- A GitHub App that may push to the protected branch (see Your own GitHub App below). The
helpers4-botApp of the helpers4 and baxyz repositories cannot be used elsewhere: minting its token takes its private key, which is a secret of those organizations. - Trusted publishers, one per registry, all pointing at this repository, the workflow file
release.ymland thereleaseenvironment:- npm: package settings → Trusted publisher (the package must already exist);
- crates.io: crate settings → Trusted Publishing (the crate must already exist);
- JSR: package settings → link the GitHub repository.
- Only one protection on the branch, or the App allowed in each. A classic branch protection
(Settings → Branches) works next to the rulesets and is not lifted by their bypass list; GitHub’s
branch API reports
protected: truefor both. Delete the classic one or let the App bypass it too. - Optional: enable immutable releases (Settings → General → Releases).
Your own GitHub App
Section titled “Your own GitHub App”The release commit goes to a protected branch, which only an actor with a bypass can do. The job gets the token of a GitHub App that is on the bypass list (at the last moment, limited to the repository):
- Create the App: Settings → Developer settings → GitHub Apps → New GitHub App (on your account or your organization). Any name and homepage, Webhook unticked, Repository permissions → Contents: Read and write, and Only on this account.
- Generate a private key (App page → Private keys; a
.pemfile downloads) and note the Client ID. - Install the App on the account or organization, for the repositories that release.
- Allow it through the branch: Settings → Rules → Rulesets → the ruleset of the branch → Bypass list → add the App, Always. A classic branch protection (Settings → Branches) is not lifted by it: delete it or let the App bypass it too.
- Store the two values as a variable and a secret of the repository or of the organization, and give
them to the
app-idandapp-keyinputs: the client ID, and the whole content of the.pemfile.
The App only needs Contents: write, and only the release step gets its token.
What a release does
Section titled “What a release does”- prepare
- Version: from the last
v*tag in the history of the branch (or on a release commit whose parent is in it, as action-release 0.1.x made them) and the Conventional Commits since then: breaking change → major,feat→ minor, otherwise patch. Before 1.0.0 everything shifts down: breaking → minor,feat→ patch.bumpforces a level, or a prerelease (alpha,beta,rc), numbered from.0. With no tag yet, the version of the manifest is the base. - Manifests: the version is written in
package.json,Cargo.toml(andCargo.lock, withcargo update --workspace; the requirements between crates released together follow),jsr.json/deno.json, and theversion-files. - Changelog: the human summary written under
## [Unreleased]ofCHANGELOG.mdbecomes the top of the new## [X.Y.Z] - datesection, followed by the list generated from the commits by git-cliff (yourcliff.tomlif any). An empty[Unreleased]stays on top for the next release. The Release notes are the same text. @since nextbecomes@since X.Y.Zin the tracked sources (a prerelease keeps it:nextis the next stable release), and"next"values of the JSON maps listed insince.json(such asapi-since.json) become the version.
- Version: from the last
- verify: your build, then
npm publish --dry-run(on the tarball packed by your package manager),cargo publish --dry-run,jsr publish --dry-run. Also checked: the OIDC permission, npm 11.5.1 or later, the App inputs. - commit: the App’s token is created only now, for this repository. One signed commit
chore(release): 🔖 vX.Y.Zthrough the API, on top of the tested commit; the branch moves only as a fast-forward, so if someone pushed meanwhile, the release stops without writing anything. Then the tag, and a draft Release. - publish:
npm publish,cargo publish,jsr publish, with OIDC. A registry that already serves the version is skipped. - finalize: the Release is published; the job summary lists the links.
Inputs and outputs
Section titled “Inputs and outputs”Everything is an input of the step: no configuration file.
| Input | Default | Meaning |
|---|---|---|
bump | auto | auto, patch, minor, major, alpha, beta, rc or resume |
dry-run | false | Prepare and verify everything, write nothing |
app-id / app-key | – | Client ID and private key of the App that pushes to the branch |
targets | detected | What is published, in this order (YAML list, see below) |
version-files | – | Manifests that carry the version without being published (one per line) |
changelog | CHANGELOG.md | Changelog file |
since-marker | next | @since next |
since-files | every tracked .ts, .js, .rs… file | Globs of the files whose @since markers get the version; "" for none |
since-json | – | JSON maps whose "next" values get the version, such as api-since.json |
crates-token | – | crates.io token for the first release of a new crate only, then remove it |
floating-major | false | true for a GitHub Action: move v1 to each stable release |
github-token | github.token | Token for the GitHub Release |
Outputs: version, tag, release-url.
Without targets, the root package.json (unless private), Cargo.toml (unless publish = false)
and jsr.json / deno.json are released. Otherwise:
crates: . on a workspace publishes all its crates that do not have publish = false; crates: crates/core
publishes one. The crates of a workspace go to a single cargo publish, which orders them by their
dependencies and checks each one, so the later ones need no earlier version on crates.io. The release writes
the version in [workspace.package] and in the version of the { path = "…", version = "…" } dependencies
between them (one-line tables). A build on a workspace runs once.
The first release of a project
Section titled “The first release of a project”npm and crates.io accept a trusted publisher only for a package that already exists, and the release
publishes with nothing else. Publish a first version by hand before the first release, for every
npm package and crate that is new to its registry, then add the trusted publisher. The first version
must be lower than the one the release computes, so it never takes its place (0.0.0 for a new
package; the version your manifest already holds for an existing project):
Then, on each registry, add this repository, the workflow file (release.yml) and the environment of
the job as the trusted publisher (package settings), and run the release. Until that is done, verify
stops before writing anything on an npm package or a crate it does not find, with this list of steps.
Instead of publishing a crate by hand, the crates-token input takes a crates.io token (scopes
publish-new and publish-update) for the first release only: the crate is published with it, then add the
trusted publisher and remove the input.
npm has no such token: it cannot be created for a package that does not exist. Instead of publishing the
first version publicly, stage it: npm stage publish puts it in the registry without making it public, and the
package then exists, so its trusted publisher can be added by hand in the package settings. Approve the staged
version (npm stage approve) or reject it (npm stage reject) when you like; the release does not need it.
Packages the build generates. npm: build/* stands for every build/<package>/package.json that
the build command writes (a multi-package library with one version). They are not known before the
build: verify runs it once, lists the packages (the private ones are skipped), then dry-runs and publishes
them. A package that depends on another one of the release goes after it (a bundle after its categories).
The release does not write their version: the build takes it from the root package.json, which you list
in version-files, and the release refuses to publish a package that carries another one.
Without build, an npm package runs its build script with your package manager; crates and JSR
packages are built by their own dry-run.
Failures and resuming
Section titled “Failures and resuming”- Before the commit (build, dry-run, preflight, branch moved): nothing was written. Fix, run again.
- After the commit: the version is on
mainand tagged. Fix the cause (a trusted publisher, for instance) and Re-run failed jobs, or run withbump: resume: only what is missing is published, then the Release. - A published version is never replaced: fix forward with the next one.
Every message is explained in Troubleshooting.
Examples
Section titled “Examples”- mozlz4 (TS library, npm + JSR): no input to add if
jsr.jsonsits next topackage.json; the hand-written[Unreleased]notes become the summary of each release. - folioscope (pnpm monorepo, Rust → WebAssembly → npm): the release job sets up Rust and
wasm-bindgen, thentargetswithnpm: packages/folioscopeand its build. - helpers4/typescript (30 npm packages built from one source, one version):
npm: build/*above. - helpers4/rust (crate): no target to configure;
since-json: api-since.json.
Requirements and limits
Section titled “Requirements and limits”- Node 22.18 or later on the runner (the action runs its TypeScript with Node itself).
- GitHub-hosted runners: trusted publishing does not support self-hosted ones.
- One version for the whole repository.
- Only
dir/*patterns (npm); the packages must exist after the build. - npm refuses for good a version that was published and then unpublished: the release stops before writing.
- npm and crates.io accept a trusted publisher only for an existing package: publish the first version by hand (see The first release of a project). A package or crate that does not exist yet stops the release before it writes anything.
License
Section titled “License”LGPL-3.0-or-later.
Troubleshooting
Section titled “Troubleshooting”Each section starts with the message of the action.
Before anything is written
Section titled “Before anything is written”These stop the release in prepare or verify: fix the cause and run it again.
”The checkout is shallow”
Section titled “”The checkout is shallow””actions/checkout fetches one commit by default. The release needs the history and the tags:
with: { fetch-depth: 0 }.
”A release runs from a branch”
Section titled “”A release runs from a branch””Run the workflow from a branch (the one the release commit goes to), not from a tag.
”Nothing to release: no commit since vX.Y.Z”
Section titled “”Nothing to release: no commit since vX.Y.Z””No commit since the last release. To release anyway (a rebuild, a dependency), choose patch,
minor or major.
”vX.Y.Z already exists”
Section titled “”vX.Y.Z already exists””The computed version is taken by a tag that is not in the history of this branch. If it is the
release you are finishing, run with bump: resume; otherwise choose another bump.
”GitHub refused to move main to the release commit”
Section titled “”GitHub refused to move main to the release commit””GitHub’s own message follows. “Changes must be made through a pull request” or “protected branch” means the App is not allowed through a rule of the branch. Check both places:
- the rulesets that apply to the branch (Settings → Rules; also those of the organization):
helpers4-bot(or your App) must be a bypass actor with Always; - a classic branch protection (Settings → Branches). It lives next to the rulesets, a ruleset’s bypass does not lift it, and a ruleset page does not show it. Delete it or let the App bypass it there too.
gh api repos/OWNER/REPO/rules/branches/main lists the rules of the rulesets; protected: true in
gh api repos/OWNER/REPO/branches/main is also set by a classic protection. The action cannot read
a classic protection itself (that needs the administration permission, which a job token cannot have),
so this is only found when the push is refused. Nothing was released at that point.
”… is not on npm yet” / ”… is not on crates.io yet”
Section titled “”… is not on npm yet” / ”… is not on crates.io yet””The package or crate does not exist, so it cannot have a trusted publisher. The message lists the steps; see The first release of a project in the README for the commands. Nothing was written.
”The job cannot request an OIDC token”
Section titled “”The job cannot request an OIDC token””Trusted publishing (npm, crates.io, JSR) needs permissions: { id-token: write } on the release job.
”npm X cannot publish with trusted publishing”
Section titled “”npm X cannot publish with trusted publishing””npm publishes with OIDC from 11.5.1. Node 24 ships a recent enough npm: actions/setup-node with
node-version: 24.
”No GitHub App to push the release commit”
Section titled “”No GitHub App to push the release commit””Set the app-id and app-key inputs: an App of your own, see Your own GitHub App in the README.
”crates.io would refuse: …”
Section titled “”crates.io would refuse: …””A crate has no description or no license / license-file (cargo only warns locally, crates.io
refuses at upload), or its archive is over 10 MB. Fix it in the crate’s Cargo.toml (or the
[workspace.package] it inherits from). Nothing was written.
”The build of … failed” / “The npm (cargo, jsr) dry-run of … failed”
Section titled “”The build of … failed” / “The npm (cargo, jsr) dry-run of … failed””The registry’s own tool reports the problem in the log above the message (a missing license for JSR, a file missing from the package, a wrong name…). Nothing was written.
”The build generated no package in …/”
Section titled “”The build generated no package in …/””A npm: dir/* target expects dir/<package>/package.json after its build command. Check that the
command writes there (and that the packages are not all private). Nothing was written.
”… is not on npm yet, and trusted publishing only works for an existing package”
Section titled “”… is not on npm yet, and trusted publishing only works for an existing package””npm only lets you configure a trusted publisher for a package that exists. Publish a first version by
hand (a classic token, or npx setup-npm-trusted-publish <name>), add the repository and the workflow as
its trusted publisher on npmjs.com, then run the release again. Nothing was written.
”npm refuses … that version was published and then unpublished”
Section titled “”npm refuses … that version was published and then unpublished””npm never accepts again a version it published once, even unpublished. Release the next version
(bump: patch). Nothing was written.
”… moved during the release”
Section titled “”… moved during the release””Someone pushed to the branch between the start of the release and its commit. The release only moves the branch as a fast-forward, so it stopped without writing: run it again.
After the commit
Section titled “After the commit”The version is on the branch and tagged. Never delete the tag: fix the cause, then Re-run failed
jobs or run with bump: resume.
”vX.Y.Z is tagged but not published on …”
Section titled “”vX.Y.Z is tagged but not published on …””The registry refused the publication. Usually the trusted publisher: it must name this repository,
the workflow file (release.yml) and the environment of the job, exactly. For npm and crates.io,
the package must exist already (publish the first version by hand).
”… has version X, not Y: the commit of vY cannot publish Y”
Section titled “”… has version X, not Y: the commit of vY cannot publish Y””The release commit of vY did not write the version in that manifest (the target was added after it was tagged, for instance). Publishing it would publish version X. Nothing was published: release the next version, which writes the version in every manifest.
A crate of a workspace was published, not the next one
Section titled “A crate of a workspace was published, not the next one”The crates go out in one cargo publish, in dependency order: if it stops in the middle, the first
ones are on crates.io. Fix the cause and Re-run failed jobs: only the missing crates are published.
”No crates.io token”
Section titled “”No crates.io token””rust-lang/crates-io-auth-action could not exchange the OIDC token: check the trusted publisher of
the crate on crates.io.
”vX.Y.Z belonged to an immutable Release that was deleted”
Section titled “”vX.Y.Z belonged to an immutable Release that was deleted””GitHub keeps the tag of a deleted immutable Release reserved forever: that version can never get a Release again. The version itself (commit, tag, packages) is released; the next one gets a Release.
