From 63c51338f54ec0d6bd5bf953ce077c977b8321ce Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Mon, 10 Aug 2026 17:53:51 +0000 Subject: [PATCH 1/9] perf(linux): publish slim Classic CI image --- .github/workflows/publish-linux.yml | 60 ++++++++++++++++ .github/workflows/validate.yml | 81 +++++++++++++++++++++- AGENTS.md | 15 ++-- CONTRIBUTING.md | 6 +- README.md | 81 +++++++++++++++++++--- classic-packages.lock | 31 +++++++++ classic-toolchain.json | 40 +++++++++++ linux/Dockerfile | 102 ++++++++++++++++++++++++++++ tools/validate-classic-check.sh | 39 +++++++++++ tools/validate-classic-image.sh | 74 ++++++++++++++++++++ 10 files changed, 512 insertions(+), 17 deletions(-) create mode 100644 classic-packages.lock create mode 100644 classic-toolchain.json create mode 100755 tools/validate-classic-check.sh create mode 100755 tools/validate-classic-image.sh diff --git a/.github/workflows/publish-linux.yml b/.github/workflows/publish-linux.yml index 8c66686..3fcfbae 100644 --- a/.github/workflows/publish-linux.yml +++ b/.github/workflows/publish-linux.yml @@ -48,6 +48,32 @@ 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}" + tags="${image}:latest + ${image}:ubuntu-26.04 + ${image}:sha-${GITHUB_SHA}" + + if [[ "${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 @@ -76,3 +102,37 @@ jobs: cache-to: | type=inline type=gha,mode=max,scope=linux-build-image,ignore-error=true + + - name: Build and smoke Classic validation target + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 + with: + context: . + file: linux/Dockerfile + target: classic-validation + 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 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 66be8fe..8b90863 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -16,6 +16,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: @@ -30,11 +31,13 @@ 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 case "${path}" in .dockerignore | .github/actionlint.yaml | \ + .github/workflows/publish-linux.yml | \ .github/workflows/validate.yml | linux/* | \ audio-toolchain.json | audio-toolchain.spdx.json | \ toolchains.json | tools/audio/* | \ @@ -43,6 +46,17 @@ jobs: ;; 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/validate-classic-check.sh | \ + tools/validate-classic-image.sh) + classic=true + ;; + esac + case "${path}" in .dockerignore | .github/workflows/validate.yml | windows/* | \ audio-toolchain.json | audio-toolchain.spdx.json | \ tools/audio/* | \ @@ -52,6 +66,7 @@ jobs: esac done < <(git diff --name-only "${BASE_SHA}" "${HEAD_SHA}") { + echo "classic=${classic}" echo "linux=${linux}" echo "windows=${windows}" } >> "${GITHUB_OUTPUT}" @@ -106,10 +121,73 @@ jobs: type=gha,scope=windows-build-image cache-to: type=gha,mode=max,scope=windows-build-image,ignore-error=true + 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 + 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: Load slim Classic image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 + with: + context: . + file: linux/Dockerfile + target: classic-final + load: true + tags: atrinik-classic-build:validation + cache-from: | + type=registry,ref=ghcr.io/atrinik/classic-build:ubuntu-26.04 + 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 0777 "${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 if: always() @@ -118,6 +196,7 @@ jobs: - name: Require successful applicable validations env: CHANGES_RESULT: ${{ needs.changes.result }} + CLASSIC_RESULT: ${{ needs.classic.result }} LINUX_RESULT: ${{ needs.linux.result }} WINDOWS_RESULT: ${{ needs.windows.result }} run: | @@ -126,7 +205,7 @@ jobs: exit 1 fi - for result in "${LINUX_RESULT}" "${WINDOWS_RESULT}"; do + for result in "${CLASSIC_RESULT}" "${LINUX_RESULT}" "${WINDOWS_RESULT}"; do if [[ "${result}" != success && "${result}" != skipped ]]; then echo "Image validation concluded ${result}." >&2 exit 1 diff --git a/AGENTS.md b/AGENTS.md index 1cfa9fa..6eac77d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,11 +15,18 @@ - 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 both images and their supported tags. Do not - create manual release tags as a substitute for semantic-release. +- `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. +- Every semantic release publishes all three images and their supported tags. + The Linux publisher owns both `linux-build` and `classic-build`; the Windows + publisher owns `windows-build`. 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`; 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. - Commits and pull-request titles use Conventional Commits. Preserve unrelated diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2c18dde..4088ccd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,6 @@ 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 both 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 Classic, and Windows 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 e76a8e8..78e3a12 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,15 @@ 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` Every successful image publication updates `latest`, its rolling platform tag, and a `sha-` tag. Publishing from an image-repository tag matching `vX.Y.Z` also publishes the corresponding `X.Y.Z` image tag. Release automation -publishes both Linux and Windows images for every version so consumers can pin -a matched toolchain release. +publishes the broad Linux development image, the slim Classic Check image, and +the Windows cross-build image for every version so consumers can pin a matched +toolchain release. ## Publishing @@ -28,12 +30,13 @@ 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 +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`. When recovering a +versioned release, dispatch the Linux and Windows publishers against the same +existing tag so all three image versions remain matched. ## Local validation @@ -42,6 +45,12 @@ 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 \ + --target classic-validation \ + --tag atrinik-classic-validation . +docker build --file linux/Dockerfile \ + --target classic-final \ + --tag atrinik-classic-build . docker build --file windows/Dockerfile \ --build-arg MXE_BUILD_JOBS="$(nproc)" \ --tag atrinik-windows-build . @@ -58,6 +67,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 @@ -68,6 +80,55 @@ docker run --rm atrinik-windows-build \ docker run --rm --user vscode atrinik-windows-build ssh -V ``` +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. 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 as SPDX 2.3. 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 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. Advance `base.apt_snapshot`, 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 client/server timings with the prior + digest. A clean runner provides the cold pull; pulling the same digest again + provides the warm-cache measurement. +3. Merge through semantic-release, wait for both publisher workflows, and copy + the `ghcr.io/atrinik/classic-build@sha256:...` manifest digest from the + versioned release into the consuming review branch. Include that digest in + the consumer's ccache invalidation key. +4. Run the consumer's cold and warm jobs, record pull/startup/build timings and + ccache statistics, and only then remove its 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 uses the live Ubuntu archive because the minimal base cannot validate +the HTTPS snapshot service, but every bootstrap package and dependency is +version-locked before snapshot access. + 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, @@ -102,11 +163,11 @@ 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 three images 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 +All three 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 @@ -135,7 +196,9 @@ is more important than preserving an optimization. Pull requests build each image whose inputs changed. Linux validation also runs actionlint over the repository workflows in a dedicated validation stage; Windows validation checks that the MXE compiler and CMake wrapper are directly -discoverable through the image's default `PATH`. +discoverable through the image's default `PATH`. 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-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..623657b 100644 --- a/linux/Dockerfile +++ b/linux/Dockerfile @@ -263,4 +263,106 @@ 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 UBUNTU_SNAPSHOT=20260810T000000Z +ENV DEBIAN_FRONTEND=noninteractive + +RUN rm -f /etc/apt/apt.conf.d/docker-clean + +COPY classic-packages.lock /tmp/classic-packages.lock +RUN apt-get update \ + && awk -F= '$1 == "ca-certificates" \ + || $1 == "libssl3t64" \ + || $1 == "openssl" \ + || $1 == "openssl-provider-legacy" { print }' \ + /tmp/classic-packages.lock \ + | xargs -r apt-get install -y --no-install-recommends \ + && rm -rf /var/lib/apt/lists/* \ + && 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 \ + && apt-get update \ + && xargs -r apt-get install -y --no-install-recommends \ + < /tmp/classic-packages.lock \ + && rm -rf /var/lib/apt/lists/* + +# 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 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 + +USER root +RUN syft dir:/ -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)' \ + && syft dir:/ --override-default-catalogers sbom-cataloger -o spdx-json \ + | jq -e '.spdxVersion == "SPDX-2.3" \ + and ([.packages[].name] | index("SDL3_mixer") != null)' + +FROM classic-ci AS classic-final + FROM toolchain AS final diff --git a/tools/validate-classic-check.sh b/tools/validate-classic-check.sh new file mode 100755 index 0000000..925e1e7 --- /dev/null +++ b/tools/validate-classic-check.sh @@ -0,0 +1,39 @@ +#!/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 + +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" + cmake --build --preset linux-coverage --parallel "${jobs}" + ctest --preset linux-coverage + gcovr --root . --filter 'src/' --exclude 'src/tests/' \ + --print-summary --xml "${component}-coverage.xml" + 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..e9d5932 --- /dev/null +++ b/tools/validate-classic-image.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ $# -ne 4 ]]; then + echo "usage: $0 PACKAGE_LOCK EXPECTED_INVENTORY INSTALLED_INVENTORY AUDIO_INVENTORY" >&2 + exit 2 +fi + +package_lock=$1 +expected=$2 +installed=$3 +audio_inventory=$4 + +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 + +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 From a19ce060e157b0cde4951bf478163aaa60432208 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Mon, 10 Aug 2026 18:16:37 +0000 Subject: [PATCH 2/9] fix(linux): address Classic image review findings --- .github/workflows/publish-linux.yml | 45 ++++++++++++++-------- .github/workflows/validate.yml | 17 +++++++-- AGENTS.md | 3 ++ README.md | 59 ++++++++++++++++++----------- linux/Dockerfile | 38 ++++++++++--------- tools/validate-classic-check.sh | 10 +++++ tools/validate-classic-image.sh | 18 ++++++++- 7 files changed, 129 insertions(+), 61 deletions(-) diff --git a/.github/workflows/publish-linux.yml b/.github/workflows/publish-linux.yml index 3fcfbae..9b1691a 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 @@ -54,11 +60,16 @@ jobs: IMAGE_NAME: classic-build run: | image="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/${IMAGE_NAME}" - tags="${image}:latest - ${image}:ubuntu-26.04 - ${image}:sha-${GITHUB_SHA}" + 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 [[ "${GITHUB_REF_TYPE}" == tag ]]; then + 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 @@ -84,7 +95,21 @@ 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 + 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: . @@ -103,18 +128,6 @@ jobs: type=inline type=gha,mode=max,scope=linux-build-image,ignore-error=true - - name: Build and smoke Classic validation target - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 - with: - context: . - file: linux/Dockerfile - target: classic-validation - 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 Classic image uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 with: diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 8b90863..5c6f766 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -196,8 +196,11 @@ jobs: - 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 }} run: | if [[ "${CHANGES_RESULT}" != success ]]; then @@ -205,9 +208,17 @@ jobs: exit 1 fi - for result in "${CLASSIC_RESULT}" "${LINUX_RESULT}" "${WINDOWS_RESULT}"; do - if [[ "${result}" != success && "${result}" != skipped ]]; then - echo "Image validation concluded ${result}." >&2 + for image in CLASSIC LINUX WINDOWS; do + selected_variable="${image}_SELECTED" + result_variable="${image}_RESULT" + selected=${!selected_variable} + result=${!result_variable} + if [[ "${selected}" == true && "${result}" != success ]]; then + echo "${image} validation was selected but concluded ${result}." >&2 + exit 1 + fi + if [[ "${selected}" == false && "${result}" != skipped ]]; then + echo "${image} validation was not selected but concluded ${result}." >&2 exit 1 fi done diff --git a/AGENTS.md b/AGENTS.md index 6eac77d..84a13f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,9 @@ The Linux publisher owns both `linux-build` and `classic-build`; the Windows publisher owns `windows-build`. Do not create manual release tags as a substitute for semantic-release. +- 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. - Validate Dockerfiles with `docker build --check`. Build and smoke-test each affected image, including `classic-validation` before `classic-final`; note that a cold Windows/MXE build is expensive and may rely on CI cache for final diff --git a/README.md b/README.md index 78e3a12..1d6ab73 100644 --- a/README.md +++ b/README.md @@ -30,13 +30,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 workflow 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. The Linux -publisher produces both `linux-build` and `classic-build`. When recovering a -versioned release, dispatch the Linux and Windows publishers against the same -existing tag so all three image versions remain matched. +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 +versions remain matched. ## Local validation @@ -99,8 +102,11 @@ with `-DCMAKE_C_COMPILER_LAUNCHER=ccache`; `CCACHE_DIR` alone does not activate compiler caching. 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 as SPDX 2.3. Pull-request validation then runs -representative client and server configure-build-test-coverage commands against +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 Classic publication updates `latest`, `ubuntu-26.04`, and @@ -108,26 +114,33 @@ Every Classic publication updates `latest`, `ubuntu-26.04`, and with BuildKit provenance and an attached SBOM. Consuming workflows should pin the digest, never a rolling tag. To update that pin: -1. Advance `base.apt_snapshot`, refresh the exact direct versions in - `classic-packages.lock`, and update the tool versions and pinned Classic - validation commit in `classic-toolchain.json`. +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 client/server timings with the prior - digest. A clean runner provides the cold pull; pulling the same digest again - provides the warm-cache measurement. -3. Merge through semantic-release, wait for both publisher workflows, and copy - the `ghcr.io/atrinik/classic-build@sha256:...` manifest digest from the - versioned release into the consuming review branch. Include that digest in - the consumer's ccache invalidation key. -4. Run the consumer's cold and warm jobs, record pull/startup/build timings and - ccache statistics, and only then remove its superseded apt or prefix-cache - setup. + 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, time the first digest pull plus container + startup, remove only the local pulled copy, and repeat for the warm registry + cache. Run the apt-based and image-based client/server jobs, record total and + setup/build/test timings plus ccache statistics, and attach the comparison + to both reviews. +5. After the evidence passes review, merge through semantic-release and wait + for both publisher workflows. Confirm the versioned Classic digest matches + the reviewed image content before updating the consumer from its candidate + reference; 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 uses the live Ubuntu archive because the minimal base cannot validate -the HTTPS snapshot service, but every bootstrap package and dependency is -version-locked before snapshot access. +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. The Linux image includes the pinned replacement toolchains recorded in [`toolchains.json`](toolchains.json): Go, Rust/rustup, Protobuf/protoc, Buf, diff --git a/linux/Dockerfile b/linux/Dockerfile index 623657b..98614d9 100644 --- a/linux/Dockerfile +++ b/linux/Dockerfile @@ -270,28 +270,29 @@ RUN actionlint \ # validate the HTTPS snapshot service. FROM ubuntu:26.04@sha256:678c6550cc43645e08669028bc177f50be4e7c5b8cca677067b1914d4afc7a03 AS classic-ci -ARG UBUNTU_SNAPSHOT=20260810T000000Z -ENV DEBIAN_FRONTEND=noninteractive - -RUN rm -f /etc/apt/apt.conf.d/docker-clean +ENV DEBIAN_FRONTEND=noninteractive \ + UBUNTU_SNAPSHOT=20260810T000000Z COPY classic-packages.lock /tmp/classic-packages.lock -RUN apt-get update \ +# APT still verifies the signed Release metadata and package hashes. Only TLS +# peer verification is temporarily unavailable in the minimal base. +RUN 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 \ + && 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 install -y --no-install-recommends \ + | xargs -r apt-get -o Acquire::https::Verify-Peer=false \ + install -y --no-install-recommends \ && rm -rf /var/lib/apt/lists/* \ - && 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 \ && apt-get update \ && xargs -r apt-get install -y --no-install-recommends \ < /tmp/classic-packages.lock \ - && rm -rf /var/lib/apt/lists/* + && 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 @@ -344,6 +345,7 @@ RUN curl --fail --location --silent --show-error \ 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 @@ -351,17 +353,19 @@ 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/audio-toolchain.json \ + /workspace/linux/Dockerfile USER root -RUN syft dir:/ -o spdx-json \ +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)' \ - && syft dir:/ --override-default-catalogers sbom-cataloger -o spdx-json \ - | jq -e '.spdxVersion == "SPDX-2.3" \ - and ([.packages[].name] | index("SDL3_mixer") != 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 diff --git a/tools/validate-classic-check.sh b/tools/validate-classic-check.sh index 925e1e7..d0a8d59 100755 --- a/tools/validate-classic-check.sh +++ b/tools/validate-classic-check.sh @@ -35,5 +35,15 @@ for component in server client; do popd >/dev/null done +cacheable_calls=$(ccache --print-stats | awk -F '\t' ' + $1 == "direct_cache_hit" || $1 == "preprocessed_cache_hit" \ + || $1 == "cache_miss" { calls += $2 } + END { print calls + 0 } +') +if [[ ${cacheable_calls} -lt 1 ]]; then + echo "Classic builds did not invoke ccache" >&2 + exit 1 +fi + ccache --show-config ccache --show-stats diff --git a/tools/validate-classic-image.sh b/tools/validate-classic-image.sh index e9d5932..1723b14 100755 --- a/tools/validate-classic-image.sh +++ b/tools/validate-classic-image.sh @@ -2,8 +2,8 @@ set -euo pipefail -if [[ $# -ne 4 ]]; then - echo "usage: $0 PACKAGE_LOCK EXPECTED_INVENTORY INSTALLED_INVENTORY AUDIO_INVENTORY" >&2 +if [[ $# -ne 5 ]]; then + echo "usage: $0 PACKAGE_LOCK EXPECTED_INVENTORY INSTALLED_INVENTORY AUDIO_INVENTORY DOCKERFILE" >&2 exit 2 fi @@ -11,6 +11,7 @@ package_lock=$1 expected=$2 installed=$3 audio_inventory=$4 +dockerfile=$5 cmp --silent "${expected}" "${installed}" @@ -28,6 +29,19 @@ jq -e ' and (.consumer_validation.commit | test("^[0-9a-f]{40}$")) ' "${expected}" >/dev/null +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 +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 From 4041928990556bc1f9bbeb50aa9aae9da62fd6ca Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Mon, 10 Aug 2026 18:27:20 +0000 Subject: [PATCH 3/9] test(classic): strengthen image acceptance evidence --- .github/workflows/validate.yml | 4 +- README.md | 33 ++++++++++++--- classic-benchmark.md | 72 +++++++++++++++++++++++++++++++++ tools/validate-classic-check.sh | 21 ++++++---- 4 files changed, 116 insertions(+), 14 deletions(-) create mode 100644 classic-benchmark.md diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 5c6f766..2224679 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -64,7 +64,7 @@ jobs: 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}" @@ -171,7 +171,7 @@ jobs: - name: Run Classic client and server checks as the runner user run: | - install -d -m 0777 "${RUNNER_TEMP}/classic-ccache" + install -d -m 1777 "${RUNNER_TEMP}/classic-ccache" docker run --rm \ --user "$(id -u):$(id -g)" \ --env CCACHE_DIR=/cache/ccache \ diff --git a/README.md b/README.md index 1d6ab73..83dad15 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,25 @@ docker run --rm atrinik-windows-build \ docker run --rm --user vscode atrinik-windows-build ssh -V ``` +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 @@ -128,12 +147,16 @@ the digest, never a rolling tag. To update that pin: 4. On clean equivalent runners, time the first digest pull plus container startup, remove only the local pulled copy, and repeat for the warm registry cache. Run the apt-based and image-based client/server jobs, record total and - setup/build/test timings plus ccache statistics, and attach the comparison - to both reviews. + 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. Confirm the versioned Classic digest matches - the reviewed image content before updating the consumer from its candidate - reference; only then remove superseded apt or prefix-cache setup. + 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 diff --git a/classic-benchmark.md b/classic-benchmark.md new file mode 100644 index 0000000..46ff7fc --- /dev/null +++ b/classic-benchmark.md @@ -0,0 +1,72 @@ +# 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 +- Image-content checkpoint: `a19ce060e157b0cde4951bf478163aaa60432208` +- Pinned Classic source: `2d3ecad2117733b1262f5195c0dd414fef4b45f3` +- Broad local comparison image: `sha256:c39ab7366fe92ef80e4c5d8b7bf9306a4d9df37d436d0da0efc3cff953c40b74` +- Slim local candidate image: `sha256:78d3348df8b70df35f7f41fe86800b4740aa88925a69d62794a37cb8a75066b7` + +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,297,079 B | 67.0% smaller | +| `docker image save \| gzip -1` | 1,207,055,637 B | 398,170,129 B | 67.0% smaller | +| five-run warm container startup mean | 0.702 s | 0.626 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. + +Commands used: + +```sh +docker image inspect IMAGE --format '{{.Id}} {{.Size}}' +docker image save IMAGE | gzip -1 | wc -c +TIMEFORMAT='%R' +for run in 1 2 3 4 5; do + time docker run --rm IMAGE 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. + +## Required candidate-digest comparison + +Before issue closure, replace every pending cell below with raw measurements +from equivalent clean runners and link the associated devcontainer and Classic +reviews. Use the immutable digest resolved from the final reviewed +`candidate-sha-` tag. + +| Measurement | Existing apt job | Candidate digest job | Status | +| --- | ---: | ---: | --- | +| Compressed transfer size | pending | pending | pending publication | +| Cold pull | n/a | pending | pending publication | +| Warm pull | n/a | pending | pending publication | +| Cold container startup | n/a | pending | pending publication | +| Warm container startup | n/a | pending | pending publication | +| Dependency/setup time | pending | pending | pending consumer branch | +| Server build/test/coverage | pending | pending | pending consumer branch | +| Client build/test/coverage | pending | pending | pending consumer branch | +| End-to-end job time | pending | pending | pending consumer branch | +| Warm ccache hits/misses | pending | pending | pending consumer branch | + +Record the runner image, CPU allocation, Docker version, commands, raw samples, +and both review URLs with the completed table. Do not compare timings collected +on materially different runners. diff --git a/tools/validate-classic-check.sh b/tools/validate-classic-check.sh index d0a8d59..a2d1ab2 100755 --- a/tools/validate-classic-check.sh +++ b/tools/validate-classic-check.sh @@ -17,6 +17,9 @@ 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 --zero-stats >/dev/null for component in server client; do @@ -28,20 +31,24 @@ for component in server client; do -DCMAKE_C_COMPILER_LAUNCHER=ccache \ -DFETCHCONTENT_SOURCE_DIR_ATRINIK_PROTOCOL="${source_root}/protocol" \ -DFETCHCONTENT_SOURCE_DIR_LIBATRINIK="${source_root}/libatrinik" - cmake --build --preset linux-coverage --parallel "${jobs}" + # 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. + cmake --build --preset linux-coverage --clean-first --parallel "${jobs}" popd >/dev/null done -cacheable_calls=$(ccache --print-stats | awk -F '\t' ' - $1 == "direct_cache_hit" || $1 == "preprocessed_cache_hit" \ - || $1 == "cache_miss" { calls += $2 } - END { print calls + 0 } +cache_hits=$(ccache --print-stats | awk -F '\t' ' + $1 == "direct_cache_hit" || $1 == "preprocessed_cache_hit" { hits += $2 } + END { print hits + 0 } ') -if [[ ${cacheable_calls} -lt 1 ]]; then - echo "Classic builds did not invoke ccache" >&2 +if [[ ${cache_hits} -lt 1 ]]; then + echo "Classic warm rebuild did not restore any compiler output from ccache" >&2 exit 1 fi From 934115c91693516bda5e9cf61496f39c41903f54 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Mon, 10 Aug 2026 18:42:04 +0000 Subject: [PATCH 4/9] fix(classic): close final image review gaps --- .github/workflows/validate.yml | 6 ++--- AGENTS.md | 3 +++ README.md | 27 ++++++++++++------- classic-benchmark.md | 47 ++++++++++++++++++++++++++------- linux/Dockerfile | 2 ++ tools/validate-classic-check.sh | 22 ++++++++------- tools/validate-classic-image.sh | 2 ++ 7 files changed, 77 insertions(+), 32 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 2224679..77e6adf 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -37,8 +37,7 @@ jobs: while IFS= read -r path; do case "${path}" in .dockerignore | .github/actionlint.yaml | \ - .github/workflows/publish-linux.yml | \ - .github/workflows/validate.yml | linux/* | \ + .github/workflows/* | linux/* | \ audio-toolchain.json | audio-toolchain.spdx.json | \ toolchains.json | tools/audio/* | \ tools/build-sdl3-mixer.sh | tools/validate-toolchains.sh) @@ -57,7 +56,8 @@ jobs: ;; esac case "${path}" in - .dockerignore | .github/workflows/validate.yml | windows/* | \ + .dockerignore | .github/workflows/publish-windows.yml | \ + .github/workflows/validate.yml | windows/* | \ audio-toolchain.json | audio-toolchain.spdx.json | \ tools/audio/* | \ tools/build-sdl3-mixer.sh | tools/verify-pe-imports.sh) diff --git a/AGENTS.md b/AGENTS.md index 84a13f5..c1082b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,9 @@ 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. - Every semantic release publishes all three images and their supported tags. The Linux publisher owns both `linux-build` and `classic-build`; the Windows publisher owns `windows-build`. Do not create manual release tags as a diff --git a/README.md b/README.md index 83dad15..6ccd9f5 100644 --- a/README.md +++ b/README.md @@ -118,17 +118,21 @@ 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. The image smoke target proves a repeated compilation hits the -cache, validates every locked direct package and tool version, checks the native +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. +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 Classic publication updates `latest`, `ubuntu-26.04`, and +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: @@ -144,10 +148,11 @@ the digest, never a rolling tag. To update that pin: `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, time the first digest pull plus container - startup, remove only the local pulled copy, and repeat for the warm registry - cache. Run the apt-based and image-based client/server jobs, record total and - setup/build/test timings plus ccache statistics in +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 @@ -163,7 +168,9 @@ 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 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, diff --git a/classic-benchmark.md b/classic-benchmark.md index 46ff7fc..f3702e4 100644 --- a/classic-benchmark.md +++ b/classic-benchmark.md @@ -10,10 +10,10 @@ job and the candidate image digest. - 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 -- Image-content checkpoint: `a19ce060e157b0cde4951bf478163aaa60432208` +- 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:78d3348df8b70df35f7f41fe86800b4740aa88925a69d62794a37cb8a75066b7` +- Slim local candidate image: `sha256:cf760df2b9ba0f825ffa568735d13e87580913edc4083931f0d5da70a780d609` The broad image is a local size/startup proxy, not the acceptance-test apt-based consumer baseline. @@ -22,22 +22,33 @@ apt-based consumer baseline. | Measurement | Broad image | Slim candidate | Result | | --- | ---: | ---: | ---: | -| Docker content size | 1,215,965,963 B | 401,297,079 B | 67.0% smaller | -| `docker image save \| gzip -1` | 1,207,055,637 B | 398,170,129 B | 67.0% smaller | -| five-run warm container startup mean | 0.702 s | 0.626 s | effectively unchanged | +| Docker content size | 1,215,965,963 B | 401,297,171 B | 67.0% smaller | +| sampled `docker image save \| gzip -1` | 1,207,055,637 B | 398,194,006 B | 67.0% smaller | +| five-run warm container startup mean | 0.912 s | 0.871 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. -Commands used: +Raw startup samples in seconds were `0.928, 0.843, 0.865, 0.898, 1.027` for +the broad image and `0.823, 0.816, 0.917, 0.883, 0.916` 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 IMAGE --format '{{.Id}} {{.Size}}' -docker image save IMAGE | gzip -1 | wc -c +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 IMAGE true >/dev/null + 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 ``` @@ -47,6 +58,22 @@ 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. + ## Required candidate-digest comparison Before issue closure, replace every pending cell below with raw measurements @@ -58,7 +85,7 @@ reviews. Use the immutable digest resolved from the final reviewed | --- | ---: | ---: | --- | | Compressed transfer size | pending | pending | pending publication | | Cold pull | n/a | pending | pending publication | -| Warm pull | n/a | pending | pending publication | +| Immediate repeat pull with local layers present | n/a | pending | pending publication | | Cold container startup | n/a | pending | pending publication | | Warm container startup | n/a | pending | pending publication | | Dependency/setup time | pending | pending | pending consumer branch | diff --git a/linux/Dockerfile b/linux/Dockerfile index 98614d9..755dbb6 100644 --- a/linux/Dockerfile +++ b/linux/Dockerfile @@ -280,6 +280,8 @@ RUN 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" \ diff --git a/tools/validate-classic-check.sh b/tools/validate-classic-check.sh index a2d1ab2..e78dc2b 100755 --- a/tools/validate-classic-check.sh +++ b/tools/validate-classic-check.sh @@ -20,6 +20,13 @@ 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 @@ -39,18 +46,15 @@ for component in server client; do --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 -cache_hits=$(ccache --print-stats | awk -F '\t' ' - $1 == "direct_cache_hit" || $1 == "preprocessed_cache_hit" { hits += $2 } - END { print hits + 0 } -') -if [[ ${cache_hits} -lt 1 ]]; then - echo "Classic warm rebuild did not restore any compiler output from ccache" >&2 - exit 1 -fi - ccache --show-config ccache --show-stats diff --git a/tools/validate-classic-image.sh b/tools/validate-classic-image.sh index 1723b14..16ca232 100755 --- a/tools/validate-classic-image.sh +++ b/tools/validate-classic-image.sh @@ -36,6 +36,8 @@ grep -Fqx "FROM ${expected_image}@${expected_digest} AS classic-ci" "${dockerfil 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 From b2aa8dbdb39a19d5c86baf81588475eaac59bae5 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Mon, 10 Aug 2026 18:54:57 +0000 Subject: [PATCH 5/9] fix(classic): enforce amd64 image contract --- .github/workflows/publish-linux.yml | 1 + .github/workflows/validate.yml | 2 ++ README.md | 2 ++ classic-benchmark.md | 12 ++++++------ linux/Dockerfile | 5 ++++- tools/validate-classic-image.sh | 1 + 6 files changed, 16 insertions(+), 7 deletions(-) diff --git a/.github/workflows/publish-linux.yml b/.github/workflows/publish-linux.yml index 9b1691a..65aab9c 100644 --- a/.github/workflows/publish-linux.yml +++ b/.github/workflows/publish-linux.yml @@ -102,6 +102,7 @@ jobs: 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 diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 77e6adf..325092d 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -144,6 +144,7 @@ jobs: 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 @@ -156,6 +157,7 @@ jobs: context: . file: linux/Dockerfile target: classic-final + platforms: linux/amd64 load: true tags: atrinik-classic-build:validation cache-from: | diff --git a/README.md b/README.md index 6ccd9f5..c7ad666 100644 --- a/README.md +++ b/README.md @@ -49,9 +49,11 @@ 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 \ diff --git a/classic-benchmark.md b/classic-benchmark.md index f3702e4..4ac46c6 100644 --- a/classic-benchmark.md +++ b/classic-benchmark.md @@ -13,7 +13,7 @@ job and the candidate image digest. - 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:cf760df2b9ba0f825ffa568735d13e87580913edc4083931f0d5da70a780d609` +- Slim local candidate image: `sha256:4e498aa55ada8bed181c3cd88153871588f92d855e92f476391b1e6c1c2c8c50` The broad image is a local size/startup proxy, not the acceptance-test apt-based consumer baseline. @@ -22,16 +22,16 @@ apt-based consumer baseline. | Measurement | Broad image | Slim candidate | Result | | --- | ---: | ---: | ---: | -| Docker content size | 1,215,965,963 B | 401,297,171 B | 67.0% smaller | -| sampled `docker image save \| gzip -1` | 1,207,055,637 B | 398,194,006 B | 67.0% smaller | -| five-run warm container startup mean | 0.912 s | 0.871 s | effectively unchanged | +| 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 `0.928, 0.843, 0.865, 0.898, 1.027` for -the broad image and `0.823, 0.816, 0.917, 0.883, 0.916` for the slim image. +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. diff --git a/linux/Dockerfile b/linux/Dockerfile index 755dbb6..07b71ad 100644 --- a/linux/Dockerfile +++ b/linux/Dockerfile @@ -270,13 +270,16 @@ RUN actionlint \ # 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 sed -i \ +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 \ diff --git a/tools/validate-classic-image.sh b/tools/validate-classic-image.sh index 16ca232..261a0cd 100755 --- a/tools/validate-classic-image.sh +++ b/tools/validate-classic-image.sh @@ -29,6 +29,7 @@ jq -e ' 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}") From e322dd6bd32371cd5bfa5cddef7dc73128f23ab5 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Mon, 10 Aug 2026 21:16:53 +0000 Subject: [PATCH 6/9] docs(classic): record hosted CI evidence --- README.md | 12 ++--- classic-benchmark.md | 101 +++++++++++++++++++++++++++++++++---------- 2 files changed, 84 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index c7ad666..dca39c9 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,12 @@ Published images: - `ghcr.io/atrinik/classic-build:ubuntu-26.04` - `ghcr.io/atrinik/windows-build:mxe` -Every successful image publication updates `latest`, its rolling platform tag, -and a `sha-` tag. Publishing from an image-repository tag matching -`vX.Y.Z` also publishes the corresponding `X.Y.Z` image tag. Release automation -publishes the broad Linux development image, the slim Classic Check image, and -the Windows cross-build image for every version so consumers can pin a matched -toolchain release. +Every successful non-candidate image publication updates `latest`, its rolling +platform tag, and a `sha-` tag. Publishing from an image-repository tag +matching `vX.Y.Z` also publishes the corresponding `X.Y.Z` image tag. Release +automation publishes the broad Linux development image, the slim Classic Check +image, and the Windows cross-build image for every version so consumers can pin +a matched toolchain release. ## Publishing diff --git a/classic-benchmark.md b/classic-benchmark.md index 4ac46c6..3c21cbc 100644 --- a/classic-benchmark.md +++ b/classic-benchmark.md @@ -74,26 +74,81 @@ 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. -## Required candidate-digest comparison - -Before issue closure, replace every pending cell below with raw measurements -from equivalent clean runners and link the associated devcontainer and Classic -reviews. Use the immutable digest resolved from the final reviewed -`candidate-sha-` tag. - -| Measurement | Existing apt job | Candidate digest job | Status | -| --- | ---: | ---: | --- | -| Compressed transfer size | pending | pending | pending publication | -| Cold pull | n/a | pending | pending publication | -| Immediate repeat pull with local layers present | n/a | pending | pending publication | -| Cold container startup | n/a | pending | pending publication | -| Warm container startup | n/a | pending | pending publication | -| Dependency/setup time | pending | pending | pending consumer branch | -| Server build/test/coverage | pending | pending | pending consumer branch | -| Client build/test/coverage | pending | pending | pending consumer branch | -| End-to-end job time | pending | pending | pending consumer branch | -| Warm ccache hits/misses | pending | pending | pending consumer branch | - -Record the runner image, CPU allocation, Docker version, commands, raw samples, -and both review URLs with the completed table. Do not compare timings collected -on materially different runners. +## 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. From 4ce19a3dd13f994c741a937c66929427be0fd8f3 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Mon, 10 Aug 2026 21:26:59 +0000 Subject: [PATCH 7/9] fix(ci): reject malformed image selectors --- .github/workflows/validate.yml | 48 +++++++++++------------------- tools/require-image-checks.sh | 46 ++++++++++++++++++++++++++++ tools/test-require-image-checks.sh | 31 +++++++++++++++++++ 3 files changed, 94 insertions(+), 31 deletions(-) create mode 100755 tools/require-image-checks.sh create mode 100755 tools/test-require-image-checks.sh diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 2f420b9..a996bc1 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -39,7 +39,10 @@ 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 @@ -49,6 +52,8 @@ jobs: 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 @@ -62,7 +67,9 @@ 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) @@ -76,6 +83,9 @@ jobs: echo "windows=${windows}" } >> "${GITHUB_OUTPUT}" + - name: Test required-check aggregation + run: tools/test-require-image-checks.sh + linux: name: Linux image needs: changes @@ -322,33 +332,9 @@ jobs: 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 image in CLASSIC LINUX WINDOWS; do - selected_variable="${image}_SELECTED" - result_variable="${image}_RESULT" - selected=${!selected_variable} - result=${!result_variable} - if [[ "${selected}" == true && "${result}" != success ]]; then - echo "${image} validation was selected but concluded ${result}." >&2 - exit 1 - fi - if [[ "${selected}" == false && "${result}" != skipped ]]; then - echo "${image} validation was not selected but concluded ${result}." >&2 - exit 1 - fi - done - - if [[ "${WINDOWS_SELECTED}" == true && \ - "${WINDOWS_NATIVE_RESULT}" != success ]]; then - echo "Native Windows validation was selected but concluded ${WINDOWS_NATIVE_RESULT}." >&2 - exit 1 - fi - if [[ "${WINDOWS_SELECTED}" == false && \ - "${WINDOWS_NATIVE_RESULT}" != skipped ]]; then - echo "Native Windows validation was not selected but concluded ${WINDOWS_NATIVE_RESULT}." >&2 - exit 1 - fi + 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/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 From a2c618e19b6d3f42aabcf33dfbe40b98cc8c9ab3 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Mon, 10 Aug 2026 21:28:05 +0000 Subject: [PATCH 8/9] fix(ci): checkout required-check helper --- .github/workflows/validate.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index a996bc1..c4cc830 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -318,9 +318,13 @@ jobs: - 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 + - name: Require successful applicable validations env: CHANGES_RESULT: ${{ needs.changes.result }} From 332fa210bb438c29bdfa916120b8092080aa5168 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Mon, 10 Aug 2026 21:28:43 +0000 Subject: [PATCH 9/9] fix(ci): avoid persisting gate credentials --- .github/workflows/validate.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index c4cc830..c9fb997 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -324,6 +324,8 @@ jobs: steps: - name: Check out required-check helper uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Require successful applicable validations env: