diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index 4ebb5bc6f..135ab97c7 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -1,5 +1,4 @@ -# The unchanged release workflow retains its protected runner labels. +# Release keeps one protected runner label, on its x86_64 prebuilt cell. self-hosted-runner: labels: - - gkp-od-4cpu-8gb-amd64 - gkp-od-16cpu-32gb-amd64-128gb diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1436c4952..54c1f4949 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -77,7 +77,7 @@ env: jobs: release: - runs-on: gkp-od-4cpu-8gb-amd64 + runs-on: ubuntu-24.04 environment: release outputs: @@ -485,7 +485,32 @@ jobs: python3 scripts/release_version.py check-install \ --prefix "${STAGE_DIR}" --expect "${NEXT}" - # Both dry-run and publication inspect every installed .a/.so/.so.*. + # The glibc comparison baseline is this triple's archive from the v1.10.0 + # release: the real released bytes, pinned by sha256 and unpacked here. + # The checker below only reads the unpacked libraries. + - name: Fetch the v1.10.0 baseline archive for the glibc comparison + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TRIPLE: ${{ matrix.triple }} + run: | + set -euo pipefail + case "${TRIPLE}" in + linux-x86_64) digest=cb412fbae9e8ba515de41dc31026c77d6c5e309693247bee26bb93c67d3c3543 ;; + linux-aarch64) digest=982b16da2d5f5ee7c274615895484fce36bd740052a14d59b66576dca496d7bb ;; + *) echo "::error::no v1.10.0 baseline archive for ${TRIPLE}" >&2; exit 1 ;; + esac + archive="pineforge-v1.10.0-${TRIPLE}.tar.gz" + root="${RUNNER_TEMP}/glibc-baseline-${TRIPLE}" + mkdir "${root}" "${root}/download" "${root}/tree" + gh release download v1.10.0 --repo "${GITHUB_REPOSITORY}" \ + --pattern "${archive}" --dir "${root}/download" + echo "${digest} ${root}/download/${archive}" | sha256sum -c - + tar -xzf "${root}/download/${archive}" -C "${root}/tree" \ + --no-same-owner --no-same-permissions + echo "BASELINE_DIR=${root}/tree" >> "${GITHUB_ENV}" + + # Both dry-run and publication inspect every installed .a/.so/.so.* and + # compare their highest glibc tag with the baseline fetched above. # Keep raw checker output and its actual exit status, including setup errors. - name: Check every staged library's glibc floor env: @@ -494,7 +519,7 @@ jobs: set -euo pipefail log="${RUNNER_TEMP}/prebuilt-glibc-${TRIPLE}" mkdir -p "${log}" - if python3 scripts/check_prebuilt_glibc.py "${STAGE_DIR}" > "${log}/stdout.jsonl" 2> "${log}/stderr.txt"; then + if python3 scripts/check_prebuilt_glibc.py "${STAGE_DIR}" --baseline-root "${BASELINE_DIR}" > "${log}/stdout.jsonl" 2> "${log}/stderr.txt"; then checker_rc=0 else checker_rc=$?; fi printf '%s\n' "${checker_rc}" > "${log}/exitcode.txt" @@ -565,7 +590,7 @@ jobs: notify-hub: needs: [release, prebuilt] if: ${{ !inputs.dry_run }} - runs-on: gkp-od-4cpu-8gb-amd64 + runs-on: ubuntu-24.04 environment: release steps: - name: Mint org App token (hub dispatch) diff --git a/docs/ci.md b/docs/ci.md index 6cc69b378..f18721619 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -113,7 +113,7 @@ Python refuse a system-wide install (PEP 668), and `.venv/` is ignored. The and fails, never skips, without it. Actionlint only accepts declared custom self-hosted runner labels; preflight -checks the declarations in `.github/actionlint.yaml`, which now declare only the two protected `gkp-od-*` labels of `release.yml`, including its x86_64 release matrix cell. Hosted workflows declare nothing. Before a workflow moves onto a `gke-*` label, add its ref to `.github/runner-group-allowlist.txt` (group 14, currently empty because no workflow uses one); protected `gkp-*` refs belong in `.github/runner-group-15-allowlist.txt` (group 15, currently only `release.yml`). Both mirrors are binding; a maintainer must confirm the corresponding live org workflow/ref entries before enabling the routes. +checks the declarations in `.github/actionlint.yaml`, which now declare only the protected `gkp-od-*` label of `release.yml`'s x86_64 release matrix cell. Hosted workflows declare nothing. Before a workflow moves onto a `gke-*` label, add its ref to `.github/runner-group-allowlist.txt` (group 14, currently empty because no workflow uses one); protected `gkp-*` refs belong in `.github/runner-group-15-allowlist.txt` (group 15, currently only `release.yml`). Both mirrors are binding; a maintainer must confirm the corresponding live org workflow/ref entries before enabling the routes. ORG-ACTIONPINS is enforced by preflight's `ci-workflow-contract`: every third-party `uses:` in `.github/workflows/*.yml` (also `.yaml`) and nested @@ -1644,7 +1644,7 @@ The native matrix is exactly `1..4` with `--ctest-shard I/4`; live sanitizers ar | `corpus-parity.yml` | Schedule, dispatch, post CI callers | `authz`, full parity and subset: `ubuntu-24.04` | | `native-live.yml` | Dispatch and post CI callers on `main` | `authz`, both aggregates and the native shards: `ubuntu-24.04`; `matrix.runner`: live-sanitizers and live-tsan both `ubuntu-24.04` | | `docs-post.yml` | Push to `main`, dispatch | `authz` and credentialed `build`: `ubuntu-24.04` | -| `release.yml` | Dispatch from `main` | `release` and `notify-hub`: `gkp-od-4cpu-8gb-amd64`; x86_64 `prebuilt`: `gkp-od-16cpu-32gb-amd64-128gb`; arm64 `prebuilt`: hosted `ubuntu-22.04-arm` | +| `release.yml` | Dispatch from `main` | `release` and `notify-hub`: hosted `ubuntu-24.04`; x86_64 `prebuilt`: `gkp-od-16cpu-32gb-amd64-128gb`; arm64 `prebuilt`: hosted `ubuntu-22.04-arm` | | `promote-baseline.yml` | Push to `main`, dispatch from `main` | `promote-guard` and `promote`: `ubuntu-24.04` | | `release-ruleset-probe.yml` | Dispatch from `main` | `authz` and `probe`: `ubuntu-24.04` | @@ -1668,15 +1668,15 @@ Trusted post workflows and PR proof legs build cold; no trusted job restores an `HOSTED_JOB_RUNNERS` pins the hosted x86 and arm64 jobs and the native profile runner matrix; `GKP_JOB_RUNNERS` separately pins the protected release jobs and sizes. Every pinned job runs directly on its hosted image. PR checks are `build`, `build-debug`, `corpus-parity-subset`, `legs` and `result` in `gke-pr.yml`. With the arm leg, the full set is `build`, `build-debug`, `build-arm`, `corpus-parity-subset`, `legs` and `result` in `gke-pr.yml`. -Release uses protected GKE runner group 15: -`release` and `notify-hub` use `gkp-od-4cpu-8gb-amd64`; +Release uses protected GKE runner group 15 for its x64 artifact only: +`release` and `notify-hub` run on hosted `ubuntu-24.04` and keep their `release` environment; Linux x64 `prebuilt` uses `gkp-od-16cpu-32gb-amd64-128gb`; baseline `promote` uses hosted `ubuntu-24.04`. The release ruleset probe runs `authz` and `probe` on hosted `ubuntu-24.04`. Release's ARM artifact cell retains hosted `ubuntu-22.04-arm`; 1.8.0 has no macOS test or artifact cells, and promotion runs on a push to `main` plus manual dispatch from `main`. -Release and notify-hub use the on-demand 4 CPU/8 GB set to prevent preemption after tag push. +Release and notify-hub run on hosted `ubuntu-24.04`, which no scale-set preemption can interrupt after tag push. Release computes versions, stamps JSON catalogs and publishes with git/gh; notify-hub mints an App token and dispatches an API request. The protected 2 CPU/4 GB set runs promotion's API/tree checks, Node campaign tooling and Cloud SQL proxy; the diff --git a/docs/reference/ci-runner-routing.md b/docs/reference/ci-runner-routing.md index fba056b5b..54850c8fa 100644 --- a/docs/reference/ci-runner-routing.md +++ b/docs/reference/ci-runner-routing.md @@ -5,7 +5,7 @@ parity verdict; an outside contributor cannot post them. GitHub Actions is advis The PR build (`ci.yml`) runs only the `fast`-labelled CTest rows (`ctest -L '^fast$'`); the post-merge legs (`ci-post.yml`) run every row, and a documentation-only change runs preflight alone among verification jobs. The seven PR/post-merge verification and documentation workflows start with the canon `authz` job and every other job in them needs it; release uses its status gate and baseline promotion its `promote-guard`. Pull-request -proof workflows and post jobs use exact hosted pins: `ubuntu-24.04` for x86 and `ubuntu-24.04-arm` for the arm64 leg. Promotion, the ruleset probe and docs deployment also run on hosted `ubuntu-24.04`, and keep their `release`/`release-bot` environments. Only `release.yml` still names protected scale sets: `release` and `notify-hub` use `gkp-od-4cpu-8gb-amd64` in protected runner group 15. Hosted PR light jobs and `docs.yml`'s `build` retain `ubuntu-24.04`; post guards and aggregates use the same pin. +proof workflows and post jobs use exact hosted pins: `ubuntu-24.04` for x86 and `ubuntu-24.04-arm` for the arm64 leg. Promotion, the ruleset probe and docs deployment also run on hosted `ubuntu-24.04`, and keep their `release`/`release-bot` environments. Only `release.yml` still names a protected scale set, in protected runner group 15: its x86_64 `prebuilt` cell. `release` and `notify-hub` run on hosted `ubuntu-24.04` and keep their `release` environment. Hosted PR light jobs and `docs.yml`'s `build` retain `ubuntu-24.04`; post guards and aggregates use the same pin. Hosted fork PRs receive no secrets, so they need no VM-only fork-refusal hook. Retired main/post VM labels remain refused. The retired `gke-*` scale sets and paid larger-runner labels are refused too. Group 14's mirror `.github/runner-group-allowlist.txt` is empty because no workflow uses a group-14 label; group 15's required ref (`release.yml`) is in `.github/runner-group-15-allowlist.txt`, with live membership confirmed by a maintainer. Release's x86_64 prebuilt cell uses `gkp-od-16cpu-32gb-amd64-128gb`, installs Eigen and asserts exactly 3.4.0 before CMake; the arm64 cell retains hosted `ubuntu-22.04-arm`. App-token jobs keep their exact `release`/`release-bot` environments. The `build-gate` job in `ci-post.yml` and `native-live.yml`'s `native-live-gate` always run and refuse a non-success authz result first. See `docs/ci.md` for the class table and docs-only exception. The commands below run the full sets. diff --git a/scripts/check_prebuilt_glibc.py b/scripts/check_prebuilt_glibc.py index c648acc0a..3175bc6a6 100644 --- a/scripts/check_prebuilt_glibc.py +++ b/scripts/check_prebuilt_glibc.py @@ -6,6 +6,18 @@ GNU ar/readelf must already be installed in the OS default executable search path. Never extract archives, load libraries, execute payloads, or write files. +--baseline-root DIR adds the released-baseline comparison. DIR is an already +unpacked released archive (the workflow checks its digest and unpacks it; this +tool still extracts nothing). The same strict inspection reads every library +under DIR, and the highest numeric GLIBC_ tag over all staged libraries may not +exceed the highest over all baseline libraries: equal or lower passes, higher +fails. A tree with no numeric tag ranks below any tag. The baseline is read, not +judged: the fixed ceiling and the __isoc23_* rule apply to the staging tree +only, since a released archive may predate them. A missing, empty or partly +uninspectable baseline, or one overlapping the staging root, fails closed. No +expected value is configured anywhere: the verdict is a function of the two +inspected trees. + All symlinks (including directory links), protected/key/config/.env* paths, unreadable entries, thin archives, non-ELF members, and wrong ELF types fail. Archives must contain REL objects with inspectable symbol tables; shared objects @@ -65,8 +77,13 @@ class InspectionError(Exception): pass +# Fields added to every record while the released baseline is being read. +EVENT_CONTEXT: dict[str, str] = {} + + def emit(event: str, **fields) -> None: - print(json.dumps({"event": event, **fields}, ensure_ascii=True), flush=True) + print(json.dumps({"event": event, **EVENT_CONTEXT, **fields}, ensure_ascii=True), + flush=True) def issue(issues: list[dict], path: Path, cause: str, **fields) -> None: @@ -282,6 +299,42 @@ def maximum_version(names: list[str]) -> str | None: return name[len("GLIBC_"):] +def tree_maximum(rows: list[dict]) -> tuple[int, ...] | None: + """Highest numeric GLIBC_ tag over every inspected library of one tree.""" + highest = maximum_version(sorted({name for row in rows for name in row["versions"]})) + return None if highest is None else tuple(int(part) for part in highest.split(".")) + + +def version_text(version: tuple[int, ...] | None) -> str | None: + return None if version is None else ".".join(str(part) for part in version) + + +def glibc_label(version: tuple[int, ...] | None) -> str: + return "none" if version is None else "GLIBC_" + version_text(version) + + +def exceeds_baseline(candidate: tuple[int, ...] | None, + baseline: tuple[int, ...] | None) -> bool: + """True when the staged tree's highest numeric tag is above the baseline's. + + Components compare with zero padding, so 2.36 equals 2.36.0. No numeric tag + at all ranks below every tag: a staged tag above a tagless baseline exceeds + it, and a tagless staged tree never does. + """ + if candidate is None: + return False + if baseline is None: + return True + width = max(len(candidate), len(baseline)) + return (candidate + (0,) * (width - len(candidate)) + > baseline + (0,) * (width - len(baseline))) + + +def roots_overlap(left: Path, right: Path) -> bool: + first, second = (Path(os.path.abspath(path)) for path in (left, right)) + return first == second or first in second.parents or second in first.parents + + def version_sections(lines: list[str], dynamic_count: int | None) -> set[str]: headers = [(index, VERSION_HEADER.fullmatch(line)) for index, line in enumerate(lines) if line.startswith("Version ")] @@ -493,7 +546,7 @@ def policy_issues(path: Path, member: dict, issues: list[dict]) -> None: def inspect_target(root_fd: int, target: dict, directories: dict, - tools: dict[str, str], issues: list[dict]) -> dict: + tools: dict[str, str], issues: list[dict], policy: bool = True) -> dict: path = target["path"] row = { "path": str(path), "kind": target["kind"], "identity": target["identity"], @@ -580,7 +633,8 @@ def inspect_target(root_fd: int, target: dict, directories: dict, try: member.update(parse_elf(lines, "REL" if expected_members is not None else "DYN")) member["inspected"] = True - policy_issues(path, member, issues) + if policy: + policy_issues(path, member, issues) except (InspectionError, ValueError) as error: issue(issues, path, str(error), member=name, ordinal=ordinal) complete = False @@ -613,16 +667,12 @@ def inspect_target(root_fd: int, target: dict, directories: dict, return row -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("staging_root", type=Path, help="explicit installed prebuilt staging root") - args = parser.parse_args(argv) - issues = [] +def inspect_tree(root: Path, issues: list[dict], policy: bool) -> tuple[list[dict], list[dict]]: + """Inventory and inspect one explicit root; the fixed policy is for staging only.""" targets = [] results = [] root_fd = None - root = args.staging_root - emit("root", path=str(root), fixed_glibc_ceiling="2.36") + emit("root", path=str(root), **({"fixed_glibc_ceiling": "2.36"} if policy else {})) try: if sys.platform != "linux": raise InspectionError("Linux descriptor-based ELF inspection is required") @@ -638,7 +688,7 @@ def main(argv: list[str] | None = None) -> int: issue(issues, root, "no shipped .so/.so.*/.a libraries in staging inventory") tools = installed_tools(root, issues) if targets else {} for target in targets: - results.append(inspect_target(root_fd, target, directories, tools, issues)) + results.append(inspect_target(root_fd, target, directories, tools, issues, policy)) # Re-open the path and re-inventory metadata to catch late additions, # removals, renames, or a replacement root; payloads are never re-read. final_fd = open_root(root) @@ -657,12 +707,59 @@ def main(argv: list[str] | None = None) -> int: finally: if root_fd is not None: os.close(root_fd) + return targets, results + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("staging_root", type=Path, help="explicit installed prebuilt staging root") + parser.add_argument("--baseline-root", default=None, metavar="DIR", + help="explicit unpacked released archive; the staging tree's highest " + "numeric GLIBC_ tag may not exceed the highest found there") + args = parser.parse_args(argv) + if args.baseline_root is not None and not args.baseline_root: + parser.error("--baseline-root must name a directory") + issues = [] + targets, results = inspect_tree(args.staging_root, issues, True) inspected = sum(row["inspected"] for row in results) - success = bool(targets) and inspected == len(targets) and not issues + complete = bool(targets) and inspected == len(targets) + baseline_fields = {} + if args.baseline_root is not None: + baseline_root = Path(args.baseline_root) + baseline_targets = [] + baseline_results = [] + if roots_overlap(args.staging_root, baseline_root): + issue(issues, baseline_root.absolute(), + "baseline root must be separate from the staging root") + else: + EVENT_CONTEXT["role"] = "baseline" + try: + baseline_targets, baseline_results = inspect_tree(baseline_root, issues, False) + finally: + EVENT_CONTEXT.clear() + baseline_inspected = sum(row["inspected"] for row in baseline_results) + baseline_fields = {"baseline_discovered": len(baseline_targets), + "baseline_inspected": baseline_inspected} + complete = (complete and bool(baseline_targets) + and baseline_inspected == len(baseline_targets)) + if complete: + staged_highest = tree_maximum(results) + released_highest = tree_maximum(baseline_results) + above = exceeds_baseline(staged_highest, released_highest) + emit("baseline_comparison", staged_max_glibc=version_text(staged_highest), + baseline_max_glibc=version_text(released_highest), exceeds=above) + if above: + issue(issues, args.staging_root.absolute(), + f"staged highest {glibc_label(staged_highest)} exceeds released baseline " + f"highest {glibc_label(released_highest)}", + staged_max_glibc=version_text(staged_highest), + baseline_max_glibc=version_text(released_highest), + baseline_root=str(baseline_root.absolute())) + success = complete and not issues emit("summary", verdict="PASS" if success else "FAIL", discovered=len(targets), inspected=inspected, archive_members=sum( sum(member["inspected"] for member in row["members"]) for row in results), - issues=len(issues)) + issues=len(issues), **baseline_fields) return 0 if success else 1 diff --git a/scripts/ci_preflight.py b/scripts/ci_preflight.py index ebb998223..b7c100728 100644 --- a/scripts/ci_preflight.py +++ b/scripts/ci_preflight.py @@ -2964,6 +2964,15 @@ def runner_findings(workflows: dict[str, str]) -> list[str]: if protected_environment and _job_values(body, 'environment') != [protected_environment]: findings.append(f'{name} job {job} must use environment {protected_environment} ' 'on its protected release job') + if protected_environment: + # The release App credential stays on the runner itself: the protected + # scale-set route refused these, and the hosted pin must not drop that. + if _job_values(body, 'container'): + findings.append(f'{name} job {job} must not declare a job container ' + 'on its protected release job') + if _job_values(body, 'services'): + findings.append(f'{name} job {job} must not declare job services ' + 'on its protected release job') native_matrix = _native_runner_matrix(name, job, body) if gke_pin == GKE_MATRIX_RUNNER and not native_matrix: findings.append(f'{name} job {job} must keep exactly ' diff --git a/scripts/test_check_prebuilt_glibc.py b/scripts/test_check_prebuilt_glibc.py index c2a7a41a1..78c9c0f7c 100644 --- a/scripts/test_check_prebuilt_glibc.py +++ b/scripts/test_check_prebuilt_glibc.py @@ -223,13 +223,29 @@ def archive(self, relative: str, members: list[Path], thin: bool = False, self.command([self.tools["ar"], "s", str(output)]) return output - def run_checker(self, root: Path | None = None, checker: Path = CHECKER): + def tree(self, name: str, build) -> Path: + """Run `build` with the fixture helpers aimed at a separate root; return it.""" + original = self.root + self.root = self.area / name + declare(self.root) + self.generated(self.root).mkdir() + try: + build() + return self.root + finally: + self.root = original + + def run_checker(self, root: Path | None = None, checker: Path = CHECKER, + baseline: Path | None = None): root = self.root if root is None else root declare(checker, root) safe_path(checker) if checker != CHECKER: self.generated(checker) argv = [sys.executable, "-B", str(checker), str(root)] + if baseline is not None: + declare(baseline) + argv += ["--baseline-root", str(baseline)] result = subprocess.run(argv, capture_output=True, env=ENV) print(json.dumps({ "checker_command": argv, "returncode": result.returncode, @@ -814,6 +830,122 @@ def test_isoc23_policy_mutation_breaks_real_rejection_assertion(self): with self.assertRaises(AssertionError): self.assert_verdict(changed, False) + def test_staged_tree_above_released_baseline_is_refused(self): + # Both trees sit under the fixed ceiling: only the comparison can refuse. + released = self.tree("released baseline", + lambda: self.shared("lib/released.so", ("GLIBC_2.34",))) + staged = self.tree("staged above", + lambda: self.shared("lib/staged.so", ("GLIBC_2.35",))) + records = self.assert_verdict(self.run_checker(staged, baseline=released), False) + self.assert_issue(records, staged, "exceeds released baseline") + comparison = next(row for row in records if row["event"] == "baseline_comparison") + self.assertEqual((comparison["staged_max_glibc"], comparison["baseline_max_glibc"], + comparison["exceeds"]), ("2.35", "2.34", True)) + self.assertEqual(records[-1]["issues"], 1) + self.assertEqual(self.targets(records)[str(staged / "lib/staged.so")]["max_glibc"], "2.35") + + def test_staged_tree_at_or_below_released_baseline_passes(self): + released = self.tree("released baseline", + lambda: self.shared("lib/released.so", ("GLIBC_2.34",))) + for label, versions in (("equal", ("GLIBC_2.34",)), ("lower", ("GLIBC_2.9",)), + ("zero padded", ("GLIBC_2.34.0",)), ("untagged", ())): + with self.subTest(staged=label): + staged = self.tree("staged " + label, + lambda versions=versions: self.shared("lib/staged.so", versions)) + records = self.assert_verdict(self.run_checker(staged, baseline=released), True) + comparison = next(row for row in records if row["event"] == "baseline_comparison") + self.assertFalse(comparison["exceeds"]) + self.assertEqual(comparison["baseline_max_glibc"], "2.34") + self.assertEqual(records[-1]["baseline_inspected"], 1) + + def test_released_baseline_is_read_but_not_held_to_the_staged_policy(self): + # The real v1.10.0 x86_64 archive needs GLIBC_2.38 and references __isoc23_*. + def build(): + self.shared("lib/released.so", ("GLIBC_2.38",)) + self.archive("lib/released.a", + [self.object("early.o", reference="__isoc23_strtol")]) + + released = self.tree("released baseline", build) + staged = self.tree("staged below", + lambda: self.shared("lib/staged.so", ("GLIBC_2.34",))) + records = self.assert_verdict(self.run_checker(staged, baseline=released), True) + rows = {row["path"]: row for row in records + if row["event"] == "target" and row.get("role") == "baseline"} + self.assertEqual(set(rows), {str(released / "lib/released.so"), + str(released / "lib/released.a")}) + self.assertTrue(all(row["inspected"] for row in rows.values())) + self.assertEqual(rows[str(released / "lib/released.so")]["max_glibc"], "2.38") + self.assertTrue(rows[str(released / "lib/released.a")]["isoc23_references"]) + self.assertEqual([row for row in records if row["event"] == "issue"], []) + comparison = next(row for row in records if row["event"] == "baseline_comparison") + self.assertEqual((comparison["staged_max_glibc"], comparison["baseline_max_glibc"]), + ("2.34", "2.38")) + + def test_released_baseline_input_decides_the_verdict(self): + # The same two trees give opposite verdicts when their roles swap, so the + # baseline input is read, not ignored. + lower = self.tree("lower tree", lambda: self.shared("lib/library.so", ("GLIBC_2.34",))) + higher = self.tree("higher tree", lambda: self.shared("lib/library.so", ("GLIBC_2.35",))) + self.assert_verdict(self.run_checker(lower, baseline=higher), True) + records = self.assert_verdict(self.run_checker(higher, baseline=lower), False) + self.assert_issue(records, higher, "exceeds released baseline") + + def test_unusable_released_baseline_fails_closed(self): + staged = self.tree("staged", lambda: self.shared("lib/staged.so", ("GLIBC_2.34",))) + self.assert_verdict(self.run_checker(staged), True) + + def linked(): + real = self.shared("lib/real.so", ("GLIBC_2.34",)) + link = self.root / "lib" / "link.so.1" + declare(link, real) + link.symlink_to(real) + + baselines = ( + ("missing", self.area / "absent baseline"), + ("without libraries", self.tree("empty baseline", lambda: None)), + ("malformed library", self.tree( + "malformed baseline", + lambda: self.source(self.root / "lib" / "broken.so", b"not an ELF"))), + ("symlinked library", self.tree("linked baseline", linked)), + ) + for label, baseline in baselines: + with self.subTest(baseline=label): + records = self.assert_verdict(self.run_checker(staged, baseline=baseline), False) + self.assertFalse(any(row["event"] == "baseline_comparison" for row in records)) + self.assertTrue(any(row["event"] == "issue" and row.get("role") == "baseline" + for row in records)) + + def test_released_baseline_overlapping_the_staging_root_is_refused(self): + staged = self.tree("staged", lambda: self.shared("lib/staged.so", ("GLIBC_2.34",))) + for label, baseline in (("same root", staged), ("inside the root", staged / "lib"), + ("around the root", staged.parent)): + with self.subTest(baseline=label): + records = self.assert_verdict(self.run_checker(staged, baseline=baseline), False) + self.assert_issue(records, baseline, "separate from the staging root") + self.assertFalse(any(row["event"] == "baseline_comparison" for row in records)) + + def test_empty_baseline_argument_is_an_invocation_error(self): + staged = self.tree("staged", lambda: self.shared("lib/staged.so", ("GLIBC_2.34",))) + declare(CHECKER, staged) + result = subprocess.run( + [sys.executable, "-B", str(CHECKER), str(staged), "--baseline-root", ""], + capture_output=True, env=ENV) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn(b"--baseline-root must name a directory", result.stderr) + self.assertEqual(result.stdout, b"") + + def test_baseline_policy_mutation_breaks_real_rejection_assertion(self): + staged = self.tree("staged above", + lambda: self.shared("lib/staged.so", ("GLIBC_2.35",))) + released = self.tree("released baseline", + lambda: self.shared("lib/released.so", ("GLIBC_2.34",))) + self.assert_verdict(self.run_checker(staged, baseline=released), False) + mutant = self.mutant("exceeds_baseline") + changed = self.run_checker(staged, checker=mutant, baseline=released) + self.assert_verdict(changed, True) + with self.assertRaises(AssertionError): + self.assert_verdict(changed, False) + if __name__ == "__main__": parser = argparse.ArgumentParser(description=__doc__) diff --git a/scripts/test_ci_preflight.py b/scripts/test_ci_preflight.py index 6c2149a6c..13523109d 100644 --- a/scripts/test_ci_preflight.py +++ b/scripts/test_ci_preflight.py @@ -414,6 +414,16 @@ def in_step(workflow, job, name, before, after): return workflow.replace(step, step.replace(before, after, 1), 1) +def legacy_release_workflow(workflow): + """Keep the isolated-form fixtures after the shipped workflow moves hosted.""" + for job in ('release', 'notify-hub'): + workflow = in_job(workflow, job, runner_declaration(HOSTED_X86), + runner_declaration(RELEASE_RUNNER)) + return in_job(workflow, 'prebuilt', + ci_preflight.RELEASE_PREBUILT_ARCHIVE_GLIBC_STEPS, + ci_preflight.RELEASE_PREBUILT_LEGACY_GLIBC_STEP) + + def routed_line(before, value): """Keep the CI matrix's JSON string shape when replacing a literal array.""" if before.startswith(' runs-on:'): @@ -759,13 +769,16 @@ def test_real_minting_jobs_have_exactly_the_inventoried_environments(self): class ProtectedGkeRunners(unittest.TestCase): """Protected routing is an exact job inventory, never a label-family exemption.""" - def workflows(self): - return {path.name: path.read_text() - for path in (ROOT / '.github/workflows').glob('*.yml')} + def workflows(self, *, legacy_release=False): + workflows = {path.name: path.read_text() + for path in (ROOT / '.github/workflows').glob('*.yml')} + if legacy_release: + workflows['release.yml'] = legacy_release_workflow(workflows['release.yml']) + return workflows def test_gkp_inventory_and_matrix(self): self.assertEqual(ci_preflight.GKP_JOB_RUNNERS, EXPECTED_GKP_JOBS) - workflows = self.workflows() + workflows = self.workflows(legacy_release=True) self.assertEqual(ci_preflight.runner_findings(workflows), []) for name, jobs in EXPECTED_GKP_JOBS.items(): for job, label in jobs.items(): @@ -829,7 +842,7 @@ def test_plain_private_key_documentation_keeps_effective_token_scopes(self): {**workflows, 'release.yml': changed})) def test_gkp_needs_group15_mirror(self): - workflows = self.workflows() + workflows = self.workflows(legacy_release=True) mirror_file = '.github/runner-group-15-allowlist.txt' mirror = (ROOT / mirror_file).read_text() repository = ci_preflight.RUNNER_ALLOWLIST_REPOSITORY @@ -852,7 +865,7 @@ def test_gkp_needs_group15_mirror(self): [f'{mirror_file}: mirror file is missing']) def test_gkp_refuses_wrong_runners(self): - workflows = self.workflows() + workflows = self.workflows(legacy_release=True) self.assertEqual(ci_preflight.runner_findings(workflows), []) for name, jobs in EXPECTED_GKP_JOBS.items(): for job, label in jobs.items(): @@ -947,7 +960,7 @@ def test_gkp_jobs_keep_environment(self): ci_preflight.runner_findings({**workflows, name: changed})) def test_cred_exempt_needs_exact_gkp(self): - workflows = self.workflows() + workflows = self.workflows(legacy_release=True) baseline = ci_preflight.runner_findings(workflows) for name, jobs in EXPECTED_GKP_JOBS.items(): for job, label in jobs.items(): @@ -978,15 +991,60 @@ def test_cred_exempt_needs_exact_gkp(self): 'on a hosted standard runner', ci_preflight.runner_findings({**workflows, 'release.yml': literal})) + def test_protected_release_jobs_refuse_containers_and_services(self): + workflows = self.workflows(legacy_release=False) + self.assertEqual(ci_preflight.runner_findings(workflows), []) + for name, job in (('release.yml', 'release'), ('release.yml', 'notify-hub'), + ('release-ruleset-probe.yml', 'probe'), + ('promote-baseline.yml', 'promote')): + for key, value, finding in ( + ('container', 'ubuntu:24.04', 'a job container'), + ('services', '\n database:\n image: postgres:16', 'job services')): + with self.subTest(workflow=name, job=job, key=key): + changed = in_job(workflows[name], job, ' steps:\n', + f' {key}: {value}\n steps:\n') + self.assertNotEqual(changed, workflows[name]) + self.assertIn(f'{name} job {job} must not declare {finding} ' + 'on its protected release job', + ci_preflight.runner_findings({**workflows, name: changed})) + + def test_release_and_notify_hub_environment_removal_is_refused_by_the_full_contract(self): + workflows = self.workflows(legacy_release=False) + sources = [(ROOT / path).read_text() for path in CI_SOURCES] + named = {Path(path).name for path in CI_SOURCES} + others = {name: text for name, text in workflows.items() if name not in named} + self.assertEqual(ci_workflow_findings(*sources, others=others), []) + release = others['release.yml'] + for job in ('release', 'notify-hub'): + body = _jobs(release)[job] + # Hosted jobs that hold the App credential keep the release environment. + self.assertEqual(ci_preflight._job_values(body, 'runs-on', unquote=False), [HOSTED_X86]) + self.assertEqual(body.count(' environment: release\n'), 1) + mint = (f'release.yml job {job}: App-token minting/private-key use requires a ' + 'literal, non-empty environment; review other minting methods by hand') + for replacement, mint_refused in ( + ('', True), (' environment: other\n', False), + (' environment: ${{ inputs.environment }}\n', True)): + with self.subTest(job=job, environment=replacement): + changed = in_job(release, job, ' environment: release\n', replacement) + self.assertNotEqual(changed, release) + findings = ci_workflow_findings( + *sources, others={**others, 'release.yml': changed}) + self.assertIn(f'release.yml job {job} must use environment release ' + 'on its protected release job', findings) + self.assertEqual(mint in findings, mint_refused) + def test_hosted_credentialed_jobs_need_their_exact_hosted_pin(self): # The probe, baseline promotion and docs deployment hold credentials and # now run on the hosted x86 image; any other image is refused at the pin. - workflows = self.workflows() + workflows = self.workflows(legacy_release=False) self.assertEqual(ci_preflight.runner_findings(workflows), []) credential_finding = 'must keep production credentials on a hosted standard runner' for name, job in (('release-ruleset-probe.yml', 'probe'), ('promote-baseline.yml', 'promote'), - ('docs-post.yml', 'build')): + ('docs-post.yml', 'build'), + ('release.yml', 'release'), + ('release.yml', 'notify-hub')): self.assertEqual(ci_preflight._job_values( _jobs(workflows[name])[job], 'runs-on', unquote=False), [HOSTED_X86]) # The probe and promotion jobs compare the runner text exactly, so a quoted @@ -1000,7 +1058,10 @@ def test_hosted_credentialed_jobs_need_their_exact_hosted_pin(self): runner_declaration(wrong)) self.assertNotEqual(changed, workflows[name]) findings = ci_preflight.runner_findings({**workflows, name: changed}) - self.assertIn(f'{name} job {job} must run on {HOSTED_X86} (hosted)', findings) + # Breaking the hosted pair restores the legacy isolated-pin finding. + expected = RELEASE_RUNNER if name == 'release.yml' else HOSTED_X86 + suffix = 'isolated release' if name == 'release.yml' else 'hosted' + self.assertIn(f'{name} job {job} must run on {expected} ({suffix})', findings) if wrong in (GKE_2, GKP_2, 'ubuntu-latest', 'ubuntu-22.04', LEGACY_PR_RUNNER): self.assertIn(f'{name} job {job} {credential_finding}', findings) @@ -1014,7 +1075,7 @@ def test_retired_vm_labels_refused(self): ci_preflight.runner_findings({**workflows, name: changed})) def test_gkp_refuses_containers(self): - workflows = self.workflows() + workflows = self.workflows(legacy_release=True) for name, jobs in EXPECTED_GKP_JOBS.items(): caller = ('on: pull_request\njobs:\n call:\n' f' uses: ./.github/workflows/{name}\n') @@ -1030,7 +1091,7 @@ def test_gkp_refuses_containers(self): ci_preflight.runner_findings({**workflows, name: changed})) def test_release_eigen_exact(self): - workflows = self.workflows() + workflows = self.workflows(legacy_release=True) workflow = workflows['release.yml'] prebuilt = _jobs(workflow)['prebuilt'] steps = ci_preflight._steps(prebuilt) @@ -1230,7 +1291,7 @@ def setUp(self): self.workflows = {path.name: path.read_text() for path in (ROOT / '.github/workflows').glob('*.yml')} self.shipped = self.workflows['release.yml'] - self.release = self.shipped + self.release = legacy_release_workflow(self.shipped) def findings(self, workflow, *, name='release.yml'): with tempfile.TemporaryDirectory() as directory: @@ -1688,7 +1749,7 @@ def setUp(self): 'promote-baseline.yml') if name not in named } - self.legacy = self.others['release.yml'] + self.legacy = legacy_release_workflow(self.others['release.yml']) def findings(self, workflow): return ci_workflow_findings( @@ -1994,10 +2055,10 @@ def test_release_keeps_its_current_pool_and_refuses_the_retired_label(self): release = self.workflows()['release.yml'] self.assertEqual(ci_preflight._events(release), {'workflow_dispatch'}) self.assertEqual(ci_preflight._job_values(_jobs(release)['release'], 'runs-on'), - [GKP_OD_4]) + [HOSTED_X86]) self.assertEqual(ci_preflight.runner_findings({'release.yml': release}), []) changed = in_job(release, 'release', - f' runs-on: {GKP_OD_4}\n', + f' runs-on: {HOSTED_X86}\n', ' runs-on: ["self-hosted","pf-ci","pf-ci-release"]\n') self.assertNotEqual(changed, release) self.assertIn(f'release.yml job release must run on {GKP_OD_4} ' @@ -2990,8 +3051,8 @@ def test_post_fleet_docs_match_runner_policy(self): for name, jobs in EXPECTED_GKP_JOBS.items(): cells = [cell for workflow, cell in rows if workflow == name] self.assertEqual(len(cells), 1, f'{name} documented runner row') - self.assertEqual(set(re.findall(r'`(gkp-[^`]+)`', cells[0])), - set(jobs.values()), + labels = {GKP_OD_PREBUILT} if name == 'release.yml' else set(jobs.values()) + self.assertEqual(set(re.findall(r'`(gkp-[^`]+)`', cells[0])), labels, f'{name} documented protected runner inventory') requirements = ( 'Only the unchanged `release.yml` keeps its protected `gkp-od-*` scale sets in runner group 15.\n' @@ -3045,8 +3106,8 @@ def test_post_fleet_docs_refuse_same_size_wrong_protected_runner(self): rows = [line for line in docs.splitlines() if line.startswith('| `release.yml` |')] self.assertEqual(len(rows), 1) - self.assertEqual(rows[0].count(f'`{GKP_OD_4}`'), 1) - changed = docs.replace(rows[0], rows[0].replace(GKP_OD_4, GKE_4, 1), 1) + self.assertEqual(rows[0].count(f'`{GKP_OD_PREBUILT}`'), 1) + changed = docs.replace(rows[0], rows[0].replace(GKP_OD_PREBUILT, GKE_4, 1), 1) self.assertNotEqual(changed, docs) self.assertEqual(len(changed.splitlines()), len(docs.splitlines())) with mock.patch.object(Path, 'read_text', return_value=changed) as read_doc: @@ -4615,13 +4676,13 @@ def test_real_tree_mirrors_hold_only_the_unchanged_release_workflow(self): self.assertEqual(self.failures(lines), [], lines) self.assertEqual(ci_preflight.RUNNER_ALLOWLIST_REPOSITORY, self.REPOSITORY) self.assertEqual([line for line in lines if line.startswith('advisory:')], []) - # Dropping release.yml's group-15 line is refused for each of its protected labels. + # Dropping release.yml's group-15 line is refused for its protected prebuilt label. self.protected_mirror.write_text('# Group 15 without its release reference.\n') code, lines = self.run_stage() self.assertEqual(code, 1, lines) self.assertCountEqual(self.failures(lines), [ self.missing('release.yml', label) - for label in (GKP_OD_4, GKP_OD_PREBUILT)]) + for label in (GKP_OD_PREBUILT,)]) def test_stray_mirror_lines_are_only_advisories(self): # A listed workflow with no group label, and a listed file that is gone, are @@ -4865,12 +4926,16 @@ def test_registered_full_collection_and_planted_assertion_propagate(self): names = {test_id.rsplit('.', 1)[-1] for test_id in ids} # expectation corrected: 33 -> 35, because the ancestor-entry and # inode-replacement controls extend the complete existing collection. - self.assertEqual(len(ids), 35) - self.assertEqual(len(names), 35) + # expectation corrected: 35 -> 43, because the released-baseline + # comparison controls extend the complete existing collection. + self.assertEqual(len(ids), 43) + self.assertEqual(len(names), 43) self.assertTrue({'test_ancestor_entry_added_between_stat_and_open_is_accepted', 'test_ancestor_inode_replaced_between_stat_and_open_is_refused', 'test_version_policy_mutation_breaks_real_rejection_assertion', - 'test_isoc23_policy_mutation_breaks_real_rejection_assertion'} <= names) + 'test_isoc23_policy_mutation_breaks_real_rejection_assertion', + 'test_staged_tree_above_released_baseline_is_refused', + 'test_baseline_policy_mutation_breaks_real_rejection_assertion'} <= names) area = Path(tempfile.mkdtemp(prefix='prebuilt-glibc-registration-')) print(f'prebuilt GLIBC registration evidence: {area}', flush=True) mutant_root = area / 'mutant' @@ -4896,7 +4961,7 @@ def test_registered_full_collection_and_planted_assertion_propagate(self): expected_code, False)]) rows = re.findall(r'^(test_\w+) \([^\n]+\) \.\.\. (ok|FAIL)$', log, re.MULTILINE) self.assertEqual(sorted(name for name, _ in rows), sorted(names), log) - self.assertRegex(log, r'Ran 35 tests in ') + self.assertRegex(log, r'Ran 43 tests in ') failed = [name for name, status in rows if status == 'FAIL'] self.assertEqual(failed, [] if expected_code == 0 else ['test_complete_inventory_members_spaces_and_content_identity']) @@ -4947,9 +5012,13 @@ def other_sources(self): for path in sorted((ROOT / '.github/workflows').iterdir()) if path.suffix in ('.yml', '.yaml') and path.name not in named} - def workflow_sources(self): - return {Path(path).name: contents for path, contents in zip(CI_SOURCES, self.ci_sources()) - if path.endswith(('.yml', '.yaml'))} | self.other_sources() + def workflow_sources(self, *, legacy_release=False): + workflows = {Path(path).name: contents + for path, contents in zip(CI_SOURCES, self.ci_sources()) + if path.endswith(('.yml', '.yaml'))} | self.other_sources() + if legacy_release: + workflows['release.yml'] = legacy_release_workflow(workflows['release.yml']) + return workflows def light_workflows(self): """The requested routing fixture, also constructible from main's workflows.""" @@ -5024,8 +5093,8 @@ def test_light_job_metadata_cannot_replace_the_hosted_pin(self): ci_preflight.runner_findings({**workflows, name: changed})) def gcp_sites(self): - """Mutate every real fleet site; derive the class from events/callers.""" - workflows = self.workflow_sources() + """Keep the 21 admitted sites, including the synthetic isolated release form.""" + workflows = self.workflow_sources(legacy_release=True) routes = ci_preflight.workflow_event_routes(workflows) for name, workflow in workflows.items(): workflow_fleet = ('main' if name in ci_preflight.RELEASE_JOB_RUNNERS else @@ -5057,7 +5126,7 @@ def gcp_sites(self): yield name, job, fleet, runner_declaration(value), finding def routed_file_findings(self, name, job, before, after): - workflows = self.workflow_sources() + workflows = self.workflow_sources(legacy_release=True) changed = in_job(workflows[name], job, before, after) self.assertNotEqual(changed, workflows[name], 'the mutation must change the input') with tempfile.TemporaryDirectory() as directory: @@ -7249,7 +7318,7 @@ def test_gcp_sites_require_the_literal_or_matrix_string_shape(self): self.assertIn(finding, findings) def test_gcp_sites_require_every_routed_job(self): - workflows = self.workflow_sources() + workflows = self.workflow_sources(legacy_release=True) for name, jobs in FLEET_JOB_IDS.items(): self.assertIn(name, workflows, f'{name} required fleet workflow is missing') for job in jobs: @@ -8166,7 +8235,7 @@ def test_paid_labels_and_larger_runner_keys_are_refused_even_in_release(self): for name in ('lint.yml', 'release.yml'): workflow = others[name] if name == 'release.yml' else lint job = 'release' if name == 'release.yml' else 'lint' - before = f' runs-on: {RELEASE_RUNNER}\n' if name == 'release.yml' else spot + before = spot baseline = ci_workflow_findings(*original, others=dict(others, **{name: workflow})) if name == 'release.yml': self.assertEqual(baseline, []) @@ -8211,7 +8280,7 @@ def test_release_jobs_require_isolation_and_reject_pr_triggers(self): f"${{{{ fromJSON('{GKP_2}') || 'ubuntu-latest' }}}}", f"${{{{ fromJSON('{RELEASE_RUNNER}') || 'ubuntu-latest' }}}}"): with self.subTest(job=job, runner=runner): - changed = in_job(release, job, f' runs-on: {RELEASE_RUNNER}\n', + changed = in_job(release, job, f' runs-on: {HOSTED_X86}\n', f' runs-on: {runner}\n') findings = ci_workflow_findings( *original, others=dict(others, **{'release.yml': changed})) @@ -8579,12 +8648,9 @@ def test_today_release_and_deployment_jobs_pass_only_on_their_pinned_routes(self for name, jobs in (('docs.yml', ('build',)), ('promote-baseline.yml', ('promote-guard', 'promote')), ('release.yml', ('release', 'prebuilt', 'notify-hub'))): for job in jobs: - # docs.yml and the promotion jobs are hosted pins; release.yml - # accepts either its legacy isolated release form or the exact - # hosted form, while its prebuilt matrix remains separate. - before = (HOSTED_MATRIX_RUNNER if job == 'prebuilt' else - HOSTED_X86 if name in ('promote-baseline.yml', 'docs.yml') else - RELEASE_RUNNER) + # The shipped release and notify-hub jobs are hosted; the prebuilt + # matrix still contains the protected x86_64 artifact cell. + before = HOSTED_MATRIX_RUNNER if job == 'prebuilt' else HOSTED_X86 after = (PR_RUNNER if job == 'prebuilt' else 'ubuntu-latest' if name in ('docs.yml', 'promote-baseline.yml') else HOSTED_ARM) @@ -8595,6 +8661,8 @@ def test_today_release_and_deployment_jobs_pass_only_on_their_pinned_routes(self finding = (f'{name} job {job} must run on ubuntu-24.04 (hosted)' if name == 'docs.yml' else f'{name} job {job} must run on {HOSTED_X86}' if job == 'promote-guard' else f'{name} job {job} must run on {HOSTED_X86} (hosted)' if job == 'promote' else + f'{name} job {job} must run on {RELEASE_RUNNER} (isolated release)' + if job in ('release', 'notify-hub') else f'{name} job {job} must run on {before} (isolated release)') self.assertIn(finding, findings) marker = f" PF_CI_LABELS: '{PR_RUNNER}'\n" @@ -8612,6 +8680,7 @@ def test_today_release_and_deployment_jobs_pass_only_on_their_pinned_routes(self {'ci.yml': original[0], **workflows}, **{name: hosted})) route = ('a hosted job' if name == 'docs.yml' else 'a pinned verification job' if job == 'promote-guard' else + 'a hosted release job' if job in ('release', 'notify-hub') else 'an isolated release job') self.assertIn(f'{name} job {job} must not set PF_CI_LABELS on {route}', findings) @@ -10184,11 +10253,10 @@ def test_actionlint_runner_labels_accepts_retained_fleets_and_refuses_retired_pr self.tree / 'scripts/ci_preflight.py', self.config, workflow, *sorted((self.tree / '.github/workflows').glob('*'))) original_config = self.config.read_text() - # Only release.yml's two protected labels stay declared; every other retired - # label of the old fleets is undeclared and refused where a workflow names it. - for label in (GKP_OD_4, GKP_OD_PREBUILT): - self.assertIn(f' - {label}\n', original_config) - for label in (GKP_2, GKP_4, GKP_PREBUILT, GKE_2): + # Only the shipped x86_64 prebuilt label stays declared; legacy release + # routing remains admitted by the separate synthetic rules fixtures. + self.assertIn(f' - {GKP_OD_PREBUILT}\n', original_config) + for label in (GKP_OD_4, GKP_2, GKP_4, GKP_PREBUILT, GKE_2): self.assertNotIn(f' - {label}\n', original_config) workflow.write_text( 'on: workflow_dispatch\njobs:\n post:\n' @@ -10205,17 +10273,24 @@ def test_actionlint_runner_labels_accepts_retained_fleets_and_refuses_retired_pr for job, label in (('post', GKE_2), ('main', GKP_2), ('prebuilt', GKP_PREBUILT)): self.assertIn(f'gcp-labels.yml job {job}: label "{label}" is not declared ' 'in .github/actionlint.yaml', output.splitlines()) - # The two retained labels pass, and removing one is refused again. + # The retained label passes and the retired release label is refused. workflow.write_text( 'on: workflow_dispatch\njobs:\n main:\n' f' runs-on: {GKP_OD_4}\n' - ' steps:\n - run: "true"\n' - ' prebuilt:\n' + ' steps:\n - run: "true"\n') + code, output = self.runner_labels() + self.assertEqual(code, 1, output) + self.assertIn(f'gcp-labels.yml job main: label "{GKP_OD_4}" is not declared ' + 'in .github/actionlint.yaml', output.splitlines()) + workflow.write_text( + 'on: workflow_dispatch\njobs:\n prebuilt:\n' f' runs-on: {GKP_OD_PREBUILT}\n' ' steps:\n - run: "true"\n') code, output = self.runner_labels() self.assertEqual(code, 0, output) - self.config.write_text(original_config.replace(f' - {GKP_OD_PREBUILT}\n', '', 1)) + # Keep a valid non-empty label list so this tests an undeclared label. + self.config.write_text(original_config.replace( + f' - {GKP_OD_PREBUILT}\n', ' - gkp-unused-declared-label\n', 1)) code, output = self.runner_labels() self.assertEqual(code, 1, output) self.assertIn(f'gcp-labels.yml job prebuilt: label "{GKP_OD_PREBUILT}" is not declared ' @@ -10262,18 +10337,17 @@ def test_actionlint_runner_labels_requires_config(self): def test_actionlint_runner_labels_rejects_removed_label(self): original = self.config.read_text() - # Only release.yml's two protected labels are still declared and used; every - # other label of the retired fleets is absent from the declaration list. - for label in (GKP_4, GKP_2, 'gke-32cpu-64gb-amd64', 'gke-ondemand-32cpu-64gb-amd64', + # Only the shipped x86_64 prebuilt label is still declared and used. + for label in (GKP_OD_4, GKP_4, GKP_2, 'gke-32cpu-64gb-amd64', 'gke-ondemand-32cpu-64gb-amd64', 'gke-ondemand-32cpu-64gb-amd64-256gb', 'gke-16cpu-32gb-amd64', 'gke-16cpu-32gb-amd64-128gb', 'gke-16cpu-32gb-amd64-256gb', 'gke-8cpu-16gb-amd64', 'gke-16cpu-32gb-arm64-128gb'): self.assertNotIn(f' - {label}\n', original) for label, sites in ( - (GKP_OD_4, (('release.yml', 'release'), ('release.yml', 'notify-hub'))), - (GKP_OD_PREBUILT, (('release.yml', 'prebuilt'),))): + (GKP_OD_PREBUILT, (('release.yml', 'prebuilt'),)),): with self.subTest(label=label): - changed = original.replace(f' - {label}\n', '', 1) + changed = original.replace(f' - {label}\n', + ' - gkp-unused-declared-label\n', 1) self.assertNotEqual(changed, original) self.config.write_text(changed) code, output = self.runner_labels() @@ -10303,6 +10377,8 @@ def test_actionlint_runner_labels_rejects_new_literal_label(self): def test_actionlint_runner_labels_rejects_unparseable_config(self): for config in ('self-hosted-runner: [\n', + 'self-hosted-runner:\n labels:\n', + 'self-hosted-runner:\n labels: []\n', 'self-hosted-runner:\n labels: pf-gcp-pr\n'): with self.subTest(config=config): self.config.write_text(config) diff --git a/scripts/test_ci_release_gate.py b/scripts/test_ci_release_gate.py index 311217a45..cb4746b18 100644 --- a/scripts/test_ci_release_gate.py +++ b/scripts/test_ci_release_gate.py @@ -1999,8 +1999,8 @@ def test_probe_dispatch_branch_and_fleets_are_documented(self): 'No job routes by event.', "The manual `release-ruleset-probe.yml` workflow runs `authz` and `probe` on\n" 'the hosted `ubuntu-24.04` image; `probe` keeps its `release` environment.', - "Release uses protected GKE runner group 15:\n" - "`release` and `notify-hub` use `gkp-od-4cpu-8gb-amd64`;\n" + "Release uses protected GKE runner group 15 for its x64 artifact only:\n" + "`release` and `notify-hub` run on hosted `ubuntu-24.04` and keep their `release` environment;\n" 'Linux x64 `prebuilt` uses `gkp-od-16cpu-32gb-amd64-128gb`; baseline `promote` uses hosted `ubuntu-24.04`.\n' 'The release ruleset probe runs `authz` and `probe` on hosted `ubuntu-24.04`.', "Dispatch **Release ruleset probe** (`release-ruleset-probe.yml`) once from\n" @@ -2327,9 +2327,12 @@ def test_bookworm_build_and_all_library_gate_are_binding_for_both_release_modes( self.assertEqual(release_prebuilt_workflow_findings(workflow), []) steps = _steps(_jobs(workflow)["prebuilt"]) names = [step.splitlines()[0] for step in steps] + baseline_fetch = "Fetch the v1.10.0 baseline archive for the glibc comparison" for earlier, later in ( ("Build x86_64 in preloaded Bookworm", "Verify the staged version"), ("Install to staging", "Check every staged library's glibc floor"), + ("Verify the staged version", baseline_fetch), + (baseline_fetch, "Check every staged library's glibc floor"), ("Check every staged library's glibc floor", "Pack tarball + sha256"), ("Pack tarball + sha256", "Consume packed x86_64 archive"), ("Consume packed x86_64 archive", "Upload to release")): @@ -2338,11 +2341,16 @@ def test_bookworm_build_and_all_library_gate_are_binding_for_both_release_modes( gate = next(step for step in steps if " - name: Check every staged library's glibc floor" in step) self.assertNotIn(" if:", gate) self.assertIn('python3 scripts/check_prebuilt_glibc.py "${STAGE_DIR}"', gate) + self.assertIn('--baseline-root "${BASELINE_DIR}"', gate) self.assertIn('exit "${checker_rc}"', gate) + # Both release modes and both triples fetch the baseline: no condition on the step. + fetch = next(step for step in steps if " - name: " + baseline_fetch in step) + self.assertNotIn(" if:", fetch) def test_glibc_invocation_rejects_raw_blank_and_comment_continuations(self): workflow = self.workflow() command = (' if python3 scripts/check_prebuilt_glibc.py "${STAGE_DIR}" ' + '--baseline-root "${BASELINE_DIR}" ' '> "${log}/stdout.jsonl" 2> "${log}/stderr.txt"; then') finding = ('release.yml prebuilt must retain the raw single-line all-library ' 'glibc checker invocation and its run context') @@ -2377,6 +2385,19 @@ def test_bookworm_workflow_controls_consume_each_mutated_workflow(self): 'python3 scripts/check_prebuilt_glibc.py "${STAGE_DIR}" || true'), ('exit "${checker_rc}"', "exit 0"), ('else checker_rc=$?; fi', 'else checker_rc=0; fi'), + # The released baseline is part of the gate: drop it, aim it at the + # staged tree, retarget the release, break its digest check or digest. + ('--baseline-root "${BASELINE_DIR}" ', ''), + ('--baseline-root "${BASELINE_DIR}"', '--baseline-root "${STAGE_DIR}"'), + ('gh release download v1.10.0', 'gh release download v1.9.1'), + ('archive="pineforge-v1.10.0-${TRIPLE}.tar.gz"', + 'archive="pineforge-v1.9.1-${TRIPLE}.tar.gz"'), + ('| sha256sum -c -', '| cat'), + ('cb412fbae9e8ba515de41dc31026c77d6c5e309693247bee26bb93c67d3c3543', + 'cb412fbae9e8ba515de41dc31026c77d6c5e309693247bee26bb93c67d3c3544'), + ('982b16da2d5f5ee7c274615895484fce36bd740052a14d59b66576dca496d7bb', + '982b16da2d5f5ee7c274615895484fce36bd740052a14d59b66576dca496d7bc'), + ('echo "BASELINE_DIR=${root}/tree"', 'echo "BASELINE_DIR=${root}/download"'), (" fetch-depth: 0", " fetch-depth: 1"), (" persist-credentials: false", " persist-credentials: true"), ):