From 55a225d62b415467a891c8f31fa48cf6ec17ab17 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 10 Aug 2026 20:37:12 +0200 Subject: [PATCH 1/2] fix(ci): start release notes from a tag that exists The notes range was built from the previous release's *name*, never from a check that it exists. 1.9.3 shipped only as rc.1, so v1.9.4-rc.1 asked git for v1.9.3..v1.9.4-rc.1 and the publish step died on `unknown revision` -- after all four platforms had already built and uploaded their artifacts. The existing rc walk-down covers a skipped RC. Nothing covered a skipped stable, and that loop does not run at all for an rc.1 (`for n = 0; n >= 1`). This is not only about release notes: publish-msstore has `needs: publish-release`, so the same missing tag would have taken the Store deployment down with it on a stable promotion, which is the one place that failure is expensive to discover. Falls back to the nearest tag reachable from the release commit's parent, which is what "since the last release" meant to begin with -- v1.9.2 for the tag that failed, a 25-commit range. If no tag is reachable at all, the root commit keeps the range valid rather than handing git an empty left-hand side. --- .github/workflows/build.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f50e86d0..6b36b0c9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -745,6 +745,24 @@ jobs: fi done fi + # Everything above computes what the previous release was *called* and + # never checks that it exists. A version line that stopped at its RC + # makes that a name for nothing: 1.9.3 shipped only as rc.1, so + # v1.9.4-rc.1 asked git for v1.9.3..v1.9.4-rc.1 and the publish step + # died on `unknown revision` -- after all four platforms had already + # built, and with publish-msstore sitting behind publish-release, so + # the same gap would silently block a stable release's Store + # deployment too. The rc walk-down above handles a skipped RC; nothing + # handled a skipped stable, and it does not run at all for an rc.1. + if ! git rev-parse -q --verify "refs/tags/${NOTES_START_TAG}" >/dev/null; then + # The nearest tag reachable from the release commit's parent, which + # is what "since the last release" meant in the first place. Empty + # only when no tag is reachable at all (a first release), so fall + # back to the root commit to keep the range valid either way. + FALLBACK="$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)" + NOTES_START_TAG="${FALLBACK:-$(git rev-list --max-parents=0 HEAD | tail -1)}" + echo "Previous-release tag did not exist; starting notes from ${NOTES_START_TAG}" + fi echo "Computed notes_start_tag=${NOTES_START_TAG} for tag=${TAG}" echo "tag=$TAG" >> "$GITHUB_OUTPUT" From 2918c4aa11bbfa78ac745fc1aab2ec163186584e Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 10 Aug 2026 23:03:01 +0200 Subject: [PATCH 2/2] fix(ci): let each consumer decide what "no previous release" means The fallback filled NOTES_START_TAG with the root commit's SHA so the range would always be valid. That is right for the RC path, which feeds it to `git log`, and wrong for the stable path, which feeds it to `gh release create --notes-start-tag` -- the API's previous_tag_name, which takes a tag NAME. A SHA there is an invalid argument, not a lenient fallback, so the one case the fallback existed for would still have failed. It now stays empty when no tag is reachable, and each consumer handles that: the RC notes walk the whole history and drop the "since " wording rather than printing a blank where a tag should be, and the stable path omits the flag so GitHub picks its own previous release -- which is the correct answer when there is not one. Unreachable on this repo today (v1.9.2 is right there), so this is about the next fork or a fresh repository rather than about 1.9.4. --- .github/workflows/build.yml | 45 ++++++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6b36b0c9..9c789b0a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -756,12 +756,15 @@ jobs: # handled a skipped stable, and it does not run at all for an rc.1. if ! git rev-parse -q --verify "refs/tags/${NOTES_START_TAG}" >/dev/null; then # The nearest tag reachable from the release commit's parent, which - # is what "since the last release" meant in the first place. Empty - # only when no tag is reachable at all (a first release), so fall - # back to the root commit to keep the range valid either way. - FALLBACK="$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)" - NOTES_START_TAG="${FALLBACK:-$(git rev-list --max-parents=0 HEAD | tail -1)}" - echo "Previous-release tag did not exist; starting notes from ${NOTES_START_TAG}" + # is what "since the last release" meant in the first place. Left + # EMPTY when no tag is reachable at all, rather than filled with the + # root commit: the stable path hands this to `gh release create + # --notes-start-tag`, which is the API's previous_tag_name and takes + # a tag NAME -- a commit SHA there is not a lenient fallback, it is + # an invalid argument. Each consumer below decides what "no previous + # release" means for it. + NOTES_START_TAG="$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)" + echo "Previous-release tag did not exist; using ${NOTES_START_TAG:-}" fi echo "Computed notes_start_tag=${NOTES_START_TAG} for tag=${TAG}" @@ -820,14 +823,26 @@ jobs: # the re-cut was for. The commit range is the actual diff and can't lie. # Stable releases keep --generate-notes below: they're the public-facing # ones and want the PR links and the New Contributors section. + # With no previous tag at all, the range is the whole history and + # there is nothing to compare against, so say so rather than + # emitting "since " with a blank where a tag should be. + if [[ -n "$NOTES_START_TAG" ]]; then + RC_RANGE="${NOTES_START_TAG}..${TAG}" + RC_HEADING="## Changes since ${NOTES_START_TAG}" + RC_LINK="**Full Changelog**: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/compare/${NOTES_START_TAG}...${TAG}" + else + RC_RANGE="$TAG" + RC_HEADING="## Changes" + RC_LINK="**Full Changelog**: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commits/${TAG}" + fi { - echo "## Changes since ${NOTES_START_TAG}" + echo "$RC_HEADING" echo git log --no-merges --reverse --pretty='- %s' \ --invert-grep --grep='^chore(release): bump to' \ - "${NOTES_START_TAG}..${TAG}" + "$RC_RANGE" echo - echo "**Full Changelog**: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/compare/${NOTES_START_TAG}...${TAG}" + echo "$RC_LINK" } > "${RUNNER_TEMP}/rc-notes.md" cat "${RUNNER_TEMP}/rc-notes.md" NOTES_ARGS=(--notes-file "${RUNNER_TEMP}/rc-notes.md") @@ -837,7 +852,17 @@ jobs: # prior release by date) doesn't work for this fork because the v1.4.0 # release in the fork was re-published after v1.5.0, which makes GitHub # pick v1.4.0 as the "previous" for any v1.5.x release. - NOTES_ARGS=(--generate-notes --notes-start-tag "$NOTES_START_TAG") + # + # Omitted entirely when there is no previous tag: this maps to the + # API's previous_tag_name, which takes a tag NAME. Passing an empty + # string or a commit SHA is an invalid argument, not a graceful + # degradation. Without it GitHub falls back to its own choice of + # previous release, which is exactly right when there isn't one. + if [[ -n "$NOTES_START_TAG" ]]; then + NOTES_ARGS=(--generate-notes --notes-start-tag "$NOTES_START_TAG") + else + NOTES_ARGS=(--generate-notes) + fi fi # shellcheck disable=SC2086 gh release create "$TAG" "${FILES[@]}" \