From 7a9d95c23d14cbfaf61f001f4ec8f894f94cab53 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Mon, 24 Aug 2026 04:40:13 +0000 Subject: [PATCH 1/7] fix(ci): gate Classic Check on published locks --- .github/workflows/measure-classic-check.yml | 9 + .github/workflows/publish-windows.yml | 7 + .github/workflows/validate.yml | 12 + README.md | 12 + .../test_verify_classic_check_dependencies.py | 181 ++++++++ tools/validate-toolchains.sh | 7 +- tools/verify_classic_check_dependencies.py | 423 ++++++++++++++++++ windows/classic-check-toolchain.json | 4 + 8 files changed, 654 insertions(+), 1 deletion(-) create mode 100644 tools/tests/test_verify_classic_check_dependencies.py create mode 100644 tools/verify_classic_check_dependencies.py diff --git a/.github/workflows/measure-classic-check.yml b/.github/workflows/measure-classic-check.yml index ab6f93a..9df6c48 100644 --- a/.github/workflows/measure-classic-check.yml +++ b/.github/workflows/measure-classic-check.yml @@ -15,8 +15,10 @@ on: - tools/build-sdl3-mixer.sh - tools/measure-classic-check-images.sh - tools/smoke-classic-check.sh + - tools/tests/** - tools/validate-toolchains.sh - tools/verify-classic-check-package.py + - tools/verify_classic_check_dependencies.py - tools/verify-pe-imports.sh - windows/** workflow_dispatch: @@ -76,6 +78,13 @@ jobs: ref: ${{ steps.classic.outputs.ref }} path: build/classic + - name: Verify pinned Classic dependency releases + env: + GH_TOKEN: ${{ github.token }} + run: | + python3 tools/verify_classic_check_dependencies.py \ + build/classic windows/classic-check-toolchain.json + - name: Log in to GitHub Container Registry uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 with: diff --git a/.github/workflows/publish-windows.yml b/.github/workflows/publish-windows.yml index d212649..cb5a763 100644 --- a/.github/workflows/publish-windows.yml +++ b/.github/workflows/publish-windows.yml @@ -65,6 +65,13 @@ jobs: ref: ${{ steps.classic.outputs.ref }} path: build/classic + - name: Verify pinned Classic dependency releases + env: + GH_TOKEN: ${{ github.token }} + run: | + python3 tools/verify_classic_check_dependencies.py \ + build/classic windows/classic-check-toolchain.json + - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index b6d1f4a..ac1464d 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -70,7 +70,9 @@ jobs: tools/require-image-checks.sh | \ tools/smoke-classic-check.sh | \ tools/test-require-image-checks.sh | \ + tools/tests/* | \ tools/validate-toolchains.sh | \ + tools/verify_classic_check_dependencies.py | \ tools/verify-classic-check-package.py | \ tools/verify-pe-imports.sh) windows=true @@ -86,6 +88,9 @@ jobs: - name: Test required-check aggregation run: tools/test-require-image-checks.sh + - name: Test Classic dependency preflight + run: python3 -m unittest tools/tests/test_verify_classic_check_dependencies.py + linux: name: Linux image needs: changes @@ -199,6 +204,13 @@ jobs: ref: ${{ steps.classic.outputs.ref }} path: build/classic + - name: Verify pinned Classic dependency releases + env: + GH_TOKEN: ${{ github.token }} + run: | + python3 tools/verify_classic_check_dependencies.py \ + build/classic windows/classic-check-toolchain.json + - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 diff --git a/README.md b/README.md index 8a7a4bd..e714119 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,18 @@ docker run --rm \ /image-source/tools/validate-classic-check.sh /workspace ``` +Before building or publishing a Classic Check image, verify the immutable +consumer coordinates and every release asset declared by its client and server +locks. The command uses the authenticated GitHub CLI for read-only release +metadata and fails before any candidate image is published when a tag, commit, +asset URL, or SHA-256 digest is missing or mismatched: + +```sh +GH_TOKEN="${GH_TOKEN:?Set a read-only GitHub token}" \ + python3 tools/verify_classic_check_dependencies.py \ + /absolute/path/to/atrinik-classic windows/classic-check-toolchain.json +``` + The `classic-final` target is a separate, amd64-only CI contract rather than a trimmed development image. It starts from the same digest-pinned Ubuntu 26.04 base, bootstraps exact locked CA and TLS runtime packages, and resolves all diff --git a/tools/tests/test_verify_classic_check_dependencies.py b/tools/tests/test_verify_classic_check_dependencies.py new file mode 100644 index 0000000..842cad7 --- /dev/null +++ b/tools/tests/test_verify_classic_check_dependencies.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[2] +SPEC = importlib.util.spec_from_file_location( + "verify_classic_check_dependencies", + ROOT / "tools" / "verify_classic_check_dependencies.py", +) +assert SPEC is not None and SPEC.loader is not None +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class FakeAPI: + def __init__(self, dependencies: list[dict[str, object]]) -> None: + self.releases: dict[tuple[str, str], dict[str, object]] = {} + self.tags: dict[tuple[str, str], str] = {} + for dependency in dependencies: + repository = str(dependency["repository"]) + tag = str(dependency["tag"]) + self.releases[(repository, tag)] = { + "tag_name": tag, + "draft": False, + "prerelease": False, + "published_at": "2026-08-24T00:00:00Z", + "assets": [ + { + "name": str(dependency["url"]).rsplit("/", 1)[1], + "browser_download_url": dependency["url"], + "size": 123, + "state": "uploaded", + "digest": f"sha256:{dependency['sha256']}", + } + ], + } + self.tags[(repository, tag)] = str(dependency["commit"]) + + def release(self, repository: str, tag: str) -> dict[str, object]: + return self.releases[(repository, tag)] + + def tag_commit(self, repository: str, tag: str) -> str: + return self.tags[(repository, tag)] + + +def dependency( + name: str, repository: str, tag: str, commit: str, digest: str +) -> dict[str, object]: + return { + "name": name, + "repository": repository, + "tag": tag, + "commit": commit, + "url": f"https://github.com/{repository}/releases/download/{tag}/{name}.tar.gz", + "sha256": digest, + "destination": name, + "strip_components": 1, + } + + +class ClassicDependencyVerificationTests(unittest.TestCase): + def setUp(self) -> None: + self.tempdir = tempfile.TemporaryDirectory() + self.root = Path(self.tempdir.name) / "classic" + (self.root / ".github/workflows").mkdir(parents=True) + (self.root / "client/tools").mkdir(parents=True) + (self.root / "server").mkdir(parents=True) + (self.root / ".github/workflows/check.yml").write_text( + "jobs:\n windows:\n name: Build native Windows tests\n" + " security:\n name: Native Windows security tests\n", + encoding="utf-8", + ) + (self.root / "client/tools/dependencies.py").write_text("# fixture\n", encoding="utf-8") + self.manifest = Path(self.tempdir.name) / "classic-check-toolchain.json" + self.client_dependency = dependency( + "sound", + "atrinik/sound", + "v1.0.3", + "a" * 40, + "b" * 64, + ) + self.server_dependency = dependency( + "content", + "atrinik/content", + "v1.0.0", + "c" * 40, + "d" * 64, + ) + self.dependencies = [self.client_dependency, self.server_dependency] + self.write_fixture() + + def tearDown(self) -> None: + self.tempdir.cleanup() + + def write_fixture(self) -> None: + manifest = { + "consumer": { + "repository": "atrinik/classic", + "validation_commit": "e" * 40, + "workflow": ".github/workflows/check.yml", + "jobs": [ + "Build native Windows tests", + "Native Windows security tests", + ], + "lock_files": [ + "client/dependencies.lock.json", + "server/dependencies.lock.json", + ], + } + } + self.manifest.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + for relative, item in zip( + ("client/dependencies.lock.json", "server/dependencies.lock.json"), + self.dependencies, + ): + path = self.root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps({"schema_version": 1, "dependencies": [item]}, indent=2) + "\n", + encoding="utf-8", + ) + + def verify(self, api: FakeAPI | None = None) -> int: + with mock.patch.object(MODULE, "git_head", return_value="e" * 40): + return MODULE.verify_consumer(self.root, self.manifest, api or FakeAPI(self.dependencies)) + + def test_validates_every_declared_lock_entry(self) -> None: + self.assertEqual(self.verify(), 2) + + def test_rejects_missing_asset(self) -> None: + api = FakeAPI(self.dependencies) + api.releases[("atrinik/content", "v1.0.0")]["assets"] = [] + with self.assertRaisesRegex(MODULE.VerificationError, "asset is missing"): + self.verify(api) + + def test_rejects_mismatched_asset_digest(self) -> None: + api = FakeAPI(self.dependencies) + api.releases[("atrinik/sound", "v1.0.3")]["assets"][0]["digest"] = "sha256:" + "f" * 64 + with self.assertRaisesRegex(MODULE.VerificationError, "digest"): + self.verify(api) + + def test_rejects_tag_pointing_at_a_different_commit(self) -> None: + api = FakeAPI(self.dependencies) + api.tags[("atrinik/sound", "v1.0.3")] = "f" * 40 + with self.assertRaisesRegex(MODULE.VerificationError, "unexpected commit"): + self.verify(api) + + def test_rejects_missing_declared_workflow_job(self) -> None: + workflow = self.root / ".github/workflows/check.yml" + workflow.write_text("name: Check\n", encoding="utf-8") + with self.assertRaisesRegex(MODULE.VerificationError, "workflow job is missing"): + self.verify() + + def test_rejects_duplicate_lock_keys(self) -> None: + path = self.root / "client/dependencies.lock.json" + path.write_text( + '{"schema_version": 1, "dependencies": [], "dependencies": []}\n', + encoding="utf-8", + ) + with self.assertRaisesRegex(MODULE.VerificationError, "duplicate JSON key"): + self.verify() + + def test_rejects_lock_url_for_a_different_tag(self) -> None: + path = self.root / "server/dependencies.lock.json" + value = json.loads(path.read_text(encoding="utf-8")) + value["dependencies"][0]["url"] = value["dependencies"][0]["url"].replace( + "/v1.0.0/", "/v0.9.0/" + ) + path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") + with self.assertRaisesRegex(MODULE.VerificationError, "repository and tag"): + self.verify() + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/validate-toolchains.sh b/tools/validate-toolchains.sh index c0b489c..62c1049 100755 --- a/tools/validate-toolchains.sh +++ b/tools/validate-toolchains.sh @@ -133,13 +133,18 @@ if [[ -n ${classic_check_expected} ]]; then and .["$schema"] == "https://json-schema.org/draft/2020-12/schema" and .schema_version == 1 and .target == "classic-check" - and (.consumer | keys == ["jobs", "repository", "validation_commit", "workflow"]) + and (.consumer | keys == [ + "jobs", "lock_files", "repository", "validation_commit", "workflow" + ]) and .consumer.repository == "atrinik/classic" and (.consumer.validation_commit | test("^[0-9a-f]{40}$")) and .consumer.workflow == ".github/workflows/check.yml" and .consumer.jobs == [ "Build native Windows tests", "Native Windows security tests" ] + and .consumer.lock_files == [ + "client/dependencies.lock.json", "server/dependencies.lock.json" + ] and .base == { "image": "mcr.microsoft.com/devcontainers/base:bookworm", "digest": "sha256:73d85a96694a2cadca1ba3fcb5721f2312a64f1d571dd86f6c77e10a708931dc" diff --git a/tools/verify_classic_check_dependencies.py b/tools/verify_classic_check_dependencies.py new file mode 100644 index 0000000..32469a4 --- /dev/null +++ b/tools/verify_classic_check_dependencies.py @@ -0,0 +1,423 @@ +#!/usr/bin/env python3 +"""Verify every release asset declared by the pinned Classic consumer.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path, PurePosixPath +import re +import subprocess +import sys +from typing import Iterable +import urllib.parse + + +MAX_JSON_BYTES = 8 * 1024 * 1024 +MAX_DEPENDENCIES = 32 +MAX_TAG_DEPTH = 8 +COMMIT_RE = re.compile(r"[0-9a-f]{40}") +SHA256_RE = re.compile(r"[0-9a-f]{64}") +TAG_RE = re.compile(r"v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)") +NAME_RE = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*") +REPOSITORY_RE = re.compile(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+") +LOCK_KEYS = { + "schema_version", + "dependencies", +} +DEPENDENCY_KEYS = { + "name", + "repository", + "tag", + "commit", + "url", + "sha256", + "destination", + "strip_components", +} + + +class VerificationError(RuntimeError): + """A Classic consumer coordinate failed closed verification.""" + + +def reject_duplicate_keys(pairs: Iterable[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise VerificationError(f"duplicate JSON key: {key}") + result[key] = value + return result + + +def load_json(path: Path, context: str) -> object: + try: + data = path.read_bytes() + except OSError as error: + raise VerificationError(f"cannot read {context}: {error}") from error + if len(data) > MAX_JSON_BYTES: + raise VerificationError(f"{context} exceeds the JSON size limit") + try: + return json.loads(data, object_pairs_hook=reject_duplicate_keys) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise VerificationError(f"{context} is not valid UTF-8 JSON: {error}") from error + + +def require_keys(value: dict[str, object], expected: set[str], context: str) -> None: + actual = set(value) + if actual != expected: + missing = sorted(expected - actual) + extra = sorted(actual - expected) + details = [] + if missing: + details.append(f"missing {', '.join(missing)}") + if extra: + details.append(f"unexpected {', '.join(extra)}") + raise VerificationError(f"{context}: {'; '.join(details)}") + + +def load_manifest(path: Path) -> dict[str, object]: + value = load_json(path, "Classic Check toolchain manifest") + if not isinstance(value, dict): + raise VerificationError("Classic Check toolchain manifest must be an object") + consumer = value.get("consumer") + if not isinstance(consumer, dict): + raise VerificationError("Classic Check toolchain manifest has no consumer object") + require_keys( + consumer, + {"repository", "validation_commit", "workflow", "jobs", "lock_files"}, + "consumer", + ) + if consumer["repository"] != "atrinik/classic": + raise VerificationError("consumer.repository must be atrinik/classic") + if not isinstance(consumer["validation_commit"], str) or not COMMIT_RE.fullmatch( + consumer["validation_commit"] + ): + raise VerificationError("consumer.validation_commit must be a full lowercase commit") + workflow = consumer["workflow"] + if not isinstance(workflow, str) or not safe_relative_path(workflow): + raise VerificationError("consumer.workflow must be a safe relative path") + jobs = consumer["jobs"] + if ( + not isinstance(jobs, list) + or not jobs + or any( + not isinstance(job, str) + or job != job.strip() + or not job + or "\r" in job + or "\n" in job + for job in jobs + ) + or len(set(jobs)) != len(jobs) + ): + raise VerificationError("consumer.jobs must be a non-empty unique string list") + lock_files = consumer["lock_files"] + if ( + not isinstance(lock_files, list) + or lock_files != [ + "client/dependencies.lock.json", + "server/dependencies.lock.json", + ] + or any(not isinstance(path, str) or not safe_relative_path(path) for path in lock_files) + ): + raise VerificationError( + "consumer.lock_files must name the client and server dependency locks" + ) + return consumer + + +def safe_relative_path(value: str) -> bool: + if not value or "\\" in value or "\x00" in value: + return False + path = PurePosixPath(value) + return ( + value == path.as_posix() + and not path.is_absolute() + and all(part not in {"", ".", ".."} for part in path.parts) + ) + + +def resolved_child(root: Path, relative: str, context: str) -> Path: + if not safe_relative_path(relative): + raise VerificationError(f"{context} is not a safe relative path") + try: + root = root.resolve(strict=True) + candidate = (root / PurePosixPath(relative)).resolve(strict=True) + except (OSError, RuntimeError) as error: + raise VerificationError(f"{context} cannot be resolved: {error}") from error + try: + candidate.relative_to(root) + except ValueError as error: + raise VerificationError(f"{context} escapes the Classic checkout") from error + if not candidate.is_file(): + raise VerificationError(f"{context} is not a regular file") + return candidate + + +def load_lock(path: Path) -> list[dict[str, object]]: + value = load_json(path, str(path)) + if not isinstance(value, dict): + raise VerificationError(f"{path}: lock root must be an object") + require_keys(value, LOCK_KEYS, f"{path}: lock root") + if value["schema_version"] != 1: + raise VerificationError(f"{path}: unsupported lock schema version") + dependencies = value["dependencies"] + if not isinstance(dependencies, list) or not dependencies: + raise VerificationError(f"{path}: dependencies must be a non-empty array") + if len(dependencies) > MAX_DEPENDENCIES: + raise VerificationError(f"{path}: dependency count exceeds the bounded limit") + + names: set[str] = set() + result: list[dict[str, object]] = [] + for index, item in enumerate(dependencies): + context = f"{path}: dependency {index}" + if not isinstance(item, dict): + raise VerificationError(f"{context} must be an object") + require_keys(item, DEPENDENCY_KEYS, context) + name = item["name"] + repository = item["repository"] + tag = item["tag"] + commit = item["commit"] + url = item["url"] + digest = item["sha256"] + destination = item["destination"] + strip_components = item["strip_components"] + if not isinstance(name, str) or not NAME_RE.fullmatch(name) or name in names: + raise VerificationError(f"{context}.name is malformed or duplicated") + names.add(name) + if not isinstance(repository, str) or not REPOSITORY_RE.fullmatch(repository): + raise VerificationError(f"{context}.repository is malformed") + if any(part in {".", ".."} for part in repository.split("/")): + raise VerificationError(f"{context}.repository is malformed") + if not isinstance(tag, str) or not TAG_RE.fullmatch(tag): + raise VerificationError(f"{context}.tag is not a canonical release tag") + if not isinstance(commit, str) or not COMMIT_RE.fullmatch(commit): + raise VerificationError(f"{context}.commit is not a full lowercase commit") + if not isinstance(digest, str) or not SHA256_RE.fullmatch(digest): + raise VerificationError(f"{context}.sha256 is not a lowercase SHA-256") + if not isinstance(destination, str) or not safe_relative_path(destination): + raise VerificationError(f"{context}.destination is unsafe") + if ( + not isinstance(strip_components, int) + or isinstance(strip_components, bool) + or not 1 <= strip_components <= 8 + ): + raise VerificationError(f"{context}.strip_components is outside the supported range") + if not isinstance(url, str): + raise VerificationError(f"{context}.url must be a string") + try: + parsed = urllib.parse.urlsplit(url) + parsed_port = parsed.port + except ValueError as error: + raise VerificationError(f"{context}.url is not a canonical GitHub HTTPS URL") from error + if ( + parsed.scheme != "https" + or parsed.netloc != "github.com" + or parsed.query + or parsed.fragment + or parsed.username is not None + or parsed.password is not None + or parsed_port is not None + ): + raise VerificationError(f"{context}.url must be a canonical GitHub HTTPS URL") + prefix = f"/{repository}/releases/download/{tag}/" + if not parsed.path.startswith(prefix): + raise VerificationError(f"{context}.url does not match its repository and tag") + asset_name = parsed.path[len(prefix) :] + if ( + not asset_name + or "/" in asset_name + or not asset_name.endswith(".tar.gz") + or urllib.parse.unquote(asset_name) != asset_name + or asset_name != urllib.parse.quote(asset_name, safe="-._~") + ): + raise VerificationError(f"{context}.url has a non-canonical asset name") + result.append(item) + return result + + +def run_json(arguments: list[str]) -> object: + result = subprocess.run( + arguments, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if len(result.stdout) > MAX_JSON_BYTES or len(result.stderr) > MAX_JSON_BYTES: + raise VerificationError("GitHub API response exceeds the bounded output limit") + if result.returncode: + message = result.stderr.decode("utf-8", errors="replace").strip() + raise VerificationError(f"GitHub API request failed: {message or 'unknown error'}") + try: + return json.loads(result.stdout, object_pairs_hook=reject_duplicate_keys) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise VerificationError(f"GitHub API returned invalid JSON: {error}") from error + + +class GitHubAPI: + """Use the runner's authenticated gh CLI without a shell.""" + + def __init__(self) -> None: + self._releases: dict[tuple[str, str], dict[str, object]] = {} + self._tag_commits: dict[tuple[str, str], str] = {} + + def get(self, endpoint: str) -> object: + return run_json(["gh", "api", "--method", "GET", endpoint]) + + def release(self, repository: str, tag: str) -> dict[str, object]: + key = (repository, tag) + if key not in self._releases: + value = self.get(f"repos/{repository}/releases/tags/{tag}") + if not isinstance(value, dict): + raise VerificationError(f"{repository}@{tag}: release response is not an object") + self._releases[key] = value + return self._releases[key] + + def tag_commit(self, repository: str, tag: str) -> str: + key = (repository, tag) + if key in self._tag_commits: + return self._tag_commits[key] + value = self.get(f"repos/{repository}/git/ref/tags/{tag}") + seen: set[str] = set() + for _ in range(MAX_TAG_DEPTH): + if not isinstance(value, dict) or not isinstance(value.get("object"), dict): + raise VerificationError(f"{repository}@{tag}: malformed tag response") + target = value["object"] + target_type = target.get("type") + sha = target.get("sha") + if not isinstance(sha, str) or not COMMIT_RE.fullmatch(sha): + raise VerificationError(f"{repository}@{tag}: malformed tag target") + if target_type == "commit": + self._tag_commits[key] = sha + return sha + if target_type != "tag" or sha in seen: + raise VerificationError(f"{repository}@{tag}: unsupported or cyclic tag") + seen.add(sha) + value = self.get(f"repos/{repository}/git/tags/{sha}") + raise VerificationError(f"{repository}@{tag}: annotated tag chain is too deep") + + +def git_head(classic_root: Path) -> str: + result = subprocess.run( + [ + "git", + "-C", + str(classic_root), + "rev-parse", + "--verify", + "--end-of-options", + "HEAD^{commit}", + ], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + if result.returncode or not COMMIT_RE.fullmatch(result.stdout.strip()): + raise VerificationError("Classic checkout HEAD is unavailable") + return result.stdout.strip() + + +def verify_layout(classic_root: Path, consumer: dict[str, object]) -> None: + actual = git_head(classic_root) + expected = consumer["validation_commit"] + if actual != expected: + raise VerificationError(f"Classic checkout is at {actual}, expected {expected}") + workflow = resolved_child(classic_root, str(consumer["workflow"]), "consumer.workflow") + try: + workflow_text = workflow.read_text(encoding="utf-8") + except (OSError, UnicodeError) as error: + raise VerificationError(f"consumer.workflow cannot be read: {error}") from error + workflow_names = { + line.strip()[len("name: ") :] + for line in workflow_text.splitlines() + if line.strip().startswith("name: ") + } + for job in consumer["jobs"]: # type: ignore[union-attr] + if job not in workflow_names: + raise VerificationError(f"declared Classic workflow job is missing: {job}") + for required in ("client/tools/dependencies.py",): + resolved_child(classic_root, required, required) + + +def verify_release( + dependency: dict[str, object], api: GitHubAPI, context: str +) -> None: + repository = str(dependency["repository"]) + tag = str(dependency["tag"]) + release = api.release(repository, tag) + if ( + release.get("tag_name") != tag + or release.get("draft") is not False + or release.get("prerelease") is not False + or not isinstance(release.get("published_at"), str) + or not release["published_at"] + ): + raise VerificationError(f"{context}: release is not published and immutable") + assets = release.get("assets") + if not isinstance(assets, list) or len(assets) > 1024: + raise VerificationError(f"{context}: release assets are malformed or unbounded") + asset_name = str(urllib.parse.urlsplit(str(dependency["url"])).path.rsplit("/", 1)[1]) + matches = [asset for asset in assets if isinstance(asset, dict) and asset.get("name") == asset_name] + if len(matches) != 1 or not isinstance(matches[0], dict): + raise VerificationError(f"{context}: declared release asset is missing or duplicated") + asset = matches[0] + if ( + asset.get("browser_download_url") != dependency["url"] + or asset.get("state") != "uploaded" + or not isinstance(asset.get("size"), int) + or isinstance(asset["size"], bool) + or asset["size"] < 1 + or asset.get("digest") != f"sha256:{dependency['sha256']}" + ): + raise VerificationError(f"{context}: release asset URL, state, size, or digest mismatches lock") + actual_commit = api.tag_commit(repository, tag) + if actual_commit != dependency["commit"]: + raise VerificationError(f"{context}: release tag resolves to an unexpected commit") + + +def verify_consumer(classic_root: Path, manifest_path: Path, api: GitHubAPI) -> int: + classic_root = classic_root.resolve(strict=True) + consumer = load_manifest(manifest_path) + verify_layout(classic_root, consumer) + dependencies: list[dict[str, object]] = [] + for relative in consumer["lock_files"]: # type: ignore[union-attr] + lock_path = resolved_child(classic_root, str(relative), f"consumer.lock_files entry {relative}") + dependencies.extend(load_lock(lock_path)) + if not dependencies or len(dependencies) > MAX_DEPENDENCIES: + raise VerificationError("combined Classic dependency count is outside the bounded limit") + seen: set[tuple[str, str, str, str, str]] = set() + for dependency in dependencies: + coordinate = ( + str(dependency["repository"]), + str(dependency["tag"]), + str(dependency["commit"]), + str(dependency["url"]), + str(dependency["sha256"]), + ) + if coordinate in seen: + continue + seen.add(coordinate) + verify_release(dependency, api, f"{dependency['repository']}@{dependency['tag']}") + return len(dependencies) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Verify pinned Classic dependency tags, assets, commits, and digests." + ) + parser.add_argument("classic_checkout", type=Path) + parser.add_argument("toolchain_manifest", type=Path) + args = parser.parse_args(argv) + try: + count = verify_consumer(args.classic_checkout, args.toolchain_manifest, GitHubAPI()) + except (OSError, UnicodeError, VerificationError) as error: + print(f"Classic dependency verification failed: {error}", file=sys.stderr) + return 1 + print(f"Verified {count} Classic dependency lock entries before image publication.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/windows/classic-check-toolchain.json b/windows/classic-check-toolchain.json index 34dfcf4..c5834a0 100644 --- a/windows/classic-check-toolchain.json +++ b/windows/classic-check-toolchain.json @@ -9,6 +9,10 @@ "jobs": [ "Build native Windows tests", "Native Windows security tests" + ], + "lock_files": [ + "client/dependencies.lock.json", + "server/dependencies.lock.json" ] }, "base": { From c28384d22258a49cb1d69e8c044681f9d5439402 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Mon, 24 Aug 2026 04:48:34 +0000 Subject: [PATCH 2/7] fix(ci): harden Classic consumer checkouts --- .github/workflows/measure-classic-check.yml | 1 + .github/workflows/publish-windows.yml | 1 + .github/workflows/validate.yml | 1 + 3 files changed, 3 insertions(+) diff --git a/.github/workflows/measure-classic-check.yml b/.github/workflows/measure-classic-check.yml index 9df6c48..a0b792f 100644 --- a/.github/workflows/measure-classic-check.yml +++ b/.github/workflows/measure-classic-check.yml @@ -77,6 +77,7 @@ jobs: repository: atrinik/classic ref: ${{ steps.classic.outputs.ref }} path: build/classic + persist-credentials: false - name: Verify pinned Classic dependency releases env: diff --git a/.github/workflows/publish-windows.yml b/.github/workflows/publish-windows.yml index cb5a763..0c331fc 100644 --- a/.github/workflows/publish-windows.yml +++ b/.github/workflows/publish-windows.yml @@ -64,6 +64,7 @@ jobs: repository: atrinik/classic ref: ${{ steps.classic.outputs.ref }} path: build/classic + persist-credentials: false - name: Verify pinned Classic dependency releases env: diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index ac1464d..2b4ac3c 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -203,6 +203,7 @@ jobs: repository: atrinik/classic ref: ${{ steps.classic.outputs.ref }} path: build/classic + persist-credentials: false - name: Verify pinned Classic dependency releases env: From 5d9beb2d02729334ccb6b58d0b2e5563aba2d585 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Mon, 24 Aug 2026 04:55:41 +0000 Subject: [PATCH 3/7] fix(ci): restrict cache-writing Classic measurements --- .github/workflows/measure-classic-check.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/measure-classic-check.yml b/.github/workflows/measure-classic-check.yml index a0b792f..66a1b6e 100644 --- a/.github/workflows/measure-classic-check.yml +++ b/.github/workflows/measure-classic-check.yml @@ -21,8 +21,6 @@ on: - tools/verify_classic_check_dependencies.py - tools/verify-pe-imports.sh - windows/** - workflow_dispatch: - permissions: contents: read From 86b401958b74f823f4a93578b130711d86d074ce Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Mon, 24 Aug 2026 04:57:36 +0000 Subject: [PATCH 4/7] fix(ci): identify unavailable Classic releases --- tools/verify_classic_check_dependencies.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tools/verify_classic_check_dependencies.py b/tools/verify_classic_check_dependencies.py index 32469a4..cc737a7 100644 --- a/tools/verify_classic_check_dependencies.py +++ b/tools/verify_classic_check_dependencies.py @@ -248,7 +248,10 @@ def run_json(arguments: list[str]) -> object: raise VerificationError("GitHub API response exceeds the bounded output limit") if result.returncode: message = result.stderr.decode("utf-8", errors="replace").strip() - raise VerificationError(f"GitHub API request failed: {message or 'unknown error'}") + endpoint = arguments[-1] if arguments else "unknown endpoint" + raise VerificationError( + f"GitHub API request failed for {endpoint}: {message or 'unknown error'}" + ) try: return json.loads(result.stdout, object_pairs_hook=reject_duplicate_keys) except (UnicodeDecodeError, json.JSONDecodeError) as error: @@ -346,7 +349,10 @@ def verify_release( ) -> None: repository = str(dependency["repository"]) tag = str(dependency["tag"]) - release = api.release(repository, tag) + try: + release = api.release(repository, tag) + except VerificationError as error: + raise VerificationError(f"{context}: release lookup failed: {error}") from error if ( release.get("tag_name") != tag or release.get("draft") is not False @@ -372,7 +378,10 @@ def verify_release( or asset.get("digest") != f"sha256:{dependency['sha256']}" ): raise VerificationError(f"{context}: release asset URL, state, size, or digest mismatches lock") - actual_commit = api.tag_commit(repository, tag) + try: + actual_commit = api.tag_commit(repository, tag) + except VerificationError as error: + raise VerificationError(f"{context}: tag lookup failed: {error}") from error if actual_commit != dependency["commit"]: raise VerificationError(f"{context}: release tag resolves to an unexpected commit") From ac5db5157cebb5d040975fa0c6a8718e304d2b61 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Mon, 24 Aug 2026 09:29:19 +0000 Subject: [PATCH 5/7] fix(ci): refresh Classic check consumer pin --- windows/classic-check-toolchain.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/windows/classic-check-toolchain.json b/windows/classic-check-toolchain.json index c5834a0..59c8e9a 100644 --- a/windows/classic-check-toolchain.json +++ b/windows/classic-check-toolchain.json @@ -4,7 +4,7 @@ "target": "classic-check", "consumer": { "repository": "atrinik/classic", - "validation_commit": "f2fe4786a7e61214230b1a18d8b64449c4d042f6", + "validation_commit": "a750375a0b69097d5642a411d4d9020c95225852", "workflow": ".github/workflows/check.yml", "jobs": [ "Build native Windows tests", From 7a1e9d3c2d5b1b3ec0bf6f360676d9bfb4d46020 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Tue, 25 Aug 2026 20:55:49 +0000 Subject: [PATCH 6/7] fix(ci): pin merged Classic lock refresh --- windows/classic-check-toolchain.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/windows/classic-check-toolchain.json b/windows/classic-check-toolchain.json index 59c8e9a..6300730 100644 --- a/windows/classic-check-toolchain.json +++ b/windows/classic-check-toolchain.json @@ -4,7 +4,7 @@ "target": "classic-check", "consumer": { "repository": "atrinik/classic", - "validation_commit": "a750375a0b69097d5642a411d4d9020c95225852", + "validation_commit": "8fec1db157bcfdd050c1ba360e77365bce701bba", "workflow": ".github/workflows/check.yml", "jobs": [ "Build native Windows tests", From d0b2307de0e6cfcfe86b4a7748bd4b92f71982e8 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Tue, 25 Aug 2026 21:02:16 +0000 Subject: [PATCH 7/7] fix(ci): align Linux Classic consumer pin --- classic-toolchain.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/classic-toolchain.json b/classic-toolchain.json index 4971239..f12c229 100644 --- a/classic-toolchain.json +++ b/classic-toolchain.json @@ -35,6 +35,6 @@ ], "consumer_validation": { "repository": "atrinik/classic", - "commit": "2d3ecad2117733b1262f5195c0dd414fef4b45f3" + "commit": "8fec1db157bcfdd050c1ba360e77365bce701bba" } }