Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions .github/scripts/pins_pr_body.py
Original file line number Diff line number Diff line change
@@ -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@<sha> # v1.2.3`
PIN = re.compile(
r"^(?P<sign>[-+])\s*(?:-\s+)?uses:\s*(?P<repo>[\w.-]+/[\w.-]+)\S*@[0-9a-f]{40}"
r"\s*#\s*(?P<version>\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"(?<![\w/`\[&])#(\d+)\b",
rf"[#\1](https://redirect.github.com/{repo}/issues/\1)",
notes,
)
notes = re.sub(r"(?<![\w`/])@([A-Za-z0-9][\w-]*(?:/[\w.-]+)?)", r"`@\1`", notes)
return "\n".join(f"> {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",
)
)
Comment on lines +84 to +97

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): The script lists at most 100 releases, so releases older than the newest 100 are omitted even when they fall between the old and new pin versions; the generated PR body then falsely claims to contain every release's notes.

Triggers: When an action has more than 100 releases and the pin range extends beyond the newest 100 releases.

Suggested fix: Paginate the release list or request enough releases to cover the old tag through the new tag instead of imposing -L 100.

Suggested change
releases = json.loads(
gh(
"release",
"list",
"-R",
repo,
"-L",
"100",
"--exclude-drafts",
"--exclude-pre-releases",
"--json",
"tagName",
)
)
releases = [
{"tagName": release["tag_name"]}
for page in json.loads(
gh(
"api",
"--paginate",
"--slurp",
f"repos/{repo}/releases",
)
)
for release in page
if not release["draft"] and not release["prerelease"]
]

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._"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): release["body"].strip() raises AttributeError when GitHub returns a null release body, so the script fails instead of using _No release notes._; the exception is not caught by the CalledProcessError handler.

Triggers: When a stable release has no release-body text.

Suggested fix: Use (release.get("body") or "").strip() before applying the empty-notes fallback.

Suggested change
notes = release["body"].strip() or "_No release notes._"
notes = (release.get("body") or "").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"<details>\n<summary>{repo} {old} → {new}</summary>\n\n"
f"[Compare {old}...{new}]({compare})\n\n"
+ "\n".join(sections)
+ "\n</details>\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()
17 changes: 8 additions & 9 deletions .github/workflows/update-template-pins.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,27 +52,26 @@ 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
with:
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 }}
Expand Down
4 changes: 2 additions & 2 deletions project_name/.github/workflows/deps-update.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
Loading