mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-25 13:10:03 +00:00
d2ac07bccf
Since 22 October the E2E jobs often time out installing browsers before any test runs: 29 installs failed that day and 30 on 23 October, against none in the week before. Each job downloads about 119 MB of Ubuntu packages for Chromium and WebKit from the runner's Azure mirror, and some GitHub-hosted runners now get tens of kB/s from it (actions/runner-images#14823 and #15040). The same package set on the same image versions installed in under a minute before then. The three browser jobs now restore the newest saved .deb set with actions/cache/restore and admit it through scripts/browser_os_packages.py. apt reuses a file already in its archive directory after checking only its size, so the helper refreshes the index and installs a private copy of a cached file only when that copy's SHA256 matches a signed index stanza for the package, version and architecture its filename names. After a successful install, and before any journey can fail the job, the helper rebuilds the cache from apt's archives and the job saves it under a run-unique key only when the set changed, so Ubuntu updates refresh the cache and an empty or unchanged set is never saved. The transport bounds and install budgets are unchanged, and the jobs hold no secrets or write tokens, as the workflow trust contract requires for caches.
219 lines
8.6 KiB
Python
219 lines
8.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Reuse verified browser OS packages across disposable E2E runners.
|
|
|
|
Since 22 October 2026 some GitHub-hosted runners download Ubuntu packages from
|
|
the Azure mirror at tens of kB/s, so the 120 MB Chromium and WebKit
|
|
dependency set can exceed the install budget before any test runs
|
|
(actions/runner-images#14823 and #15040). The E2E jobs keep the last package
|
|
set in an Actions cache instead.
|
|
|
|
apt reuses a file already in its archive directory after checking only its
|
|
size (apt 2.8 ``pkgAcqArchive::QueueNext``), so a restored file is admitted
|
|
only when its SHA256 equals the value in the freshly fetched, signed package
|
|
index. Package identity comes from the archive filename apt writes, never
|
|
from the untrusted file's contents.
|
|
|
|
seed CACHE_DIR refresh the index, then copy verified cached packages
|
|
into apt's archive directory
|
|
collect CACHE_DIR replace CACHE_DIR with apt's current archives and set
|
|
the step output changed=true|false
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
from urllib.parse import unquote
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
from write_github_output import append_output # noqa: E402
|
|
|
|
ARCHIVES = Path("/var/cache/apt/archives")
|
|
PACKAGE_RE = re.compile(r"^[a-z0-9][a-z0-9+.-]+$")
|
|
VERSION_RE = re.compile(r"^[0-9A-Za-z.+~:-]+$")
|
|
ARCH_RE = re.compile(r"^[a-z0-9-]+$")
|
|
QUERY_BATCH = 50
|
|
|
|
Identity = tuple[str, str, str]
|
|
|
|
|
|
def parse_archive_name(name: str) -> Identity | None:
|
|
"""Return (package, version, architecture) for an apt archive filename.
|
|
|
|
apt stores ``name_version_arch.deb`` with ``_`` and ``:`` percent-encoded
|
|
inside each part, so the version's epoch appears as ``%3a``.
|
|
"""
|
|
if not name.endswith(".deb"):
|
|
return None
|
|
parts = name[: -len(".deb")].split("_")
|
|
if len(parts) != 3:
|
|
return None
|
|
package, version, arch = (unquote(part) for part in parts)
|
|
if not (PACKAGE_RE.fullmatch(package) and VERSION_RE.fullmatch(version) and ARCH_RE.fullmatch(arch)):
|
|
return None
|
|
return package, version, arch
|
|
|
|
|
|
def archive_files(directory: Path) -> dict[str, Path]:
|
|
"""Regular, correctly named .deb files directly inside *directory*."""
|
|
if not directory.is_dir():
|
|
return {}
|
|
files = {}
|
|
for path in directory.iterdir():
|
|
if path.is_symlink() or not path.is_file():
|
|
continue
|
|
if parse_archive_name(path.name) is not None:
|
|
files[path.name] = path
|
|
return files
|
|
|
|
|
|
def index_hashes(identities: list[Identity], apt_cache: str) -> dict[Identity, set[str]]:
|
|
"""Every SHA256 apt's current index lists for each requested package version.
|
|
|
|
Queries name ``package:arch`` without a version: apt-cache then prints
|
|
every record for that architecture, whereas ``=version`` keeps only the
|
|
first, which would drop another repository's hash for the same version.
|
|
The exact package, version, and architecture are matched afterwards.
|
|
"""
|
|
wanted = set(identities)
|
|
found: dict[Identity, set[str]] = {}
|
|
queries = sorted({f"{package}:{arch}" for package, _, arch in identities})
|
|
for start in range(0, len(queries), QUERY_BATCH):
|
|
# apt-cache can exit non-zero when a package is unknown; the records
|
|
# it does print are still used.
|
|
output = subprocess.run(
|
|
[apt_cache, "show", *queries[start:start + QUERY_BATCH]],
|
|
capture_output=True, text=True, check=False,
|
|
).stdout
|
|
for stanza in output.split("\n\n"):
|
|
fields = dict(
|
|
line.split(": ", 1) for line in stanza.splitlines()
|
|
if ": " in line and not line.startswith(" ")
|
|
)
|
|
identity = (fields.get("Package", ""), fields.get("Version", ""), fields.get("Architecture", ""))
|
|
digest = fields.get("SHA256", "")
|
|
if identity in wanted and re.fullmatch(r"[0-9a-f]{64}", digest):
|
|
found.setdefault(identity, set()).add(digest)
|
|
return found
|
|
|
|
|
|
def sha256(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as handle:
|
|
for block in iter(lambda: handle.read(1 << 20), b""):
|
|
digest.update(block)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def private_copy(source: Path, directory: Path) -> tuple[Path, str]:
|
|
"""Copy *source* into a private directory, hashing exactly the bytes copied.
|
|
|
|
The copy, not the cache file, is what reaches apt, so replacing the cache
|
|
file after hashing cannot put unverified bytes into the archives.
|
|
"""
|
|
copy = directory / source.name
|
|
digest = hashlib.sha256()
|
|
with source.open("rb") as reader, copy.open("xb") as writer:
|
|
for block in iter(lambda: reader.read(1 << 20), b""):
|
|
digest.update(block)
|
|
writer.write(block)
|
|
return copy, digest.hexdigest()
|
|
|
|
|
|
def install_file(source: Path, archives: Path) -> None:
|
|
destination = archives / source.name
|
|
if os.access(archives, os.W_OK):
|
|
shutil.copyfile(source, destination)
|
|
destination.chmod(0o644)
|
|
else:
|
|
subprocess.run(["sudo", "install", "-m", "0644", str(source), str(destination)], check=True)
|
|
|
|
|
|
def seed(cache_dir: Path, archives: Path, apt_cache: str, update: list[str] | None) -> int:
|
|
cached = archive_files(cache_dir)
|
|
if not cached:
|
|
print("No cached browser OS packages to admit")
|
|
return 0
|
|
if update:
|
|
subprocess.run(update, check=True)
|
|
identities = {name: parse_archive_name(name) for name in cached}
|
|
expected = index_hashes(list(identities.values()), apt_cache)
|
|
admitted = rejected = unknown = 0
|
|
with tempfile.TemporaryDirectory(prefix="pulse-browser-os-") as private:
|
|
for name, path in sorted(cached.items()):
|
|
want = expected.get(identities[name])
|
|
if not want:
|
|
unknown += 1
|
|
continue
|
|
copy, got = private_copy(path, Path(private))
|
|
if got not in want:
|
|
rejected += 1
|
|
print(f"Rejected {name}: SHA256 does not match the signed index")
|
|
else:
|
|
install_file(copy, archives)
|
|
admitted += 1
|
|
copy.unlink()
|
|
print(f"Admitted {admitted} cached browser OS packages; {unknown} no longer in the index; {rejected} rejected")
|
|
return 0
|
|
|
|
|
|
def collect(cache_dir: Path, archives: Path) -> int:
|
|
current = archive_files(archives)
|
|
cached = archive_files(cache_dir)
|
|
previous = set(cached)
|
|
# Compare content too: a same-named cached file that seed rejected must
|
|
# be replaced, or every run would reject it and download again.
|
|
changed = bool(current) and (
|
|
set(current) != previous
|
|
or any(sha256(path) != sha256(cached[name]) for name, path in current.items())
|
|
)
|
|
if not current:
|
|
print("::warning::apt kept no downloaded browser OS packages; the cache is left unchanged")
|
|
elif changed:
|
|
# Rebuild in a fresh directory so nothing restored (a symlink, a
|
|
# directory, a stale file) survives or redirects a write.
|
|
fresh = cache_dir.with_name(cache_dir.name + ".new")
|
|
if fresh.is_symlink() or fresh.is_file():
|
|
fresh.unlink()
|
|
elif fresh.exists():
|
|
shutil.rmtree(fresh)
|
|
fresh.mkdir(parents=True)
|
|
for name, path in current.items():
|
|
shutil.copyfile(path, fresh / name)
|
|
if cache_dir.is_symlink() or cache_dir.is_file():
|
|
cache_dir.unlink()
|
|
elif cache_dir.exists():
|
|
shutil.rmtree(cache_dir)
|
|
fresh.rename(cache_dir)
|
|
print(f"Collected {len(current)} browser OS packages ({len(set(current) - previous)} new)")
|
|
else:
|
|
print(f"The {len(current)} browser OS packages match the restored cache")
|
|
output = os.environ.get("GITHUB_OUTPUT")
|
|
if output:
|
|
append_output(Path(output), "changed", "true" if changed else "false")
|
|
return 0
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0])
|
|
parser.add_argument("command", choices=("seed", "collect"))
|
|
parser.add_argument("cache_dir", type=Path)
|
|
parser.add_argument("--archives", type=Path, default=ARCHIVES)
|
|
parser.add_argument("--apt-cache", default="apt-cache")
|
|
parser.add_argument("--no-update", action="store_true", help="skip apt-get update (tests)")
|
|
args = parser.parse_args(argv)
|
|
if args.command == "seed":
|
|
update = None if args.no_update else ["sudo", "apt-get", "update"]
|
|
return seed(args.cache_dir, args.archives, args.apt_cache, update)
|
|
return collect(args.cache_dir, args.archives)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|