From c1fa14722d6d5325005bdcf7b9bb39b72d684e95 Mon Sep 17 00:00:00 2001 From: Jose Armesto Date: Wed, 3 Jun 2026 17:25:40 +0200 Subject: [PATCH] feat: auto-update CHANGELOG.md on release After `gh release create` succeeds, splice the same release-notes body into CHANGELOG.md and push the resulting commit back to the branch. - New sentinel comment `` in CHANGELOG.md marks the insertion point. Pre-conventional-commit history (everything below the marker) is preserved verbatim. - cliff.toml gains a commit_parser skip rule for the auto-generated `chore: update CHANGELOG.md` commit so it doesn't trigger spurious patch bumps or appear as noise in subsequent releases' notes. - The commit-back uses `[skip ci]` to prevent re-triggering the workflow on itself (GitHub Actions + CircleCI both honor it). - Pull-rebase before push handles the narrow race window where a human merges between job start and commit-back. --- .github/workflows/auto-release.yaml | 86 +++++++++++++++++++++++++++++ CHANGELOG.md | 2 + cliff.toml | 11 ++++ 3 files changed, 99 insertions(+) diff --git a/.github/workflows/auto-release.yaml b/.github/workflows/auto-release.yaml index c951ca3bc..b50b644e3 100644 --- a/.github/workflows/auto-release.yaml +++ b/.github/workflows/auto-release.yaml @@ -144,3 +144,89 @@ jobs: --title "$TAG" \ --notes-file release-notes.md \ --target "$GITHUB_SHA" + + # Splice the newly-rendered release-notes.md into CHANGELOG.md and + # push the resulting commit back to the branch. The release tag and + # GitHub Release already exist at this point (previous step) — this + # step is best-effort historical record-keeping. If it fails (branch + # protection rejects the push, race with a human commit, etc.) the + # release itself is unaffected; the file falls one release behind and + # self-heals on the next successful run. + # + # The splice uses a sentinel comment `` + # in CHANGELOG.md as the insertion point. Pre-conventional-commit + # history above the marker is preserved verbatim. New sections are + # inserted ABOVE older auto-generated ones, so reading top-to-bottom + # walks releases newest -> oldest, matching the existing file's order. + - name: Update CHANGELOG.md and commit back + if: steps.decide.outputs.tag != '' + env: + TAG: ${{ steps.decide.outputs.tag }} + REPO: ${{ github.repository }} + BRANCH: ${{ github.ref_name }} + run: | + set -euo pipefail + + MARKER='' + if ! grep -qF "$MARKER" CHANGELOG.md; then + echo "Marker '$MARKER' not found in CHANGELOG.md - skipping update." + echo "(The release itself succeeded; CHANGELOG.md will self-heal once the marker is restored.)" + exit 0 + fi + + # Previous reachable tag for the compare URL. Empty on the first + # release of a repo, in which case we drop the compare link. + PREV=$(git describe --tags --abbrev=0 --match='v*.*.*' "${TAG}^" 2>/dev/null || echo "") + DATE=$(date -u +%Y-%m-%d) + REPO_URL="https://github.com/${REPO}" + + if [ -n "$PREV" ]; then + HEADER="## [${TAG}](${REPO_URL}/compare/${PREV}...${TAG}) - ${DATE}" + else + HEADER="## [${TAG}] - ${DATE}" + fi + + # Build the new section. release-notes.md (from the earlier cliff + # step) already contains the Added/Fixed/Changed/Security bullet + # groups for this release - the only thing missing is the version + # header above them. + { + echo "$HEADER" + echo + cat release-notes.md + } > /tmp/new-section.md + + # awk-splice: print every line, and after the marker line, emit a + # blank separator followed by the new section verbatim. + awk -v marker="$MARKER" ' + { print } + $0 == marker { + print "" + while ((getline line < "/tmp/new-section.md") > 0) print line + close("/tmp/new-section.md") + } + ' CHANGELOG.md > CHANGELOG.new + mv CHANGELOG.new CHANGELOG.md + + if git diff --quiet -- CHANGELOG.md; then + echo "CHANGELOG.md unchanged after splice - nothing to commit." + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add CHANGELOG.md + # `[skip ci]` keeps GitHub Actions (and CircleCI) from running on + # this commit. Combined with the cliff.toml skip rule for + # `^chore: update CHANGELOG.md`, future releases also exclude it + # from bump computation and rendered notes. + git commit -m "chore: update CHANGELOG.md for ${TAG} [skip ci]" + + # Pull-rebase guards the narrow window between job start and push + # where a human could have merged something else to the branch. + # The concurrency group serializes auto-release jobs against each + # other, but not against human pushes. If rebase fails (genuine + # conflict on CHANGELOG.md, exotic), the job errors out - the + # release is already shipped, and the next release re-splices. + git pull --rebase origin "$BRANCH" + git push origin "HEAD:$BRANCH" diff --git a/CHANGELOG.md b/CHANGELOG.md index 70a799829..fef6d5bc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ All notable changes to this project will be documented in this file. + + ## [Unreleased] ### Added diff --git a/cliff.toml b/cliff.toml index 646681d1f..45854d431 100644 --- a/cliff.toml +++ b/cliff.toml @@ -46,6 +46,17 @@ commit_preprocessors = [ # include it anywhere either. Bump decision is unaffected by these group # names: git-cliff only bumps on feat / fix / breaking regardless. commit_parsers = [ + # The auto-release workflow commits a "chore: update CHANGELOG.md for vX.Y.Z" + # commit back to the branch after each release (see .github/workflows/auto- + # release.yaml). `[skip ci]` in that commit message prevents the workflow + # itself from re-running on it, but a *subsequent* feature push would still + # see the chore in the --unreleased range and: (a) potentially trigger a + # patch bump on its own, (b) show up in the next release's "Changed" + # section as noise. Skipping this specific message via commit_parsers + # filters it out of both bump computation and the rendered notes. Must be + # listed BEFORE the general `^chore` rule — parsers are evaluated in order + # and first match wins. + { message = "^chore: update CHANGELOG\\.md", skip = true }, { message = "^feat", group = "Added" }, { message = "^fix", group = "Fixed" }, { message = "^refactor", group = "Changed" },