Skip to content

Fix release workflow: stuck-at-v1.7.6 version bump + leaked 403 JSON in changelog#203

Open
JeromySt wants to merge 1 commit intomainfrom
fix/release-version-bump-and-notes-leak
Open

Fix release workflow: stuck-at-v1.7.6 version bump + leaked 403 JSON in changelog#203
JeromySt wants to merge 1 commit intomainfrom
fix/release-version-bump-and-notes-leak

Conversation

@JeromySt
Copy link
Copy Markdown
Member

@JeromySt JeromySt commented May 1, 2026

Summary

Fixes two coupled defects in the auto-pre-release pipeline that produced the v1.7.6-pre2/3/4 series with garbage release bodies:

Bug Visible symptom Root cause
Version stuck at v1.7.6-preN forever after v1.7.6 shipped Latest pre-release is v1.7.6-pre4 even though #194/#197/#200/#201 have all merged since v1.7.6 was published sort --version-sort orders v1.7.6-pre1 AFTER v1.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 existing v1.7.6-pre1, the safety loop walks up to the next free -pre, and the cycle repeats forever.
403 JSON literal in pre-release bodies Each v1.7.6-preN body contains the raw error JSON from releases/generate-notes instead of a real changelog create_changelog job declared permissions: contents: read but the releases/generate-notes API requires contents: write. The 403 response body leaked into NOTES via gh api ... --jq '.body' 2>/dev/null and was appended to CHANGELOG.md.

Fix 1 — semver-aware version bump with [bump:minor] hybrid

Replaced the linear sort+grep+regex with an explicit semver ranker that tracks LATEST_OFFICIAL and LATEST_PRE separately:

  • If LATEST_PRE belongs to a version strictly newer than LATEST_OFFICIAL → keep iterating: -preN-pre(N+1).
  • Otherwise → 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 commit message (or PR title for squash-merge) of the change that warrants it.

Dry-run scenarios (all verified)

Tag set Marker Next tag
v1.7.6 + v1.7.6-pre4 (none) v1.7.7-pre1 ← the bug
v1.7.6 + v1.7.6-pre4 [bump:minor] v1.8.0-pre1
v1.7.6 + v1.7.7-pre1 (none) v1.7.7-pre2 (still iterating)
v1.7.6 + v1.7.7 + v1.7.7-pre1 (none) v1.7.8-pre1 (after promotion)
v1.7.6 + v1.7.7 + v1.7.7-pre1 [bump:minor] v1.8.0-pre1
(empty) v1.0.0-pre1 (cold start)
v1.0.0-pre1 + v1.0.0-pre2 (no official) v1.0.0-pre3
v1.7.6-pre4 + v1.7.6 + v1.7.7 + v1.7.8 (none) 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.

Fix 2 — eliminate the 403 JSON leak

Three coordinated changes to create_changelog:

  1. permissions: contents: write — the documented minimum for releases/generate-notes.
  2. actions/checkout@v6 added to the job. Without it, gh release list / gh release view log failed to run git: fatal: not a git repository and PREVIOUS_TAG came up empty (visible in the failed run's log).
  3. Explicit success check replacing --jq '.body' 2>/dev/null || echo "":
    • 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 ::warning:: to the workflow log, leave NOTES empty.

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.
  • 8/8 dry-run scenarios produce the expected next tag.
  • Diff stat: +170 / -44 lines in .github/workflows/dotnet.yml.

Out of scope (separate decision)

Cleanup of the existing v1.7.6-pre2/3/4 release bodies. Options after this lands:

  • Delete the corrupted pre-releases (cleanest).
  • Edit each body manually with the real notes.
  • Leave them alone — the new "Previous Releases" filter masks them in future changelogs.

Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com

…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants