Fix release workflow: stuck-at-v1.7.6 version bump + leaked 403 JSON in changelog#203
Open
Fix release workflow: stuck-at-v1.7.6 version bump + leaked 403 JSON in changelog#203
Conversation
…in changelog
Two coupled defects in the auto-pre-release pipeline that produced the
v1.7.6-pre2/pre3/pre4 series with garbage release bodies:
1) Version stuck at v1.7.6-preN forever after v1.7.6 shipped
============================================================
The previous logic in `create_release` was:
CURRENT_TAG=$(git tag | grep ... | sort --version-sort | tail -n1)
if pre: NEW_TAG = base + `-pre`
else: NEW_TAG = base + `-pre1` # then collision-loop incremented
Two problems compounded:
* `sort --version-sort` orders `v1.7.6-pre1` AFTER `v1.7.6`, opposite
of semver. So once any -preN tag exists, the script always picked it and
entered the "increment pre" branch.
* Even if we fixed the sort, the "else: -pre1" branch would land on a tag
that already exists (`v1.7.6-pre1` was created the same day as
`v1.7.6`), and the collision loop would walk up through every existing
-preN until landing back on the same v1.7.6-pre(N+1) sequence.
Net effect: nothing ever escaped v1.7.6 even though commits #194, #197,
#200, #201 had landed on main since v1.7.6 was officially published.
Replaced with a semver-aware ranker that tracks LATEST_OFFICIAL and
LATEST_PRE separately and decides:
* If LATEST_PRE belongs to a version strictly NEWER than LATEST_OFFICIAL,
keep iterating: -preN -> -pre(N+1).
* Otherwise (the historical stuck-at case), start a NEW cycle bumped from
LATEST_OFFICIAL and emit -pre1.
Major is locked at 1 per project policy. MINOR vs PATCH is decided by
scanning `git log <latest_official>..HEAD` for the literal marker
`[bump:minor]` in any commit body. Default is PATCH. To force a minor
bump, include `[bump:minor]` in the merge commit message (or PR title
when squash-merging) of the change that warrants it.
Dry-run scenarios verified (synthetic tag lists, simulated logic):
v1.7.6 + v1.7.6-pre4 -> v1.7.7-pre1 (the user's bug)
v1.7.6 + v1.7.6-pre4 + [bump:minor]-> v1.8.0-pre1
v1.7.6 + v1.7.7-pre1 -> v1.7.7-pre2 (still iterating)
v1.7.6 + v1.7.7 + v1.7.7-pre1 -> v1.7.8-pre1 (after promotion)
<empty> -> v1.0.0-pre1 (cold start)
v1.7.6-pre4 + v1.7.6 + v1.7.7 + v1.7.8 -> v1.7.9-pre1 (catch-up)
The defensive collision-loop is preserved (10 tries instead of 5) so a
race against a concurrent push or local-state drift still resolves.
2) Leaked 403 JSON in pre-release bodies
======================================
`create_changelog` declared `permissions: contents: read`, but
`repos/.../releases/generate-notes` requires `contents: write` per
GitHub's REST docs. The 403 response body
`{"message":"Resource not accessible by integration",...}` was leaking
into the NOTES variable and ending up as the literal release-body content
for every auto-pre-release on main.
Three coordinated fixes:
* Granted the job `contents: write` (the API's documented minimum).
* Added `actions/checkout@v6` so `gh release list` / `gh release view`
have a git context to discover the repo from. Previously the job logged
`failed to run git: fatal: not a git repository` and PREVIOUS_TAG came
up empty.
* Replaced the silent `--jq '.body' 2>/dev/null || echo ""` pattern with
an explicit success check: capture stdout and stderr to temp files,
check `gh api` exit status, only consume the body when 2xx AND
`jq -r '.body // empty'` returned a non-empty string. On failure,
emit a workflow `::warning::` and leave NOTES empty rather than
letting the response body leak.
* Defensive filter in the cumulative "Previous Releases" loop so the
v1.7.6-pre2/3/4 historical garbage (which is still in those release
bodies) does not propagate into every future changelog.
Verified
--------
* `python -c "import yaml; yaml.safe_load(open('.github/workflows/dotnet.yml'))"`
— YAML parses cleanly.
* Dry-run of the version-bump algorithm (8 scenarios, all expected values).
* Diff stat: +170 / -44 in dotnet.yml.
Cleanup of the existing v1.7.6-pre2/3/4 release bodies is intentionally
NOT in this PR — pending user decision (delete the bad pre-releases vs
overwrite their bodies vs leave as-is and let the filter mask them).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
elantiguamsft
approved these changes
May 1, 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.
Summary
Fixes two coupled defects in the auto-pre-release pipeline that produced the
v1.7.6-pre2/3/4series with garbage release bodies:v1.7.6-preNforever afterv1.7.6shippedv1.7.6-pre4even though #194/#197/#200/#201 have all merged sincev1.7.6was publishedsort --version-sortordersv1.7.6-pre1AFTERv1.7.6(opposite of semver), so the script always picks a pre as the "latest tag" and enters the "increment pre" branch. Even with semver-correct sort, the "if last is official → make-pre1" path collides with the existingv1.7.6-pre1, the safety loop walks up to the next free-pre, and the cycle repeats forever.v1.7.6-preNbody contains the raw error JSON fromreleases/generate-notesinstead of a real changelogcreate_changelogjob declaredpermissions: contents: readbut thereleases/generate-notesAPI requirescontents: write. The 403 response body leaked intoNOTESviagh api ... --jq '.body' 2>/dev/nulland was appended toCHANGELOG.md.Fix 1 — semver-aware version bump with
[bump:minor]hybridReplaced the linear sort+grep+regex with an explicit semver ranker that tracks
LATEST_OFFICIALandLATEST_PREseparately:LATEST_PREbelongs to a version strictly newer thanLATEST_OFFICIAL→ keep iterating:-preN→-pre(N+1).LATEST_OFFICIALand emit-pre1.Major is locked at 1 per project policy.
MINOR vs PATCH is decided by scanning
git log <latest_official>..HEADfor the literal marker[bump:minor]in any commit body. Default is PATCH. To force a minor bump, include[bump:minor]in the commit message (or PR title for squash-merge) of the change that warrants it.Dry-run scenarios (all verified)
v1.7.6+v1.7.6-pre4v1.7.7-pre1← the bugv1.7.6+v1.7.6-pre4[bump:minor]v1.8.0-pre1v1.7.6+v1.7.7-pre1v1.7.7-pre2(still iterating)v1.7.6+v1.7.7+v1.7.7-pre1v1.7.8-pre1(after promotion)v1.7.6+v1.7.7+v1.7.7-pre1[bump:minor]v1.8.0-pre1v1.0.0-pre1(cold start)v1.0.0-pre1+v1.0.0-pre2(no official)v1.0.0-pre3v1.7.6-pre4+v1.7.6+v1.7.7+v1.7.8v1.7.9-pre1(catch-up)The defensive collision-loop is preserved (10 tries instead of 5) so a race against a concurrent push or local-state drift still resolves.
Fix 2 — eliminate the 403 JSON leak
Three coordinated changes to
create_changelog:permissions: contents: write— the documented minimum forreleases/generate-notes.actions/checkout@v6added to the job. Without it,gh release list/gh release viewlogfailed to run git: fatal: not a git repositoryandPREVIOUS_TAGcame up empty (visible in the failed run's log).--jq '.body' 2>/dev/null || echo "":gh apiexit status.jq -r '.body // empty'returned a non-empty string.::warning::to the workflow log, leaveNOTESempty.Defensive filter in the cumulative "Previous Releases" loop: skip any historical release body that contains
Resource not accessible by integration, so the existing v1.7.6-pre2/3/4 garbage cannot propagate into future changelogs.Verified
python -c "import yaml; yaml.safe_load(...)": parses cleanly.+170 / -44lines in.github/workflows/dotnet.yml.Out of scope (separate decision)
Cleanup of the existing
v1.7.6-pre2/3/4release bodies. Options after this lands:Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com