From 3f50f55f86d4e9510d7fef151e36c7acdade7d80 Mon Sep 17 00:00:00 2001 From: hyperpolymath-bot Date: Tue, 22 Sep 2026 15:45:22 +0000 Subject: [PATCH] fix(changelog): break infinite regenerate self-trigger loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaced by maa-framework#219: the changelog-reusable workflow was opening an unending chain of PRs (#199, #202–#219, ...) because each squash-merge of a changelog PR added a new conventional commit (`chore(changelog): regenerate from conventional commits (#NNN)`) which git-cliff picked up under Chores, producing a fresh diff and opening the next PR. Two complementary guards added: 1. templates/cliff.toml — add a skip = true commit_parser for the bot's own `chore(changelog): regenerate ...` subjects, placed BEFORE the generic ^chore matcher (commit_parsers is first-match-wins). This is the primary fix because it survives GitHub's squash-merge (which uses the PR title as the new subject on main). 2. changelog-reusable.yml — add a `guard` job that short-circuits when the triggering HEAD commit already carries a [skip changelog] marker, and tag every commit/PR body the workflow produces with that marker. In pr-back mode, also close older open bot/changelog-* PRs against the same base to prevent duplicate-PR spam when pushes land between merges. Verified end-to-end against maa-framework's current HEAD: after applying the cliff.toml skip rule, re-running git-cliff produces zero diff even after a simulated squash-merge of the cleanup PR. --- .github/workflows/changelog-reusable.yml | 75 +++++++++++++++++++++++- templates/cliff.toml | 9 +++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/.github/workflows/changelog-reusable.yml b/.github/workflows/changelog-reusable.yml index 72dae03ef..e1f99d068 100644 --- a/.github/workflows/changelog-reusable.yml +++ b/.github/workflows/changelog-reusable.yml @@ -29,6 +29,19 @@ # `check-only` - Render the changelog but do NOT commit. Fail the job if # the on-disk CHANGELOG.md disagrees with the regenerated # output. Use as a `pull_request` gate. +# +# Loop-prevention (surfaced by maa-framework PR #219): two complementary guards +# prevent the workflow from reacting to its own output and opening an infinite +# chain of changelog PRs: +# 1. The canonical cliff.toml has a `skip = true` parser for the bot's own +# "chore(changelog): regenerate from conventional commits" subjects. This +# is the primary fix because it survives GitHub squash-merge (which uses +# the PR title as the new subject). +# 2. Every commit/PR this workflow produces carries a [skip changelog] +# marker in its body, and the `guard` job below bails out entirely when +# the triggering HEAD commit already has that marker. This protects +# `commit-back` mode callers (where the auto commit lands directly on +# main without a squash step) and provides defense-in-depth for pr-back. on: workflow_call: @@ -54,7 +67,42 @@ permissions: contents: read jobs: + guard: + name: Check for self-trigger loop + runs-on: ${{ inputs.runs-on }} + outputs: + skip: ${{ steps.loopguard.outputs.skip }} + steps: + - name: Checkout caller (HEAD only is enough for the guard) + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ github.repository }} + ref: ${{ github.ref }} + fetch-depth: 1 + path: caller + + - name: Loop guard + id: loopguard + working-directory: caller + run: | + set -euo pipefail + # If the push that triggered us is itself a changelog regeneration + # (commit-back's direct commit, or a squash-merged pr-back PR whose + # body still contains the [skip changelog] marker placed below), + # skip the whole generate job. cliff.toml's subject-matcher is the + # primary fix; this guard handles cases where the marker is on the + # body (e.g. commit-back mode, rebase-merges, mergers who preserve + # the PR body). + if git log -1 --pretty=%B | grep -q '\[skip changelog\]'; then + echo "HEAD commit carries [skip changelog]; skipping generate job to break the regenerate loop." + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + generate: + needs: guard + if: ${{ needs.guard.outputs.skip != 'true' }} timeout-minutes: 10 name: Generate CHANGELOG.md runs-on: ${{ inputs.runs-on }} @@ -151,6 +199,8 @@ jobs: fi git commit -m "chore(changelog): regenerate from conventional commits + [skip changelog] + Auto-generated by hyperpolymath/standards changelog-reusable.yml. See standards/templates/cliff.toml for the canonical config. @@ -174,12 +224,35 @@ jobs: git config user.name "github-actions[bot]" git checkout -b "$branch" git add CHANGELOG.md - git commit -m "chore(changelog): regenerate from conventional commits" + git commit -m "chore(changelog): regenerate from conventional commits + + [skip changelog]" git push -u origin "$branch" + # Before opening a new PR, close any older open bot/changelog-* PRs + # against the same base so we don't accumulate duplicate PRs if a + # maintainer hasn't merged the previous one before another push lands. + for pr in $(gh pr list --repo "${{ github.repository }}" \ + --base "${{ github.ref_name }}" \ + --head 'bot/changelog-' \ + --state open \ + --json number,headRefName \ + --jq '.[] | select(.headRefName != "'"$branch"'") | .number'); do + echo "Closing superseded changelog PR #$pr" + gh pr close "$pr" --repo "${{ github.repository }}" \ + --comment "Superseded by a newer changelog regeneration PR." + done gh pr create \ + --repo "${{ github.repository }}" \ --title "chore(changelog): regenerate from conventional commits" \ --body "Auto-generated by hyperpolymath/standards changelog-reusable.yml. + [skip changelog] + + This PR body carries the [skip changelog] marker so that a + squash-merge of this PR will not re-trigger the changelog + workflow (loop-prevention added in response to maa-framework#219). + Please do not remove that marker when editing the description. + Closes part of the 2026-05-26 CHANGELOG gap (standards#197 audit)." \ --base "${{ github.ref_name }}" \ --head "$branch" diff --git a/templates/cliff.toml b/templates/cliff.toml index a6b34b750..91e891a4b 100644 --- a/templates/cliff.toml +++ b/templates/cliff.toml @@ -54,6 +54,15 @@ commit_parsers = [ { message = "^docs", group = "Documentation" }, { message = "^test", group = "Tests" }, { message = "^ci", group = "CI" }, + # Never re-emit the auto-changelog's own commits; that causes an infinite + # regenerate loop (surfaced by maa-framework PR #219). Must come BEFORE + # the generic ^chore matcher because commit_parsers is first-match-wins. + # The regex matches both the bot's branch commit subject and the default + # GitHub squash-merge subject ("... (#NNN)"), which is critical because + # in pr-back mode the squash-merge subject (not the branch commit body) + # survives into main. + { message = "^chore\\(changelog\\):\\s*regenerate from conventional commits", skip = true }, + { body = "\\[skip changelog\\]", skip = true }, { message = "^chore", group = "Chores" }, { message = "^style", skip = true }, { message = "^revert", group = "Reverted" },