From ed7c47343d12393da2edbd04da5e440829af9acb Mon Sep 17 00:00:00 2001 From: MarTrepodi Date: Tue, 13 Oct 2026 10:03:06 -0400 Subject: [PATCH 1/3] ci(release): restate closing keywords in the release PR Closing keywords ("Closes #12") only take effect for pull requests merged into the default branch. Every PR here merges into develop, so GitHub records the link and never closes the issue, and the release PR that carries the fix to main has no keywords of its own. The issues fixed in v2.5.0 had to be closed by hand. Add .github/tools/closing_issues.py, which reads the keywords back out of merged PRs (description and commit messages) using GitHub's matching rules, and have prepare-release add a "Closes #N" line per still-open issue to the release PR body. The issues then close when the release merges into main. Run against v2.4.0..v2.5.0 it lists exactly the ten issues that were left open. Co-Authored-By: Claude Opus 5.5 --- .github/tools/closing_issues.py | 165 ++++++++++++++++++++++++++ .github/workflows/prepare-release.yml | 20 +++- 2 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 .github/tools/closing_issues.py diff --git a/.github/tools/closing_issues.py b/.github/tools/closing_issues.py new file mode 100644 index 0000000..47ee0a1 --- /dev/null +++ b/.github/tools/closing_issues.py @@ -0,0 +1,165 @@ +"""Find the issues that merged pull requests resolve, for the develop -> main release flow. + +GitHub only acts on closing keywords ("Closes #12") in a pull request that merges into the +DEFAULT branch (main). Work here merges into develop, so the keywords are recorded but never +fire, and the release PR that later carries the fix to main has no keywords of its own. This +script reads the keywords back out of the merged pull requests (descriptions and commit +messages) so they can be: + +* ``release``: re-stated in the release PR body, so GitHub closes the issues when the release + merges into main. +* ``develop``: announced on each still-open issue as soon as its fix merges into develop. + +Matching follows GitHub's rules: a keyword (close/closes/closed, fix/fixes/fixed, +resolve/resolves/resolved, optional colon) directly before ONE reference — ``#12``, +``owner/repo#12`` or an issue URL in this repository. "Closes #1, #2" closes only #1, as on +GitHub. HTML comments (the PR template's hints) and code are ignored. + +Usage (from the repo root, with ``gh`` authenticated):: + + python3 .github/tools/closing_issues.py release --since v2.5.0 + python3 .github/tools/closing_issues.py develop --sha [--dry-run] +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys + +KEYWORD_REGEX = re.compile( + r"(?[\w.-]+/[\w.-]+)?#(?P\d+)" + r"|https://github\.com/(?P[\w.-]+/[\w.-]+)/issues/(?P\d+))\b", + re.IGNORECASE, +) +# Text GitHub does not scan for keywords: HTML comments, fenced code blocks, inline code. +IGNORED_REGEX = re.compile(r"|^(?P```|~~~).*?^(?P=fence)|`[^`\n]*`", re.DOTALL | re.MULTILINE) +MERGE_SUBJECT_REGEX = re.compile(r"^Merge pull request #(\d+) from ") +# Marks comments this script posted, so a re-run never posts the same note twice. +MARKER = "" + + +def gh(*args: str) -> str: + return subprocess.run(["gh", *args], check=True, capture_output=True, text=True).stdout + + +def referenced_issues(text: str, repo: str) -> list[int]: + """Issue numbers in ``repo`` that ``text`` closes, in order of first mention.""" + text = IGNORED_REGEX.sub(" ", text) + numbers: dict[int, None] = {} + for m in KEYWORD_REGEX.finditer(text): + ref_repo = m["repo"] or m["url_repo"] or repo + if ref_repo.lower() == repo.lower(): + numbers[int(m["number"] or m["url_number"])] = None + return list(numbers) + + +def pull_request_text(repo: str, pr: int) -> str: + """The PR description plus every commit message in it.""" + body = gh("api", f"repos/{repo}/pulls/{pr}", "--jq", '.body // ""') + messages = gh("api", "--paginate", f"repos/{repo}/pulls/{pr}/commits", "--jq", ".[].commit.message") + return f"{body}\n\n{messages}" + + +def is_open_issue(repo: str, number: int) -> bool: + """True for an open issue; False for a closed one, a pull request, or a missing number.""" + try: + issue = json.loads(gh("api", f"repos/{repo}/issues/{number}")) + except subprocess.CalledProcessError: + return False + return issue["state"] == "open" and "pull_request" not in issue + + +def issues_by_pr(repo: str, prs: list[int]) -> dict[int, list[int]]: + """Map each still-open issue to the merged PRs that resolve it.""" + found: dict[int, list[int]] = {} + for pr in prs: + for number in referenced_issues(pull_request_text(repo, pr), repo): + found.setdefault(number, []).append(pr) + return {n: found[n] for n in sorted(found) if is_open_issue(repo, n)} + + +def refs(prs: list[int]) -> str: + return ", ".join(f"#{pr}" for pr in prs) + + +def release(repo: str, since: str) -> int: + """Print the release PR section that re-states the closing keywords merged since ``since``.""" + subjects = subprocess.run( + ["git", "log", "--merges", "--format=%s", f"{since}..HEAD"], check=True, capture_output=True, text=True + ).stdout.splitlines() + prs = sorted({int(m[1]) for s in subjects if (m := MERGE_SUBJECT_REGEX.match(s))}) + found = issues_by_pr(repo, prs) + if not found: + print(f"_No open issues are referenced by the {len(prs)} pull requests merged since {since}._") + return 0 + # One keyword per line: GitHub closes only the first reference after each keyword. + print("### Issues resolved by this release\n") + print("Merging this PR into `main` closes:\n") + for number, by in found.items(): + print(f"- Closes #{number} (via {refs(by)})") + return 0 + + +def develop(repo: str, sha: str, dry_run: bool) -> int: + """Comment on each open issue resolved by the PR(s) that ``sha`` merged into develop.""" + prs = [ + int(n) + for n in gh( + "api", + f"repos/{repo}/commits/{sha}/pulls", + "--jq", + '.[] | select(.merged_at != null and .base.ref == "develop") | .number', + ).split() + ] + if not prs: + print(f"{sha[:7]} is not a pull request merge into develop; nothing to do.") + return 0 + summary = [] + for number, by in issues_by_pr(repo, prs).items(): + for pr in by: + marker = MARKER.format(pr=pr) + existing = gh("api", "--paginate", f"repos/{repo}/issues/{number}/comments", "--jq", ".[].body") + if marker in existing: + summary.append(f"- #{number}: already noted for #{pr}") + continue + body = ( + f"{marker}\nAddressed by #{pr}, which is now merged into `develop`. This issue will close" + " automatically when that change is released to `main`." + ) + if not dry_run: + gh("api", "-X", "POST", f"repos/{repo}/issues/{number}/comments", "-f", f"body={body}") + summary.append(f"- #{number}: {'would comment' if dry_run else 'commented'} (resolved by #{pr})") + report = "\n".join(summary) or f"No open issues are referenced by {refs(prs)}." + print(report) + if path := os.environ.get("GITHUB_STEP_SUMMARY"): + with open(path, "a", encoding="utf-8") as fh: + fh.write(f"## Issues addressed by {refs(prs)}\n\n{report}\n") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="Carry closing keywords from develop merges through to main.") + sub = parser.add_subparsers(dest="command", required=True) + rel = sub.add_parser("release", help="print the release PR's 'Closes #N' section") + rel.add_argument("--since", required=True, help="the last release tag") + dev = sub.add_parser("develop", help="comment on open issues resolved by a merge into develop") + dev.add_argument("--sha", required=True, help="the merge commit pushed to develop") + dev.add_argument("--dry-run", action="store_true", help="report without commenting") + args = parser.parse_args() + + repo = os.environ.get("GITHUB_REPOSITORY") or gh( + "repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner" + ) + repo = repo.strip() + if args.command == "release": + return release(repo, args.since) + return develop(repo, args.sha, args.dry_run) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 318b3d4..8935480 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -23,6 +23,9 @@ on: permissions: contents: write + # Read merged PRs and their issues to restate closing keywords (closing_issues.py). + pull-requests: read + issues: read jobs: prepare: @@ -71,6 +74,20 @@ jobs: - name: Regenerate CHANGELOG.md run: uv run git-changelog --bump '${{ inputs.bump }}' + # Closing keywords ("Closes #12") only fire for PRs merged into the default branch, so + # the ones in PRs merged into develop never close anything. Restate them in the release + # PR, which does merge into main, so the issues close when the release lands. + - name: Collect the issues this release closes + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + # Newest release tag, not `git describe`: until the post-release back-merge lands, + # develop cannot reach the latest tag and describe would fall back to an older one. + since="$(git tag --list 'v*' --sort=-v:refname | head -n1)" + python3 .github/tools/closing_issues.py release --since "${since}" > "${RUNNER_TEMP}/closes.md" + cat "${RUNNER_TEMP}/closes.md" + - name: Write the release PR text id: pr_text run: | @@ -89,7 +106,8 @@ jobs: printf 'Automated release prep for **%s**. CHANGELOG.md regenerated via git-changelog.\n\n' "${tag}" printf 'Merging promotes `develop` -> `main` with the changelog already in place.\n' printf 'Afterwards, run the **release** workflow on `main` to build, publish, and tag %s.\n\n' "${tag}" - printf -- '---\n\n' + cat "${RUNNER_TEMP}/closes.md" + printf '\n---\n\n' printf '%s\n' "${notes}" } > "${body_file}" From c41eb8d779c9b935747d3eaf915e48e0f044302a Mon Sep 17 00:00:00 2001 From: MarTrepodi Date: Tue, 13 Oct 2026 10:03:08 -0400 Subject: [PATCH 2/3] ci: comment on issues when their fix merges into develop Until the next release reaches main, an issue fixed on develop stays open with no sign of progress. On every push to develop, look up the merged PR, find the still-open issues its closing keywords reference, and leave a comment saying the fix is in develop and the issue closes on release. A hidden marker keeps re-runs from posting the same note twice. It runs on push rather than pull_request_target so it never handles fork code with a write token, while still being able to comment for fork PRs. CONTRIBUTING explains the linking rules and the release behaviour. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/issue-fixed-in-develop.yml | 41 ++++++++++++++++++++ CONTRIBUTING.md | 10 ++++- 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/issue-fixed-in-develop.yml diff --git a/.github/workflows/issue-fixed-in-develop.yml b/.github/workflows/issue-fixed-in-develop.yml new file mode 100644 index 0000000..93ecb0c --- /dev/null +++ b/.github/workflows/issue-fixed-in-develop.yml @@ -0,0 +1,41 @@ +name: note issues fixed in develop + +# When a pull request merges into develop, comment on each still-open issue it resolves +# (via "Closes #12" / "Fixes #12" in its description or commit messages). +# +# GitHub only closes issues for PRs merged into the default branch (main), so an issue fixed +# on develop stays open with no sign of progress until the next release. This note fills that +# gap; prepare-release then restates the same keywords in the release PR, and GitHub closes +# the issue when the release merges into main. See .github/tools/closing_issues.py. +# +# Triggered by `push` rather than `pull_request_target`: a push to develop happens only after a +# maintainer merges, runs entirely in this repository's context, and never executes or checks +# out code from a fork — yet still gets a token that can comment on issues, fork PRs included. + +on: + push: + branches: [develop] + +permissions: {} + +concurrency: + group: issue-fixed-in-develop-${{ github.sha }} + cancel-in-progress: false + +jobs: + note: + name: Comment on resolved issues + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + pull-requests: read + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Comment on open issues this merge resolves + env: + GH_TOKEN: ${{ github.token }} + run: python3 .github/tools/closing_issues.py develop --sha "${GITHUB_SHA}" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b966cab..67d5343 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -130,12 +130,14 @@ comlink-python/ │ │ ├── documentation.yml # Documentation issue template │ │ └── feature_request.yml # Feature request template │ ├── release-pr/ # Release PR bodies written by prepare-release.yml +│ ├── tools/ # Python helpers the workflows run (release + issue bookkeeping) │ ├── workflows/ │ │ ├── backmerge.yml # Stages the main → develop back-merge after a release │ │ ├── ci.yml # CI pipeline (lint, type-check, test, docs, build) │ │ ├── commitlint.yml # Commit message validation (runs the pre-commit hook) │ │ ├── docs.yml # Publishes the docs site to GitHub Pages from main │ │ ├── integration.yml # Integration tests against live comlink services +│ │ ├── issue-fixed-in-develop.yml # Comments on issues a merge into develop resolves │ │ ├── labeler.yml # Auto-label PRs by file path │ │ ├── prepare-release.yml # Regenerates CHANGELOG.md and stages a release branch │ │ └── release.yml # Build → PyPI → tag + GitHub Release (version via hatch-vcs) @@ -478,6 +480,12 @@ commit would be left out of the changelog entirely. Commit Lint rejects `!` befo - How you tested the change - A short checklist mirroring the CI checks + **Linking issues:** use a closing keyword (`Closes #51`, `Fixes #51`, `Resolves #51`), with one + keyword per issue — `Closes #1, #2` only closes #1. Because PRs merge into `develop` rather than + the default branch, GitHub won't close the issue at merge time. Instead, a bot comment on the + issue notes that the fix is in `develop`, and the issue closes automatically when the next + release merges into `main`. + 6. **After submitting:** CI checks (lint, type check, tests on Python 3.10–3.14, docs build, package build, commit message validation, and integration tests) run automatically. @@ -538,7 +546,7 @@ Never paste your comlink `access_key` / `secret_key` into an issue. Releases are handled by the maintainer through GitHub Actions. Contributors don't need to manage versioning or releases, but here's how it works for reference: 1. **prepare release** (`prepare-release.yml`) is dispatched on `develop`. It computes the next version from the Conventional Commits since the last tag (or a chosen `patch`/`minor`/`major` bump), regenerates `CHANGELOG.md`, and pushes a `release/vX.Y.Z` branch. If the computed version is a major, the job summary lists the commit lines that triggered it, and an `auto` bump stops unless one is a real `BREAKING CHANGE:` footer (see [Commit Messages](#commit-messages)). -2. The maintainer opens the release PR from that branch into `main` (a link is in the workflow summary). CI runs as normal and the PR is merged. +2. The maintainer opens the release PR from that branch into `main` (a link is in the workflow summary). Its body restates every `Closes #N` from the PRs merged since the last release, so GitHub closes those issues when it merges. CI runs as normal and the PR is merged. 3. **comlink-python release** (`release.yml`) is dispatched on `main`. It reads the version from `CHANGELOG.md`, builds the package — [hatch-vcs](https://github.com/ofek/hatch-vcs) stamps that version, so **no version string is committed to the repo** — and publishes to [PyPI](https://pypi.org/project/swgoh-comlink/) via trusted publishing. 4. Only after a successful publish is the Git tag created and a GitHub Release cut, with release notes auto-generated from the PR history. 5. **back-merge main into develop** (`backmerge.yml`) then stages a branch so `main` can be merged back into `develop`. From 82a42dcaabb17a1b947194c3f7d2dc0449cbe3c4 Mon Sep 17 00:00:00 2001 From: MarTrepodi Date: Tue, 13 Oct 2026 10:03:58 -0400 Subject: [PATCH 3/3] ci: ignore fenced code indented inside list items GitHub treats a fence indented under a list item as code, but the pattern only matched fences at column 0, so a keyword shown in such a block was read as a real reference. Co-Authored-By: Claude Opus 5.5 --- .github/tools/closing_issues.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/tools/closing_issues.py b/.github/tools/closing_issues.py index 47ee0a1..bd302c6 100644 --- a/.github/tools/closing_issues.py +++ b/.github/tools/closing_issues.py @@ -36,8 +36,11 @@ r"|https://github\.com/(?P[\w.-]+/[\w.-]+)/issues/(?P\d+))\b", re.IGNORECASE, ) -# Text GitHub does not scan for keywords: HTML comments, fenced code blocks, inline code. -IGNORED_REGEX = re.compile(r"|^(?P```|~~~).*?^(?P=fence)|`[^`\n]*`", re.DOTALL | re.MULTILINE) +# Text GitHub does not scan for keywords: HTML comments, fenced code blocks (including ones +# indented inside list items), and inline code. +IGNORED_REGEX = re.compile( + r"|^[ \t]*(?P```|~~~).*?^[ \t]*(?P=fence)|`[^`\n]*`", re.DOTALL | re.MULTILINE +) MERGE_SUBJECT_REGEX = re.compile(r"^Merge pull request #(\d+) from ") # Marks comments this script posted, so a re-run never posts the same note twice. MARKER = ""