From 2d9f6eeda4929c2db6a57c2925ba6fd591ffc64e Mon Sep 17 00:00:00 2001 From: Tsvika Shapira Date: Sun, 27 Sep 2026 15:17:02 +0300 Subject: [PATCH] ci(pins): attach release notes to the template pins PR The PR body was a hard-wrapped YAML block, which GitHub rendered as ragged lines, and carried no release notes to review the bumps against. A new script reads the pin changes from `git diff` and writes the body, one line per paragraph, with the notes of every release in each bumped range in a collapsed
. The notes are quoted as Dependabot does: links go through redirect.github.com, bare #123 points upstream, and @mentions sit in code spans, so upstream gets no pings or backlinks. Also correct the dispatch-step comment: a PR opened with GITHUB_TOKEN does get `pull_request` runs, but they are held for approval. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GyMoD8tx4oz1bpJ1tEL845 --- .github/scripts/pins_pr_body.py | 145 ++++++++++++++++++ .github/workflows/update-template-pins.yml | 17 +- .../.github/workflows/deps-update.yml | 4 +- 3 files changed, 155 insertions(+), 11 deletions(-) create mode 100644 .github/scripts/pins_pr_body.py diff --git a/.github/scripts/pins_pr_body.py b/.github/scripts/pins_pr_body.py new file mode 100644 index 0000000..aa2e114 --- /dev/null +++ b/.github/scripts/pins_pr_body.py @@ -0,0 +1,145 @@ +"""Write the body of the template action pins PR, with the release notes of each bump. + +Reads the pin changes from `git diff` of the template's workflows, and fetches the +notes of every release in each bumped range with `gh`. Prints the body to stdout. +""" + +import json +import re +import subprocess +import sys + +WORKFLOWS = "project_name/.github/workflows" +# `- - uses: owner/repo/optional/path@ # v1.2.3` +PIN = re.compile( + r"^(?P[-+])\s*(?:-\s+)?uses:\s*(?P[\w.-]+/[\w.-]+)\S*@[0-9a-f]{40}" + r"\s*#\s*(?P\S+)" +) +# GitHub rejects PR bodies over 65536 characters. +MAX_NOTES = 5_000 +MAX_BODY = 60_000 + +# One line per paragraph, since GitHub renders line breaks inside a paragraph. +INTRO = ( + "Automated update of the SHA-pinned actions under" + " `project_name/.github/workflows/`, which Dependabot cannot reach." + " Each pin is moved to the latest release" + " and its `# vX.Y.Z` comment is rewritten to match.\n" + "\n" + "Review the diff as you would a Dependabot PR:" + " check the release notes below for anything behavioural," + " especially on major bumps.\n" +) + + +def version_key(version: str) -> tuple[int, ...]: + return tuple(int(n) for n in re.findall(r"\d+", version)) + + +def quote_notes(notes: str, repo: str) -> str: + """Quote upstream notes so that they neither notify nor cross-reference upstream. + + As Dependabot does: `redirect.github.com` links create no "mentioned this" + backlinks, bare `#123` would otherwise point at this repo, and `@user` in a code + span pings no one. + """ + notes = notes.replace("https://github.com/", "https://redirect.github.com/") + notes = re.sub( + r"(? {line}".rstrip() for line in notes.splitlines()) + + +def gh(*args: str) -> str: + return subprocess.run( + ["gh", *args], capture_output=True, text=True, check=True + ).stdout + + +def pin_changes() -> dict[str, tuple[str, str]]: + """Map each bumped `owner/repo` to its (oldest old, newest new) version.""" + diff = subprocess.run( + ["git", "diff", "-U0", "--", WORKFLOWS], + capture_output=True, + text=True, + check=True, + ).stdout + versions: dict[str, dict[str, set[str]]] = {} + for line in diff.splitlines(): + if m := PIN.match(line): + by_sign = versions.setdefault(m["repo"], {"-": set(), "+": set()}) + by_sign[m["sign"]].add(m["version"]) + return { + repo: (min(v["-"], key=version_key), max(v["+"], key=version_key)) + for repo, v in sorted(versions.items()) + if v["-"] and v["+"] and v["-"] != v["+"] + } + + +def release_notes(repo: str, old: str, new: str) -> str: + """The notes of every stable release of `repo` after `old`, up to `new`.""" + releases = json.loads( + gh( + "release", + "list", + "-R", + repo, + "-L", + "100", + "--exclude-drafts", + "--exclude-pre-releases", + "--json", + "tagName", + ) + ) + lo, hi = version_key(old), version_key(new) + tags = sorted( + (r["tagName"] for r in releases if lo < version_key(r["tagName"]) <= hi), + key=version_key, + reverse=True, + ) + sections = [] + for tag in tags: + release = json.loads( + gh("release", "view", tag, "-R", repo, "--json", "body,url") + ) + notes = release["body"].strip() or "_No release notes._" + if len(notes) > MAX_NOTES: + notes = ( + notes[:MAX_NOTES] + + f"\n\n… truncated, see [the release]({release['url']})." + ) + notes = quote_notes(notes, repo) + sections.append(f"### [{tag}]({release['url']})\n\n{notes}\n") + if not sections: + sections.append(f"No releases found between `{old}` and `{new}`.\n") + compare = f"https://github.com/{repo}/compare/{old}...{new}" + return ( + f"
\n{repo} {old} → {new}\n\n" + f"[Compare {old}...{new}]({compare})\n\n" + + "\n".join(sections) + + "\n
\n" + ) + + +def main() -> None: + body = INTRO + "\n## Release notes\n\n" + for repo, (old, new) in pin_changes().items(): + try: + section = release_notes(repo, old, new) + except subprocess.CalledProcessError as exc: + print(f"{repo}: {exc.stderr}", file=sys.stderr) + section = f"- {repo} {old} → {new}: could not fetch the release notes.\n" + if len(body) + len(section) > MAX_BODY: + section = ( + f"- {repo} {old} → {new}: omitted, the PR body is at its size limit.\n" + ) + body += section + print(body) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/update-template-pins.yml b/.github/workflows/update-template-pins.yml index eb1a37a..38e3440 100644 --- a/.github/workflows/update-template-pins.yml +++ b/.github/workflows/update-template-pins.yml @@ -52,6 +52,11 @@ jobs: pinact run --update --min-age 7 project_name/.github/workflows/* done + - name: Write the pull request body, with release notes + env: + GH_TOKEN: ${{ github.token }} + run: python3 .github/scripts/pins_pr_body.py > "$RUNNER_TEMP/pr-body.md" + - name: Create pull request id: cpr uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 @@ -59,20 +64,14 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} commit-message: "ci: update action pins in the template" title: "⬆️ Update action pins in the template" - body: | - Automated update of the SHA-pinned actions under `project_name/.github/workflows/`, - which Dependabot cannot reach. Each pin is moved to the latest release - and its `# vX.Y.Z` comment is rewritten to match. - - Review the diff as you would a Dependabot PR: check the release notes - for anything behavioural, especially on major bumps. + body-path: ${{ runner.temp }}/pr-body.md branch: ci/template-action-pins delete-branch: true labels: dependencies - name: Run tests on the pull request - # Pull requests opened with GITHUB_TOKEN trigger no `pull_request` workflows, - # but `workflow_dispatch` is exempt, and its checks show on the PR head. + # A pull request opened with GITHUB_TOKEN gets `pull_request` runs held for + # approval, but `workflow_dispatch` runs right away, on the PR head. if: steps.cpr.outputs.pull-request-operation == 'created' || steps.cpr.outputs.pull-request-operation == 'updated' env: GH_TOKEN: ${{ github.token }} diff --git a/project_name/.github/workflows/deps-update.yml b/project_name/.github/workflows/deps-update.yml index 228e953..fa28e31 100644 --- a/project_name/.github/workflows/deps-update.yml +++ b/project_name/.github/workflows/deps-update.yml @@ -61,8 +61,8 @@ jobs: delete-branch: true labels: dependencies - name: Run tests on the pull request - # Pull requests opened with GITHUB_TOKEN trigger no `pull_request` workflows, - # but `workflow_dispatch` is exempt, and its checks show on the PR head. + # A pull request opened with GITHUB_TOKEN gets `pull_request` runs held for + # approval, but `workflow_dispatch` runs right away, on the PR head. if: steps.cpr.outputs.pull-request-operation == 'created' || steps.cpr.outputs.pull-request-operation == 'updated' env: GH_TOKEN: ${{ github.token }}