Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .github/workflows/measure-classic-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ 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:

permissions:
contents: read

Expand Down Expand Up @@ -75,6 +75,14 @@ jobs:
repository: atrinik/classic
ref: ${{ steps.classic.outputs.ref }}
path: build/classic
persist-credentials: false

- 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
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/publish-windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,14 @@ jobs:
repository: atrinik/classic
ref: ${{ steps.classic.outputs.ref }}
path: build/classic
persist-credentials: false

- 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
Expand Down
13 changes: 13 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -198,6 +203,14 @@ jobs:
repository: atrinik/classic
ref: ${{ steps.classic.outputs.ref }}
path: build/classic
persist-credentials: false

- 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
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion classic-toolchain.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,6 @@
],
"consumer_validation": {
"repository": "atrinik/classic",
"commit": "2d3ecad2117733b1262f5195c0dd414fef4b45f3"
"commit": "8fec1db157bcfdd050c1ba360e77365bce701bba"
}
}
181 changes: 181 additions & 0 deletions tools/tests/test_verify_classic_check_dependencies.py
Original file line number Diff line number Diff line change
@@ -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()
7 changes: 6 additions & 1 deletion tools/validate-toolchains.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading