From fcacaa802aeb2e5dc27fa6dff2c9c6831f98fbdd Mon Sep 17 00:00:00 2001 From: Marcelo Alcantara Date: Wed, 28 Oct 2026 13:44:43 +1000 Subject: [PATCH 1/3] ci: let the merge queue take a pull request run of the same tree The merge queue built, tested and booted every pull request again even when a run at the pull request's head had just done so for an identical tree: run 40616429413 for #249 had the same build key and the same full tree as its queue commit, and the queue spent another 12 minutes on it. In a merge queue run only, lookup-build.py now also accepts a pull request run's packages and verification when the run is kernel CI on a pull request event in this repository at the head the queue merges (the queue commit's second parent, approved after its last push), passed at its first attempt and was never re-run, has exactly one live package and one live verification artifact for the key, and its verification record names a merge commit whose tree is the queue commit's tree, CI files and expected results included. The package job downloads that exact package artifact by its ID. The queue uploads no reusable package, metadata or verification record on this path, so the push after the merge still trusts only the branch and the queue. Anything missing, ambiguous or unreadable leaves the usual lookup. This changes what the required check attests for those pull requests: the record is the pull request run's own word, not authenticated per job or per package, and the trusted build moves to the push after the merge. Signed-off-by: Marcelo Alcantara --- .github/workflows/kernel-ci-jobs.yml | 18 +- .github/workflows/kernel-ci.yml | 8 +- tools/aurora-ci/README.rst | 53 ++++-- tools/aurora-ci/lookup-build.py | 167 +++++++++++++++++- tools/aurora-ci/result.py | 11 +- tools/aurora-ci/tests/test_lookup_build.py | 196 ++++++++++++++++++++- 6 files changed, 421 insertions(+), 32 deletions(-) diff --git a/.github/workflows/kernel-ci-jobs.yml b/.github/workflows/kernel-ci-jobs.yml index 0953618523e805..befd1fa6355ca6 100644 --- a/.github/workflows/kernel-ci-jobs.yml +++ b/.github/workflows/kernel-ci-jobs.yml @@ -51,7 +51,9 @@ jobs: kernel_tree: ${{ steps.lookup.outputs.kernel_tree }} recipe_commit: ${{ steps.lookup.outputs.recipe_commit }} package_run: ${{ steps.lookup.outputs.package_run }} + package_artifact: ${{ steps.lookup.outputs.package_artifact }} verified_run: ${{ steps.lookup.outputs.verified_run }} + pull_request_run: ${{ steps.lookup.outputs.pull_request_run }} base_tree: ${{ steps.lookup.outputs.base_tree }} baseline_run: ${{ steps.lookup.outputs.baseline_run }} steps: @@ -96,9 +98,11 @@ jobs: GITHUB_TOKEN: ${{ github.token }} BASE_SHA: ${{ steps.plan.outputs.base_sha }} FORCE: ${{ steps.plan.outputs.force }} + MERGE_QUEUE: ${{ github.event_name == 'merge_group' }} run: | python3 -I tools/aurora-ci/lookup-build.py --kernel . \ - --recipe-repo "$RUNNER_TEMP/omarchy-pkgs" --base-sha "$BASE_SHA" --force "$FORCE" + --recipe-repo "$RUNNER_TEMP/omarchy-pkgs" --base-sha "$BASE_SHA" --force "$FORCE" \ + --merge-queue "$MERGE_QUEUE" commits: name: commits @@ -333,13 +337,23 @@ jobs: cp omarchy-pkgs/build-output/edge/aarch64/linux-aurora-*.pkg.tar.zst out/packages/ ls -l out/packages - name: Download the packages built for this key - if: env.REUSE_RUN != '' + if: env.REUSE_RUN != '' && needs.plan.outputs.package_artifact == '' uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: kernel-package-${{ env.KEY }} path: out run-id: ${{ env.REUSE_RUN }} github-token: ${{ github.token }} + # The very artifact plan paired with the verification; fails if a + # re-run of that pull request run has replaced it since. + - name: Download the pull request run's packages + if: needs.plan.outputs.package_artifact != '' + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + artifact-ids: ${{ needs.plan.outputs.package_artifact }} + path: out + run-id: ${{ env.REUSE_RUN }} + github-token: ${{ github.token }} # Not every runner image ships kmod (Namespace's does not). - name: Install depmod run: | diff --git a/.github/workflows/kernel-ci.yml b/.github/workflows/kernel-ci.yml index 64855775d8474f..70fce1e5e1f6eb 100644 --- a/.github/workflows/kernel-ci.yml +++ b/.github/workflows/kernel-ci.yml @@ -7,9 +7,11 @@ # label for a build now (kernel-ci-label.yml) # merge queue everything: the kernel package built with omarchy-pkgs' # own recipe, KUnit, a QEMU boot that loads every module, -# and a comparison with the target branch's package -# push the merged commit, reusing the queue's build; attests -# the packages +# and a comparison with the target branch's package; the +# build and tests are skipped when an eligible run at the +# head being merged tested exactly the same tree +# push the merged commit, reusing a branch or queue build for +# the key, else building it; attests the packages # nightly rebuilds from scratch, so a change in omarchy-pkgs or in # the Arch Linux ARM packages it builds with shows up # before the next release diff --git a/tools/aurora-ci/README.rst b/tools/aurora-ci/README.rst index 54bc1f1752a14b..4ed723255ecd81 100644 --- a/tools/aurora-ci/README.rst +++ b/tools/aurora-ci/README.rst @@ -14,7 +14,8 @@ What runs when Builds take time and, on some runners, money. Pull requests get the checks that run on free GitHub-hosted runners on every push; the team's pull requests are also built on every push, and everyone's kernel is built again -in the merge queue, from exactly the tree that will land. +in the merge queue, from exactly the tree that will land, unless a run at +the pull request's head already tested exactly that tree (see below). ==================== ================================================= Event Jobs @@ -24,8 +25,11 @@ pull request push plan, commits, compile, device trees, host tests, trusted author push all of the above, then package, KUnit and boot (not on drafts) ``ci:build`` label all of the above, then package, KUnit and boot -merge queue everything -push to the branch package (reused from the queue), attestation +merge queue everything, or the checks only when an eligible + run at the head being merged tested exactly the tree + being merged +push to the branch package (reused from a branch or queue run, else + built), attestation nightly package, KUnit and boot, rebuilt from scratch manual run everything, never reusing an earlier result ==================== ================================================= @@ -156,13 +160,42 @@ once approved they run the checks only unless a maintainer applies On a ``pull_request`` event GitHub runs the workflow files from the pull request itself, so these rules decide cost, not security: a fork could edit -them. For the same reason no pull request's run is a source of results, and -neither is a manual run of another branch: both run code nobody has reviewed -yet. Earlier packages, verification records and baselines are reused only -from ``kernel-ci.yml`` runs on ``integration/aurora`` and its merge queue -branches (``lookup-build.py``), so no one can plant a package that a later -run would skip testing and attest. The merge queue therefore builds a pull -request again even after a ``ci:build`` run. What keeps untrusted code off +them. For the same reason a pull request's run is not a source of results +before review, and a manual run of another branch never is: both run code +nobody has reviewed yet. Earlier packages, verification records and +baselines are reused from ``kernel-ci.yml`` runs on ``integration/aurora`` +and its merge queue branches (``lookup-build.py``), so no one can plant a +package that a later run would skip testing and attest. + +The one exception is the merge queue itself. A queued pull request has been +approved after its last push, and a push takes it out of the queue, so a +run at the head being merged (the queue commit's second parent) ran the +reviewed code. The queue takes the packages and verification of such a run +instead of building and booting again when the run is ``kernel-ci.yml`` or +``kernel-ci-label.yml`` on a pull request event in this repository, passed +at its first attempt and was never re-run, has exactly one live package +and one live verification record for the build key, and its record names +a merge commit with exactly the queue commit's tree (CI files and expected +results included). The package job downloads that very artifact by its ID. +A later queue entry also carries the entries ahead of it, so its tree +normally differs and it builds; an equal tree is taken. The run is matched +by its head, not by its pull request number. The queue still runs the +checks and, when the branch has a baseline, compares the packages with it, +but uploads no reusable package, metadata or verification record, so +nothing from a pull request becomes evidence a later run trusts. Anything +else, or evidence that cannot be read, leaves the usual lookup, which +trusts only the branch and the queue. + +What this trusts: the record is the pull request run's own word. Nothing +shows which job of that run wrote it, or that it verified those exact +package bytes, so code at the reviewed head that ran in that run could +have written it. And for these pull requests the trusted build moves after +the merge: the push takes the usual path for the key (a branch or queue +run's packages and verification, else a build, and KUnit and boot unless a +branch or queue run already verified the key), so a failure there comes +after the change has merged. + +What keeps untrusted code off paid runners is GitHub's setting to require approval before running workflows from outside collaborators, and, for a runner like Namespace's, a runner group limited to this repository. diff --git a/tools/aurora-ci/lookup-build.py b/tools/aurora-ci/lookup-build.py index 37bf6275fc13ac..c5fc26a7d9a5c5 100755 --- a/tools/aurora-ci/lookup-build.py +++ b/tools/aurora-ci/lookup-build.py @@ -21,18 +21,26 @@ This script computes the key, pins the omarchy-pkgs commit it was computed from, and looks those artifacts up through the Actions API. It accepts only artifacts that kernel-ci.yml uploaded after review: from the branch, the -merge queue, the nightly run or a manual run of the branch, never from a -pull request (see TRUSTED_EVENTS). +merge queue, the nightly run or a manual run of the branch (see +TRUSTED_EVENTS). The one exception is the merge queue: it also accepts the +build and verification of a pull request run at the very head it is merging, +the code its reviewers approved, when that run tested exactly the tree being +merged (see pull_request_run). """ import argparse import hashlib +import io import json import os +import re import subprocess import sys +import urllib.error import urllib.parse import urllib.request +import zipfile +import zlib from pathlib import Path # What omarchy-pkgs' bin/build uses to build linux-aurora: the recipe, the @@ -97,24 +105,68 @@ def trusted_branch(event, branch): return branch == TRUSTED_BRANCH +# A pull request's own run is reviewed code only at the head being merged: +# the merge queue takes only approved pull requests, the approval has to +# follow the last push, and a push takes the pull request out of the queue. +# Runs of earlier heads ran code nobody approved, so they never count. +PULL_REQUEST_WORKFLOWS = {".github/workflows/kernel-ci.yml", ".github/workflows/kernel-ci-label.yml"} + + +def pull_request_head(repo, rev="HEAD"): + """The pull request head a merge queue commit merges: its second parent, + read from the commit object, which a depth-1 checkout has. A later + queue entry also carries the entries ahead of it; its tree then differs + from the one the pull request's run tested, which pull_request_run + rejects.""" + header = git(repo, "cat-file", "commit", rev).split("\n\n", 1)[0] + parents = [line.split()[1] for line in header.splitlines() if line.startswith("parent ")] + return parents[1] if len(parents) == 2 else "" + + +# verified.json is a few hundred bytes; anything far larger is not one. +RECORD_LIMIT = 64 * 1024 + + +class _KeepRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None + + class Api: def __init__(self, url, repository, token, repository_id): self.url, self.repository, self.token = url.rstrip("/"), repository, token self.repository_id = int(repository_id) self._runs = {} - def get(self, path, **query): + def request(self, path, **query): address = f"{self.url}/repos/{self.repository}/{path}" if query: address += "?" + urllib.parse.urlencode(query) - request = urllib.request.Request(address, headers={ + return urllib.request.Request(address, headers={ "Accept": "application/vnd.github+json", "Authorization": f"Bearer {self.token}", "X-GitHub-Api-Version": "2022-11-28", }) - with urllib.request.urlopen(request, timeout=60) as response: + + def get(self, path, **query): + with urllib.request.urlopen(self.request(path, **query), timeout=60) as response: return json.load(response) + def artifact_file(self, artifact_id, member, limit=RECORD_LIMIT): + """One small file of an artifact, at most limit bytes zipped or not. + The API redirects to storage, which must not be sent the token.""" + try: + urllib.request.build_opener(_KeepRedirect).open( + self.request(f"actions/artifacts/{artifact_id}/zip"), timeout=60).close() + raise ValueError(f"artifact {artifact_id}: no download address") + except urllib.error.HTTPError as error: + if error.code not in (301, 302, 303, 307, 308): + raise + location = error.headers["Location"] + with urllib.request.urlopen(location, timeout=60) as response: + data = response.read(limit + 1) + return read_member(data, member, limit) + def trusted_run(self, run_id): """Whether a run is this repository's kernel CI on reviewed code: the branch or the merge queue, never a pull request or another @@ -143,17 +195,110 @@ def newest_artifact(self, name): return str(artifact["workflow_run"]["id"]) return "" + def pull_request_run(self, run_id, head_sha): + """Whether a run is this repository's kernel CI for a pull request + whose head is head_sha, passed at its first attempt and was never + re-run, so its artifacts all come from that one attempt. The head + may come from a fork.""" + run = self.get(f"actions/runs/{run_id}") + return (run.get("event") == "pull_request" + and run.get("path") in PULL_REQUEST_WORKFLOWS + and run.get("head_sha") == head_sha + and run.get("run_attempt") == 1 + and run.get("status") == "completed" and run.get("conclusion") == "success" + and (run.get("repository") or {}).get("id") == self.repository_id) + + def run_artifacts(self, run_id, name): + """Every artifact named name in a run, expired or not.""" + data = self.get(f"actions/runs/{run_id}/artifacts", name=name, per_page=100) + artifacts = [a for a in data.get("artifacts", []) if a.get("name") == name] + if data.get("total_count") != len(artifacts): + raise ValueError(f"run {run_id}: incomplete listing of {name}") + return artifacts + + def pull_request_evidence(self, key, head_sha, tree): + """(run ID, package artifact ID) of the newest eligible pull request + run at head_sha (see pull_request_run) that uploaded one live + kernel-package- and one live kernel-verified- (a run that + reused another run's packages uploads none) and whose verification + record names key, the run, and a commit with this tree; else + ('', ''). The record is the run's own word: nothing here proves + which job of the run wrote it, or which package bytes it verified.""" + verified_name, package_name = f"kernel-verified-{key}", f"kernel-package-{key}" + data = self.get("actions/artifacts", name=verified_name, per_page=100) + candidates = {str(a["workflow_run"]["id"]): a.get("created_at", "") for a in data.get("artifacts", []) + if a.get("name") == verified_name + and a.get("workflow_run", {}).get("repository_id") == self.repository_id + and a.get("workflow_run", {}).get("head_sha") == head_sha} + for run in sorted(candidates, key=candidates.get, reverse=True): + if not self.pull_request_run(int(run), head_sha): + continue + verified, packages = self.run_artifacts(run, verified_name), self.run_artifacts(run, package_name) + if len(verified) != 1 or len(packages) != 1 or verified[0].get("expired") or packages[0].get("expired"): + continue + record = json.loads(self.artifact_file(verified[0]["id"], "verified.json")) + if (isinstance(record, dict) and record.get("key") == key + and str(record.get("github_run_id")) == run + and isinstance(record.get("github_sha"), str) + and re.fullmatch(r"[0-9a-f]{40}", record["github_sha"]) + and self.commit_tree(record["github_sha"]) == tree): + return run, str(packages[0]["id"]) + return "", "" + + def commit_tree(self, sha): + return self.get(f"git/commits/{sha}")["tree"]["sha"] + + +def read_member(data, member, limit): + if len(data) > limit: + raise ValueError(f"artifact larger than {limit} bytes") + with zipfile.ZipFile(io.BytesIO(data)) as archive: + info = archive.getinfo(member) + if info.file_size > limit: + raise ValueError(f"{member} larger than {limit} bytes") + if info.flag_bits & 0x1 or info.compress_type not in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED): + raise ValueError(f"{member} is encrypted or compressed in an unexpected way") + with archive.open(info) as file: + content = file.read(limit + 1) + if len(content) > limit: + raise ValueError(f"{member} larger than {limit} bytes") + return content + + +def pull_request_run(api, kernel, key, head_sha): + """The pull request run whose packages and verification stand in for the + merge queue's, or ''. Its record must name a merge commit with exactly + the queue commit's tree, CI and expected results included. The queue + then records no verification of its own, so none of this becomes + evidence a later run trusts. Anything missing, ambiguous or unreadable + leaves the usual lookup, which trusts the branch and the queue only. + Returns (run ID, package artifact ID) or ('', '').""" + tree = git(kernel, "rev-parse", "HEAD^{tree}") + try: + return api.pull_request_evidence(key, head_sha, tree) + except (OSError, EOFError, KeyError, TypeError, ValueError, RuntimeError, NotImplementedError, + zipfile.BadZipFile, zlib.error) as error: + print(f"::warning title=Pull request results::not reused: {error}", file=sys.stderr) + return "", "" + -def lookup(api, kernel, recipe_repo, base_sha, force): +def lookup(api, kernel, recipe_repo, base_sha, force, pull_request=""): + """pull_request: in the merge queue, the head of the pull request being + merged, whose own run may stand in for the queue's build and tests.""" kernel_tree = kernel_id(kernel, "HEAD") recipe_commit = git(recipe_repo, "rev-parse", "HEAD") recipe_trees = {part: git(recipe_repo, "rev-parse", f"HEAD:{part}") for part in RECIPE_PARTS} key = build_key(kernel_tree, recipe_trees) out = {"key": key, "kernel_tree": kernel_tree, "recipe_commit": recipe_commit, - "package_run": "", "verified_run": "", "base_tree": "", "baseline_run": ""} + "package_run": "", "verified_run": "", "pull_request_run": "", "package_artifact": "", + "base_tree": "", "baseline_run": ""} if not force: out["package_run"] = api.newest_artifact(f"kernel-package-{key}") out["verified_run"] = api.newest_artifact(f"kernel-verified-{key}") + if pull_request and not out["verified_run"]: + run, package = pull_request_run(api, kernel, key, pull_request) + if run: + out.update(package_run=run, verified_run=run, pull_request_run=run, package_artifact=package) if base_sha: ensure_commit(kernel, base_sha) out["base_tree"] = kernel_id(kernel, base_sha) @@ -167,13 +312,16 @@ def main(argv=None): parser.add_argument("--recipe-repo", type=Path, required=True, help="omarchy-pkgs checkout") parser.add_argument("--base-sha", default="", help="commit whose build is the comparison baseline") parser.add_argument("--force", default="false", help="'true' ignores earlier builds and results") + parser.add_argument("--merge-queue", default="false", + help="'true' when --kernel is a merge queue commit: accept the merged pull request's run") args = parser.parse_args(argv) api = Api(os.environ.get("GITHUB_API_URL", "https://api.github.com"), os.environ["GITHUB_REPOSITORY"], os.environ["GITHUB_TOKEN"], os.environ["GITHUB_REPOSITORY_ID"]) try: - result = lookup(api, args.kernel, args.recipe_repo, args.base_sha, args.force == "true") + pull_request = pull_request_head(args.kernel) if args.merge_queue == "true" else "" + result = lookup(api, args.kernel, args.recipe_repo, args.base_sha, args.force == "true", pull_request) except (subprocess.CalledProcessError, OSError, KeyError, ValueError) as error: print(f"::error title=Build lookup::{error}", file=sys.stderr) return 1 @@ -189,6 +337,9 @@ def main(argv=None): reuse.append(f"packages from run {result['package_run']}") if result["verified_run"]: reuse.append(f"verified by run {result['verified_run']}") + if result["pull_request_run"]: + reuse = [f"packages and verification from run {result['pull_request_run']} " + f"of the pull request's head {pull_request[:12]}"] with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as out: out.write(f"Build key `{result['key']}` (kernel `{result['kernel_tree'][:12]}`, " f"omarchy-pkgs `{result['recipe_commit'][:12]}`)" diff --git a/tools/aurora-ci/result.py b/tools/aurora-ci/result.py index cf58708d783d65..c15a15426a0d07 100755 --- a/tools/aurora-ci/result.py +++ b/tools/aurora-ci/result.py @@ -13,7 +13,8 @@ pull request or merge group range (push, schedule, manual runs). A light run that passes says only that the checks passed: the kernel itself -is built in the merge queue, and the summary says so. +is built in the merge queue (or, there, taken from the pull request's run of +the same tree), and the summary says so. """ import argparse @@ -73,8 +74,12 @@ def main(argv=None): "| Job | Result | Expected | |", "|---|---|---|---|"] lines += [f"| {job} | {got} | {want} | {verdict} |" for job, got, want, verdict in rows] if outputs.get("tier") == "light" and ok: - lines += ["", "The kernel was not built in this run; the merge queue builds and boots it."] - if outputs.get("verified_run"): + lines += ["", "The kernel was not built in this run; the merge queue builds and boots it, " + "unless the run of the head it merges passed for exactly the tree it merges."] + if outputs.get("pull_request_run"): + lines += ["", f"Packages, KUnit and boot taken from pull request run {outputs['pull_request_run']}, " + "whose record names a commit with this exact tree; that record is the run's own word."] + elif outputs.get("verified_run"): lines += ["", f"KUnit and the boot test passed for this build key in run {outputs['verified_run']}."] text = "\n".join(lines) + "\n" print(text) diff --git a/tools/aurora-ci/tests/test_lookup_build.py b/tools/aurora-ci/tests/test_lookup_build.py index a2b93af97e67d7..cba48d67ad73d7 100644 --- a/tools/aurora-ci/tests/test_lookup_build.py +++ b/tools/aurora-ci/tests/test_lookup_build.py @@ -1,17 +1,28 @@ # SPDX-License-Identifier: GPL-2.0 +import io +import json +import unittest +import zipfile + from support import TempDirTestCase, commit_file, git, load lookup = load("lookup-build") class FakeApi: - def __init__(self, artifacts=None): + def __init__(self, artifacts=None, evidence=None): self.artifacts, self.asked = artifacts or {}, [] + self.evidence = evidence or {} def newest_artifact(self, name): self.asked.append(name) return self.artifacts.get(name, "") + def pull_request_evidence(self, key, head_sha, tree): + self.asked.append(("evidence", key, head_sha)) + run, tested, package = self.evidence.get((key, head_sha), ("", None, "")) + return (run, package) if tested == tree else ("", "") + class LookupTest(TempDirTestCase): def setUp(self): @@ -65,6 +76,44 @@ def test_kernel_tree_changes_the_key(self): commit_file(self.kernel, path, "x\n", "change") self.assertNotEqual(self.key(), before) + def pull_request_api(self, tested_tree=None, artifacts=None): + tree = git(self.kernel, "rev-parse", "HEAD^{tree}") + return FakeApi(artifacts, {(self.key(), "c0ffee"): ("21", tested_tree or tree, "77")}) + + def queue(self, api, force=False, pull_request="c0ffee"): + result = lookup.lookup(api, self.kernel, self.recipe, "", force=force, pull_request=pull_request) + return result["package_run"], result["verified_run"], result["pull_request_run"], result["package_artifact"] + + def test_the_merge_queue_takes_the_pull_requests_run_of_this_tree(self): + api = self.pull_request_api(artifacts={f"kernel-package-{self.key()}": "11"}) + self.assertEqual(self.queue(api), ("21", "21", "21", "77")) + + def test_a_run_of_another_tree_does_not_count(self): + # Same build key, but the run tested other CI files or expectations. + self.assertEqual(self.queue(self.pull_request_api(tested_tree="0" * 40)), ("", "", "", "")) + + def test_a_trusted_verification_comes_first(self): + key = self.key() + api = self.pull_request_api(artifacts={f"kernel-package-{key}": "11", f"kernel-verified-{key}": "12"}) + self.assertEqual(self.queue(api), ("11", "12", "", "")) + self.assertNotIn(("evidence", key, "c0ffee"), api.asked) + + def test_no_pull_request_run_outside_the_merge_queue_or_when_forced(self): + for pull_request, force in (("", False), ("c0ffee", True)): + with self.subTest(pull_request=pull_request, force=force): + api = self.pull_request_api() + self.assertEqual(self.queue(api, force, pull_request), ("", "", "", "")) + self.assertEqual([a for a in api.asked if isinstance(a, tuple)], []) + + def test_unreadable_evidence_leaves_the_usual_lookup(self): + key = self.key() + for error in (OSError("HTTP 404"), ValueError("incomplete listing"), RuntimeError("encrypted"), + NotImplementedError("compression"), zipfile.BadZipFile("bad")): + with self.subTest(error=error): + api = self.pull_request_api(artifacts={f"kernel-package-{key}": "11"}) + api.pull_request_evidence = lambda key, head, tree, error=error: (_ for _ in ()).throw(error) + self.assertEqual(self.queue(api), ("11", "", "", "")) + def test_ci_changes_do_not_change_the_key(self): before = self.key() commit_file(self.kernel, ".github/workflows/kernel-ci.yml", "on: push\n", "workflow") @@ -72,22 +121,69 @@ def test_ci_changes_do_not_change_the_key(self): self.assertEqual(self.key(), before) +class PullRequestHeadTest(TempDirTestCase): + def test_the_second_parent_of_a_merge(self): + repo = self.init_repo() + commit_file(repo, "Makefile", "1\n", "base") + git(repo, "checkout", "--quiet", "-b", "topic") + head = commit_file(repo, "README", "x\n", "the pull request") + git(repo, "checkout", "--quiet", "main") + commit_file(repo, "Makefile", "2\n", "the branch moved") + git(repo, "merge", "--quiet", "--no-ff", "-m", "Merge\n\nparent " + "0" * 40, "topic") + self.assertEqual(lookup.pull_request_head(repo), head) + self.assertEqual(lookup.pull_request_head(repo, "HEAD^1"), "") + + +class ReadMemberTest(unittest.TestCase): + def archive(self, files, compression=zipfile.ZIP_DEFLATED): + data = io.BytesIO() + with zipfile.ZipFile(data, "w", compression) as archive: + for name, content in files.items(): + archive.writestr(name, content) + return data.getvalue() + + def test_reads_a_small_member(self): + self.assertEqual(lookup.read_member(self.archive({"verified.json": b"{}"}), "verified.json", 1000), b"{}") + + def test_refuses_what_is_not_a_small_record(self): + # The encryption flag, set in the central directory entry. + plain = self.archive({"verified.json": b"{}"}, zipfile.ZIP_STORED) + entry = plain.index(b"PK\x01\x02") + 8 + encrypted = plain[:entry] + bytes([plain[entry] | 0x1]) + plain[entry + 1:] + cases = { + "an encrypted member": (encrypted, ValueError), + "another compression": (self.archive({"verified.json": b"{}"}, zipfile.ZIP_BZIP2), ValueError), + "a large download": (b"x" * 1001, ValueError), + "a member that unpacks large": (self.archive({"verified.json": b"0" * 10000}), ValueError), + "not a zip": (b"not a zip", zipfile.BadZipFile), + "no record": (self.archive({"other.json": b"{}"}), KeyError), + } + for name, (data, error) in cases.items(): + with self.subTest(name): + with self.assertRaises(error): + lookup.read_member(data, "verified.json", 1000) + + REPO = 1000 +HEAD = "c0ffee" * 6 + "c0ff" -def artifact(run, created, head_repo=REPO, expired=False, name="kernel-package-x"): +def artifact(run, created, head_repo=REPO, expired=False, name="kernel-package-x", head_sha=HEAD): return {"name": name, "expired": expired, "created_at": created, - "workflow_run": {"id": run, "repository_id": REPO, "head_repository_id": head_repo}} + "workflow_run": {"id": run, "repository_id": REPO, "head_repository_id": head_repo, + "head_sha": head_sha}} QUEUE = "gh-readonly-queue/integration/aurora/pr-7-0123456789abcdef0123456789abcdef01234567" -def run(event="push", path=".github/workflows/kernel-ci.yml", head_repo=REPO, branch=None): +def run(event="push", path=".github/workflows/kernel-ci.yml", head_repo=REPO, branch=None, head_sha=HEAD, + repo=REPO, conclusion="success", attempt=1): if branch is None: branch = QUEUE if event == "merge_group" else "integration/aurora" - return {"event": event, "path": path, "head_branch": branch, - "repository": {"id": REPO}, "head_repository": {"id": head_repo}} + return {"event": event, "path": path, "head_branch": branch, "head_sha": head_sha, + "status": "completed", "conclusion": conclusion, "run_attempt": attempt, + "repository": {"id": repo}, "head_repository": {"id": head_repo}} class ApiTest(TempDirTestCase): @@ -146,3 +242,91 @@ def test_an_unreviewed_run_is_skipped_for_an_older_reviewed_one(self): api = self.api([artifact(2, "2026-10-18"), artifact(9, "2026-10-20")], {2: run(), 9: run("pull_request", branch="topic")}) self.assertEqual(api.newest_artifact("kernel-package-x"), "2") + + def pair(self, run_id, created, head_sha=HEAD, head_repo=REPO, key="x"): + return [dict(artifact(run_id, created, head_repo, name=f"kernel-verified-{key}", head_sha=head_sha), + id=run_id * 10), + dict(artifact(run_id, created, head_repo, name=f"kernel-package-{key}", head_sha=head_sha), + id=run_id * 10 + 1)] + + def evidence(self, artifacts, runs, records=None, trees=None, listings=None): + """listings: run ID -> that run's own artifacts, when not the same + as the repository-wide ones.""" + api = self.api(artifacts, runs) + records, trees, listings = records or {}, trees or {"b" * 40: "tree"}, listings or {} + search = api.get + + def get(path, **query): + if path.endswith("/artifacts") and path != "actions/artifacts": + run_id = int(path.split("/")[2]) + own = [a for a in listings.get(run_id, artifacts) + if a["workflow_run"]["id"] == run_id and a["name"] == query["name"]] + return {"total_count": query.get("total", len(own)), "artifacts": own} + return search(path, **query) + api.get = get + api.artifact_file = lambda artifact_id, member: json.dumps( + records.get(artifact_id, {"key": "x", "github_run_id": artifact_id // 10, "github_sha": "b" * 40})) + api.commit_tree = lambda sha: trees[sha] + return api.pull_request_evidence("x", HEAD, "tree") + + def test_the_newest_eligible_pull_request_run_at_the_head_with_both_artifacts(self): + pull_request = run("pull_request", branch="topic", head_repo=2000) + artifacts = (self.pair(3, "2026-10-19", head_repo=2000) + self.pair(4, "2026-10-20", head_repo=2000) + + self.pair(5, "2026-10-21", head_repo=2000)[:1]) + self.assertEqual(self.evidence(artifacts, {3: pull_request, 4: pull_request, 5: pull_request}), ("4", "41")) + + def test_a_requested_build_counts(self): + self.assertEqual(self.evidence(self.pair(3, "2026-10-19"), { + 3: run("pull_request", ".github/workflows/kernel-ci-label.yml", branch="topic")}), ("3", "31")) + + def test_an_older_run_of_this_tree_when_the_newest_tested_another(self): + pull_request = run("pull_request", branch="topic") + other = {"key": "x", "github_run_id": 4, "github_sha": "c" * 40} + self.assertEqual(self.evidence(self.pair(3, "2026-10-19") + self.pair(4, "2026-10-20"), + {3: pull_request, 4: pull_request}, {40: other}, + {"b" * 40: "tree", "c" * 40: "another tree"}), ("3", "31")) + + def test_only_an_eligible_pull_request_run_of_this_repository_at_the_head(self): + other = "0" * 40 + pull_request = run("pull_request", branch="topic") + cases = { + "an earlier head": (self.pair(3, "2026-10-19", head_sha=other), + run("pull_request", branch="topic", head_sha=other), None), + "a run that says another head": (self.pair(3, "2026-10-19"), + run("pull_request", branch="topic", head_sha=other), None), + "a failed run": (self.pair(3, "2026-10-19"), run("pull_request", branch="topic", conclusion="failure"), + None), + "a re-run": (self.pair(3, "2026-10-19"), run("pull_request", branch="topic", attempt=2), None), + "another workflow": (self.pair(3, "2026-10-19"), + run("pull_request", ".github/workflows/something-else.yml", branch="topic"), None), + "pull_request_target": (self.pair(3, "2026-10-19"), run("pull_request_target", branch="topic"), None), + "another repository": (self.pair(3, "2026-10-19"), run("pull_request", branch="topic", repo=3000), None), + "no packages": (self.pair(3, "2026-10-19")[:1], pull_request, None), + "expired packages": (self.pair(3, "2026-10-19"), pull_request, + {3: [self.pair(3, "2026-10-19")[0], dict(self.pair(3, "2026-10-19")[1], + expired=True)]}), + "two package uploads in the run": (self.pair(3, "2026-10-19"), pull_request, + {3: self.pair(3, "2026-10-19") + + [dict(self.pair(3, "2026-10-20")[1], id=99)]}), + } + for name, (artifacts, untrusted, listings) in cases.items(): + with self.subTest(name): + self.assertEqual(self.evidence(artifacts, {3: untrusted}, listings=listings), ("", "")) + + def test_an_incomplete_listing_of_the_run_is_an_error(self): + api = self.api(self.pair(3, "2026-10-19"), {3: run("pull_request", branch="topic")}) + api.get = lambda path, **query: {"total_count": 101, "artifacts": []} + with self.assertRaises(ValueError): + api.run_artifacts(3, "kernel-package-x") + + def test_the_record_must_name_the_key_the_run_and_a_commit_of_this_tree(self): + pull_request = run("pull_request", branch="topic") + for record in ({"key": "y", "github_run_id": 3, "github_sha": "b" * 40}, + {"key": "x", "github_run_id": 9, "github_sha": "b" * 40}, + {"key": "x", "github_run_id": 3, "github_sha": "c" * 40}, + {"key": "x", "github_run_id": 3, "github_sha": "../" + "b" * 37}, + {"key": "x", "github_run_id": 3, "github_sha": 7}, + [], None, "verified"): + with self.subTest(record=record): + self.assertEqual(self.evidence(self.pair(3, "2026-10-19"), {3: pull_request}, {30: record}, + {"b" * 40: "tree", "c" * 40: "another tree"}), ("", "")) From b4330615c3fd7f4d57d73a1842e3234e6efe384f Mon Sep 17 00:00:00 2001 From: Marcelo Alcantara Date: Wed, 28 Oct 2026 13:46:29 +1000 Subject: [PATCH 2/3] ci: say the push may reuse a trusted build When the merge queue takes a pull request run's results, the push after the merge takes the usual lookup, so it builds and tests only what no branch or queue run already has for the key. Signed-off-by: Marcelo Alcantara --- tools/aurora-ci/README.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tools/aurora-ci/README.rst b/tools/aurora-ci/README.rst index 4ed723255ecd81..b966cba90c4d49 100644 --- a/tools/aurora-ci/README.rst +++ b/tools/aurora-ci/README.rst @@ -189,11 +189,11 @@ trusts only the branch and the queue. What this trusts: the record is the pull request run's own word. Nothing shows which job of that run wrote it, or that it verified those exact package bytes, so code at the reviewed head that ran in that run could -have written it. And for these pull requests the trusted build moves after -the merge: the push takes the usual path for the key (a branch or queue -run's packages and verification, else a build, and KUnit and boot unless a -branch or queue run already verified the key), so a failure there comes -after the change has merged. +have written it. And for these pull requests any trusted build and test +still needed happens after the merge: the push takes the usual path for the +key (a branch or queue run's packages and verification, else a build, and +KUnit and boot unless a branch or queue run already verified the key), so a +failure there comes after the change has merged. What keeps untrusted code off paid runners is GitHub's setting to From 5e17ebcb9f9230390155db5960f39f59d2ee69f4 Mon Sep 17 00:00:00 2001 From: Marcelo Alcantara Date: Wed, 28 Oct 2026 14:37:08 +1000 Subject: [PATCH 3/3] ci: take only the queued pull request's own run of the merge A run "at the head being merged" proved less than it seemed. Anyone who can push a branch can open a second pull request from the same head onto a branch of their own, whose workflows GitHub then runs on the merge. That run has the same head, so it passed every check, and it could upload a verification record claiming any commit with the queue's tree. Take the pull request number from the queue ref, and use what GitHub recorded for the run instead of what the run says about itself: exactly one call of kernel-ci-jobs.yml, made from the run's own workflow file (ref refs/pull//merge, path ending in the commit it ran), whose commit has exactly the queue commit's tree. The record must name that commit. A call to another ref, commit or repository keeps that target in its path and does not count. Also fall back to the usual lookup on a truncated API response (http.client.HTTPException), which escaped the handler before. Signed-off-by: Marcelo Alcantara --- .github/workflows/kernel-ci-jobs.yml | 4 +- tools/aurora-ci/README.rst | 61 ++++++----- tools/aurora-ci/lookup-build.py | 104 ++++++++++++------- tools/aurora-ci/tests/test_lookup_build.py | 112 +++++++++++++++------ 4 files changed, 183 insertions(+), 98 deletions(-) diff --git a/.github/workflows/kernel-ci-jobs.yml b/.github/workflows/kernel-ci-jobs.yml index befd1fa6355ca6..a89bf9eae35868 100644 --- a/.github/workflows/kernel-ci-jobs.yml +++ b/.github/workflows/kernel-ci-jobs.yml @@ -98,11 +98,11 @@ jobs: GITHUB_TOKEN: ${{ github.token }} BASE_SHA: ${{ steps.plan.outputs.base_sha }} FORCE: ${{ steps.plan.outputs.force }} - MERGE_QUEUE: ${{ github.event_name == 'merge_group' }} + MERGE_QUEUE_REF: ${{ github.event_name == 'merge_group' && github.ref || '' }} run: | python3 -I tools/aurora-ci/lookup-build.py --kernel . \ --recipe-repo "$RUNNER_TEMP/omarchy-pkgs" --base-sha "$BASE_SHA" --force "$FORCE" \ - --merge-queue "$MERGE_QUEUE" + --merge-queue-ref "$MERGE_QUEUE_REF" commits: name: commits diff --git a/tools/aurora-ci/README.rst b/tools/aurora-ci/README.rst index b966cba90c4d49..841dfe526882b8 100644 --- a/tools/aurora-ci/README.rst +++ b/tools/aurora-ci/README.rst @@ -169,31 +169,42 @@ package that a later run would skip testing and attest. The one exception is the merge queue itself. A queued pull request has been approved after its last push, and a push takes it out of the queue, so a -run at the head being merged (the queue commit's second parent) ran the -reviewed code. The queue takes the packages and verification of such a run -instead of building and booting again when the run is ``kernel-ci.yml`` or -``kernel-ci-label.yml`` on a pull request event in this repository, passed -at its first attempt and was never re-run, has exactly one live package -and one live verification record for the build key, and its record names -a merge commit with exactly the queue commit's tree (CI files and expected -results included). The package job downloads that very artifact by its ID. -A later queue entry also carries the entries ahead of it, so its tree -normally differs and it builds; an equal tree is taken. The run is matched -by its head, not by its pull request number. The queue still runs the -checks and, when the branch has a baseline, compares the packages with it, -but uploads no reusable package, metadata or verification record, so -nothing from a pull request becomes evidence a later run trusts. Anything -else, or evidence that cannot be read, leaves the usual lookup, which -trusts only the branch and the queue. - -What this trusts: the record is the pull request run's own word. Nothing -shows which job of that run wrote it, or that it verified those exact -package bytes, so code at the reviewed head that ran in that run could -have written it. And for these pull requests any trusted build and test -still needed happens after the merge: the push takes the usual path for the -key (a branch or queue run's packages and verification, else a build, and -KUnit and boot unless a branch or queue run already verified the key), so a -failure there comes after the change has merged. +run of that pull request at the head being merged (the queue commit's +second parent) ran the reviewed code, provided it ran on the merge GitHub +makes with ``integration/aurora``: a run of the same head in another pull +request, say one onto a branch with other workflows, runs that branch's +workflows. So the queue takes the packages and verification of a run +instead of building and booting again only when: + +- the run is ``kernel-ci.yml`` or ``kernel-ci-label.yml`` on a pull request + event in this repository, at that head, and passed at its first attempt + (never re-run); +- GitHub recorded exactly one call of ``kernel-ci-jobs.yml`` for it, from the + run's own workflow file (ref ``refs/pull//merge`` of the pull request + the queue is merging, path ending in the commit it ran), and that commit's + tree is exactly the queue commit's tree, CI files and expected results + included; +- the run has exactly one live package and one live verification record for + the build key, and the record names the key, the run and that commit. + +The package job downloads that very artifact by its ID. A later queue +entry also carries the entries ahead of it, so its tree normally differs +and it builds; an equal tree is taken. The queue still runs the checks and, +when the branch has a baseline, compares the packages with it, but uploads +no reusable package, metadata or verification record, so nothing from a +pull request becomes evidence a later run trusts. Anything else, or +evidence that cannot be read, leaves the usual lookup, which trusts only +the branch and the queue. + +What this trusts: GitHub's record of which pull request and commit the run +ran, and, within that run, the verification record, which is the run's own +word. Nothing shows which job of the run wrote it, or that it verified +those exact package bytes, so code at the reviewed head that ran in that +run could have written it. And for these pull requests any trusted build +and test still needed happens after the merge: the push takes the usual +path for the key (a branch or queue run's packages and verification, else a +build, and KUnit and boot unless a branch or queue run already verified +the key), so a failure there comes after the change has merged. What keeps untrusted code off paid runners is GitHub's setting to diff --git a/tools/aurora-ci/lookup-build.py b/tools/aurora-ci/lookup-build.py index c5fc26a7d9a5c5..ca1a99d7b5432c 100755 --- a/tools/aurora-ci/lookup-build.py +++ b/tools/aurora-ci/lookup-build.py @@ -30,6 +30,7 @@ import argparse import hashlib +import http.client import io import json import os @@ -108,8 +109,17 @@ def trusted_branch(event, branch): # A pull request's own run is reviewed code only at the head being merged: # the merge queue takes only approved pull requests, the approval has to # follow the last push, and a push takes the pull request out of the queue. -# Runs of earlier heads ran code nobody approved, so they never count. +# Runs of earlier heads ran code nobody approved, so they never count. Nor +# does a run of the same head in another pull request, which may target a +# branch with other workflows: GitHub runs the workflows of the merge. PULL_REQUEST_WORKFLOWS = {".github/workflows/kernel-ci.yml", ".github/workflows/kernel-ci-label.yml"} +JOBS_WORKFLOW = ".github/workflows/kernel-ci-jobs.yml" + + +def queued_pull_request(ref): + """The number of the pull request a merge queue ref merges last, or ''.""" + match = re.fullmatch(rf"refs/heads/{re.escape(QUEUE_PREFIX)}pr-([0-9]+)-[0-9a-f]{{40}}", ref) + return match.group(1) if match else "" def pull_request_head(repo, rev="HEAD"): @@ -195,18 +205,35 @@ def newest_artifact(self, name): return str(artifact["workflow_run"]["id"]) return "" - def pull_request_run(self, run_id, head_sha): - """Whether a run is this repository's kernel CI for a pull request - whose head is head_sha, passed at its first attempt and was never - re-run, so its artifacts all come from that one attempt. The head - may come from a fork.""" + def pull_request_merge(self, run_id, number, head_sha): + """The merge commit a run ran on, if the run is this repository's + kernel CI for pull request number at head head_sha (the head may + come from a fork), passed at its first attempt and was never re-run, + so its artifacts all come from that one attempt; else ''. + + The commit is the one GitHub recorded for the run's call of the jobs + workflow. A call from the run's own workflow file is recorded with + the run's ref (refs/pull//merge) and the commit it ran, the + same commit as the calling file; a call to another repository or + ref keeps that ref in its path instead. A run with any other shape + does not count.""" run = self.get(f"actions/runs/{run_id}") - return (run.get("event") == "pull_request" + if not (run.get("event") == "pull_request" and run.get("path") in PULL_REQUEST_WORKFLOWS and run.get("head_sha") == head_sha and run.get("run_attempt") == 1 and run.get("status") == "completed" and run.get("conclusion") == "success" - and (run.get("repository") or {}).get("id") == self.repository_id) + and (run.get("repository") or {}).get("id") == self.repository_id): + return "" + calls = run.get("referenced_workflows") + if not isinstance(calls, list) or len(calls) != 1 or not isinstance(calls[0], dict): + return "" + sha = calls[0].get("sha") + if (not isinstance(sha, str) or not re.fullmatch(r"[0-9a-f]{40}", sha) + or calls[0].get("ref") != f"refs/pull/{number}/merge" + or calls[0].get("path") != f"{self.repository}/{JOBS_WORKFLOW}@{sha}"): + return "" + return sha def run_artifacts(self, run_id, name): """Every artifact named name in a run, expired or not.""" @@ -216,13 +243,14 @@ def run_artifacts(self, run_id, name): raise ValueError(f"run {run_id}: incomplete listing of {name}") return artifacts - def pull_request_evidence(self, key, head_sha, tree): - """(run ID, package artifact ID) of the newest eligible pull request - run at head_sha (see pull_request_run) that uploaded one live - kernel-package- and one live kernel-verified- (a run that - reused another run's packages uploads none) and whose verification - record names key, the run, and a commit with this tree; else - ('', ''). The record is the run's own word: nothing here proves + def pull_request_evidence(self, key, number, head_sha, tree): + """(run ID, package artifact ID) of the newest run of pull request + number at head_sha (see pull_request_merge) whose merge commit has + this tree, which uploaded one live kernel-package- and one live + kernel-verified- (a run that reused another run's packages + uploads none), and whose verification record names key, the run and + that commit; else ('', ''). The run then ran exactly the reviewed + workflows; the record is still its own word: nothing here proves which job of the run wrote it, or which package bytes it verified.""" verified_name, package_name = f"kernel-verified-{key}", f"kernel-package-{key}" data = self.get("actions/artifacts", name=verified_name, per_page=100) @@ -231,17 +259,15 @@ def pull_request_evidence(self, key, head_sha, tree): and a.get("workflow_run", {}).get("repository_id") == self.repository_id and a.get("workflow_run", {}).get("head_sha") == head_sha} for run in sorted(candidates, key=candidates.get, reverse=True): - if not self.pull_request_run(int(run), head_sha): + merge = self.pull_request_merge(int(run), number, head_sha) + if not merge or self.commit_tree(merge) != tree: continue verified, packages = self.run_artifacts(run, verified_name), self.run_artifacts(run, package_name) if len(verified) != 1 or len(packages) != 1 or verified[0].get("expired") or packages[0].get("expired"): continue record = json.loads(self.artifact_file(verified[0]["id"], "verified.json")) if (isinstance(record, dict) and record.get("key") == key - and str(record.get("github_run_id")) == run - and isinstance(record.get("github_sha"), str) - and re.fullmatch(r"[0-9a-f]{40}", record["github_sha"]) - and self.commit_tree(record["github_sha"]) == tree): + and str(record.get("github_run_id")) == run and record.get("github_sha") == merge): return run, str(packages[0]["id"]) return "", "" @@ -265,26 +291,26 @@ def read_member(data, member, limit): return content -def pull_request_run(api, kernel, key, head_sha): - """The pull request run whose packages and verification stand in for the - merge queue's, or ''. Its record must name a merge commit with exactly - the queue commit's tree, CI and expected results included. The queue - then records no verification of its own, so none of this becomes - evidence a later run trusts. Anything missing, ambiguous or unreadable - leaves the usual lookup, which trusts the branch and the queue only. - Returns (run ID, package artifact ID) or ('', '').""" +def pull_request_run(api, kernel, key, number, head_sha): + """The run of the queued pull request whose packages and verification + stand in for the merge queue's, or ''. It must have run on a merge + commit with exactly the queue commit's tree, CI and expected results + included. The queue then records no verification of its own, so none of + this becomes evidence a later run trusts. Anything missing, ambiguous or + unreadable leaves the usual lookup, which trusts the branch and the + queue only. Returns (run ID, package artifact ID) or ('', '').""" tree = git(kernel, "rev-parse", "HEAD^{tree}") try: - return api.pull_request_evidence(key, head_sha, tree) + return api.pull_request_evidence(key, number, head_sha, tree) except (OSError, EOFError, KeyError, TypeError, ValueError, RuntimeError, NotImplementedError, - zipfile.BadZipFile, zlib.error) as error: + http.client.HTTPException, zipfile.BadZipFile, zlib.error) as error: print(f"::warning title=Pull request results::not reused: {error}", file=sys.stderr) return "", "" -def lookup(api, kernel, recipe_repo, base_sha, force, pull_request=""): - """pull_request: in the merge queue, the head of the pull request being - merged, whose own run may stand in for the queue's build and tests.""" +def lookup(api, kernel, recipe_repo, base_sha, force, pull_request=None): + """pull_request: in the merge queue, (number, head) of the pull request + being merged, whose own run may stand in for the queue's build and tests.""" kernel_tree = kernel_id(kernel, "HEAD") recipe_commit = git(recipe_repo, "rev-parse", "HEAD") recipe_trees = {part: git(recipe_repo, "rev-parse", f"HEAD:{part}") for part in RECIPE_PARTS} @@ -296,7 +322,7 @@ def lookup(api, kernel, recipe_repo, base_sha, force, pull_request=""): out["package_run"] = api.newest_artifact(f"kernel-package-{key}") out["verified_run"] = api.newest_artifact(f"kernel-verified-{key}") if pull_request and not out["verified_run"]: - run, package = pull_request_run(api, kernel, key, pull_request) + run, package = pull_request_run(api, kernel, key, *pull_request) if run: out.update(package_run=run, verified_run=run, pull_request_run=run, package_artifact=package) if base_sha: @@ -312,15 +338,17 @@ def main(argv=None): parser.add_argument("--recipe-repo", type=Path, required=True, help="omarchy-pkgs checkout") parser.add_argument("--base-sha", default="", help="commit whose build is the comparison baseline") parser.add_argument("--force", default="false", help="'true' ignores earlier builds and results") - parser.add_argument("--merge-queue", default="false", - help="'true' when --kernel is a merge queue commit: accept the merged pull request's run") + parser.add_argument("--merge-queue-ref", default="", + help="the merge queue ref --kernel was checked out from: accept the queued pull request's run") args = parser.parse_args(argv) api = Api(os.environ.get("GITHUB_API_URL", "https://api.github.com"), os.environ["GITHUB_REPOSITORY"], os.environ["GITHUB_TOKEN"], os.environ["GITHUB_REPOSITORY_ID"]) try: - pull_request = pull_request_head(args.kernel) if args.merge_queue == "true" else "" + number = queued_pull_request(args.merge_queue_ref) + head = pull_request_head(args.kernel) if number else "" + pull_request = (number, head) if head else None result = lookup(api, args.kernel, args.recipe_repo, args.base_sha, args.force == "true", pull_request) except (subprocess.CalledProcessError, OSError, KeyError, ValueError) as error: print(f"::error title=Build lookup::{error}", file=sys.stderr) @@ -339,7 +367,7 @@ def main(argv=None): reuse.append(f"verified by run {result['verified_run']}") if result["pull_request_run"]: reuse = [f"packages and verification from run {result['pull_request_run']} " - f"of the pull request's head {pull_request[:12]}"] + f"of pull request #{pull_request[0]} at {pull_request[1][:12]}"] with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as out: out.write(f"Build key `{result['key']}` (kernel `{result['kernel_tree'][:12]}`, " f"omarchy-pkgs `{result['recipe_commit'][:12]}`)" diff --git a/tools/aurora-ci/tests/test_lookup_build.py b/tools/aurora-ci/tests/test_lookup_build.py index cba48d67ad73d7..334637d1e78006 100644 --- a/tools/aurora-ci/tests/test_lookup_build.py +++ b/tools/aurora-ci/tests/test_lookup_build.py @@ -1,4 +1,5 @@ # SPDX-License-Identifier: GPL-2.0 +import http.client import io import json import unittest @@ -18,9 +19,9 @@ def newest_artifact(self, name): self.asked.append(name) return self.artifacts.get(name, "") - def pull_request_evidence(self, key, head_sha, tree): - self.asked.append(("evidence", key, head_sha)) - run, tested, package = self.evidence.get((key, head_sha), ("", None, "")) + def pull_request_evidence(self, key, number, head_sha, tree): + self.asked.append(("evidence", key, number, head_sha)) + run, tested, package = self.evidence.get((key, number, head_sha), ("", None, "")) return (run, package) if tested == tree else ("", "") @@ -78,9 +79,9 @@ def test_kernel_tree_changes_the_key(self): def pull_request_api(self, tested_tree=None, artifacts=None): tree = git(self.kernel, "rev-parse", "HEAD^{tree}") - return FakeApi(artifacts, {(self.key(), "c0ffee"): ("21", tested_tree or tree, "77")}) + return FakeApi(artifacts, {(self.key(), "7", "c0ffee"): ("21", tested_tree or tree, "77")}) - def queue(self, api, force=False, pull_request="c0ffee"): + def queue(self, api, force=False, pull_request=("7", "c0ffee")): result = lookup.lookup(api, self.kernel, self.recipe, "", force=force, pull_request=pull_request) return result["package_run"], result["verified_run"], result["pull_request_run"], result["package_artifact"] @@ -96,10 +97,10 @@ def test_a_trusted_verification_comes_first(self): key = self.key() api = self.pull_request_api(artifacts={f"kernel-package-{key}": "11", f"kernel-verified-{key}": "12"}) self.assertEqual(self.queue(api), ("11", "12", "", "")) - self.assertNotIn(("evidence", key, "c0ffee"), api.asked) + self.assertNotIn(("evidence", key, "7", "c0ffee"), api.asked) def test_no_pull_request_run_outside_the_merge_queue_or_when_forced(self): - for pull_request, force in (("", False), ("c0ffee", True)): + for pull_request, force in ((None, False), (("7", "c0ffee"), True)): with self.subTest(pull_request=pull_request, force=force): api = self.pull_request_api() self.assertEqual(self.queue(api, force, pull_request), ("", "", "", "")) @@ -108,10 +109,11 @@ def test_no_pull_request_run_outside_the_merge_queue_or_when_forced(self): def test_unreadable_evidence_leaves_the_usual_lookup(self): key = self.key() for error in (OSError("HTTP 404"), ValueError("incomplete listing"), RuntimeError("encrypted"), - NotImplementedError("compression"), zipfile.BadZipFile("bad")): + NotImplementedError("compression"), zipfile.BadZipFile("bad"), + http.client.IncompleteRead(b"{")): with self.subTest(error=error): api = self.pull_request_api(artifacts={f"kernel-package-{key}": "11"}) - api.pull_request_evidence = lambda key, head, tree, error=error: (_ for _ in ()).throw(error) + api.pull_request_evidence = lambda key, number, head, tree, error=error: (_ for _ in ()).throw(error) self.assertEqual(self.queue(api), ("11", "", "", "")) def test_ci_changes_do_not_change_the_key(self): @@ -121,6 +123,18 @@ def test_ci_changes_do_not_change_the_key(self): self.assertEqual(self.key(), before) +class QueuedPullRequestTest(unittest.TestCase): + def test_the_number_in_the_queue_ref(self): + base = "937b727ef9a3a9ff09489d597ef4b28f3b6fdbfa" + self.assertEqual(lookup.queued_pull_request(f"refs/heads/gh-readonly-queue/integration/aurora/pr-249-{base}"), + "249") + for ref in ("", "refs/heads/integration/aurora", f"refs/heads/gh-readonly-queue/topic/pr-249-{base}", + "refs/heads/gh-readonly-queue/integration/aurora/pr-249-short", "refs/pull/249/merge", + f"refs/heads/gh-readonly-queue/integration/aurora/pr-x-{base}"): + with self.subTest(ref=ref): + self.assertEqual(lookup.queued_pull_request(ref), "") + + class PullRequestHeadTest(TempDirTestCase): def test_the_second_parent_of_a_merge(self): repo = self.init_repo() @@ -177,13 +191,24 @@ def artifact(run, created, head_repo=REPO, expired=False, name="kernel-package-x QUEUE = "gh-readonly-queue/integration/aurora/pr-7-0123456789abcdef0123456789abcdef01234567" +MERGE = "b" * 40 + + +def call(number=7, sha=MERGE, path=None, ref=None): + """A referenced_workflows entry as GitHub records a call from the run's + own workflow file.""" + return {"path": path or f"o/r/.github/workflows/kernel-ci-jobs.yml@{sha}", + "ref": ref or f"refs/pull/{number}/merge", "sha": sha} + + def run(event="push", path=".github/workflows/kernel-ci.yml", head_repo=REPO, branch=None, head_sha=HEAD, - repo=REPO, conclusion="success", attempt=1): + repo=REPO, conclusion="success", attempt=1, calls=None): if branch is None: branch = QUEUE if event == "merge_group" else "integration/aurora" return {"event": event, "path": path, "head_branch": branch, "head_sha": head_sha, "status": "completed", "conclusion": conclusion, "run_attempt": attempt, - "repository": {"id": repo}, "head_repository": {"id": head_repo}} + "repository": {"id": repo}, "head_repository": {"id": head_repo}, + "referenced_workflows": [call()] if calls is None else calls} class ApiTest(TempDirTestCase): @@ -253,7 +278,7 @@ def evidence(self, artifacts, runs, records=None, trees=None, listings=None): """listings: run ID -> that run's own artifacts, when not the same as the repository-wide ones.""" api = self.api(artifacts, runs) - records, trees, listings = records or {}, trees or {"b" * 40: "tree"}, listings or {} + records, trees, listings = records or {}, trees or {MERGE: "tree"}, listings or {} search = api.get def get(path, **query): @@ -265,11 +290,11 @@ def get(path, **query): return search(path, **query) api.get = get api.artifact_file = lambda artifact_id, member: json.dumps( - records.get(artifact_id, {"key": "x", "github_run_id": artifact_id // 10, "github_sha": "b" * 40})) - api.commit_tree = lambda sha: trees[sha] - return api.pull_request_evidence("x", HEAD, "tree") + records.get(artifact_id, {"key": "x", "github_run_id": artifact_id // 10, "github_sha": MERGE})) + api.commit_tree = lambda sha: trees.get(sha, "another tree") + return api.pull_request_evidence("x", "7", HEAD, "tree") - def test_the_newest_eligible_pull_request_run_at_the_head_with_both_artifacts(self): + def test_the_newest_eligible_run_of_the_pull_request_with_both_artifacts(self): pull_request = run("pull_request", branch="topic", head_repo=2000) artifacts = (self.pair(3, "2026-10-19", head_repo=2000) + self.pair(4, "2026-10-20", head_repo=2000) + self.pair(5, "2026-10-21", head_repo=2000)[:1]) @@ -279,14 +304,15 @@ def test_a_requested_build_counts(self): self.assertEqual(self.evidence(self.pair(3, "2026-10-19"), { 3: run("pull_request", ".github/workflows/kernel-ci-label.yml", branch="topic")}), ("3", "31")) - def test_an_older_run_of_this_tree_when_the_newest_tested_another(self): - pull_request = run("pull_request", branch="topic") - other = {"key": "x", "github_run_id": 4, "github_sha": "c" * 40} - self.assertEqual(self.evidence(self.pair(3, "2026-10-19") + self.pair(4, "2026-10-20"), - {3: pull_request, 4: pull_request}, {40: other}, - {"b" * 40: "tree", "c" * 40: "another tree"}), ("3", "31")) + def test_an_older_run_of_this_tree_when_the_newest_ran_another(self): + other = "c" * 40 + runs = {3: run("pull_request", branch="topic"), + 4: run("pull_request", branch="topic", calls=[call(sha=other)])} + records = {40: {"key": "x", "github_run_id": 4, "github_sha": other}} + self.assertEqual(self.evidence(self.pair(3, "2026-10-19") + self.pair(4, "2026-10-20"), runs, records, + {MERGE: "tree", other: "another tree"}), ("3", "31")) - def test_only_an_eligible_pull_request_run_of_this_repository_at_the_head(self): + def test_only_an_eligible_run_of_the_queued_pull_request_at_the_head(self): other = "0" * 40 pull_request = run("pull_request", branch="topic") cases = { @@ -313,20 +339,40 @@ def test_only_an_eligible_pull_request_run_of_this_repository_at_the_head(self): with self.subTest(name): self.assertEqual(self.evidence(artifacts, {3: untrusted}, listings=listings), ("", "")) - def test_an_incomplete_listing_of_the_run_is_an_error(self): - api = self.api(self.pair(3, "2026-10-19"), {3: run("pull_request", branch="topic")}) - api.get = lambda path, **query: {"total_count": 101, "artifacts": []} - with self.assertRaises(ValueError): - api.run_artifacts(3, "kernel-package-x") + def test_the_run_must_have_run_the_queued_pull_requests_merge_of_this_tree(self): + # The run's call of the jobs workflow, as GitHub recorded it, names + # the pull request and the commit it ran; nothing else will do. + cases = { + "another pull request of the same head (say, onto another branch)": [call(number=8)], + "a call to a pinned commit": [{"path": f"o/r/.github/workflows/kernel-ci-jobs.yml@{MERGE}", + "sha": MERGE}], + "a call to a branch": [call(path="o/r/.github/workflows/kernel-ci-jobs.yml@main", ref="refs/heads/main")], + "a call to the pull request's merge ref by name": + [call(path="o/r/.github/workflows/kernel-ci-jobs.yml@refs/pull/7/merge")], + "a call into another repository": [call(path=f"x/r/.github/workflows/kernel-ci-jobs.yml@{MERGE}")], + "another workflow file": [call(path=f"o/r/.github/workflows/other.yml@{MERGE}")], + "two calls (a nested one)": [call(), call(path=f"o/r/.github/workflows/nested.yml@{MERGE}")], + "no call": [], + "an abbreviated commit": [call(sha="b" * 12)], + "not a list": {"path": "x"}, + "not an entry": ["o/r/.github/workflows/kernel-ci-jobs.yml"], + } + for name, calls in cases.items(): + with self.subTest(name): + self.assertEqual(self.evidence(self.pair(3, "2026-10-19"), + {3: run("pull_request", branch="topic", calls=calls)}), ("", "")) + with self.subTest("a merge of another tree"): + self.assertEqual(self.evidence(self.pair(3, "2026-10-19"), {3: run("pull_request", branch="topic")}, + trees={MERGE: "another tree"}), ("", "")) - def test_the_record_must_name_the_key_the_run_and_a_commit_of_this_tree(self): + def test_the_record_must_name_the_key_the_run_and_the_merge_it_ran(self): pull_request = run("pull_request", branch="topic") - for record in ({"key": "y", "github_run_id": 3, "github_sha": "b" * 40}, - {"key": "x", "github_run_id": 9, "github_sha": "b" * 40}, + for record in ({"key": "y", "github_run_id": 3, "github_sha": MERGE}, + {"key": "x", "github_run_id": 9, "github_sha": MERGE}, + # A commit with the same tree is not the commit the run ran. {"key": "x", "github_run_id": 3, "github_sha": "c" * 40}, - {"key": "x", "github_run_id": 3, "github_sha": "../" + "b" * 37}, {"key": "x", "github_run_id": 3, "github_sha": 7}, [], None, "verified"): with self.subTest(record=record): self.assertEqual(self.evidence(self.pair(3, "2026-10-19"), {3: pull_request}, {30: record}, - {"b" * 40: "tree", "c" * 40: "another tree"}), ("", "")) + {MERGE: "tree", "c" * 40: "tree"}), ("", ""))