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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .dockerignore
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
.git
**/.DS_Store
**/*~

# Consumer checkouts, generated evidence and bytecode are not image inputs.
/build/
**/.git
**/__pycache__
**/*.pyc
87 changes: 87 additions & 0 deletions .github/workflows/publish-linux.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -109,6 +109,60 @@ jobs:
type=gha,scope=classic-build-image
cache-to: type=gha,mode=max,scope=classic-build-image,ignore-error=true

# The portable target has its own ABI and real consumer; validate it before aliases move.
- name: Read portable consumer revision
if: ${{ !inputs.candidate_only }}
id: portable-consumer
run: echo "commit=$(jq -er '.consumer.commit' portable/contract.json)" >> "${GITHUB_OUTPUT}"

- name: Check out portable Classic consumer
if: ${{ !inputs.candidate_only }}
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: atrinik/classic
ref: ${{ steps.portable-consumer.outputs.commit }}
path: build/portable-classic
persist-credentials: false

- name: Check portable Dockerfile
if: ${{ !inputs.candidate_only }}
run: docker build --check --file portable/Dockerfile .

- name: Validate portable baseline
if: ${{ !inputs.candidate_only }}
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
file: portable/Dockerfile
target: portable-validation
platforms: linux/amd64
outputs: type=cacheonly
cache-from: type=gha,scope=classic-portable-build-image
cache-to: type=gha,mode=max,scope=classic-portable-build-image,ignore-error=true

- name: Load portable release candidate
if: ${{ !inputs.candidate_only }}
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
file: portable/Dockerfile
target: portable-final
platforms: linux/amd64
load: true
tags: atrinik-classic-portable:release-validation
cache-from: type=gha,scope=classic-portable-build-image

- name: Validate real portable consumer before publication
if: ${{ !inputs.candidate_only }}
run: |
install -d build/portable-reports
docker run --rm --network none \
--user "$(id -u):$(id -g)" --env HOME=/tmp/portable-home \
--volume "${GITHUB_WORKSPACE}/build/portable-classic:/classic:ro" \
--volume "${GITHUB_WORKSPACE}/build/portable-reports:/reports" \
atrinik-classic-portable:release-validation \
/opt/atrinik-portable/consumer.sh /classic /reports

- name: Build and publish Linux image
if: ${{ !inputs.candidate_only }}
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
Expand DownExpand Up@@ -150,3 +204,36 @@ jobs:
cache-to: |
type=inline
type=gha,mode=max,scope=classic-build-image,ignore-error=true

- name: Publish immutable portable candidate
if: ${{ !inputs.candidate_only }}
id: portable-publish
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
file: portable/Dockerfile
target: portable-final
platforms: linux/amd64
push: true
tags: ghcr.io/${{ github.repository_owner }}/classic-portable-build:sha-${{ github.sha }}
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
sbom: true
provenance: mode=max
cache-from: type=gha,scope=classic-portable-build-image

- name: Promote validated portable aliases
if: ${{ !inputs.candidate_only }}
env:
PORTABLE_DIGEST: ${{ steps.portable-publish.outputs.digest }}
run: |
[[ "${PORTABLE_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]]
image="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/classic-portable-build"
tags=(--tag "${image}:latest" --tag "${image}:debian-12")
if [[ "${GITHUB_REF_TYPE}" == tag ]]; then
[[ "${GITHUB_REF_NAME}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]
tags+=(--tag "${image}:${GITHUB_REF_NAME#v}")
fi
docker buildx imagetools create "${tags[@]}" "${image}@${PORTABLE_DIGEST}"
printf '%s@%s\n' "${image}" "${PORTABLE_DIGEST}" >> "${GITHUB_STEP_SUMMARY}"
96 changes: 95 additions & 1 deletion .github/workflows/validate.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ jobs:
name: Select changed images
runs-on: ubuntu-26.04
outputs:
portable: ${{ steps.changes.outputs.portable }}
classic: ${{ steps.changes.outputs.classic }}
linux: ${{ steps.changes.outputs.linux }}
windows: ${{ steps.changes.outputs.windows }}
Expand All@@ -30,10 +31,21 @@ jobs:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
portable=false
classic=false
linux=false
windows=false
while IFS= read -r path; do
case "${path}" in
.dockerignore | .github/actionlint.yaml | .github/workflows/* | \
portable/* | classic-packages.lock | \
classic-shader-toolchain.json | classic-shader-toolchain.spdx.json | \
audio-toolchain.json | audio-toolchain.spdx.json | tools/audio/* | \
tools/build-sdl3-mixer.sh | tools/install_classic_shader_toolchain.py | \
tools/require-image-checks.sh | tools/test-require-image-checks.sh)
portable=true
;;
esac
case "${path}" in
.dockerignore | .github/actionlint.yaml | \
.github/workflows/* | linux/* | \
Expand DownExpand Up@@ -97,11 +109,15 @@ jobs:
esac
done < <(git diff --no-renames --name-only "${BASE_SHA}" "${HEAD_SHA}")
{
echo "portable=${portable}"
echo "classic=${classic}"
echo "linux=${linux}"
echo "windows=${windows}"
} >> "${GITHUB_OUTPUT}"

- name: Test portable ABI checks
run: python3 -m unittest discover -s portable -p 'test_*.py'

- name: Test required-check aggregation
run: tools/test-require-image-checks.sh

Expand DownExpand Up@@ -407,10 +423,85 @@ jobs:
atrinik-classic-build:validation \
/image-source/tools/validate-classic-check.sh /workspace

portable:
name: Portable Classic image
needs: changes
if: needs.changes.outputs.portable == 'true'
runs-on: ubuntu-26.04
timeout-minutes: 180
permissions:
contents: read
steps:
- name: Check out image sources
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false

- name: Read immutable portable consumer
id: consumer
run: echo "commit=$(jq -er '.consumer.commit' portable/contract.json)" >> "${GITHUB_OUTPUT}"

- name: Check out immutable Classic sources
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: atrinik/classic
ref: ${{ steps.consumer.outputs.commit }}
path: build/portable-classic
persist-credentials: false

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4

- name: Check portable Dockerfile
run: docker build --check --file portable/Dockerfile .

- name: Build and smoke baseline as non-root
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
file: portable/Dockerfile
target: portable-validation
platforms: linux/amd64
outputs: type=cacheonly
cache-from: type=gha,scope=classic-portable-build-image
cache-to: type=gha,mode=max,scope=classic-portable-build-image,ignore-error=true

- name: Load portable image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
file: portable/Dockerfile
target: portable-final
platforms: linux/amd64
load: true
tags: atrinik-classic-portable:validation
cache-from: type=gha,scope=classic-portable-build-image

- name: Build and test real non-root Classic client on the baseline
run: |
install -d build/portable-reports
docker run --rm --network none \
--user "$(id -u):$(id -g)" --env HOME=/tmp/portable-home \
--volume "${GITHUB_WORKSPACE}/build/portable-classic:/classic:ro" \
--volume "${GITHUB_WORKSPACE}/build/portable-reports:/reports" \
atrinik-classic-portable:validation \
/opt/atrinik-portable/consumer.sh /classic /reports

- name: Retain portable consumer and ABI evidence
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: classic-portable-consumer-evidence
path: build/portable-reports
if-no-files-found: error
retention-days: 7

required:
name: Required checks
needs:
- changes
- portable
- classic
- linux
- windows
Expand All@@ -427,6 +518,8 @@ jobs:

- name: Require successful applicable validations
env:
PORTABLE_SELECTED: ${{ needs.changes.outputs.portable }}
PORTABLE_RESULT: ${{ needs.portable.result }}
CHANGES_RESULT: ${{ needs.changes.result }}
CLASSIC_SELECTED: ${{ needs.changes.outputs.classic }}
CLASSIC_RESULT: ${{ needs.classic.result }}
Expand All@@ -441,4 +534,5 @@ jobs:
"${CLASSIC_SELECTED}" "${CLASSIC_RESULT}" \
"${LINUX_SELECTED}" "${LINUX_RESULT}" \
"${WINDOWS_SELECTED}" "${WINDOWS_RESULT}" \
"${WINDOWS_NATIVE_RESULT}"
"${WINDOWS_NATIVE_RESULT}" \
"${PORTABLE_SELECTED}" "${PORTABLE_RESULT}"
3 changes: 3 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
/build/

__pycache__/
*.pyc
10 changes: 9 additions & 1 deletion AGENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,13 @@
- 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.
- `portable/Dockerfile` owns the separate `portable-final` Debian 12/glibc 2.36
Linux/amd64 build target and its sealed shader cohort. Keep its input/package,
CPU/ABI/provider, source/license, non-root consumer, required-check and
`classic-portable-build` publication contracts synchronized. Generate shaders
with the unchanged canonical tools in the build-only Ubuntu stage; copy only
canonical-manifest-verified data into Debian. The consumer rejects changed
source commits or shader inputs. Never repurpose this target as a coordinator.
- `classic-final` is the slim Classic Check target. Keep its Ubuntu snapshot,
direct package lock, tool inventory, non-root ccache mount, Classic validation
revision, shader-toolchain inventory, GPU runtime, smoke/SBOM checks, and
Expand All@@ -40,7 +47,8 @@
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`;
supported tags. The Linux publisher owns `linux-build`, `classic-build`, and
`classic-portable-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
Expand Down
58 changes: 58 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -409,3 +409,61 @@ the pinned Classic client and server checks as a non-root user.
The repository's original build configuration and automation are MIT licensed;
see [LICENSE](LICENSE). Software installed into the published images retains
its own upstream license.

## Portable Linux Classic baseline

`portable/Dockerfile` provides `portable-final` for Linux/amd64. It builds the
modern SDL3 family and OpenSSL 3.5 against a digest-pinned Debian 12/glibc 2.36
base and signed package snapshot. The separate contract is
[`portable/contract.json`](portable/contract.json). This target does not change
the canonical Ubuntu coordinator, existing Classic Check, or Windows images.

The canonical DXC payload needs newer glibc than Debian 12. A build-only Ubuntu
stage uses the unchanged shader-toolchain lock to generate the pinned Classic
cohort, compares it with the source's canonical manifest, and records input,
tool, installer and output hashes. Only shader data and notices cross into
Debian. Consumers must verify the exact source commit and every shader input,
then pass `/opt/atrinik-portable/shaders` as `ATRINIK_GPU_SHADER_DIRECTORY`.
Changed source inputs require a reviewed image update; the consumer check fails
rather than silently regenerating with a different compiler.

The image exposes its contract, actual tool/package versions, Debian source
coordinates, source archives, notices, and shader-generation record under
`/opt/atrinik-portable`. Shared application libraries live under `/usr/local`;
Debian packages provide the baseline transitive libraries. Compiler flags use
`-march=x86-64 -mtune=generic`. Verification checks actual ELF dependency
providers, versioned symbols, loader relocations and CPU notes, plus device-free
image/font decoding, audio decoding and OpenSSL provider loading. The
Vulkan, X11-XCB and D-Bus loaders receive the same recursive ABI and source
checks as linked dependencies. FDO dlopen notes are parsed independently of GNU
CPU properties for compatibility with Debian 12 binutils. X11 is the supported
display backend; host graphics drivers stay external. Native Wayland is not
enabled. A Wayland desktop requires an XWayland display route, which remains
subject to parent integration qualification. SDL Steam user storage is
unsupported by this Classic target; its exact SDL feature/provider declaration
is recorded as excluded in the contract.
Unused OpenGL/OpenGL ES backends are disabled so Debian Mesa driver packages are
not pulled into this Classic SDL_GPU build target.

Automatic PR CI explicitly selects `Portable Classic image`, builds/checks the
Dockerfile without registry credentials, runs non-root smoke, and compiles and
tests the exact Classic consumer offline in the baseline image. `Required
checks` fails for any selected missing, skipped, cancelled or failed portable
job. The consumer artifact retains source, compiler, test and ELF evidence.
These container checks do not qualify hardware gameplay, audible playback or
relocation across the final Ubuntu/Debian distribution matrix.

After an authorized maintainer merge and semantic release, the Linux publisher
validates this target and consumer before release aliases move. It publishes
`classic-portable-build:sha-COMMIT` with SBOM/provenance, then promotes that exact
digest to `latest`, `debian-12` and the semantic version. Recover a partial
promotion by rerunning the failed job from the same workflow run. The existing
`candidate_only` dispatch retains its Classic-only behavior. A dispatch,
registry push, merge or release is a separate publication action; PR CI performs
none of them. Consumers select only the actual published immutable manifest
digest and retain producer-run/source coordinates.

Redistributors must retain the pinned source archives, notices, build recipes,
and Debian source coordinates and satisfy the corresponding-source and LGPL
replacement/relinking obligations in the contract. Authored game media remain
`content@main`, resources and sound inputs owned by their respective repositories.
Loading