diff --git a/.github/workflows/publish-linux.yml b/.github/workflows/publish-linux.yml index 8c66686..65aab9c 100644 --- a/.github/workflows/publish-linux.yml +++ b/.github/workflows/publish-linux.yml @@ -5,6 +5,12 @@ on: tags: - "v*" workflow_dispatch: + inputs: + candidate_only: + description: Publish only a commit-addressed Classic review candidate + required: true + type: boolean + default: false permissions: contents: read @@ -48,6 +54,37 @@ jobs: echo EOF } >> "${GITHUB_OUTPUT}" + - name: Select Classic image tags + id: classic-metadata + env: + IMAGE_NAME: classic-build + run: | + image="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/${IMAGE_NAME}" + if [[ "${{ inputs.candidate_only }}" == true ]]; then + tags="${image}:candidate-sha-${GITHUB_SHA}" + else + tags="${image}:latest + ${image}:ubuntu-26.04 + ${image}:sha-${GITHUB_SHA}" + fi + + if [[ "${{ inputs.candidate_only }}" != true \ + && "${GITHUB_REF_TYPE}" == tag ]]; then + if [[ ! "${GITHUB_REF_NAME}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Invalid image release tag: ${GITHUB_REF_NAME}" >&2 + exit 1 + fi + tags="${tags} + ${image}:${GITHUB_REF_NAME#v}" + fi + + { + echo "image=${image}" + echo 'tags<> "${GITHUB_OUTPUT}" + - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 @@ -58,7 +95,22 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + # Prove both Linux-owned image contracts before moving any release tag. + - name: Build and smoke Classic validation target + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 + with: + context: . + file: linux/Dockerfile + target: classic-validation + platforms: linux/amd64 + outputs: type=cacheonly + cache-from: | + type=registry,ref=ghcr.io/atrinik/classic-build:ubuntu-26.04 + type=gha,scope=classic-build-image + cache-to: type=gha,mode=max,scope=classic-build-image,ignore-error=true + - name: Build and publish Linux image + if: ${{ !inputs.candidate_only }} uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 with: context: . @@ -76,3 +128,25 @@ jobs: cache-to: | type=inline type=gha,mode=max,scope=linux-build-image,ignore-error=true + + - name: Build and publish Classic image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 + with: + context: . + file: linux/Dockerfile + target: classic-final + platforms: linux/amd64 + pull: true + push: true + tags: ${{ steps.classic-metadata.outputs.tags }} + labels: | + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + sbom: true + provenance: mode=max + cache-from: | + type=registry,ref=ghcr.io/atrinik/classic-build:ubuntu-26.04 + type=gha,scope=classic-build-image + cache-to: | + type=inline + type=gha,mode=max,scope=classic-build-image,ignore-error=true diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 7e0ba3f..c9fb997 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -15,6 +15,7 @@ jobs: name: Select changed images runs-on: ubuntu-26.04 outputs: + classic: ${{ steps.changes.outputs.classic }} linux: ${{ steps.changes.outputs.linux }} windows: ${{ steps.changes.outputs.windows }} steps: @@ -29,6 +30,7 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | + classic=false linux=false windows=false while IFS= read -r path; do @@ -37,11 +39,27 @@ jobs: .github/workflows/* | linux/* | \ audio-toolchain.json | audio-toolchain.spdx.json | \ toolchains.json | tools/audio/* | \ - tools/build-sdl3-mixer.sh | tools/validate-toolchains.sh) + tools/build-sdl3-mixer.sh | \ + tools/require-image-checks.sh | \ + tools/test-require-image-checks.sh | \ + tools/validate-toolchains.sh) linux=true ;; esac case "${path}" in + .dockerignore | .github/workflows/publish-linux.yml | \ + .github/workflows/validate.yml | linux/* | \ + audio-toolchain.json | audio-toolchain.spdx.json | \ + classic-packages.lock | classic-toolchain.json | \ + tools/audio/* | tools/build-sdl3-mixer.sh | \ + tools/require-image-checks.sh | \ + tools/test-require-image-checks.sh | \ + tools/validate-classic-check.sh | \ + tools/validate-classic-image.sh) + classic=true + ;; + esac + case "${path}" in .dockerignore | .github/workflows/measure-classic-check.yml | \ .github/workflows/publish-windows.yml | \ .github/workflows/validate.yml | windows/* | \ @@ -49,19 +67,25 @@ jobs: tools/audio/* | \ tools/build-sdl3-mixer.sh | \ tools/measure-classic-check-images.sh | \ + tools/require-image-checks.sh | \ tools/smoke-classic-check.sh | \ + tools/test-require-image-checks.sh | \ tools/validate-toolchains.sh | \ tools/verify-classic-check-package.py | \ tools/verify-pe-imports.sh) windows=true ;; esac - done < <(git diff --name-only "${BASE_SHA}" "${HEAD_SHA}") + done < <(git diff --no-renames --name-only "${BASE_SHA}" "${HEAD_SHA}") { + echo "classic=${classic}" echo "linux=${linux}" echo "windows=${windows}" } >> "${GITHUB_OUTPUT}" + - name: Test required-check aggregation + run: tools/test-require-image-checks.sh + linux: name: Linux image needs: changes @@ -225,33 +249,98 @@ jobs: } } + classic: + name: Classic CI image + needs: changes + if: needs.changes.outputs.classic == 'true' + runs-on: ubuntu-26.04 + timeout-minutes: 60 + steps: + - name: Check out image sources + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Read pinned Classic validation revision + id: consumer + run: echo "commit=$(jq -r '.consumer_validation.commit' classic-toolchain.json)" >> "${GITHUB_OUTPUT}" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 + + - name: Build and smoke Classic validation target + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 + with: + context: . + file: linux/Dockerfile + target: classic-validation + platforms: linux/amd64 + outputs: type=cacheonly + cache-from: type=gha,scope=classic-build-image + cache-to: type=gha,mode=max,scope=classic-build-image,ignore-error=true + + - name: Load slim Classic image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 + with: + context: . + file: linux/Dockerfile + target: classic-final + platforms: linux/amd64 + load: true + tags: atrinik-classic-build:validation + cache-from: type=gha,scope=classic-build-image + + - name: Check out pinned Classic sources + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: atrinik/classic + ref: ${{ steps.consumer.outputs.commit }} + path: classic + + - name: Run Classic client and server checks as the runner user + run: | + install -d -m 1777 "${RUNNER_TEMP}/classic-ccache" + docker run --rm \ + --user "$(id -u):$(id -g)" \ + --env CCACHE_DIR=/cache/ccache \ + --env HOME=/tmp/classic-home \ + --volume "${GITHUB_WORKSPACE}:/image-source:ro" \ + --volume "${GITHUB_WORKSPACE}/classic:/workspace" \ + --volume "${RUNNER_TEMP}/classic-ccache:/cache/ccache" \ + --workdir /workspace \ + atrinik-classic-build:validation \ + /image-source/tools/validate-classic-check.sh /workspace + required: name: Required checks needs: - changes + - classic - linux - windows - windows-native if: always() - permissions: {} + permissions: + contents: read runs-on: ubuntu-26.04 steps: + - name: Check out required-check helper + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Require successful applicable validations env: CHANGES_RESULT: ${{ needs.changes.result }} + CLASSIC_SELECTED: ${{ needs.changes.outputs.classic }} + CLASSIC_RESULT: ${{ needs.classic.result }} + LINUX_SELECTED: ${{ needs.changes.outputs.linux }} LINUX_RESULT: ${{ needs.linux.result }} + WINDOWS_SELECTED: ${{ needs.changes.outputs.windows }} WINDOWS_RESULT: ${{ needs.windows.result }} WINDOWS_NATIVE_RESULT: ${{ needs.windows-native.result }} run: | - if [[ "${CHANGES_RESULT}" != success ]]; then - echo "Change selection concluded ${CHANGES_RESULT}." >&2 - exit 1 - fi - - for result in "${LINUX_RESULT}" "${WINDOWS_RESULT}" \ - "${WINDOWS_NATIVE_RESULT}"; do - if [[ "${result}" != success && "${result}" != skipped ]]; then - echo "Image validation concluded ${result}." >&2 - exit 1 - fi - done + tools/require-image-checks.sh \ + "${CHANGES_RESULT}" \ + "${CLASSIC_SELECTED}" "${CLASSIC_RESULT}" \ + "${LINUX_SELECTED}" "${LINUX_RESULT}" \ + "${WINDOWS_SELECTED}" "${WINDOWS_RESULT}" \ + "${WINDOWS_NATIVE_RESULT}" diff --git a/AGENTS.md b/AGENTS.md index bdcebf7..a8fba01 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,8 +15,20 @@ - Treat Dockerfile inputs, `.dockerignore`, cache scopes, build arguments, published tags, and workflow path filters as one contract. If a relevant file changes, the required aggregate validation must still run. -- Every semantic release publishes the Linux image plus the general and - task-focused Windows images with their supported tags. Keep the +- `classic-final` is the slim Classic Check target. Keep its Ubuntu snapshot, + direct package lock, tool inventory, non-root ccache mount, Classic validation + revision, smoke/SBOM checks, and published tags synchronized. Do not make it + inherit the broad replacement/development toolchain. +- Keep a stable numeric runner UID when restoring a Classic ccache directory; + the mode-1777 mount root supports non-root initialization but does not make + ccache's owner-writable nested directories reusable across different UIDs. +- A Linux `candidate_only` dispatch is the pre-merge Classic review path. It + must publish only `classic-build:candidate-sha-` after validation and + must never move a rolling, platform, or version tag. +- Every semantic release publishes the broad Linux, slim Linux Classic, + general Windows, and task-focused Windows Classic images with their + supported tags. The Linux publisher owns `linux-build` and `classic-build`; + the Windows publisher owns both `windows-build` variants. Keep the `classic-check` target branched from the expensive shared MXE foundation before general-image Python/worldmaker additions, preserve the general Windows image as the default Dockerfile result, validate immutable SHA @@ -24,8 +36,10 @@ alias promotion by rerunning the failed job from the same workflow run. Do not create manual release tags as a substitute for semantic-release. - Validate Dockerfiles with `docker build --check`. Build and smoke-test each - affected image; note that a cold Windows/MXE build is expensive and may rely - on CI cache for final verification. + affected image, including `classic-validation` before `classic-final` and the + Windows `classic-check` target plus native bundle; note that a cold + Windows/MXE build is expensive and may rely on CI cache for final + verification. - Workflow changes also require actionlint and Atrinik GitHub-governance review for permissions, pinned actions, check names, and ruleset compatibility. Never expose private-package permissions or images to `pull_request` code; diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index abfa596..abf3883 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,7 @@ Use a Conventional Commits pull-request title and run the image checks documented in `README.md`. Keep image inputs reproducible and avoid embedding -credentials or host-specific state. Every squash merge releases the Linux, -general Windows, and task-focused Classic Check images, so changes must leave -both publishing workflows valid even when only one Dockerfile is edited. +credentials or host-specific state. Every squash merge releases the broad +Linux, slim Linux Classic, general Windows, and task-focused Windows Classic +Check images, so changes must leave both publishing workflows valid even when +only one Dockerfile is edited. diff --git a/README.md b/README.md index a75bafc..9d116a6 100644 --- a/README.md +++ b/README.md @@ -11,17 +11,21 @@ to game code do not rebuild the toolchains. Published images: - `ghcr.io/atrinik/linux-build:ubuntu-26.04` +- `ghcr.io/atrinik/classic-build:ubuntu-26.04` - `ghcr.io/atrinik/windows-build:mxe` - `ghcr.io/atrinik/windows-build:classic-check-mxe` -Every image publication creates an immutable `sha-` candidate. The -Windows publisher also creates a `classic-check-sha-` candidate in the +Every non-candidate Linux publication updates `latest`, `ubuntu-26.04`, and a +`sha-` tag for both Linux packages. A Windows publication first creates +immutable `sha-` and `classic-check-sha-` candidates in the existing private `windows-build` package, preserving its governed Classic -Actions read access. It smokes that exact published digest and executes its six -staged tests on Windows before promoting `latest`, `mxe`, `classic-check`, and -`classic-check-mxe`. Publishing from an image-repository tag matching `vX.Y.Z` -also promotes the corresponding `X.Y.Z` and `classic-check-X.Y.Z` aliases. -Release automation publishes all three image variants for every version so +Actions read access. It smokes the exact published Classic digest and executes +its six staged tests on Windows before promoting `latest`, `mxe`, +`classic-check`, and `classic-check-mxe`. Publishing from an image-repository +tag matching `vX.Y.Z` also promotes the corresponding `X.Y.Z` and +`classic-check-X.Y.Z` aliases. +Release automation publishes the broad Linux, slim Linux Classic, general +Windows, and task-focused Windows Classic variants for every version so consumers can pin a matched, verified toolchain release. GHCR cannot atomically move aliases for two different manifests. The Windows @@ -40,12 +44,16 @@ version. Every other conventional type advances at least the patch version, so every squash merge creates a tag. Each new tag dispatches both image publishers. -Either publisher can also be started manually from the Actions page for a -reviewed rebuild or recovery of an existing ref. Manual dispatch does not +Either publisher workflow can also be started manually from the Actions page +for a reviewed rebuild or recovery of an existing ref. Manual dispatch does not create a Git tag or semantic release. Semantic-release alone creates new -`vX.Y.Z` tags; do not create or push a release tag manually. When recovering a -versioned release, dispatch both publishers against the same existing tag so -the Linux and Windows image versions remain matched. +`vX.Y.Z` tags; do not create or push a release tag manually. The Linux +publisher produces both `linux-build` and `classic-build`. Its +`candidate_only` input skips the broad image and publishes Classic only as +`candidate-sha-` without moving any stable or version tag. When +recovering a versioned release, leave that input disabled and dispatch the +Linux and Windows publishers against the same existing tag so all three image +packages and all four variants remain matched. ## Local validation @@ -54,6 +62,14 @@ docker build --check --file linux/Dockerfile . docker build --check --file windows/Dockerfile . docker build --file linux/Dockerfile --tag atrinik-linux-build . +docker build --file linux/Dockerfile \ + --platform linux/amd64 \ + --target classic-validation \ + --tag atrinik-classic-validation . +docker build --file linux/Dockerfile \ + --platform linux/amd64 \ + --target classic-final \ + --tag atrinik-classic-build . docker build --file windows/Dockerfile \ --build-arg MXE_BUILD_JOBS="$(nproc)" \ --tag atrinik-windows-build . @@ -74,6 +90,9 @@ docker run --rm atrinik-linux-build protoc-gen-go --version docker run --rm atrinik-linux-build protoc-gen-prost --version docker run --rm atrinik-linux-build node --version docker run --rm atrinik-linux-build pnpm --version +docker run --rm atrinik-classic-build gcc --version +docker run --rm atrinik-classic-build cmake --version +docker run --rm atrinik-classic-build ccache --version docker run --rm atrinik-linux-build \ atrinik-sdl3-mixer-probe \ /usr/local/share/atrinik/audio/opus-probe.opus @@ -86,6 +105,95 @@ docker run --rm --user vscode atrinik-windows-check \ x86_64-w64-mingw32.shared-gcc --version ``` +Run the pinned full Classic contract from this repository root, substituting +isolated absolute paths for the source checkout and cache: + +```sh +classic_source=/absolute/path/to/atrinik-classic +classic_cache=/absolute/path/to/empty-classic-ccache +install -d -m 1777 "${classic_cache}" +docker run --rm \ + --user "$(id -u):$(id -g)" \ + --env CCACHE_DIR=/cache/ccache \ + --env HOME=/tmp/classic-home \ + --volume "$(pwd):/image-source:ro" \ + --volume "${classic_source}:/workspace" \ + --volume "${classic_cache}:/cache/ccache" \ + --workdir /workspace \ + atrinik-classic-build \ + /image-source/tools/validate-classic-check.sh /workspace +``` + +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 +packages from the timestamp in +[`classic-toolchain.json`](classic-toolchain.json). Direct +package versions are locked in +[`classic-packages.lock`](classic-packages.lock). The image contains GCC, +CMake/Ninja, Python/gcovr, Check, ccache, and the union of native dependencies +needed by the Classic client and server. SDL3_mixer and its codec closure retain +the checksum-pinned source and nested SPDX inventory used by the development +image. + +Classic runs as the unprivileged `ubuntu` user by default. `/cache/ccache` is a +mode-1777 mount contract so CI can run with its own numeric UID and persist the +directory without granting root. Consumers must still select ccache explicitly +with `-DCMAKE_C_COMPILER_LAUNCHER=ccache`; `CCACHE_DIR` alone does not activate +compiler caching. Persistent reuse must keep that numeric UID stable because +ccache's nested directories are owner-writable; if the runner UID changes, +discard or reinitialize the cache instead of sharing it across UIDs. The image +smoke target proves a repeated compilation hits the cache, validates every +locked direct package and tool version, checks the native +`pkg-config` surface, decodes the bundled Opus fixture, and inspects the image +plus nested dependency inventory in one SPDX 2.3 scan. The attached BuildKit +SBOM inventories discoverable image packages; the bundled +`/usr/local/share/atrinik/audio-toolchain.spdx.json` is the authoritative source +inventory for statically linked SDL3_mixer and codecs. Pull-request validation +then runs representative client and server configure-build-test-coverage +commands against the exact Classic revision recorded in the inventory as a +non-root runner UID. + +Every non-candidate Classic publication updates `latest`, `ubuntu-26.04`, and +`sha-`. A semantic-release tag also publishes the matching `X.Y.Z` tag, +with BuildKit provenance and an attached SBOM. Consuming workflows should pin +the digest, never a rolling tag. To update that pin: + +1. Update the matching Ubuntu base digest and snapshot value in both + `linux/Dockerfile` and `classic-toolchain.json`, refresh the exact direct + versions in `classic-packages.lock`, and update the tool versions and pinned + Classic validation commit in `classic-toolchain.json`. +2. Build `classic-validation` and `classic-final`, run the repository checks, + and compare compressed image size plus local client/server timings with the + prior digest. +3. Before merge, dispatch `Publish Linux build image` on the reviewed head with + `candidate_only` enabled. Resolve the immutable digest from + `ghcr.io/atrinik/classic-build:candidate-sha-` and put that digest in + the consuming review branch, including it in the ccache invalidation key. +4. On clean equivalent runners with the digest absent, time the first digest + pull plus container startup. Without removing it, pull and start the same + digest again to measure the warm local layer cache. Run the apt-based and + image-based client/server jobs, record total and setup/build/test timings + plus ccache statistics in + [`classic-benchmark.md`](classic-benchmark.md), and attach the comparison to + both reviews. +5. After the evidence passes review, merge through semantic-release and wait + for both publisher workflows. Record the new versioned digest, rerun the + image smoke against it, and compare its embedded tool inventories, package + lock, and source checksums with the candidate before updating the consumer. + The manifest digests themselves will differ because revision labels and + provenance describe different builds. Only then remove superseded apt or + prefix-cache setup. + +The package snapshot is deliberately fail-closed: changing the snapshot or a +locked version requires a reviewed inventory update. The initial CA/TLS +bootstrap already uses the signed snapshot metadata and package hashes; only +TLS peer verification is temporarily disabled because the minimal base has no +CA bundle. After installing the exact locked TLS closure, a verified HTTPS +snapshot update must pass before any remaining package is installed. Snapshot +sources disable metadata expiry so the fixed timestamp remains rebuildable; +APT still verifies its signed metadata and package hashes. + The Linux image includes the pinned replacement toolchains recorded in [`toolchains.json`](toolchains.json): Go, Rust/rustup, Protobuf/protoc, Buf, Node.js, pnpm, Syft, and Trivy. It also includes GCC, Clang with compiler-rt, @@ -120,16 +228,16 @@ exact Windows import contract. The matching [`audio-toolchain.spdx.json`](audio-toolchain.spdx.json) records SDL3_mixer and all three statically linked codec packages in SPDX 2.3 form, because a scanner cannot infer static source dependencies from the resulting shared library. -Both images carry the inventory and SBOM under +All four image variants carry the inventory and SBOM under `/usr/local/share/atrinik/`; Syft's nested-SBOM cataloger incorporates those packages in whole-image SBOM output. -Both builds compile the same no-device decoder probe. Linux validation runs it -during the image build, enumerates the required `WAV`, `STBVORBIS`, `DRMP3`, -and `OPUS` decoders, rejects MIDI and module decoders, fully decodes the -bundled Opus fixture, and rejects empty PCM. The -Windows image puts `atrinik-sdl3-mixer-probe.exe` and the fixture under the MXE -prefix so a clean native Windows package test can copy and run the identical +Both Dockerfiles compile the same no-device decoder probe for their image +variants. Linux validation runs it during the image build, enumerates the +required `WAV`, `STBVORBIS`, `DRMP3`, and `OPUS` decoders, rejects MIDI and +module decoders, fully decodes the bundled Opus fixture, and rejects empty PCM. +The Windows image puts `atrinik-sdl3-mixer-probe.exe` and the fixture under the +MXE prefix so a clean native Windows package test can copy and run the identical contract without installing codecs separately. The MXE build cannot execute a Windows binary, so its image validation instead compiles the probe and verifies the exact imports of the self-contained `SDL3_mixer.dll`; native execution @@ -183,7 +291,9 @@ runs actionlint over the repository workflows in a dedicated validation stage. Windows validation checks that ccache, the MXE compiler, and the CMake wrapper are directly discoverable through the image's default `PATH`, cross-builds and stages every native test in the pinned Classic Check contract without network -access, and executes the complete bundle on `windows-2025`. +access, and executes the complete bundle on `windows-2025`. Linux Classic +validation builds its smoke/SBOM target, loads the slim final target, and runs +the pinned Classic client and server checks as a non-root user. ## License diff --git a/classic-benchmark.md b/classic-benchmark.md new file mode 100644 index 0000000..3c21cbc --- /dev/null +++ b/classic-benchmark.md @@ -0,0 +1,154 @@ +# Classic CI image benchmark + +This file is the durable measurement ledger for the slim Classic image. Local +measurements are useful implementation evidence, but they do not replace the +required equivalent-runner comparison between the existing apt-based Classic +job and the candidate image digest. + +## Environment + +- Measurement date: 2026-08-10 UTC +- Host: Linux 6.6.87.2-microsoft-standard-WSL2, x86_64, 32 logical CPUs +- Docker client/server: 29.7.1 / 29.7.1 +- Devcontainer branch: `feat/classic-ci-image` (final head recorded in PR #28) +- Pinned Classic source: `2d3ecad2117733b1262f5195c0dd414fef4b45f3` +- Broad local comparison image: `sha256:c39ab7366fe92ef80e4c5d8b7bf9306a4d9df37d436d0da0efc3cff953c40b74` +- Slim local candidate image: `sha256:4e498aa55ada8bed181c3cd88153871588f92d855e92f476391b1e6c1c2c8c50` + +The broad image is a local size/startup proxy, not the acceptance-test +apt-based consumer baseline. + +## Local results + +| Measurement | Broad image | Slim candidate | Result | +| --- | ---: | ---: | ---: | +| Docker content size | 1,215,965,963 B | 401,296,878 B | 67.0% smaller | +| sampled `docker image save \| gzip -1` | 1,207,055,637 B | 398,180,724 B | 67.0% smaller | +| five-run warm container startup mean | 0.985 s | 0.948 s | effectively unchanged | + +The candidate ran the pinned server's 36 tests and client's 31 tests with +coverage as numeric UID/GID 1000. A clean repeated build restored all 330 +server and 190 client compiler outputs as direct ccache hits. + +Raw startup samples in seconds were `1.165, 0.921, 0.925, 0.952, 0.960` for +the broad image and `0.995, 0.850, 1.005, 0.933, 0.959` for the slim image. +The gzip figures are transfer-size proxies sampled from Docker's serialized +archive; repeated serialization can differ slightly and is not a content +digest. + +Exact commands used: + +```sh +docker image inspect atrinik-linux-build:issue-21 --format '{{.Id}} {{.Size}}' +docker image inspect atrinik-classic-build:issue-24 --format '{{.Id}} {{.Size}}' +docker image save atrinik-linux-build:issue-21 | gzip -1 | wc -c +docker image save atrinik-classic-build:issue-24 | gzip -1 | wc -c +TIMEFORMAT='%R' +for run in 1 2 3 4 5; do + time docker run --rm atrinik-linux-build:issue-21 true >/dev/null +done +for run in 1 2 3 4 5; do + time docker run --rm atrinik-classic-build:issue-24 true >/dev/null +done +``` + +The full workload command is the `docker run` recipe in `README.md`, using the +isolated Classic checkout at the pinned revision and a dedicated empty cache +directory for the first run. The validation script performs an intentional +clean rebuild so its reported hit count proves restoration from that mounted +cache rather than merely proving that ccache was invoked. + +The empty-cache validation reported this raw summary after both intentional +warm rebuilds: + +```text +Cacheable calls: 1040 / 1040 (100.0%) +Hits: 520 / 1040 (50.00%) + Direct: 520 / 520 (100.0%) +Misses: 520 / 1040 (50.00%) +``` + +The validation snapshots hits immediately before each component's warm build +and requires a positive delta, so preexisting cache entries cannot satisfy the +assertion by themselves. Persistent consumers must use a stable numeric UID; +mode 1777 makes the root mount initially writable but does not make ccache's +owner-writable nested directories safe to reuse under a different UID. + +## Hosted candidate-digest comparison + +The candidate was published by +[devcontainer run 31427897129](https://github.com/atrinik/devcontainer/actions/runs/31427897129) +from reviewed source `b9a86c4f52205c927373caa7583d3a43989cfca7`. +The tag +`candidate-sha-b9a86c4f52205c927373caa7583d3a43989cfca7` +resolved to index digest +`sha256:e117b858d5aecdb8eb39dc56451378b6e6bd72dd5e042ab96fee5b6154000043`; +its amd64 manifest is +`sha256:cdba4bfd40f288e577842b3308e88ccfb7252623ac7b13d08074fc45cc305b8e`. + +The linked [Classic review](https://github.com/atrinik/classic/pull/98) tested +source `934af663a1f0d5892026a366f12b907459f3cd50`. The apt baseline was +[run 31405779774](https://github.com/atrinik/classic/actions/runs/31405779774). +Candidate [run 31430896836](https://github.com/atrinik/classic/actions/runs/31430896836) +attempt 3 started without matching compiler caches; unchanged attempt 4 ran on +new hosted runners and restored all three caches. All three executions used +GitHub's `ubuntu-24.04` image version `20260720.247.2` and runner `2.336.0`. +The same runner image in +[final evidence run 31432805795](https://github.com/atrinik/classic/actions/runs/31432805795) +recorded the remaining hosted environment details: + +- Runner OS / architecture / logical CPUs: `ubuntu24` / `X64` / `4` +- Kernel: `Linux 6.17.0-1020-azure x86_64 GNU/Linux` +- Docker client / server: `28.0.4` / `28.0.4` + +Each candidate attempt removed the exact local image reference before its first +pull, immediately repeated the pull with local layers present, then measured +one cold and five warm `docker run --rm IMAGE true` invocations. Compressed +size is the sum of the registry manifest's amd64 layers, not a sampled Docker +archive. + +| Measurement | Cold-cache attempt 3 | Restored-cache attempt 4 | +| --- | ---: | ---: | +| Compressed amd64 layers | 401,282,166 B | 401,282,166 B | +| Docker content size | 1,136,279,102 B | 1,136,279,102 B | +| First pull | 18,226 ms | 21,643 ms | +| Immediate repeat pull | 210 ms | 176 ms | +| Cold startup | 269 ms | 272 ms | +| Warm startup samples | 184, 173, 178, 171, 187 ms | 180, 193, 186, 189, 197 ms | +| Warm startup mean | 178.6 ms | 189.0 ms | + +Job times are exact differences between GitHub's `started_at` and +`completed_at` timestamps. They are hosted samples, not performance +guarantees. + +| Job | Apt baseline | Candidate cold | Candidate warm | Warm vs baseline | +| --- | ---: | ---: | ---: | ---: | +| Core validation | 206 s | 116 s | 84 s | -122 s (-59.2%) | +| Client validation | 84 s | 67 s | 40 s | -44 s (-52.4%) | +| Server validation | 202 s | 235 s | 141 s | -61 s (-30.2%) | + +The baseline core dependency-install step took 28 seconds. Its complete client +and server validation steps took 67 and 185 seconds. The cold candidate paid +25 and 33 seconds for the client and server first pulls; its respective +validation steps took 19 and 181 seconds. With restored compiler caches those +steps took 9 and 100 seconds. + +The complete uploaded `ccache --print-stats` outputs report: + +| Component | Cold direct hits | Cold misses | Warm direct hits | Warm misses | +| --- | ---: | ---: | ---: | ---: | +| Core | 0 | 87 | 87 | 0 | +| Client | 0 | 193 | 193 | 0 | +| Server | 10 | 986 | 996 | 0 | + +The ten cold server hits occurred within its repeated configurations. The new +warm runners restored 1,276 direct hits and recorded zero misses. GitHub stored +exactly three component-separated PR cache keys under `refs/pull/98/merge`; a +lookup for the trusted-main prefix returned no entry. + +The Classic repository preserves the raw coordinates, commands, measurements, +cache evidence, invalidation tests, and reproduction procedure in +[`docs/CI-LINUX-IMAGE.md`](https://github.com/atrinik/classic/blob/perf/ci-linux-cache/docs/CI-LINUX-IMAGE.md). +The candidate digest is pre-merge evidence only. After this publisher change is +released, Classic must verify the versioned image's inventories and pin its +released digest before its consumer review becomes ready. diff --git a/classic-packages.lock b/classic-packages.lock new file mode 100644 index 0000000..b5fcbaa --- /dev/null +++ b/classic-packages.lock @@ -0,0 +1,31 @@ +build-essential=12.12ubuntu2.26.04.2 +ca-certificates=20260601~26.04.1 +ccache=4.12.3-1 +check=0.15.2-3build1 +cmake=4.2.3-2ubuntu2 +curl=8.18.0-1ubuntu2.3 +flex=2.6.4-8.2build2 +g++=4:15.2.0-5ubuntu1 +gcc=4:15.2.0-5ubuntu1 +gcovr=7.2+really-2 +git=1:2.53.0-1ubuntu1 +jq=1.8.1-4ubuntu2 +libcurl4-openssl-dev=8.18.0-1ubuntu2.3 +libgd-dev=2.3.3-13ubuntu2 +libidn2-dev=2.3.8-4build1 +libminiupnpc-dev=2.3.3-2build1 +libreadline-dev=8.3-4 +libsdl3-dev=3.4.2+ds-1ubuntu1 +libsdl3-image-dev=3.4.0+ds-1 +libsdl3-ttf-dev=3.2.2+ds-1build1 +libssl-dev=3.5.5-1ubuntu3.3 +libssl3t64=3.5.5-1ubuntu3.3 +libsubunit-dev=1.4.4+1-1 +libxml2-dev=2.15.2+dfsg-0.1ubuntu0.1 +ninja-build=1.13.2-1 +openssl=3.5.5-1ubuntu3.3 +openssl-provider-legacy=3.5.5-1ubuntu3.3 +pkgconf=2.5.1-4 +python3=3.14.3-0ubuntu2 +python3-dev=3.14.3-0ubuntu2 +zlib1g-dev=1:1.3.dfsg+really1.3.1-1ubuntu3 diff --git a/classic-toolchain.json b/classic-toolchain.json new file mode 100644 index 0000000..4971239 --- /dev/null +++ b/classic-toolchain.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "schema_version": 1, + "platform": "linux/amd64", + "target": "classic-final", + "image": "ghcr.io/atrinik/classic-build", + "base": { + "image": "ubuntu:26.04", + "digest": "sha256:678c6550cc43645e08669028bc177f50be4e7c5b8cca677067b1914d4afc7a03", + "apt_snapshot": "20260810T000000Z" + }, + "tools": { + "ccache": "4.12.3", + "cmake": "4.2.3", + "gcc": "15.2.0", + "gcovr": "7.2", + "ninja": "1.13.2", + "python": "3.14.4" + }, + "ccache": { + "directory": "/cache/ccache", + "default_user": "ubuntu", + "compiler_launcher_required": true + }, + "pkg_config": [ + "libcurl", + "libidn2", + "openssl", + "sdl3", + "sdl3-image", + "sdl3-mixer", + "sdl3-ttf", + "libxml-2.0", + "zlib" + ], + "consumer_validation": { + "repository": "atrinik/classic", + "commit": "2d3ecad2117733b1262f5195c0dd414fef4b45f3" + } +} diff --git a/linux/Dockerfile b/linux/Dockerfile index fc5974e..07b71ad 100644 --- a/linux/Dockerfile +++ b/linux/Dockerfile @@ -263,4 +263,115 @@ RUN actionlint \ (($inventory[0].dependencies + [$inventory[0].sdl_mixer]) \ | map([.name, .version]) | sort)' +# Classic Check uses a separate task-focused target. It intentionally starts +# from the pinned Ubuntu base instead of inheriting the replacement toolchain, +# and resolves packages from a fixed Ubuntu archive snapshot. Its exact CA and +# TLS runtime packages are bootstrapped first because the minimal base cannot +# validate the HTTPS snapshot service. +FROM ubuntu:26.04@sha256:678c6550cc43645e08669028bc177f50be4e7c5b8cca677067b1914d4afc7a03 AS classic-ci + +ARG TARGETARCH +ENV DEBIAN_FRONTEND=noninteractive \ + UBUNTU_SNAPSHOT=20260810T000000Z + +COPY classic-packages.lock /tmp/classic-packages.lock +# APT still verifies the signed Release metadata and package hashes. Only TLS +# peer verification is temporarily unavailable in the minimal base. +RUN test "${TARGETARCH}" = amd64 \ + && test "$(dpkg --print-architecture)" = amd64 \ + && sed -i \ + -e "s|http://archive.ubuntu.com/ubuntu/|https://snapshot.ubuntu.com/ubuntu/${UBUNTU_SNAPSHOT}/|" \ + -e "s|http://security.ubuntu.com/ubuntu/|https://snapshot.ubuntu.com/ubuntu/${UBUNTU_SNAPSHOT}/|" \ + /etc/apt/sources.list.d/ubuntu.sources \ + && sed -i '/^Signed-By:/a Check-Valid-Until: no' \ + /etc/apt/sources.list.d/ubuntu.sources \ + && apt-get -o Acquire::https::Verify-Peer=false update \ + && awk -F= '$1 == "ca-certificates" \ + || $1 == "libssl3t64" \ + || $1 == "openssl" \ + || $1 == "openssl-provider-legacy" { print }' \ + /tmp/classic-packages.lock \ + | xargs -r apt-get -o Acquire::https::Verify-Peer=false \ + install -y --no-install-recommends \ + && rm -rf /var/lib/apt/lists/* \ + && apt-get update \ + && xargs -r apt-get install -y --no-install-recommends \ + < /tmp/classic-packages.lock \ + && rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*.deb + +# Classic requires the same checksum-pinned SDL3_mixer contract as the broad +# Linux image. Its codec dependencies are statically linked into the shared +# library, while SDL3 and the remaining native libraries come from the frozen +# Ubuntu package snapshot above. +COPY audio-toolchain.json /tmp/audio-toolchain.json +COPY tools/build-sdl3-mixer.sh /tmp/build-sdl3-mixer.sh +RUN /tmp/build-sdl3-mixer.sh \ + /tmp/audio-toolchain.json cmake /usr/local "$(nproc)" \ + && ldconfig \ + && test "$(pkg-config --modversion sdl3-mixer)" \ + = "$(jq -r '.sdl_mixer.version' /tmp/audio-toolchain.json)" + +COPY tools/audio/opus-probe.opus /usr/local/share/atrinik/audio/opus-probe.opus +COPY tools/audio/sdl3-mixer-probe.c /tmp/sdl3-mixer-probe.c +RUN test "$(sha256sum /usr/local/share/atrinik/audio/opus-probe.opus \ + | cut -d' ' -f1)" \ + = "$(jq -r '.fixture.sha256' /tmp/audio-toolchain.json)" \ + && cc -std=c17 -Wall -Wextra -Werror \ + /tmp/sdl3-mixer-probe.c \ + -o /usr/local/bin/atrinik-sdl3-mixer-probe \ + $(pkg-config --cflags --libs sdl3-mixer) \ + && atrinik-sdl3-mixer-probe \ + /usr/local/share/atrinik/audio/opus-probe.opus \ + && rm /tmp/sdl3-mixer-probe.c + +ENV CCACHE_DIR=/cache/ccache +RUN install -d -m 1777 "${CCACHE_DIR}" + +COPY classic-toolchain.json /usr/local/share/atrinik/classic-toolchain.json +COPY classic-packages.lock /usr/local/share/atrinik/classic-packages.lock +COPY audio-toolchain.json /usr/local/share/atrinik/audio-toolchain.json +COPY audio-toolchain.spdx.json /usr/local/share/atrinik/audio-toolchain.spdx.json + +USER ubuntu +WORKDIR /workspace + +FROM classic-ci AS classic-validation + +USER root +ARG SYFT_VERSION=1.50.0 +ARG SYFT_SHA256=bf7b29ff57f06da30918266a0e1c2885a8f99784798d1bdb1628886aa015d788 +RUN curl --fail --location --silent --show-error \ + "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/syft_${SYFT_VERSION}_linux_amd64.tar.gz" \ + --output /tmp/syft.tar.gz \ + && echo "${SYFT_SHA256} /tmp/syft.tar.gz" | sha256sum -c - \ + && tar -xzf /tmp/syft.tar.gz -C /usr/local/bin syft \ + && rm /tmp/syft.tar.gz + +COPY classic-packages.lock /workspace/classic-packages.lock +COPY classic-toolchain.json /workspace/classic-toolchain.json +COPY audio-toolchain.json /workspace/audio-toolchain.json +COPY linux/Dockerfile /workspace/linux/Dockerfile +COPY tools/validate-classic-image.sh /workspace/tools/validate-classic-image.sh + +USER ubuntu +RUN /workspace/tools/validate-classic-image.sh \ + /workspace/classic-packages.lock \ + /workspace/classic-toolchain.json \ + /usr/local/share/atrinik/classic-toolchain.json \ + /workspace/audio-toolchain.json \ + /workspace/linux/Dockerfile + +USER root +RUN syft dir:/ --select-catalogers +sbom-cataloger -o spdx-json \ + | jq -e '.spdxVersion == "SPDX-2.3" \ + and ([.packages[].name] | index("ccache") != null) \ + and ([.packages[].name] | index("cmake") != null) \ + and ([.packages[].name] | index("gcc") != null) \ + and ([.packages[].name] | index("SDL3_mixer") != null) \ + and ([.packages[].name] | index("libogg") != null) \ + and ([.packages[].name] | index("libopus") != null) \ + and ([.packages[].name] | index("libopusfile") != null)' + +FROM classic-ci AS classic-final + FROM toolchain AS final diff --git a/tools/require-image-checks.sh b/tools/require-image-checks.sh new file mode 100755 index 0000000..175b232 --- /dev/null +++ b/tools/require-image-checks.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 8 ]]; then + echo "Usage: $0 CHANGES CLASSIC_SELECTED CLASSIC_RESULT LINUX_SELECTED LINUX_RESULT WINDOWS_SELECTED WINDOWS_RESULT WINDOWS_NATIVE_RESULT" >&2 + exit 2 +fi + +changes_result=$1 +classic_selected=$2 +classic_result=$3 +linux_selected=$4 +linux_result=$5 +windows_selected=$6 +windows_result=$7 +windows_native_result=$8 + +if [[ ${changes_result} != success ]]; then + echo "Change selection concluded ${changes_result}." >&2 + exit 1 +fi + +require_result() { + local name=$1 + local selected=$2 + local result=$3 + + if [[ ${selected} != true && ${selected} != false ]]; then + echo "${name} selection is invalid: '${selected}'." >&2 + exit 1 + fi + if [[ ${selected} == true && ${result} != success ]]; then + echo "${name} validation was selected but concluded ${result}." >&2 + exit 1 + fi + if [[ ${selected} == false && ${result} != skipped ]]; then + echo "${name} validation was not selected but concluded ${result}." >&2 + exit 1 + fi +} + +require_result CLASSIC "${classic_selected}" "${classic_result}" +require_result LINUX "${linux_selected}" "${linux_result}" +require_result WINDOWS "${windows_selected}" "${windows_result}" +require_result "Native Windows" "${windows_selected}" \ + "${windows_native_result}" diff --git a/tools/test-require-image-checks.sh b/tools/test-require-image-checks.sh new file mode 100755 index 0000000..bc54fc8 --- /dev/null +++ b/tools/test-require-image-checks.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +gate=${script_dir}/require-image-checks.sh + +expect_success() { + if ! "${gate}" "$@" >/dev/null 2>&1; then + echo "Expected success: $*" >&2 + exit 1 + fi +} + +expect_failure() { + if "${gate}" "$@" >/dev/null 2>&1; then + echo "Expected failure: $*" >&2 + exit 1 + fi +} + +expect_success success true success true success true success success +expect_success success false skipped false skipped false skipped skipped + +expect_failure failure true success true success true success success +expect_failure success true success true success true success +expect_failure success '' skipped false skipped false skipped skipped +expect_failure success malformed skipped false skipped false skipped skipped +expect_failure success true skipped false skipped false skipped skipped +expect_failure success false success false skipped false skipped skipped +expect_failure success false skipped false skipped true success skipped +expect_failure success false skipped false skipped false skipped success diff --git a/tools/validate-classic-check.sh b/tools/validate-classic-check.sh new file mode 100755 index 0000000..e78dc2b --- /dev/null +++ b/tools/validate-classic-check.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: $0 CLASSIC_SOURCE" >&2 + exit 2 +fi + +source_root=$(realpath "$1") +jobs=$(nproc) + +mkdir -p "${HOME}" + +export CCACHE_BASEDIR=${source_root} +export CCACHE_COMPILERCHECK=content +export CCACHE_DIR=${CCACHE_DIR:-/cache/ccache} +export CCACHE_NOHASHDIR=true + +test -w "${CCACHE_DIR}" +test "$(stat --format='%a' "${CCACHE_DIR}")" = 1777 + +ccache_hits() { + ccache --print-stats | awk -F '\t' ' + $1 == "direct_cache_hit" || $1 == "preprocessed_cache_hit" { hits += $2 } + END { print hits + 0 } + ' +} + +ccache --zero-stats >/dev/null + +for component in server client; do + pushd "${source_root}/${component}" >/dev/null + python3 -m unittest discover -s tools/tests -p 'test_*.py' + python3 tools/dependencies.py sync + python3 tools/dependencies.py verify + cmake --preset linux-coverage \ + -DCMAKE_C_COMPILER_LAUNCHER=ccache \ + -DFETCHCONTENT_SOURCE_DIR_ATRINIK_PROTOCOL="${source_root}/protocol" \ + -DFETCHCONTENT_SOURCE_DIR_LIBATRINIK="${source_root}/libatrinik" + # Populate the dedicated cache deterministically even when the mounted source + # checkout already contains build outputs from an earlier validation. + cmake --build --preset linux-coverage --clean-first --parallel "${jobs}" + ctest --preset linux-coverage + gcovr --root . --filter 'src/' --exclude 'src/tests/' \ + --print-summary --xml "${component}-coverage.xml" + # Force one identical rebuild against the mounted cache. A no-op incremental + # build would not prove that consumer compiler outputs can be restored. + warm_hits_before=$(ccache_hits) + cmake --build --preset linux-coverage --clean-first --parallel "${jobs}" + warm_hits_after=$(ccache_hits) + if [[ ${warm_hits_after} -le ${warm_hits_before} ]]; then + echo "Classic ${component} warm rebuild restored no compiler outputs from ccache" >&2 + exit 1 + fi + popd >/dev/null +done + +ccache --show-config +ccache --show-stats diff --git a/tools/validate-classic-image.sh b/tools/validate-classic-image.sh new file mode 100755 index 0000000..261a0cd --- /dev/null +++ b/tools/validate-classic-image.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ $# -ne 5 ]]; then + echo "usage: $0 PACKAGE_LOCK EXPECTED_INVENTORY INSTALLED_INVENTORY AUDIO_INVENTORY DOCKERFILE" >&2 + exit 2 +fi + +package_lock=$1 +expected=$2 +installed=$3 +audio_inventory=$4 +dockerfile=$5 + +cmp --silent "${expected}" "${installed}" + +jq -e ' + .schema_version == 1 + and .platform == "linux/amd64" + and .target == "classic-final" + and .image == "ghcr.io/atrinik/classic-build" + and (.base.digest | test("^sha256:[0-9a-f]{64}$")) + and (.base.apt_snapshot | test("^[0-9]{8}T[0-9]{6}Z$")) + and .ccache.directory == "/cache/ccache" + and .ccache.default_user == "ubuntu" + and .ccache.compiler_launcher_required == true + and .consumer_validation.repository == "atrinik/classic" + and (.consumer_validation.commit | test("^[0-9a-f]{40}$")) +' "${expected}" >/dev/null + +test "linux/$(dpkg --print-architecture)" = "$(jq -r '.platform' "${expected}")" +expected_image=$(jq -r '.base.image' "${expected}") +expected_digest=$(jq -r '.base.digest' "${expected}") +expected_snapshot=$(jq -r '.base.apt_snapshot' "${expected}") +grep -Fqx "FROM ${expected_image}@${expected_digest} AS classic-ci" "${dockerfile}" +test "${UBUNTU_SNAPSHOT}" = "${expected_snapshot}" +grep -Fq "https://snapshot.ubuntu.com/ubuntu/${expected_snapshot}/" \ + /etc/apt/sources.list.d/ubuntu.sources +test "$(grep -Fxc 'Check-Valid-Until: no' \ + /etc/apt/sources.list.d/ubuntu.sources)" -eq 2 +if grep -Eq 'https?://(archive|security)\.ubuntu\.com/ubuntu/' \ + /etc/apt/sources.list.d/ubuntu.sources; then + echo "mutable Ubuntu source remains configured" >&2 + exit 1 +fi + +while IFS='=' read -r package version; do + if [[ -z ${package} || -z ${version} ]]; then + echo "invalid package lock entry" >&2 + exit 1 + fi + test "$(dpkg-query --show --showformat='${Version}' "${package}")" = "${version}" +done < "${package_lock}" + +test "$(ccache --version | sed -n '1s/^ccache version //p')" = \ + "$(jq -r '.tools.ccache' "${expected}")" +test "$(cmake --version | sed -n '1s/^cmake version //p')" = \ + "$(jq -r '.tools.cmake' "${expected}")" +test "$(gcc -dumpfullversion)" = "$(jq -r '.tools.gcc' "${expected}")" +test "$(gcovr --version | sed -n '1s/^gcovr //p')" = \ + "$(jq -r '.tools.gcovr' "${expected}")" +test "$(ninja --version)" = "$(jq -r '.tools.ninja' "${expected}")" +test "$(python3 --version | cut -d' ' -f2)" = \ + "$(jq -r '.tools.python' "${expected}")" + +test "${CCACHE_DIR}" = "$(jq -r '.ccache.directory' "${expected}")" +test -w "${CCACHE_DIR}" +test "$(stat --format='%a' "${CCACHE_DIR}")" = 1777 + +while IFS= read -r module; do + pkg-config --exists "${module}" +done < <(jq -r '.pkg_config[]' "${expected}") + +test "$(pkg-config --modversion sdl3-mixer)" = \ + "$(jq -r '.sdl_mixer.version' "${audio_inventory}")" +atrinik-sdl3-mixer-probe /usr/local/share/atrinik/audio/opus-probe.opus + +work=$(mktemp -d) +trap 'rm -rf -- "${work}"' EXIT +printf 'int answer(void) { return 42; }\n' > "${work}/cache-smoke.c" +ccache --zero-stats >/dev/null +ccache gcc -c "${work}/cache-smoke.c" -o "${work}/cache-smoke.o" +rm "${work}/cache-smoke.o" +ccache gcc -c "${work}/cache-smoke.c" -o "${work}/cache-smoke.o" +hits=$(ccache --print-stats | awk -F '\t' ' + $1 == "direct_cache_hit" || $1 == "preprocessed_cache_hit" { hits += $2 } + END { print hits + 0 } +') +test "${hits}" -ge 1 +ccache --clear >/dev/null