From f305bc0f717d8a30b5fdd72dd9d26427becf32af Mon Sep 17 00:00:00 2001 From: Vladimir Berezhnoy Date: Sat, 26 Sep 2026 03:50:04 -0400 Subject: [PATCH] fix: stop hardcoding the manifest's "." key, dedupe linked-versions changelog repeats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both bugs surfaced setting up release-please-forecast against a Cargo workspace using the cargo-workspace + linked-versions plugins (per-member packages, no "." package at all). - current_version/candidate read .release-please-manifest.json at "." unconditionally, so a manifest keyed by package path instead comes back empty with no indication anything went wrong. Add a package-name input to name the key explicitly, falling back to the manifest's first key (right for a linked-versions group with merge: true, wrong otherwise — preflight now warns when this fallback is in effect). - The preview comment repeated the same changelog body once per linked package, since linked-versions with merge: true still appears to walk one release candidate per component internally before printing a single combined update count. Collapse immediately-repeated identical
blocks defensively, regardless of root cause. Fixes #20. --- README.md | 40 ++++++++++++++++++++ action.yml | 109 +++++++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 133 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 3dd2a10..895f45b 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,14 @@ which is the premise this action exists to model. Squash-merging avoids it and t 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](#multi-package-repositories)): the changelog +release-please's own dry run produces there appears to repeat the identical +`
version…
` 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. @@ -86,6 +94,37 @@ release-please release is 0.2.0, with 0.1.0 never published at all. Start the ma `{".": "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. +## Multi-package repositories + +`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](#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: + +```yaml + - uses: non7top/release-please-forecast@v1 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + package-name: core +``` + +This 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. + ## Usage ```yaml @@ -256,6 +295,7 @@ Set `preflight: 'false'` to switch all of it off. | `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](#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](#multi-package-repositories). | ## Outputs diff --git a/action.yml b/action.yml index 95a8768..4a4ef72 100644 --- a/action.yml +++ b/action.yml @@ -44,6 +44,20 @@ inputs: certain. required: false default: 'true' + package-name: + description: >- + Key into `.release-please-manifest.json` this action reads the + current version from and classifies the predicted bump against. + Leave empty for the common layout, where the whole repository is one + package keyed by "."; this action falls back to the manifest's first + key when "." is absent, which only tracks the whole repo's version + correctly when every package in the manifest is kept at the same + version by a linked-versions group with merge: true. Set this + explicitly for any other multi-package layout (e.g. a cargo-workspace + repo whose packages release independently), naming the one package + this prediction should follow. + required: false + default: '' outputs: version: @@ -83,6 +97,7 @@ runs: GH_TOKEN: ${{ inputs.github-token }} REPO: ${{ github.repository }} WORKFLOW_REF: ${{ github.workflow_ref }} + PACKAGE_NAME: ${{ inputs.package-name }} run: | # Every warning goes two places: the job's annotations, and a file # the comment step reads back. An annotation is only ever seen by @@ -166,6 +181,15 @@ runs: fi if [ ! -f .release-please-manifest.json ]; then warn "No '.release-please-manifest.json' in this checkout, so the current version can't be read and the bump can't be classified — 'version' and 'bump-type' will come back empty." + elif [ -z "$PACKAGE_NAME" ] && ! jq -e 'has(".")' .release-please-manifest.json > /dev/null 2>&1; then + # A manifest keyed by package path rather than "." (cargo-workspace + # + linked-versions, say) has no single repo-wide version to read. + # Guessing the manifest's first key is only right when every + # package in it moves together, and this check can't tell that — + # it only sees the manifest, not the plugin config that groups + # packages — so it warns unconditionally rather than trying to + # infer linkage from release-please-config.json's shape. + warn "'.release-please-manifest.json' has no '.' key, so this action is guessing the repo's version from the manifest's first key. That's only correct if every package in the manifest is kept at the same version by a linked-versions group with merge: true — set the 'package-name' input to the package this prediction should follow otherwise. See the README." fi # release-please's own release PR is the one PR this action must *not* @@ -280,6 +304,7 @@ runs: HEAD_REF: ${{ github.head_ref }} BASE_REF: ${{ github.event.pull_request.base.ref }} MERGE_SHA: ${{ steps.build_merge.outputs.merge_sha }} + PACKAGE_NAME: ${{ inputs.package-name }} run: | # Echo the bump type between two versions, and nothing at all # unless $2 is strictly newer than $1 (including when either isn't @@ -308,19 +333,44 @@ runs: fi } + # Which key in .release-please-manifest.json holds the version this + # action tracks. classify_bump below can only ever compare two + # scalar versions, so this only ever speaks to one package's + # history — the common layout keys the whole repo at ".". A repo + # split into per-directory packages (release-please's + # cargo-workspace/linked-versions plugins, say) has no "." entry at + # all, so PACKAGE_NAME lets a caller name the package that stands + # in for the repo's version; failing that, the manifest's first key + # is used, since a linked-versions group with merge: true keeps + # every member at the same version anyway. Picking the wrong key + # here silently classifies against the wrong package rather than + # erroring, which is the tradeoff of guessing at all over the + # previous hardcoded "." simply coming back empty. + # + # `// empty` matters: indexing a manifest that lacks the resolved + # key makes jq print the string "null", which is not a version but + # is also not empty, so it would sail past every `-z` check below + # and be reported as a real version. + manifest_version() { + ref="$1" + if [ -z "$(git ls-tree --name-only "$ref" -- .release-please-manifest.json)" ]; then + return + fi + manifest=$(git show "$ref:.release-please-manifest.json") + if [ -n "$PACKAGE_NAME" ]; then + printf '%s' "$manifest" | jq -r --arg k "$PACKAGE_NAME" '.[$k] // empty' + elif printf '%s' "$manifest" | jq -e 'has(".")' > /dev/null 2>&1; then + printf '%s' "$manifest" | jq -r '."." // empty' + else + printf '%s' "$manifest" | jq -r '[.[]][0] // empty' + fi + } + # The last released version, read from the manifest on the base # branch — i.e. before this PR's own changes. Missing rather than # fatal: a repo configured some other way still gets a prediction # from release-please itself, just no bump classification. - # - # `// empty` matters: a manifest without a `.` entry makes jq print - # the string "null", which is not a version but is also not empty, - # so it would sail past every `-z` check below and be reported as - # the version bumped from. - current_version="" - if [ -n "$(git ls-tree --name-only preview-base -- .release-please-manifest.json)" ]; then - current_version=$(git show preview-base:.release-please-manifest.json | jq -r '."." // empty') - fi + current_version=$(manifest_version preview-base) # The PR that adopts release-please is the one PR whose base branch # has no manifest to read — because this PR is what adds it — and @@ -330,8 +380,8 @@ runs: # this PR leaves it, so that is the honest baseline to classify # against here too. Only consulted when the base branch yielded # nothing, so it can't override a real released version. - if [ -z "$current_version" ] && [ -n "$(git ls-tree --name-only preview-head -- .release-please-manifest.json)" ]; then - current_version=$(git show preview-head:.release-please-manifest.json | jq -r '."." // empty') + if [ -z "$current_version" ]; then + current_version=$(manifest_version preview-head) fi would_release=false @@ -342,9 +392,7 @@ runs: # own head rather than the simulated merge, so this still works # when simulating the merge hit a conflict. would_release=true - if [ -n "$(git ls-tree --name-only preview-head -- .release-please-manifest.json)" ]; then - candidate=$(git show preview-head:.release-please-manifest.json | jq -r '."." // empty') - fi + candidate=$(manifest_version preview-head) else # "Would open 0 pull requests" still matches /^Would open/, so # the trimmed file is never actually empty — check the real count @@ -371,7 +419,7 @@ runs: if [ -n "$bump_type" ]; then version="$candidate" elif [ -z "$current_version" ]; then - unreliable_reason="no current version could be read from \`.release-please-manifest.json\` at \`.\` — neither on \`$BASE_REF\` nor in this PR's own copy of it — so there is nothing to compare a prediction against" + unreliable_reason="no current version could be read from \`.release-please-manifest.json\` — neither on \`$BASE_REF\` nor in this PR's own copy of it — so there is nothing to compare a prediction against" elif [ -z "$candidate" ]; then unreliable_reason="release-please didn't report a plain \`X.Y.Z\` version for this release — this action only recognizes plain semver, so prereleases and other version shapes come back empty" else @@ -430,6 +478,35 @@ runs: capturing { print name_merge_commit($0) } ' dryrun-output.txt > trimmed-output.txt + # A linked-versions group with merge: true still appears to walk + # one release candidate per linked package internally, each + # wrapped in its own identical
version + # block, before ever printing the single combined `updates: N` + # line the capture above stops at — so the body just captured can + # already contain that same block repeated once per linked + # package. Not verified against release-please's own source; + # dropping repeats defensively here reads right either way this + # turns out to be caused. + # + # Only *consecutive* identical blocks collapse — `prev` resets on + # any non-blank line that isn't part of a block, so a repeat that + # is genuinely separated by other content is left alone. This is + # aimed at the one observed shape, not textual dedup in general. + awk ' + /^
/ { block = $0 "\n"; next } + block != "" { + block = block $0 "\n" + if ($0 == "
") { + if (block != prev) { printf "%s", block; prev = block } + block = "" + } + next + } + /^$/ { print; next } + { print; prev = "" } + ' trimmed-output.txt > trimmed-output.deduped.txt + mv trimmed-output.deduped.txt trimmed-output.txt + # Explain the pair, but only when there is a pair to explain — # a PR title that is not a conventional commit produces no # merge-commit entry at all, and a note about one would just be @@ -450,7 +527,7 @@ runs: echo echo "The changelog it would publish is this PR's own description, so there's nothing to dry-run here." elif [ "$IS_RELEASE_PR" = true ]; then - echo "⚠️ This is release-please's own release PR (\`$HEAD_REF\`), but the version it proposes couldn't be determined from \`.release-please-manifest.json\` at \`.\` — found \`${candidate:-nothing}\`, against \`${current_version:-nothing}\` on \`$BASE_REF\`. No version is being reported." + echo "⚠️ This is release-please's own release PR (\`$HEAD_REF\`), but the version it proposes couldn't be determined from \`.release-please-manifest.json\` — found \`${candidate:-nothing}\`, against \`${current_version:-nothing}\` on \`$BASE_REF\`. No version is being reported." elif [ -n "$unreliable_reason" ]; then echo "⚠️ A release would be triggered, but the version isn't being reported, because $unreliable_reason." echo