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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<version>`, 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
Expand Down
16 changes: 10 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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/<versione>`; 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`.
Expand Down
23 changes: 17 additions & 6 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<version>` 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 <version> --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 <version> --target <that commit> --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

Expand All @@ -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.

Expand Down
67 changes: 54 additions & 13 deletions scripts/bump_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"""

import re
import shutil
import subprocess
import sys
from datetime import datetime
Expand Down Expand Up @@ -132,17 +133,56 @@
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/<version>, 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, encoding="utf-8", 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/<version> 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, 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")
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
Expand Down Expand Up @@ -174,12 +214,13 @@

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()
47 changes: 40 additions & 7 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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:
Expand All @@ -90,6 +122,7 @@ def main():
try:
run([
"gh", "release", "create", version,
"--target", sha,
"--title", version,
"--notes-file", notes_path,
"--latest"
Expand Down
116 changes: 116 additions & 0 deletions tests/test_release_through_pr.py
Original file line number Diff line number Diff line change
@@ -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/<version> 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"
Loading