diff --git a/.github/workflows/kernel-ci-jobs.yml b/.github/workflows/kernel-ci-jobs.yml index 32b832713dc7f6..8bb2bbe6e6988b 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_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" + --recipe-repo "$RUNNER_TEMP/omarchy-pkgs" --base-sha "$BASE_SHA" --force "$FORCE" \ + --merge-queue-ref "$MERGE_QUEUE_REF" 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..841dfe526882b8 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,53 @@ 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 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 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 fa52af3e293414..0d7766986f563b 100755 --- a/tools/aurora-ci/lookup-build.py +++ b/tools/aurora-ci/lookup-build.py @@ -21,18 +21,27 @@ 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 http.client +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 @@ -100,24 +109,77 @@ 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. 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"): + """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 @@ -146,17 +208,126 @@ def newest_artifact(self, name): return str(artifact["workflow_run"]["id"]) return "" + 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}") + 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): + 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.""" + 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, 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) + 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): + 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 record.get("github_sha") == merge): + 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, 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, number, head_sha, tree) + except (OSError, EOFError, KeyError, TypeError, ValueError, RuntimeError, NotImplementedError, + 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): +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} 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) @@ -170,13 +341,18 @@ 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-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: - result = lookup(api, args.kernel, args.recipe_repo, args.base_sha, args.force == "true") + 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) return 1 @@ -192,6 +368,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 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/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 a70a3e4ef50819..baa0b35c210496 100644 --- a/tools/aurora-ci/tests/test_lookup_build.py +++ b/tools/aurora-ci/tests/test_lookup_build.py @@ -1,5 +1,10 @@ # SPDX-License-Identifier: GPL-2.0 +import http.client +import io +import json import subprocess +import unittest +import zipfile from support import TempDirTestCase, commit_file, git, load @@ -7,13 +12,19 @@ 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, 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 ("", "") + class LookupTest(TempDirTestCase): def setUp(self): @@ -67,6 +78,45 @@ 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(), "7", "c0ffee"): ("21", tested_tree or tree, "77")}) + + 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"] + + 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, "7", "c0ffee"), api.asked) + + def test_no_pull_request_run_outside_the_merge_queue_or_when_forced(self): + 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), ("", "", "", "")) + 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"), + 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, 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): before = self.key() commit_file(self.kernel, ".github/workflows/kernel-ci.yml", "on: push\n", "workflow") @@ -96,22 +146,92 @@ def test_fetches_only_the_missing_commit_into_a_partial_clone(self): self.assertFalse(self.present(clone, first)) +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() + 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): +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, calls=None): 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}, + "referenced_workflows": [call()] if calls is None else calls} class ApiTest(TempDirTestCase): @@ -170,3 +290,112 @@ 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 {MERGE: "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": 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_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]) + 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_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_run_of_the_queued_pull_request_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_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_the_merge_it_ran(self): + pull_request = run("pull_request", branch="topic") + 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": 7}, + [], None, "verified"): + with self.subTest(record=record): + self.assertEqual(self.evidence(self.pair(3, "2026-10-19"), {3: pull_request}, {30: record}, + {MERGE: "tree", "c" * 40: "tree"}), ("", ""))