From 5d73175ed3059b8b908f4b69e10c3b287a6f34c2 Mon Sep 17 00:00:00 2001 From: Christopher Obbard Date: Wed, 30 Sep 2026 21:34:19 +0100 Subject: [PATCH 1/9] ci: add a script bumping the Release refs to the newest tags Bumping the Release rows in ci/build-matrix.json is done by hand today. Add propose-release-bump.py, which resolves the newest tag of each variant's Daily row with resolve-kernel-ref.sh, the same lookup daily.yml uses, and bumps the Release row when that tag's snapshot date is newer. A Release row never moves backwards. The script shows what it would bump and asks before writing the matrix; --dry-run only shows it and --yes skips the question. Signed-off-by: Christopher Obbard --- ci/scripts/propose-release-bump.py | 183 +++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100755 ci/scripts/propose-release-bump.py diff --git a/ci/scripts/propose-release-bump.py b/ci/scripts/propose-release-bump.py new file mode 100755 index 0000000..9cbc2ac --- /dev/null +++ b/ci/scripts/propose-release-bump.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +# Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +# SPDX-License-Identifier: BSD-3-Clause-Clear + +"""Bump the Release refs in ci/build-matrix.json. + +For each kernel variant with a Release row, resolve the newest tag of its +Daily row (ref_strategy latest_tag) with ci/scripts/resolve-kernel-ref.sh, the +same lookup daily.yml uses. When that tag's trailing YYYYMMDD is newer than +the Release row's branch_or_tag, the row is bumped to it. A Release row never +moves backwards. + +Usage: + ci/scripts/propose-release-bump.py [--matrix-path PATH] [--dry-run] [--yes] + +Options: + --matrix-path PATH Matrix to bump in place (default: ci/build-matrix.json + in this checkout). + --dry-run Show what would change; write nothing. + -y, --yes Do not ask before writing PATH. + +Before writing, the script shows what it would bump and asks for +confirmation. Progress, including every command it runs, goes to stderr. + +Output: + One "variant: old -> new" line per bumped Release row, or "up to date". + +Exit codes: + 0 Success, including when everything is up to date. + 1 Error. +""" + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +RESOLVE_KERNEL_REF = SCRIPT_DIR / "resolve-kernel-ref.sh" +MATRIX_REL_PATH = "ci/build-matrix.json" + +# The snapshot date resolve-kernel-ref.sh sorts tags on. +DATE_SUFFIX = re.compile(r"-(\d{8})$") + + +def log(msg): + print(msg, file=sys.stderr, flush=True) + + +def step(msg): + log(f"==> {msg}") + + +def run(cmd, cwd=None, capture=False, stdin=None): + log(f" $ {' '.join(map(str, cmd))}") + sys.stdout.flush() + result = subprocess.run( + cmd, cwd=cwd, check=True, text=True, input=stdin, + stdout=subprocess.PIPE if capture else None, + ) + return result.stdout.strip() if capture else None + + +def confirm(prompt, args): + """Ask before changing anything; --yes answers for the user.""" + if args.yes: + log(f"{prompt} [y/N] y (--yes)") + return True + if not sys.stdin.isatty(): + raise SystemExit("ERROR: not a terminal; pass --yes to proceed") + try: + answer = input(f"{prompt} [y/N] ") + except EOFError: + answer = "" + return answer.strip().lower() in ("y", "yes") + + +def date_key(ref): + m = DATE_SUFFIX.search(ref) + return m.group(1) if m else None + + +def load_matrix(path): + return json.loads(path.read_text(encoding="utf-8")) + + +def write_matrix(path, matrix): + out = json.dumps(matrix, indent=2, ensure_ascii=False) + "\n" + path.write_text(out, encoding="utf-8") + + +def bump_matrix(matrix): + """Bump the stale Release rows of matrix in memory. + + Returns a list of (variant, old, new) tuples, one per bumped row. + """ + rows = matrix["deliveries"] + + daily = {r["kernel_variant"]: r for r in rows if r["type"] == "Daily"} + newest = {} + bumps = [] + + for row in rows: + if row["type"] != "Release": + continue + variant = row["kernel_variant"] + src = daily.get(variant) + if not src or src.get("ref_strategy") != "latest_tag": + log(f"{variant}: no latest_tag Daily row, skipping") + continue + + key = (src["git_clone"], src["tag_pattern"]) + if key not in newest: + step(f"Resolving the newest {key[1]} tag in {key[0]}") + newest[key] = run( + [str(RESOLVE_KERNEL_REF), "--url", key[0], + "--latest-tag", key[1]], + capture=True, + ) + log(f" newest: {newest[key]}") + tag = newest[key] + + old = row["branch_or_tag"] + old_date = date_key(old) + log(f"{variant}: Release pins {old}, newest tag is {tag}") + if old_date is None: + log(f"{variant}: {old} has no -YYYYMMDD suffix, treating as older") + elif old_date >= date_key(tag): + log(f"{variant}: {old_date} >= {date_key(tag)}, up to date") + continue + else: + log(f"{variant}: {old_date} < {date_key(tag)}, bumping") + + row["branch_or_tag"] = tag + bumps.append((variant, old, tag)) + + return bumps + + +def bump_file(args): + path = args.matrix_path + step(f"Checking {path}") + matrix = load_matrix(path) + bumps = bump_matrix(matrix) + report(bumps) + if not bumps or args.dry_run: + return + if not confirm(f"Write {path}?", args): + log(f"Aborted; {path} is unchanged.") + return + write_matrix(path, matrix) + log(f"Wrote {path}") + + +def report(bumps): + for variant, old, new in bumps: + print(f"{variant}: {old} -> {new}", flush=True) + if not bumps: + print("up to date", flush=True) + + +def main(): + parser = argparse.ArgumentParser( + description=__doc__.split("\n\n")[0], + epilog="See the header of this script for details.") + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--matrix-path", type=Path, + default=SCRIPT_DIR.parent.parent / MATRIX_REL_PATH) + parser.add_argument("-y", "--yes", action="store_true") + args = parser.parse_args() + + try: + bump_file(args) + except subprocess.CalledProcessError as e: + log(f"ERROR: {' '.join(map(str, e.cmd))} failed ({e.returncode})") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 7d08fbae71fb29a7b9f5e8546007898e02ae01b7 Mon Sep 17 00:00:00 2001 From: Christopher Obbard Date: Tue, 13 Oct 2026 22:08:04 +0100 Subject: [PATCH 2/9] .gitignore: ignore pycache Signed-off-by: Christopher Obbard --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 364c626..bd5edbb 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ debian/linux-image-*.prerm # Default kernel source and build output directories kernel-source/ kernel-build/ + +# Script cache +__pycache__/ From aeb99a3731a73454910f1efaa8c6d9a3414442dc Mon Sep 17 00:00:00 2001 From: Christopher Obbard Date: Wed, 30 Sep 2026 21:34:19 +0100 Subject: [PATCH 3/9] ci: propose the Release ref bump as a pull request By default, bump the matrix in a temporary worktree of origin/main, so the current checkout is never touched, and commit every bumped row in one signed-off commit. After showing the diff, commit message and PR target and asking for confirmation, push it to bump/ and open a PR with gh. --auto-merge arms auto-merge with a merge commit, so the PR merges once the required checks pass and it is approved. Re-running is safe: an already open PR for the branch is reported and left alone. The script is run by hand rather than from a workflow: a PR opened with GITHUB_TOKEN would not trigger release-dry-run.yml, and the actions that open PRs are not allowed in this repository. The repository settings --auto-merge needs are listed at the top of the script. Signed-off-by: Christopher Obbard --- ci/scripts/propose-release-bump.py | 200 +++++++++++++++++++++++++++-- 1 file changed, 186 insertions(+), 14 deletions(-) diff --git a/ci/scripts/propose-release-bump.py b/ci/scripts/propose-release-bump.py index 9cbc2ac..fbd45c1 100755 --- a/ci/scripts/propose-release-bump.py +++ b/ci/scripts/propose-release-bump.py @@ -2,7 +2,7 @@ # Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. # SPDX-License-Identifier: BSD-3-Clause-Clear -"""Bump the Release refs in ci/build-matrix.json. +"""Bump the Release refs in ci/build-matrix.json and propose a PR. For each kernel variant with a Release row, resolve the newest tag of its Daily row (ref_strategy latest_tag) with ci/scripts/resolve-kernel-ref.sh, the @@ -10,31 +10,61 @@ the Release row's branch_or_tag, the row is bumped to it. A Release row never moves backwards. +By default this works in a temporary git worktree of /, so the +current checkout is never touched. Every stale Release row is bumped in one +signed-off commit, pushed to , and proposed as a PR to with gh. +Merging that PR starts release.yml for the bumped variants; +release-dry-run.yml builds them on the PR first. + +Run it by hand as a maintainer: a PR opened with a workflow's GITHUB_TOKEN +would not trigger release-dry-run.yml. + +Repository settings needed for --auto-merge: + - Settings > General > "Allow auto-merge" must be on. + - main needs branch protection or a ruleset that requires the + release-dry-run.yml checks and at least one approving review. Without + required checks, gh either merges the PR at once or refuses to arm + auto-merge. + Usage: - ci/scripts/propose-release-bump.py [--matrix-path PATH] [--dry-run] [--yes] + ci/scripts/propose-release-bump.py [--auto-merge] [--dry-run] [--yes] + ci/scripts/propose-release-bump.py --matrix-path PATH [--dry-run] [--yes] Options: - --matrix-path PATH Matrix to bump in place (default: ci/build-matrix.json - in this checkout). - --dry-run Show what would change; write nothing. - -y, --yes Do not ask before writing PATH. - -Before writing, the script shows what it would bump and asks for -confirmation. Progress, including every command it runs, goes to stderr. + --remote NAME Remote to fetch from and push to (default: + origin). + --base BRANCH Branch to bump and open the PR against (default: main). + --branch NAME Branch to push (default: bump/). + --auto-merge Arm auto-merge (merge commit) on the PR, so it merges + once the required checks pass and it is approved. + --dry-run Show what would change (the diff, in the worktree + mode); commit, push and write nothing. + --matrix-path PATH Only bump PATH in place: no worktree, git or gh. + -y, --yes Do not ask before committing, pushing and opening the + PR (or writing PATH). + +Before changing anything, the script shows the diff, the commit message and +what it will push, and asks for confirmation. Progress, including every +command it runs, goes to stderr. Output: - One "variant: old -> new" line per bumped Release row, or "up to date". + One "variant: old -> new" line per bumped Release row, or "up to date", + followed by the PR URL when one is opened. Exit codes: - 0 Success, including when everything is up to date. + 0 Success, including when everything is up to date or a PR for + is already open. 1 Error. """ import argparse import json import re +import shutil import subprocess import sys +import tempfile +import textwrap from pathlib import Path SCRIPT_DIR = Path(__file__).resolve().parent @@ -139,6 +169,142 @@ def bump_matrix(matrix): return bumps +def newest_tag(bumps): + return max((new for _, _, new in bumps), key=date_key) + + +def commit_message(bumps): + news = {new for _, _, new in bumps} + olds = {old for _, old, _ in bumps} + variants = [v for v, _, _ in bumps] + + if len(news) == 1: + title = f"ci: update the Release ref to {newest_tag(bumps)}" + else: + title = "ci: update the Release refs to the newest tags" + + if len(news) == 1 and len(olds) == 1: + new, old = next(iter(news)), next(iter(olds)) + if len(bumps) == 1: + body = (f"The {variants[0]} Release row pins {old}. Bump it to " + f"the newer {new} snapshot.") + else: + who = ("Both" if len(bumps) == 2 else f"All {len(bumps)}") + body = (f"{who} Release rows ({', '.join(variants)}) pin {old}. " + f"Bump them to the newer {new} snapshot.") + body = textwrap.fill(body, width=72, break_on_hyphens=False) + else: + body = "Bump the Release rows to the newer snapshots:\n\n" + "\n".join( + f" {v}: {old} -> {new}" for v, old, new in bumps) + + return f"{title}\n\n{body}\n" + + +def github_repo(remote): + """Return owner/name of the GitHub repository behind remote.""" + url = run(["git", "remote", "get-url", remote], capture=True) + m = re.search(r"github\.com[:/]([^/]+/[^/]+?)(?:\.git)?/?$", url) + if not m: + raise SystemExit(f"ERROR: {remote} ({url}) is not a GitHub remote") + return m.group(1) + + +def open_pr(wt, repo, args, bumps): + branch = args.branch or f"bump/{newest_tag(bumps)}" + + step(f"Checking for an open PR from {branch}") + existing = run( + ["gh", "pr", "list", "--repo", repo, "--head", branch, + "--state", "open", "--json", "url", "--jq", ".[].url"], + capture=True, + ) + if existing: + print(f"PR for {branch} already open: {existing}") + return + + message = commit_message(bumps) + step("Proposed change") + run(["git", "--no-pager", "diff"], cwd=wt) + log("") + log("Commit message:") + log(textwrap.indent(message, " ")) + log(f"Push: {branch} to {args.remote}") + log(f"PR: {repo} {args.base} <- {branch}") + log(f"Auto-merge: {'yes (merge commit)' if args.auto_merge else 'no'}") + log("") + if not confirm("Commit, push and open the PR?", args): + log("Aborted; nothing was committed or pushed.") + return + + step(f"Committing on {branch}") + run(["git", "switch", "-q", "-c", branch], cwd=wt) + run(["git", "add", MATRIX_REL_PATH], cwd=wt) + run(["git", "commit", "-q", "-s", "-F", "-"], cwd=wt, + stdin=message) + step(f"Pushing {branch} to {args.remote}") + run(["git", "push", args.remote, f"HEAD:refs/heads/{branch}"], cwd=wt) + step("Opening the PR") + url = run( + ["gh", "pr", "create", "--repo", repo, "--base", args.base, + "--head", branch, "--fill"], + cwd=wt, capture=True, + ) + print(url) + + if args.auto_merge: + step("Arming auto-merge") + run(["gh", "pr", "merge", "--repo", repo, "--auto", "--merge", url]) + print("auto-merge armed") + + step("Looking for other open bump/* PRs") + others = run( + ["gh", "pr", "list", "--repo", repo, "--state", "open", + "--limit", "100", "--json", "headRefName,url", + "--jq", '.[] | select(.headRefName | startswith("bump/")) | .url'], + capture=True, + ) + others = [u for u in others.splitlines() if u != url] + if others: + log("Other open bump PRs, superseded by this one:") + for u in others: + log(f" {u}") + + +def propose(args): + if not shutil.which("gh"): + raise SystemExit("ERROR: gh is not installed") + repo = github_repo(args.remote) + log(f"Repository: {repo}") + if not args.dry_run: + step("Checking gh authentication") + run(["gh", "auth", "status", "--hostname", "github.com"], + capture=True) + + step(f"Fetching {args.remote}/{args.base}") + run(["git", "fetch", "-q", args.remote, args.base]) + with tempfile.TemporaryDirectory(prefix="release-bump-") as tmp: + wt = Path(tmp) / "wt" + step(f"Creating a temporary worktree of {args.remote}/{args.base}") + run(["git", "worktree", "add", "-q", "--detach", str(wt), + f"{args.remote}/{args.base}"]) + try: + path = wt / MATRIX_REL_PATH + matrix = load_matrix(path) + bumps = bump_matrix(matrix) + report(bumps) + if bumps: + # Only the temporary worktree; nothing is committed yet. + write_matrix(path, matrix) + if bumps and args.dry_run: + step("Proposed change (--dry-run: stopping here)") + run(["git", "--no-pager", "diff"], cwd=wt) + elif bumps: + open_pr(wt, repo, args, bumps) + finally: + step("Removing the temporary worktree") + run(["git", "worktree", "remove", "--force", str(wt)]) + + def bump_file(args): path = args.matrix_path step(f"Checking {path}") @@ -165,14 +331,20 @@ def main(): parser = argparse.ArgumentParser( description=__doc__.split("\n\n")[0], epilog="See the header of this script for details.") + parser.add_argument("--remote", default="origin") + parser.add_argument("--base", default="main") + parser.add_argument("--branch") + parser.add_argument("--auto-merge", action="store_true") parser.add_argument("--dry-run", action="store_true") - parser.add_argument("--matrix-path", type=Path, - default=SCRIPT_DIR.parent.parent / MATRIX_REL_PATH) + parser.add_argument("--matrix-path", type=Path) parser.add_argument("-y", "--yes", action="store_true") args = parser.parse_args() try: - bump_file(args) + if args.matrix_path: + bump_file(args) + else: + propose(args) except subprocess.CalledProcessError as e: log(f"ERROR: {' '.join(map(str, e.cmd))} failed ({e.returncode})") return 1 From 7c18913775188bc50a260219c101584fda680c16 Mon Sep 17 00:00:00 2001 From: Christopher Obbard Date: Wed, 30 Sep 2026 21:34:19 +0100 Subject: [PATCH 4/9] docs: point at propose-release-bump.py for Release ref updates Mention the script in the README's release section and the release.yml header as the way to open the Release ref bump PR. Documentation only. Signed-off-by: Christopher Obbard --- .github/workflows/release.yml | 13 ++++++++----- README.md | 6 ++++++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a6b9871..e3b5cc7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,11 +9,14 @@ name: release # lib/release. # # To update a release ref: open a PR that changes the Release row for that -# kernel variant in ci/build-matrix.json. release-dry-run.yml builds the -# changed rows on the PR, and merging it starts this workflow for exactly the -# variants whose branch_or_tag changed, every suite of each. Nothing else in -# the matrix starts a release. Every run still waits for approval in the -# Production environment before building and again before promoting. +# kernel variant in ci/build-matrix.json, or run +# ci/scripts/propose-release-bump.py to open one that bumps every Release row +# to its newest tag (with --auto-merge it merges once checks pass and it is +# approved). release-dry-run.yml builds the changed rows on the PR, and +# merging it starts this workflow for exactly the variants whose +# branch_or_tag changed, every suite of each. Nothing else in the matrix +# starts a release. Every run still waits for approval in the Production +# environment before building and again before promoting. # # Manual dispatch remains for releasing without a ref change and for retries. # There is intentionally no schedule trigger. diff --git a/README.md b/README.md index de2e04b..7c85429 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,12 @@ Release is the controlled promotion path. counts as a change, so it releases the ref reverted to. - Before merge, `release-dry-run.yml` builds the same variants from the PR's new ref, chosen by the same script, and promotes nothing. +- `ci/scripts/propose-release-bump.py` proposes that PR: it bumps every + Release row whose Daily row has a newer tag, pushes a `bump/` branch + and opens the PR with `gh`, after showing the diff and asking. Run it by + hand, not from a workflow, so the PR triggers `release-dry-run.yml`. With + `--auto-merge` the PR merges once the required checks pass and it is + approved; `--dry-run` only shows the diff. - A manual run uses one **Release scope** for a kernel variant: - **Selected variant (all suites)** promotes every configured Release suite for that variant. From dafa8dbc37d096f8ad10a5477ac7633e708a7d08 Mon Sep 17 00:00:00 2001 From: Christopher Obbard Date: Tue, 13 Oct 2026 21:57:32 +0100 Subject: [PATCH 5/9] ci: name the Release bump branch after the current time The default bump/ branch collides when two runs bump to the same tag, for example after an earlier PR was closed. Name it after the UTC time instead, so every run gets its own branch, and drop the check for an open PR from that branch, which can no longer match. Signed-off-by: Christopher Obbard --- README.md | 8 ++++---- ci/scripts/propose-release-bump.py | 19 +++++-------------- 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 7c85429..272784d 100644 --- a/README.md +++ b/README.md @@ -164,10 +164,10 @@ Release is the controlled promotion path. - Before merge, `release-dry-run.yml` builds the same variants from the PR's new ref, chosen by the same script, and promotes nothing. - `ci/scripts/propose-release-bump.py` proposes that PR: it bumps every - Release row whose Daily row has a newer tag, pushes a `bump/` branch - and opens the PR with `gh`, after showing the diff and asking. Run it by - hand, not from a workflow, so the PR triggers `release-dry-run.yml`. With - `--auto-merge` the PR merges once the required checks pass and it is + Release row whose Daily row has a newer tag, pushes a `bump/` + branch and opens the PR with `gh`, after showing the diff and asking. Run + it by hand, not from a workflow, so the PR triggers `release-dry-run.yml`. + With `--auto-merge` the PR merges once the required checks pass and it is approved; `--dry-run` only shows the diff. - A manual run uses one **Release scope** for a kernel variant: - **Selected variant (all suites)** promotes every configured Release suite diff --git a/ci/scripts/propose-release-bump.py b/ci/scripts/propose-release-bump.py index fbd45c1..b0660c0 100755 --- a/ci/scripts/propose-release-bump.py +++ b/ci/scripts/propose-release-bump.py @@ -34,7 +34,7 @@ --remote NAME Remote to fetch from and push to (default: origin). --base BRANCH Branch to bump and open the PR against (default: main). - --branch NAME Branch to push (default: bump/). + --branch NAME Branch to push (default: bump/). --auto-merge Arm auto-merge (merge commit) on the PR, so it merges once the required checks pass and it is approved. --dry-run Show what would change (the diff, in the worktree @@ -52,8 +52,7 @@ followed by the PR URL when one is opened. Exit codes: - 0 Success, including when everything is up to date or a PR for - is already open. + 0 Success, including when everything is up to date. 1 Error. """ @@ -65,6 +64,7 @@ import sys import tempfile import textwrap +from datetime import datetime, timezone from pathlib import Path SCRIPT_DIR = Path(__file__).resolve().parent @@ -210,17 +210,8 @@ def github_repo(remote): def open_pr(wt, repo, args, bumps): - branch = args.branch or f"bump/{newest_tag(bumps)}" - - step(f"Checking for an open PR from {branch}") - existing = run( - ["gh", "pr", "list", "--repo", repo, "--head", branch, - "--state", "open", "--json", "url", "--jq", ".[].url"], - capture=True, - ) - if existing: - print(f"PR for {branch} already open: {existing}") - return + now = datetime.now(timezone.utc) + branch = args.branch or f"bump/{now:%Y%m%d-%H%M%S}" message = commit_message(bumps) step("Proposed change") From 20e0e9c22c93fed1ea94e15f3facb9f56036bdc0 Mon Sep 17 00:00:00 2001 From: Christopher Obbard Date: Tue, 13 Oct 2026 22:20:46 +0100 Subject: [PATCH 6/9] ci: ask separately before pushing the Release bump propose-release-bump.py asked once before committing, pushing and opening the PR. Ask before committing, then again before pushing the branch and opening the PR. Answering no to the second leaves the commit on the local bump/ branch to check or push by hand. --no-push skips the second step, and gh is then not needed. --yes answers both questions. Signed-off-by: Christopher Obbard --- README.md | 13 +++--- ci/scripts/propose-release-bump.py | 65 +++++++++++++++++++----------- 2 files changed, 49 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 272784d..f783f61 100644 --- a/README.md +++ b/README.md @@ -163,12 +163,13 @@ Release is the controlled promotion path. counts as a change, so it releases the ref reverted to. - Before merge, `release-dry-run.yml` builds the same variants from the PR's new ref, chosen by the same script, and promotes nothing. -- `ci/scripts/propose-release-bump.py` proposes that PR: it bumps every - Release row whose Daily row has a newer tag, pushes a `bump/` - branch and opens the PR with `gh`, after showing the diff and asking. Run - it by hand, not from a workflow, so the PR triggers `release-dry-run.yml`. - With `--auto-merge` the PR merges once the required checks pass and it is - approved; `--dry-run` only shows the diff. +- `ci/scripts/propose-release-bump.py` prepares that PR: it bumps every + Release row whose Daily row has a newer tag and commits that on a local + `bump/` branch, after showing the diff and asking. It then asks + again before pushing the branch and opening the PR with `gh`; `--no-push` + stops after the commit. Run it by hand, not from a workflow, so the PR + triggers `release-dry-run.yml`. With `--auto-merge` the PR merges once the + required checks pass and it is approved; `--dry-run` only shows the diff. - A manual run uses one **Release scope** for a kernel variant: - **Selected variant (all suites)** promotes every configured Release suite for that variant. diff --git a/ci/scripts/propose-release-bump.py b/ci/scripts/propose-release-bump.py index b0660c0..155fe3f 100755 --- a/ci/scripts/propose-release-bump.py +++ b/ci/scripts/propose-release-bump.py @@ -2,7 +2,7 @@ # Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. # SPDX-License-Identifier: BSD-3-Clause-Clear -"""Bump the Release refs in ci/build-matrix.json and propose a PR. +"""Bump the Release refs in ci/build-matrix.json and optionally propose a PR. For each kernel variant with a Release row, resolve the newest tag of its Daily row (ref_strategy latest_tag) with ci/scripts/resolve-kernel-ref.sh, the @@ -12,9 +12,10 @@ By default this works in a temporary git worktree of /, so the current checkout is never touched. Every stale Release row is bumped in one -signed-off commit, pushed to , and proposed as a PR to with gh. -Merging that PR starts release.yml for the bumped variants; -release-dry-run.yml builds them on the PR first. +signed-off commit on a local . Then is pushed and proposed +as a PR to with gh, unless --no-push. Merging that PR starts +release.yml for the bumped variants; release-dry-run.yml builds them on the +PR first. Run it by hand as a maintainer: a PR opened with a workflow's GITHUB_TOKEN would not trigger release-dry-run.yml. @@ -27,29 +28,34 @@ auto-merge. Usage: - ci/scripts/propose-release-bump.py [--auto-merge] [--dry-run] [--yes] + ci/scripts/propose-release-bump.py [--no-push | --auto-merge] [--dry-run] + [--yes] ci/scripts/propose-release-bump.py --matrix-path PATH [--dry-run] [--yes] Options: --remote NAME Remote to fetch from and push to (default: origin). --base BRANCH Branch to bump and open the PR against (default: main). - --branch NAME Branch to push (default: bump/). + --branch NAME Branch to commit on (default: bump/). + --no-push Only commit on the local ; do not push it or + open the PR. --auto-merge Arm auto-merge (merge commit) on the PR, so it merges once the required checks pass and it is approved. --dry-run Show what would change (the diff, in the worktree mode); commit, push and write nothing. --matrix-path PATH Only bump PATH in place: no worktree, git or gh. - -y, --yes Do not ask before committing, pushing and opening the - PR (or writing PATH). + -y, --yes Do not ask before committing, before pushing and + opening the PR, or before writing PATH. Before changing anything, the script shows the diff, the commit message and -what it will push, and asks for confirmation. Progress, including every -command it runs, goes to stderr. +what it will push, and asks before committing. After the commit it asks +again before pushing and opening the PR; answering no leaves the commit on +the local . Progress, including every command it runs, goes to +stderr. Output: One "variant: old -> new" line per bumped Release row, or "up to date", - followed by the PR URL when one is opened. + followed by the PR URL, or the local branch name when not pushed. Exit codes: 0 Success, including when everything is up to date. @@ -219,11 +225,13 @@ def open_pr(wt, repo, args, bumps): log("") log("Commit message:") log(textwrap.indent(message, " ")) - log(f"Push: {branch} to {args.remote}") - log(f"PR: {repo} {args.base} <- {branch}") - log(f"Auto-merge: {'yes (merge commit)' if args.auto_merge else 'no'}") + log(f"Branch: {branch}") + if args.push: + log(f"Push: {branch} to {args.remote}") + log(f"PR: {repo} {args.base} <- {branch}") + log(f"Auto-merge: {'yes (merge commit)' if args.auto_merge else 'no'}") log("") - if not confirm("Commit, push and open the PR?", args): + if not confirm(f"Commit on {branch}?", args): log("Aborted; nothing was committed or pushed.") return @@ -232,6 +240,11 @@ def open_pr(wt, repo, args, bumps): run(["git", "add", MATRIX_REL_PATH], cwd=wt) run(["git", "commit", "-q", "-s", "-F", "-"], cwd=wt, stdin=message) + if not (args.push and confirm( + f"Push {branch} to {args.remote} and open the PR?", args)): + log(f"Not pushed; the bump is on the local branch {branch}.") + print(branch) + return step(f"Pushing {branch} to {args.remote}") run(["git", "push", args.remote, f"HEAD:refs/heads/{branch}"], cwd=wt) step("Opening the PR") @@ -262,14 +275,16 @@ def open_pr(wt, repo, args, bumps): def propose(args): - if not shutil.which("gh"): - raise SystemExit("ERROR: gh is not installed") - repo = github_repo(args.remote) - log(f"Repository: {repo}") - if not args.dry_run: - step("Checking gh authentication") - run(["gh", "auth", "status", "--hostname", "github.com"], - capture=True) + repo = None + if args.push: + if not shutil.which("gh"): + raise SystemExit("ERROR: gh is not installed") + repo = github_repo(args.remote) + log(f"Repository: {repo}") + if not args.dry_run: + step("Checking gh authentication") + run(["gh", "auth", "status", "--hostname", "github.com"], + capture=True) step(f"Fetching {args.remote}/{args.base}") run(["git", "fetch", "-q", args.remote, args.base]) @@ -325,11 +340,15 @@ def main(): parser.add_argument("--remote", default="origin") parser.add_argument("--base", default="main") parser.add_argument("--branch") + parser.add_argument("--push", action=argparse.BooleanOptionalAction, + default=True) parser.add_argument("--auto-merge", action="store_true") parser.add_argument("--dry-run", action="store_true") parser.add_argument("--matrix-path", type=Path) parser.add_argument("-y", "--yes", action="store_true") args = parser.parse_args() + if args.auto_merge and not args.push: + parser.error("--auto-merge needs a PR; drop --no-push") try: if args.matrix_path: From 47694bd102dfc4e656ba92178f4c18c1897e57be Mon Sep 17 00:00:00 2001 From: Christopher Obbard Date: Tue, 13 Oct 2026 22:21:05 +0100 Subject: [PATCH 7/9] ci: arm auto-merge on the Release bump PR by default A Release bump PR still has to pass the release-dry-run.yml checks and be approved before it merges, so arming auto-merge only saves a maintainer from coming back to press the button. Do it by default and add --no-auto-merge to skip it. Signed-off-by: Christopher Obbard --- .github/workflows/release.yml | 12 ++++++------ README.md | 5 +++-- ci/scripts/propose-release-bump.py | 26 +++++++++++++------------- 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e3b5cc7..df3f2b7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,12 +11,12 @@ name: release # To update a release ref: open a PR that changes the Release row for that # kernel variant in ci/build-matrix.json, or run # ci/scripts/propose-release-bump.py to open one that bumps every Release row -# to its newest tag (with --auto-merge it merges once checks pass and it is -# approved). release-dry-run.yml builds the changed rows on the PR, and -# merging it starts this workflow for exactly the variants whose -# branch_or_tag changed, every suite of each. Nothing else in the matrix -# starts a release. Every run still waits for approval in the Production -# environment before building and again before promoting. +# to its newest tag (it merges once checks pass and it is approved). +# release-dry-run.yml builds the changed rows on the PR, and merging it starts +# this workflow for exactly the variants whose branch_or_tag changed, every +# suite of each. Nothing else in the matrix starts a release. Every run still +# waits for approval in the Production environment before building and again +# before promoting. # # Manual dispatch remains for releasing without a ref change and for retries. # There is intentionally no schedule trigger. diff --git a/README.md b/README.md index f783f61..ac67b4c 100644 --- a/README.md +++ b/README.md @@ -168,8 +168,9 @@ Release is the controlled promotion path. `bump/` branch, after showing the diff and asking. It then asks again before pushing the branch and opening the PR with `gh`; `--no-push` stops after the commit. Run it by hand, not from a workflow, so the PR - triggers `release-dry-run.yml`. With `--auto-merge` the PR merges once the - required checks pass and it is approved; `--dry-run` only shows the diff. + triggers `release-dry-run.yml`. The PR merges once the required checks + pass and it is approved, unless `--no-auto-merge`; `--dry-run` only shows + the diff. - A manual run uses one **Release scope** for a kernel variant: - **Selected variant (all suites)** promotes every configured Release suite for that variant. diff --git a/ci/scripts/propose-release-bump.py b/ci/scripts/propose-release-bump.py index 155fe3f..6ccbd03 100755 --- a/ci/scripts/propose-release-bump.py +++ b/ci/scripts/propose-release-bump.py @@ -13,14 +13,14 @@ By default this works in a temporary git worktree of /, so the current checkout is never touched. Every stale Release row is bumped in one signed-off commit on a local . Then is pushed and proposed -as a PR to with gh, unless --no-push. Merging that PR starts -release.yml for the bumped variants; release-dry-run.yml builds them on the -PR first. +as a PR to with gh, unless --no-push, and auto-merge is armed on +the PR unless --no-auto-merge. Merging that PR starts release.yml for the +bumped variants; release-dry-run.yml builds them on the PR first. Run it by hand as a maintainer: a PR opened with a workflow's GITHUB_TOKEN would not trigger release-dry-run.yml. -Repository settings needed for --auto-merge: +Repository settings needed for auto-merge (or pass --no-auto-merge): - Settings > General > "Allow auto-merge" must be on. - main needs branch protection or a ruleset that requires the release-dry-run.yml checks and at least one approving review. Without @@ -28,8 +28,8 @@ auto-merge. Usage: - ci/scripts/propose-release-bump.py [--no-push | --auto-merge] [--dry-run] - [--yes] + ci/scripts/propose-release-bump.py [--no-push] [--no-auto-merge] + [--dry-run] [--yes] ci/scripts/propose-release-bump.py --matrix-path PATH [--dry-run] [--yes] Options: @@ -39,8 +39,9 @@ --branch NAME Branch to commit on (default: bump/). --no-push Only commit on the local ; do not push it or open the PR. - --auto-merge Arm auto-merge (merge commit) on the PR, so it merges - once the required checks pass and it is approved. + --no-auto-merge Do not arm auto-merge on the PR. By default it is armed + (rebase), so the PR merges once the required + checks pass and it is approved. --dry-run Show what would change (the diff, in the worktree mode); commit, push and write nothing. --matrix-path PATH Only bump PATH in place: no worktree, git or gh. @@ -229,7 +230,7 @@ def open_pr(wt, repo, args, bumps): if args.push: log(f"Push: {branch} to {args.remote}") log(f"PR: {repo} {args.base} <- {branch}") - log(f"Auto-merge: {'yes (merge commit)' if args.auto_merge else 'no'}") + log(f"Auto-merge: {'yes (rebase)' if args.auto_merge else 'no'}") log("") if not confirm(f"Commit on {branch}?", args): log("Aborted; nothing was committed or pushed.") @@ -257,7 +258,7 @@ def open_pr(wt, repo, args, bumps): if args.auto_merge: step("Arming auto-merge") - run(["gh", "pr", "merge", "--repo", repo, "--auto", "--merge", url]) + run(["gh", "pr", "merge", "--repo", repo, "--auto", "--rebase", url]) print("auto-merge armed") step("Looking for other open bump/* PRs") @@ -342,13 +343,12 @@ def main(): parser.add_argument("--branch") parser.add_argument("--push", action=argparse.BooleanOptionalAction, default=True) - parser.add_argument("--auto-merge", action="store_true") + parser.add_argument("--auto-merge", action=argparse.BooleanOptionalAction, + default=True) parser.add_argument("--dry-run", action="store_true") parser.add_argument("--matrix-path", type=Path) parser.add_argument("-y", "--yes", action="store_true") args = parser.parse_args() - if args.auto_merge and not args.push: - parser.error("--auto-merge needs a PR; drop --no-push") try: if args.matrix_path: From 0b882e0e82050203248f76e01fca0b734dd5ae3a Mon Sep 17 00:00:00 2001 From: Christopher Obbard Date: Tue, 13 Oct 2026 21:59:26 +0100 Subject: [PATCH 8/9] ci: make one Release bump commit per variant A single commit bumping every variant cannot be reverted or cherry-picked for one variant alone, and its message has to cover several shapes of change. Commit each bumped Release row separately with a one-line message, and give a multi-commit PR an explicit title and a body listing the commits, as --fill would title it after the branch name. Signed-off-by: Christopher Obbard --- ci/scripts/propose-release-bump.py | 91 +++++++++++++++--------------- 1 file changed, 44 insertions(+), 47 deletions(-) diff --git a/ci/scripts/propose-release-bump.py b/ci/scripts/propose-release-bump.py index 6ccbd03..23a091d 100755 --- a/ci/scripts/propose-release-bump.py +++ b/ci/scripts/propose-release-bump.py @@ -11,11 +11,11 @@ moves backwards. By default this works in a temporary git worktree of /, so the -current checkout is never touched. Every stale Release row is bumped in one -signed-off commit on a local . Then is pushed and proposed -as a PR to with gh, unless --no-push, and auto-merge is armed on -the PR unless --no-auto-merge. Merging that PR starts release.yml for the -bumped variants; release-dry-run.yml builds them on the PR first. +current checkout is never touched. Each stale Release row is bumped in its +own signed-off commit on a local . Then is pushed and +proposed as a PR to with gh, unless --no-push, and auto-merge is +armed on the PR unless --no-auto-merge. Merging that PR starts release.yml +for the bumped variants; release-dry-run.yml builds them on the PR first. Run it by hand as a maintainer: a PR opened with a workflow's GITHUB_TOKEN would not trigger release-dry-run.yml. @@ -48,11 +48,11 @@ -y, --yes Do not ask before committing, before pushing and opening the PR, or before writing PATH. -Before changing anything, the script shows the diff, the commit message and -what it will push, and asks before committing. After the commit it asks -again before pushing and opening the PR; answering no leaves the commit on -the local . Progress, including every command it runs, goes to -stderr. +Before changing anything, the script shows the diff, the commit messages +and what it will push, and asks before committing. After the commits it +asks again before pushing and opening the PR; answering no leaves the +commits on the local . Progress, including every command it runs, +goes to stderr. Output: One "variant: old -> new" line per bumped Release row, or "up to date", @@ -176,35 +176,18 @@ def bump_matrix(matrix): return bumps -def newest_tag(bumps): - return max((new for _, _, new in bumps), key=date_key) +def apply_bump(matrix, bump): + """Set the Release row of matrix that bump is for to its new ref.""" + variant, old, new = bump + for row in matrix["deliveries"]: + if (row["type"] == "Release" and row["kernel_variant"] == variant + and row["branch_or_tag"] == old): + row["branch_or_tag"] = new -def commit_message(bumps): - news = {new for _, _, new in bumps} - olds = {old for _, old, _ in bumps} - variants = [v for v, _, _ in bumps] - - if len(news) == 1: - title = f"ci: update the Release ref to {newest_tag(bumps)}" - else: - title = "ci: update the Release refs to the newest tags" - - if len(news) == 1 and len(olds) == 1: - new, old = next(iter(news)), next(iter(olds)) - if len(bumps) == 1: - body = (f"The {variants[0]} Release row pins {old}. Bump it to " - f"the newer {new} snapshot.") - else: - who = ("Both" if len(bumps) == 2 else f"All {len(bumps)}") - body = (f"{who} Release rows ({', '.join(variants)}) pin {old}. " - f"Bump them to the newer {new} snapshot.") - body = textwrap.fill(body, width=72, break_on_hyphens=False) - else: - body = "Bump the Release rows to the newer snapshots:\n\n" + "\n".join( - f" {v}: {old} -> {new}" for v, old, new in bumps) - - return f"{title}\n\n{body}\n" +def commit_message(bump): + variant, _, new = bump + return f"ci: bump {variant} Release ref to {new}\n" def github_repo(remote): @@ -216,16 +199,16 @@ def github_repo(remote): return m.group(1) -def open_pr(wt, repo, args, bumps): +def open_pr(wt, repo, args, base, bumps): now = datetime.now(timezone.utc) branch = args.branch or f"bump/{now:%Y%m%d-%H%M%S}" - message = commit_message(bumps) + messages = [commit_message(b) for b in bumps] step("Proposed change") run(["git", "--no-pager", "diff"], cwd=wt) log("") - log("Commit message:") - log(textwrap.indent(message, " ")) + log("Commits:") + log(textwrap.indent("".join(messages), " ")) log(f"Branch: {branch}") if args.push: log(f"Push: {branch} to {args.remote}") @@ -238,20 +221,33 @@ def open_pr(wt, repo, args, bumps): step(f"Committing on {branch}") run(["git", "switch", "-q", "-c", branch], cwd=wt) - run(["git", "add", MATRIX_REL_PATH], cwd=wt) - run(["git", "commit", "-q", "-s", "-F", "-"], cwd=wt, - stdin=message) + for bump, message in zip(bumps, messages): + apply_bump(base, bump) + write_matrix(wt / MATRIX_REL_PATH, base) + run(["git", "add", MATRIX_REL_PATH], cwd=wt) + # A throwaway worktree that never merges: rerere and detached + # auto-maintenance only race the next commit for MERGE_RR.lock. + run(["git", "-c", "rerere.enabled=false", "-c", + "maintenance.auto=false", "commit", "-q", "-s", "-F", "-"], + cwd=wt, stdin=message) if not (args.push and confirm( f"Push {branch} to {args.remote} and open the PR?", args)): - log(f"Not pushed; the bump is on the local branch {branch}.") + log(f"Not pushed; the commits are on the local branch {branch}.") print(branch) return step(f"Pushing {branch} to {args.remote}") run(["git", "push", args.remote, f"HEAD:refs/heads/{branch}"], cwd=wt) step("Opening the PR") + if len(messages) == 1: + fill = ["--fill"] + else: + # --fill would title a multi-commit PR after the branch name. + tags = ", ".join(dict.fromkeys(new for _, _, new in bumps)) + fill = ["--title", f"Bump the Release refs ({tags})", + "--body", "".join(f"- {m}" for m in messages)] url = run( ["gh", "pr", "create", "--repo", repo, "--base", args.base, - "--head", branch, "--fill"], + "--head", branch, *fill], cwd=wt, capture=True, ) print(url) @@ -296,6 +292,7 @@ def propose(args): f"{args.remote}/{args.base}"]) try: path = wt / MATRIX_REL_PATH + base = load_matrix(path) matrix = load_matrix(path) bumps = bump_matrix(matrix) report(bumps) @@ -306,7 +303,7 @@ def propose(args): step("Proposed change (--dry-run: stopping here)") run(["git", "--no-pager", "diff"], cwd=wt) elif bumps: - open_pr(wt, repo, args, bumps) + open_pr(wt, repo, args, base, bumps) finally: step("Removing the temporary worktree") run(["git", "worktree", "remove", "--force", str(wt)]) From 6cc52c46d398ca32e7b0fd5cf59811e4aeabb5c0 Mon Sep 17 00:00:00 2001 From: Christopher Obbard Date: Tue, 13 Oct 2026 21:59:51 +0100 Subject: [PATCH 9/9] ci: add --variant to bump only some Release rows Run on main, the script bumps every variant, so a PR for one variant can overlap an already open bump PR for another. Add --variant, taking a comma-separated list of exact kernel variant names (qcom-next does not include qcom-next-debug), and error on a name with no Release row. Other open bump PRs are no longer listed as superseded in that case. Signed-off-by: Christopher Obbard --- README.md | 3 ++- ci/scripts/propose-release-bump.py | 38 +++++++++++++++++++++++++----- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index ac67b4c..1c9e7fe 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,8 @@ Release is the controlled promotion path. stops after the commit. Run it by hand, not from a workflow, so the PR triggers `release-dry-run.yml`. The PR merges once the required checks pass and it is approved, unless `--no-auto-merge`; `--dry-run` only shows - the diff. + the diff. `--variant qcom-next[,...]` bumps only those variants, so the PR + does not overlap another open one. - A manual run uses one **Release scope** for a kernel variant: - **Selected variant (all suites)** promotes every configured Release suite for that variant. diff --git a/ci/scripts/propose-release-bump.py b/ci/scripts/propose-release-bump.py index 23a091d..2cb571b 100755 --- a/ci/scripts/propose-release-bump.py +++ b/ci/scripts/propose-release-bump.py @@ -17,6 +17,9 @@ armed on the PR unless --no-auto-merge. Merging that PR starts release.yml for the bumped variants; release-dry-run.yml builds them on the PR first. +With --variant only the named variants are bumped, so a PR for one variant +does not overlap an open PR for another. + Run it by hand as a maintainer: a PR opened with a workflow's GITHUB_TOKEN would not trigger release-dry-run.yml. @@ -28,15 +31,22 @@ auto-merge. Usage: - ci/scripts/propose-release-bump.py [--no-push] [--no-auto-merge] + ci/scripts/propose-release-bump.py [--variant NAME[,NAME...]] + [--no-push] [--no-auto-merge] [--dry-run] [--yes] - ci/scripts/propose-release-bump.py --matrix-path PATH [--dry-run] [--yes] + ci/scripts/propose-release-bump.py --matrix-path PATH + [--variant NAME[,NAME...]] [--dry-run] + [--yes] Options: --remote NAME Remote to fetch from and push to (default: origin). --base BRANCH Branch to bump and open the PR against (default: main). --branch NAME Branch to commit on (default: bump/). + --variant NAME[,NAME...] + Only bump the Release rows of these kernel variants + (exact names, so qcom-next does not include + qcom-next-debug). Default: every variant. --no-push Only commit on the local ; do not push it or open the PR. --no-auto-merge Do not arm auto-merge on the PR. By default it is armed @@ -128,13 +138,22 @@ def write_matrix(path, matrix): path.write_text(out, encoding="utf-8") -def bump_matrix(matrix): +def bump_matrix(matrix, variants=None): """Bump the stale Release rows of matrix in memory. - Returns a list of (variant, old, new) tuples, one per bumped row. + With variants, only the Release rows of those kernel variants. Returns a + list of (variant, old, new) tuples, one per bumped row. """ rows = matrix["deliveries"] + if variants: + known = {r["kernel_variant"] for r in rows if r["type"] == "Release"} + unknown = [v for v in variants if v not in known] + if unknown: + raise SystemExit( + f"ERROR: no Release row for {', '.join(unknown)} " + f"(known: {', '.join(sorted(known))})") + daily = {r["kernel_variant"]: r for r in rows if r["type"] == "Daily"} newest = {} bumps = [] @@ -143,6 +162,8 @@ def bump_matrix(matrix): if row["type"] != "Release": continue variant = row["kernel_variant"] + if variants and variant not in variants: + continue src = daily.get(variant) if not src or src.get("ref_strategy") != "latest_tag": log(f"{variant}: no latest_tag Daily row, skipping") @@ -257,6 +278,9 @@ def open_pr(wt, repo, args, base, bumps): run(["gh", "pr", "merge", "--repo", repo, "--auto", "--rebase", url]) print("auto-merge armed") + if args.variant: + # Bump PRs for other variants are not superseded by this one. + return step("Looking for other open bump/* PRs") others = run( ["gh", "pr", "list", "--repo", repo, "--state", "open", @@ -294,7 +318,7 @@ def propose(args): path = wt / MATRIX_REL_PATH base = load_matrix(path) matrix = load_matrix(path) - bumps = bump_matrix(matrix) + bumps = bump_matrix(matrix, args.variant) report(bumps) if bumps: # Only the temporary worktree; nothing is committed yet. @@ -313,7 +337,7 @@ def bump_file(args): path = args.matrix_path step(f"Checking {path}") matrix = load_matrix(path) - bumps = bump_matrix(matrix) + bumps = bump_matrix(matrix, args.variant) report(bumps) if not bumps or args.dry_run: return @@ -338,6 +362,8 @@ def main(): parser.add_argument("--remote", default="origin") parser.add_argument("--base", default="main") parser.add_argument("--branch") + parser.add_argument( + "--variant", type=lambda s: [v for v in s.split(",") if v]) parser.add_argument("--push", action=argparse.BooleanOptionalAction, default=True) parser.add_argument("--auto-merge", action=argparse.BooleanOptionalAction,