A composite GitHub Action that predicts the exact next release-please semver bump a pull request would trigger, before it's merged.
release-please decides the next version by reading conventional-commit messages back to the last
release tag. But if your repository merges pull requests via "Create a merge commit", the
commit that actually lands on your base branch is the merge commit — subject
Merge pull request #N from owner/branch, body: the PR's title — not the PR branch's own commits.
GitHub bakes the PR title into that merge commit's body, and release-please reads it as its own
conventional commit. That means a plain dry run against just the PR branch's commits can
under-predict the real bump whenever the PR title has drifted from what those commits actually say.
This action closes that gap: it builds the exact merge commit GitHub would create for the PR, dry-runs release-please's CLI against that synthetic history, and reports what would happen — with no side effects on the real repository unless you opt into them.
A consequence of that premise, and the first thing people take for a bug: the previewed changelog lists the same change twice — once for the commit on the branch, once for the merge commit carrying the PR title. Both are real. release-please reads both, and the published changelog has both entries too; it just isn't obvious when the PR title matches the commit subject word for word, since the two entries then read identically.
What is not real is that merge commit's SHA. It exists only inside the runner, so its link resolves to nothing and GitHub will create a different commit on merge. The preview therefore names that entry instead of citing it:
* **ci:** extract apt-repo publish/sign into reusable composite actions (the merge commit GitHub would create for this PR)
* **ci:** extract apt-repo publish/sign into reusable composite actions ([380196e](…))There is no good lever for removing the doubling: it follows from merging via merge commits at all, which is the premise this action exists to model. Squash-merging avoids it and takes the prediction with it. Giving the PR a title distinct from the commit subject at least makes the two entries say different things, which is how this repository's own changelog reads.
A different, unwanted doubling can show up on a repository using a linked-versions plugin group
with merge: true (see Multi-package repositories): the changelog
release-please's own dry run produces there appears to repeat the identical
<details><summary>version</summary>…</details> block once per linked package, before it ever gets
to printing one combined update count. This action drops immediately-repeated identical blocks
defensively, so that repetition doesn't reach the preview comment — regardless of whether it turns
out to be release-please's own behavior or an artifact of the dry run specifically.
Optionally (default on), it also posts/updates a PR comment previewing the release-please output, and adds/removes a label on the PR to flag whether merging it would trigger a release.
That comment ends by naming the three inputs it was computed from — the head SHA, the base branch and the commit it was at, and the PR title as the run was handed it. All three move, and the base moves without anything re-running this action, since another PR merging is not an event on this one. The footer is what lets a reader tell a current prediction from one that describes a state the repository has since left.
One PR must not be dry-run: the release PR release-please opens itself. Merging it lands a bump in
.release-please-manifest.json to a version with no tag or GitHub release behind it — that release
is exactly what the PR proposes. Dry-running the merge result leaves release-please unable to find
the commit of the "last release" its own manifest names, so it decides the repository still needs
bootstrapping and replays the entire history from the first commit. That re-lists every commit ever
in the changelog, and lets an ancient Release-As: footer (release-please's own bootstrap commit
carries one) override the computed bump — which is how a repository sitting at 4.0.2 gets told that
merging its 4.1.0 release PR would release 1.0.0.
Nothing needs predicting for that PR anyway, so this action detects it — by the
release-please-- branch prefix or the autorelease: pending label — and reports the version
straight from the PR's own manifest bump, skipping the dry run.
As a backstop for any other way the same bootstrapping replay can be triggered (a version in the
manifest whose tag or release went missing, say), a predicted version that isn't strictly newer than
the current one is treated as untrustworthy: version and bump-type come back empty and the
preview comment explains what looks wrong, rather than a bogus number being passed on to whatever
consumes the output. The changelog release-please produced is still shown, folded, underneath that
explanation — the doubt is over the number, and a comment that showed nothing at all read as if the
action had failed to notice a release was coming.
The one PR whose base branch has no .release-please-manifest.json to read is the PR that adds it,
and that is a repository where a prediction is worth the most: nobody has seen this pipeline run
yet. So when the base branch has no manifest, the baseline is read from this PR's own copy of
it instead. That matches what release-please itself will do after the merge, since it classifies the
bump against the manifest as the merge leaves it.
Worth knowing what that means for a first release, because it surprises people: the version in the
manifest is one release-please treats as already released, not one it will publish — it holds
even with no tag and no GitHub release behind it. Commit {".": "0.1.0"} and the first
release-please release is 0.2.0, with 0.1.0 never published at all. Start the manifest at
{".": "0.0.0"} for 0.1.0 to be the first published release, since a feat: is a minor bump by
default and 0.0.0 plus a minor is 0.1.0.
current_version/candidate/bump-type can only ever compare two scalar versions, so this action
only ever speaks to one package's history at a time. The common layout — a single package keyed by
"." in .release-please-manifest.json — needs nothing from you, and is read the same way as
before.
A repository split into per-directory packages (release-please's cargo-workspace/linked-versions
plugins, say) has no "." entry at all — the manifest is keyed by package path instead (e.g. core,
app, tools/pack-cities). Without telling this action which key stands in for the repo's version,
it falls back to the manifest's first key. That fallback is only correct when every package in
the manifest is kept at the same version by a linked-versions group with merge: true — the case
that shape of manifest usually comes from — since then any one key's version is every package's
version. Preflight warns about this fallback whenever it's in effect (see
Preflight checks), because it can't tell from the manifest alone whether your
packages are actually linked that way.
If your packages release independently (no linked-versions group, or one without merge: true),
the first-key fallback is guessing at the wrong thing — set the package-name input to the one
package whose version this prediction should follow:
- uses: non7top/release-please-forecast@v1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
package-name: coreThis action still predicts one version for the whole PR, not one per package — that limitation isn't new here, it's just no longer silently pointed at nothing.
on:
pull_request:
# `edited` matters here, and isn't in GitHub's defaults — see below.
types: [opened, synchronize, reopened, edited]
branches: [master]
# Also not optional — see "Why the concurrency group matters" below.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
preview:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v7
with:
# Full history and tags: this action needs to walk back to the
# last release tag, and it builds its own merge commit on top of
# this clone.
fetch-depth: 0
- uses: non7top/release-please-forecast@v1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}Add edited to the defaults. GitHub runs a pull_request workflow on
opened, synchronize, and reopened only
unless you say otherwise, and that set is not enough for this action:
edited— fires when a PR's title or body is edited. The title is not cosmetic here. This action bakes it into the merge commit body exactly as GitHub would, and release-please parses it as a conventional commit — so retitling a PR fromfix: ...tofeat!: ...changes the predicted bump. Withoutedited, that retitle produces no run: the preview comment, the release label and theversionoutput all keep asserting the old prediction, with nothing to indicate they're stale. This is precisely the drift the action exists to catch, so leavingeditedout undermines the point of running it.synchronize— covers new commits, including a rebase or force-push, on its own.opened,reopened— the obvious ones.
The cost of edited is some redundant runs, since it also fires on body edits, which never affect the
prediction. A run is a few seconds (and skips the dry run entirely on release-please's own release
PR), so this is normally a fair trade for never showing a stale number.
Retargeting a PR onto a different base branch is also reported to fire edited, which would matter
for a workflow using a branches: filter. GitHub's published description of edited mentions only
the title and body, so that isn't confirmed here — treat it as a bonus rather than something to rely
on.
Adding edited creates a second problem that the concurrency group solves, so the two belong
together. Force-push a branch and rename its PR at the same time and GitHub delivers two events a
second apart — synchronize, carrying the old title, then edited, carrying the new one. Two
runs start, against the same head SHA but different titles, and therefore different predictions.
Both then delete and repost this PR's comment and set its label, with nothing ordering them. Last to
finish wins, and which run that is has nothing to do with which one held the newer title.
This is not hypothetical. Seen in the wild on a PR renamed from fix: to chore(ci): while being
force-pushed: the edited run finished first, correctly reported no release and removed the label;
the synchronize run finished three seconds later, deleted that comment, and left a 0.4.1 patch
prediction and a RELEASE label on a PR that would release nothing.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: trueKeyed per PR, so concurrent PRs still preview in parallel. cancel-in-progress: true also stops
paying for a run whose answer is already superseded; a group without it serialises rather than
cancels, which fixes the ordering too and is why preflight only checks that a concurrency group
exists at all.
Cancelling is the only fix here that covers the label as well as the comment. Everything else this action does to keep a stale answer off a PR works by putting information in the comment, and a label has no body to carry it.
To only compute the prediction (e.g. to name build artifacts) without touching the PR at all:
- name: Predict next version
id: predict
if: github.event_name == 'pull_request'
uses: non7top/release-please-forecast@v1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
post-comment: 'false'
- run: echo "Would release ${{ steps.predict.outputs.version }}"
if: steps.predict.outputs.version != ''Note: this action leaves the working tree checked out on its own synthetic preview-merge branch
(or, if simulating the merge failed, on the PR's own unmerged head commit) so it can compute the
prediction. If a later step in your job needs the PR's real head commit checked out again (e.g. to
build it), restore it explicitly, since this action already fetched it locally:
- run: git checkout ${{ github.event.pull_request.head.sha }}This action only runs anything meaningful on pull_request events — it reads
github.event.pull_request.* context that isn't populated on other event types.
- The repository must already be configured for release-please (a
release-please-config.jsonand.release-please-manifest.jsonat minimum) — this action only previews what release-please would do, it doesn't replace configuring it. - The checkout step before this action must use
fetch-depth: 0(full history and tags). - The repository's pull requests must be merged via "Create a merge commit" for the prediction to match reality; squash- or rebase-merge workflows don't produce the merge commit this action simulates, so the prediction may not reflect what actually lands.
- The calling workflow should declare a
concurrencygroup keyed per PR, or two runs racing can leave the older one's prediction on the PR — see Why the concurrency group matters.
Every requirement above is one you can get silently wrong: nothing fails, the prediction is just quietly computed from a false premise and then reported with exactly the confidence of a correct one. So the action checks its own preconditions first and reports anything missing:
| check | warns when |
|---|---|
| full history | the checkout is shallow (git rev-parse --is-shallow-repository), so release-please may not see back to the last release |
| merge method | the repository has merge commits disabled, meaning no PR here can produce the merge commit this action simulates |
edited trigger |
the running workflow file never mentions edited, so retitling a PR won't re-run it |
concurrency group |
the running workflow file never mentions concurrency, so two runs racing can leave the older one's prediction on the PR |
| release-please config | release-please-config.json or .release-please-manifest.json is absent from the checkout |
Anything found is reported twice: as a job annotation, and as a warning block at the top of the
preview comment (when post-comment is true), above the version and changelog it qualifies. The
comment is the copy that matters — an annotation is only seen by someone who opens the run summary,
whereas these warnings all amount to "the version below may be wrong", which needs to reach whoever
is about to click merge.
These only ever warn — preflight never fails the run — and they stay quiet whenever they can't tell for certain. A field the token can't read, a workflow file that isn't in the checkout, a reusable workflow defined in another repository: all pass silently rather than being guessed at. The asymmetry is deliberate. A warning that fires wrongly on a shared action teaches people to ignore all of its warnings, so missing a real misconfiguration is the cheaper mistake.
The edited check reads only the workflow that is currently running, which needs no YAML parsing:
this action does nothing except on pull_request events, so that file necessarily has the trigger
already, and the only remaining question is whether edited appears in it. A mention anywhere in the
file — a comment included — counts as configured. Scanning just that one file also avoids having to
work out which workflows reference this action, which isn't reliably answerable once a repository has
been renamed or the action is pinned by SHA.
Set preflight: 'false' to switch all of it off.
| Name | Required | Default | Description |
|---|---|---|---|
github-token |
yes | — | Token for release-please's own API calls and (if post-comment is true) for commenting/labeling the PR. Composite actions can't read the secrets context directly, so this must be passed in explicitly (e.g. secrets.GITHUB_TOKEN). |
post-comment |
no | 'true' |
Whether to also post/update a preview PR comment and set/remove the release label as a side effect. |
release-label |
no | 'RELEASE' |
Name of the label to create (if missing) and add/remove/recolor on the PR when post-comment is true, to flag whether merging it would trigger a release and (via color) what kind of bump it would be. |
preflight |
no | 'true' |
Whether to check the calling workflow and repository for the setup this action depends on and report anything missing, as job annotations and at the top of the preview comment. Warnings only — never fails the run, and stays quiet when it can't tell. See Preflight checks. |
package-name |
no | '' |
Key into .release-please-manifest.json this action reads the current version from and classifies the predicted bump against. Leave empty for the common single-package layout keyed by ".". See Multi-package repositories. |
| Name | Description |
|---|---|
version |
Predicted next version (e.g. 1.2.0) if this PR were merged right now. Empty when there are no releasable changes, and also when a release would happen but the version couldn't be predicted with confidence — check would-release to tell the two apart. |
would-release |
Whether merging this PR would trigger a release ("true"/"false"). |
bump-type |
Predicted bump type ("major"/"minor"/"patch") if this PR were merged right now. Empty in exactly the cases version is empty. |
Only plain X.Y.Z versions are recognized; a prerelease or otherwise differently shaped version
comes back empty rather than guessed at.
When post-comment is true, the release label is also recolored by bump-type: red (D93F0B) for
major, yellow (FBCA04) for minor, green (0E8A16) for patch, and grey (6E7781) when a release
would happen but the bump couldn't be determined (the preview comment says why). If your workflow
also colors this same label elsewhere (e.g. from release-please's own generated release PR, once
merged), keep both color schemes in sync so the label means the same thing everywhere it shows up.
The calling job needs:
permissions:
contents: read
pull-requests: write # only needed when post-comment is trueTagged releases follow semver (v1.0.0, v1.1.0, ...), with a moving major tag (v1) kept up to
date with the latest compatible release, per the
GitHub Actions versioning convention.
Pin to @v1 to track non-breaking updates, or to an exact tag for full reproducibility.
Nothing about releasing is manual. This repository runs release-please on itself: land a conventional
commit on master, release-please opens a release PR, and merging that PR cuts the tag and the GitHub
release. .github/workflows/publish-tag.yml then force-moves the
matching major tag (v1) onto the same commit.
A missed major-tag move is silent — @v1 consumers simply stay on older code, with nothing anywhere
to hint at it — which is exactly what happened before this was automated, and why it isn't left to
hand. publish-tag.yml is reachable two ways for the same reason: its push: tags trigger for a
hand-cut tag, and a direct workflow_call from the release workflow, because a tag pushed with
GITHUB_TOKEN can't be relied on to fire that trigger.
.github/workflows/release-please-preview.yml runs
this action on this repository's own pull requests, using uses: ./ rather than a released tag — so a
PR runs the action as that PR would change it, and one that breaks the prediction fails its own
preview instead of shipping.
That matters more than ordinary dogfooding. The bug behind #2 only ever surfaced on release-please's own release PR — a shape no unit test reproduces, and one this repository now generates for itself every release. A regression there shows up as a visibly wrong comment on the PR that caused it.
MIT