From badb9f9facf1ed0f0f1f708a53729447eae2022e Mon Sep 17 00:00:00 2001 From: maksimtech <268127343+maksimtech@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:07:01 +0200 Subject: [PATCH 1/2] feat(release): the version bump goes through a pull request main is about to take pull requests only, the owner included, after a cloud session pushed straight to it with the owner's account. bump_version.py committed the bump on main and release.py began with git push origin main, which that rule refuses. bump_version.py now commits the bump on release/, pushes that branch and opens its pull request with auto-merge (when gh is there and origin is this repository on GitHub). release.py pushes nothing: after the confirmation it checks that the local main is clean and is the remote's, neither ahead nor behind, and creates the GitHub release on that exact commit with --target. Tested against a real bare remote. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 +++ CLAUDE.md | 16 +++-- RELEASING.md | 23 ++++-- scripts/bump_version.py | 66 ++++++++++++++---- scripts/release.py | 47 +++++++++++-- tests/test_release_through_pr.py | 116 +++++++++++++++++++++++++++++++ 6 files changed, 245 insertions(+), 32 deletions(-) create mode 100644 tests/test_release_through_pr.py diff --git a/CHANGELOG.md b/CHANGELOG.md index fd3097a..764873f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,6 +94,15 @@ what has already shipped, and for nothing else. See `RELEASING.md`. --- +### Changed + +- **The release goes through a pull request.** main now takes pull requests only, the owner + included, after a cloud session pushed straight to it with the owner's account. + `bump_version.py` commits the bump on `release/`, pushes that branch and opens its + pull request with auto-merge; `release.py` pushes nothing, checks that the local main is the + remote's, and creates the GitHub release on that exact commit (`--target`). + + ## [2026.46] — 2026-10-10 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 230473a..1702dda 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,9 +14,10 @@ può usare l'interfaccia web è in `docs/nis2-compliance.md`, sezioni 2 e 3. ## Regole di lavoro - **Ogni modifica passa da una pull request** su `main`, unita quando i controlli - obbligatori sono verdi, anche se il ruleset di questo repository non la impone. - Niente push diretti su `main` e niente `gh pr merge --admin`. Unica eccezione, - il commit di versione che crea `scripts/release.py` (vedi `RELEASING.md`). + obbligatori sono verdi. Dal 2026-10-12 il ruleset la impone senza eccezioni, + nemmeno per il proprietario: un push diretto su `main` viene rifiutato, e lo è + anche `gh pr merge --admin`. Anche il commit di versione passa da una PR (vedi + `RELEASING.md`). - **Commit firmati** con l'identità e la chiave configurate nel repository: `git log -1 --format=%G?` deve dare `G`. Messaggi `tipo(ambito): …`, con il perché nel corpo. La riga `Co-Authored-By` la indica l'ambiente di Claude Code; @@ -58,9 +59,12 @@ uv lock --check # con uv 0.12.18, la versione fissata in quality.yml ## Release -`scripts/release.py` porta la versione (CalVer `YYYY.count[.fix]`, vedi -`RELEASING.md`) e crea la release GitHub con un tag senza `v`, per esempio -`2026.46`. L'evento di release fa partire PyPI e l'immagine +`scripts/bump_version.py` porta la versione (CalVer `YYYY.count[.fix]`, vedi +`RELEASING.md`), fa girare la suite e mette il commit su `release/`; poi +pubblica quel ramo e apre la PR con auto-merge. Dopo il merge, `git pull` e +`scripts/release.py`, che non spinge nulla: verifica che `main` locale sia quello +del remoto e crea la release GitHub su quel commit, con un tag senza `v` (per +esempio `2026.46`). L'evento di release fa partire PyPI e l'immagine `maksimtech/patchradar:2026.46`; poi Release Evidence (`sbom.yml`) allega SBOM e scansioni datate. Per una release già uscita: `gh workflow run sbom.yml -f tag=2026.46`. diff --git a/RELEASING.md b/RELEASING.md index b0811a0..0a02922 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -54,19 +54,30 @@ under a heading matching the version about to ship. ## Releasing +`main` takes pull requests only, the owner's included (since 2026-10-12, after a +cloud session pushed straight to it with the owner's account), so the version bump +travels like any other change. + ```bash # 1. Version bump (interactive: it asks for confirmation) python scripts/bump_version.py # the count goes up python scripts/bump_version.py --fix # or the third segment -# 2. Release (interactive: it asks for confirmation) -python scripts/release.py +# 2. Once its pull request is merged +git pull +python scripts/release.py # interactive: it asks for confirmation ``` +`bump_version.py` writes the version, runs the suite, commits the bump on +`release/` and, if asked, pushes that branch and opens its pull request +with auto-merge (when `gh` is installed and `origin` is this repository on GitHub). + `release.py` does, in this order: -1. `git push origin main` -2. `gh release create --latest`, with the notes taken from the CHANGELOG +1. checks that the local `main` is clean and is `main` as the remote has it — not + ahead, not behind — and pushes nothing +2. `gh release create --target --latest`, with the notes + taken from the CHANGELOG 3. it stops: **`publish.yml` starts on its own** on `release: published` and publishes to PyPI with trusted publishing — no token to hand over @@ -75,8 +86,8 @@ Step 3 is **irreversible**: a version on PyPI cannot be withdrawn, only marked ## Branches -`main` is the only permanent branch and is always releasable: release commits go -straight onto it. Longer or riskier work sits on short-lived branches (`feat/…`, +`main` is the only permanent branch and is always releasable; every change reaches +it through a pull request, the release bump included. Longer or riskier work sits on short-lived branches (`feat/…`, `fix/…`) until it is ready — that is how `feature/version-gap` stayed out of 2026.41, because it had the code and not yet a face. diff --git a/scripts/bump_version.py b/scripts/bump_version.py index b710524..396b4ee 100755 --- a/scripts/bump_version.py +++ b/scripts/bump_version.py @@ -5,6 +5,7 @@ """ import re +import shutil import subprocess import sys from datetime import datetime @@ -132,17 +133,55 @@ def check_suite(command: list | None = None, cwd=None) -> bool: return completed.returncode == 0 -def git_commit(version: str) -> None: - subprocess.run(["git", "add", "pyproject.toml", str(INIT)], check=True) - subprocess.run( - ["git", "commit", "-m", f"chore: bump version to {version}"], - check=True - ) - print(f"✅ Git commit: chore: bump version to {version}") +RELEASE_REPO = "maksimtech/patchradar" -def git_push() -> None: - subprocess.run(["git", "push"], check=True) - print("✅ Pushed to remote") + +def release_branch(version: str) -> str: + return f"release/{version}" + + +def git_commit(version: str, files: list[str] | None = None, cwd: Path | None = None) -> None: + """Commit the bump on release/, not on main. + + Since 2026-10-12 main takes pull requests only, the owner's included: a cloud + session had pushed straight to it with the owner's account and left it red, so + the ruleset lost its bypass. The bump travels like any other change. + """ + subprocess.run(["git", "switch", "-c", release_branch(version)], check=True, cwd=cwd) + subprocess.run(["git", "add", *(files or ["pyproject.toml", str(INIT)])], check=True, cwd=cwd) + subprocess.run(["git", "commit", "-m", f"chore: bump version to {version}"], check=True, cwd=cwd) + print(f"✅ Git commit on {release_branch(version)}: chore: bump version to {version}") + + +def _origin_is_github(cwd: Path | None) -> bool: + url = subprocess.run( + ["git", "remote", "get-url", "origin"], capture_output=True, text=True, check=True, cwd=cwd + ).stdout + return "github.com" in url and RELEASE_REPO in url + + +def git_push(version: str, cwd: Path | None = None) -> None: + """Push release/ only, back to main, and open the pull request. + + The pull request is opened with auto-merge only when origin is this repository + on GitHub and gh is there to ask; otherwise the next step is printed. + """ + branch = release_branch(version) + subprocess.run(["git", "push", "-u", "origin", branch], check=True, cwd=cwd) + subprocess.run(["git", "switch", "main"], check=True, cwd=cwd) + print(f"✅ Pushed {branch} (main is not touched)") + if shutil.which("gh") and _origin_is_github(cwd): + title = f"chore: bump version to {version}" + url = subprocess.run( + ["gh", "pr", "create", "--repo", RELEASE_REPO, "--base", "main", "--head", branch, + "--title", title, "--body", f"Version bump for PatchRadar {version}, made by bump_version.py."], + capture_output=True, text=True, check=True, cwd=cwd, + ).stdout.strip() + subprocess.run(["gh", "pr", "merge", url, "--auto", "--squash", "--subject", title], check=True, cwd=cwd) + print(f"🔀 {url} will be merged when its checks pass") + else: + print(f"ℹ️ Open a pull request from {branch} to main.") + print(" Once it is merged: git pull, then python scripts/release.py") def main(): # --fix: a third segment on the count that already shipped, for something @@ -174,12 +213,13 @@ def main(): git_commit(new) - push = input("Push to remote? [y/N] ").strip().lower() + push = input(f"Push {release_branch(new)} and open its pull request? [y/N] ").strip().lower() if push == "y": - git_push() + git_push(new) + else: + print(f" Left on {release_branch(new)}: push it and open a pull request to main when ready.") print(f"\n✅ Done! Version bumped to {new}") - print(f" Next: create release {new} on GitHub") if __name__ == "__main__": main() diff --git a/scripts/release.py b/scripts/release.py index dc9c7e6..063873e 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -59,17 +59,51 @@ def run(cmd: list, **kwargs) -> subprocess.CompletedProcess: print(f"$ {' '.join(cmd)}") return subprocess.run(cmd, check=True, **kwargs) + +def _git(cwd: Path, *args: str) -> str: + return subprocess.run( + ["git", *args], cwd=cwd, capture_output=True, text=True, encoding="utf-8", check=True + ).stdout.strip() + + +def check_main_published(cwd: Path = REPO_ROOT) -> str: + """The commit the release is made from: main, as the remote has it. + + Since 2026-10-12 main takes pull requests only, the owner's included, so this + script no longer pushes it: the version bump reaches main through the pull + request bump_version.py opens. What is checked here is that the local main is + that main, clean and neither ahead nor behind, and its commit is returned so + that the GitHub release is made on exactly it rather than on whatever main is + by the time GitHub reads the request. + """ + branch = _git(cwd, "rev-parse", "--abbrev-ref", "HEAD") + if branch != "main": + raise SystemExit(f"Not on main (current branch: {branch})") + if _git(cwd, "status", "--porcelain"): + raise SystemExit("Working tree not clean: commit or discard the changes first") + _git(cwd, "fetch", "--quiet", "origin", "main") + head = _git(cwd, "rev-parse", "HEAD") + if head == _git(cwd, "rev-parse", "origin/main"): + return head + ahead = int(_git(cwd, "rev-list", "--count", "origin/main..HEAD")) + if ahead: + raise SystemExit( + f"main has {ahead} commit(s) the remote does not: main takes pull requests only, " + f"so the bump has to arrive through its pull request (python scripts/bump_version.py)" + ) + raise SystemExit("main is behind the remote: git pull, then run this again") + def main(): version = get_current_version() - # Fail before anything is pushed if the entry is missing. + # Fail before anything is created if the entry is missing. notes = extract_release_notes(version) print("\n🛡️ PatchRadar Release") print(f" Version: {version}") print(f" Notes: {CHANGELOG_FILE} § [{version}] ({len(notes.splitlines())} lines)") print("\nThis will:") - print(" 1. Push main to remote") - print(f" 2. Create GitHub release {version}") + print(f" 1. Check that main, as the remote has it, carries {version}") + print(f" 2. Create GitHub release {version} on that commit") print(" 3. GitHub Actions publishes to PyPI automatically") print() @@ -78,10 +112,8 @@ def main(): print("Aborted.") sys.exit(0) - print("\n📤 Pushing to remote...") - run(["git", "push", "origin", "main"]) - - print(f"\n🚀 Creating GitHub release {version}...") + sha = check_main_published() + print(f"\n🚀 Creating GitHub release {version} on {sha[:12]}...") with tempfile.NamedTemporaryFile( "w", suffix=".md", delete=False, encoding="utf-8" ) as handle: @@ -90,6 +122,7 @@ def main(): try: run([ "gh", "release", "create", version, + "--target", sha, "--title", version, "--notes-file", notes_path, "--latest" diff --git a/tests/test_release_through_pr.py b/tests/test_release_through_pr.py new file mode 100644 index 0000000..5832e13 --- /dev/null +++ b/tests/test_release_through_pr.py @@ -0,0 +1,116 @@ +"""The release goes through a pull request, the owner's included. + +Since 2026-10-12 main takes nothing but pull requests: the ruleset has no bypass, +because a cloud session had pushed straight to it with the owner's account and +left it red. Until then `bump_version.py` committed the bump on main and +`release.py` began with `git push origin main`, which that rule refuses. + +Now `bump_version.py` commits the bump on release/ and pushes that +branch, and `release.py` pushes nothing: it checks that main, as it is on the +remote, already carries the version, and creates the GitHub release on that very +commit. These cases run the real functions against a real bare repository on +disk and a clone of it; nothing of git is simulated, and GitHub is not asked, +because origin is not github.com. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +SCRIPTS = Path(__file__).resolve().parents[1] / "scripts" +sys.path.insert(0, str(SCRIPTS)) + +import bump_version as bump # noqa: E402 +import release # noqa: E402 + + +def _git(cwd: Path, *args: str) -> str: + return subprocess.run( + ["git", *args], cwd=cwd, capture_output=True, text=True, encoding="utf-8", check=True + ).stdout.strip() + + +@pytest.fixture +def repo(tmp_path, monkeypatch): + config = tmp_path / "gitconfig" + config.write_text( + "[user]\n\tname = Test\n\temail = test@example.com\n" + "[commit]\n\tgpgsign = false\n[tag]\n\tgpgsign = false\n", + encoding="utf-8", + ) + monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(config)) + monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1") + remote = tmp_path / "remote.git" + _git(tmp_path, "init", "-q", "--bare", "-b", "main", str(remote)) + work = tmp_path / "work" + _git(tmp_path, "clone", "-q", str(remote), str(work)) + (work / "pyproject.toml").write_text('[project]\nversion = "2026.46"\n', encoding="utf-8") + _git(work, "add", ".") + _git(work, "commit", "-q", "-m", "initial") + _git(work, "push", "-q", "origin", "main") + return work, remote + + +# --- release.py --------------------------------------------------------------- + + +def test_release_pushes_nothing_and_targets_the_commit_it_checked(): + source = (SCRIPTS / "release.py").read_text(encoding="utf-8") + assert '"git", "push"' not in source + assert '"--target", sha' in source + + +def test_a_published_main_is_accepted_and_its_commit_returned(repo): + work, remote = repo + assert release.check_main_published(cwd=work) == _git(remote, "rev-parse", "main") + + +def test_an_unpushed_bump_on_main_is_refused(repo): + """What the old flow did: a bump committed on main, waiting to be pushed.""" + work, _ = repo + (work / "pyproject.toml").write_text('[project]\nversion = "2026.47"\n', encoding="utf-8") + _git(work, "commit", "-q", "-am", "chore: bump version to 2026.47") + with pytest.raises(SystemExit, match="pull request"): + release.check_main_published(cwd=work) + + +def test_a_main_behind_the_remote_is_refused(repo, tmp_path): + work, remote = repo + other = tmp_path / "other" + _git(tmp_path, "clone", "-q", str(remote), str(other)) + (other / "CHANGELOG.md").write_text("merged elsewhere\n", encoding="utf-8") + _git(other, "add", ".") + _git(other, "commit", "-q", "-m", "merged elsewhere") + _git(other, "push", "-q", "origin", "main") + with pytest.raises(SystemExit, match="git pull"): + release.check_main_published(cwd=work) + + +def test_another_branch_or_a_dirty_tree_is_refused(repo): + work, _ = repo + (work / "pyproject.toml").write_text('[project]\nversion = "2026.47"\n', encoding="utf-8") + with pytest.raises(SystemExit, match="not clean"): + release.check_main_published(cwd=work) + _git(work, "checkout", "-q", "--", "pyproject.toml") + _git(work, "switch", "-q", "-c", "elsewhere") + with pytest.raises(SystemExit, match="Not on main"): + release.check_main_published(cwd=work) + + +# --- bump_version.py ------------------------------------------------------------ + + +def test_the_bump_is_committed_on_a_release_branch_and_main_is_left_alone(repo): + work, remote = repo + (work / "pyproject.toml").write_text('[project]\nversion = "2026.47"\n', encoding="utf-8") + + bump.git_commit("2026.47", files=["pyproject.toml"], cwd=work) + bump.git_push("2026.47", cwd=work) + + assert _git(remote, "log", "-1", "--format=%s", "release/2026.47") == "chore: bump version to 2026.47" + assert _git(remote, "log", "-1", "--format=%s", "main") == "initial", "main was pushed" + assert _git(work, "rev-parse", "--abbrev-ref", "HEAD") == "main" From 6f2bc0459ee3eb277f6c17c656bd409f0dabcb8c Mon Sep 17 00:00:00 2001 From: maksimtech <268127343+maksimtech@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:09:44 +0200 Subject: [PATCH 2/2] fix(release): decode git and gh output as UTF-8, as the encoding contract asks Co-Authored-By: Claude Opus 5.5 --- scripts/bump_version.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/bump_version.py b/scripts/bump_version.py index 396b4ee..94db003 100755 --- a/scripts/bump_version.py +++ b/scripts/bump_version.py @@ -155,7 +155,8 @@ def git_commit(version: str, files: list[str] | None = None, cwd: Path | None = def _origin_is_github(cwd: Path | None) -> bool: url = subprocess.run( - ["git", "remote", "get-url", "origin"], capture_output=True, text=True, check=True, cwd=cwd + ["git", "remote", "get-url", "origin"], capture_output=True, text=True, encoding="utf-8", check=True, + cwd=cwd, ).stdout return "github.com" in url and RELEASE_REPO in url @@ -175,7 +176,7 @@ def git_push(version: str, cwd: Path | None = None) -> None: url = subprocess.run( ["gh", "pr", "create", "--repo", RELEASE_REPO, "--base", "main", "--head", branch, "--title", title, "--body", f"Version bump for PatchRadar {version}, made by bump_version.py."], - capture_output=True, text=True, check=True, cwd=cwd, + capture_output=True, text=True, encoding="utf-8", check=True, cwd=cwd, ).stdout.strip() subprocess.run(["gh", "pr", "merge", url, "--auto", "--squash", "--subject", title], check=True, cwd=cwd) print(f"🔀 {url} will be merged when its checks pass")