From 7721ecea602b4617af448b388b8abaa3dbe8d40b Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Wed, 12 Aug 2026 19:05:36 +0000 Subject: [PATCH 1/2] feat(linux): provide pinned gh-stack tooling --- .github/workflows/validate.yml | 48 +++++++++++++++++++++++++- README.md | 36 +++++++++++++++++++- linux/Dockerfile | 61 ++++++++++++++++++++++++++++++++-- 3 files changed, 141 insertions(+), 4 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index c9fb997..47cc943 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -4,6 +4,7 @@ on: pull_request: permissions: + attestations: read contents: read concurrency: @@ -105,10 +106,55 @@ jobs: context: . file: linux/Dockerfile target: validation - outputs: type=cacheonly + load: true + tags: atrinik-linux-validation:ci cache-from: type=gha,scope=linux-build-image cache-to: type=gha,mode=max,scope=linux-build-image,ignore-error=true + - name: Verify non-root gh-stack contract + env: + GH_TOKEN: ${{ github.token }} + run: | + docker run --rm \ + --user ubuntu \ + --env HOME=/home/ubuntu \ + --env GH_TOKEN \ + atrinik-linux-validation:ci \ + sh -eu -c ' + test "$(gh version | sed -n "1s/^gh version \([^ ]*\) .*/\1/p")" = 2.97.0 + test "$(git version)" = "git version 2.53.0" + test "$(gh extension list)" = "gh stack$(printf "\t")github/gh-stack$(printf "\t")v0.1.0" + test "$(gh stack --version)" = "gh stack version 0.1.0" + gh stack --help >/dev/null + echo "358552dd7dce0a46ce153fe196270cec482b84f080947890aad4061a8d44bc0b /home/ubuntu/.local/share/gh/extensions/gh-stack/gh-stack" | sha256sum -c - + ' + + - name: Verify gh-stack release attestation + env: + GH_TOKEN: ${{ github.token }} + run: | + docker run --rm \ + --user ubuntu \ + --env HOME=/home/ubuntu \ + --env GH_TOKEN \ + atrinik-linux-validation:ci \ + gh attestation verify \ + /home/ubuntu/.local/share/gh/extensions/gh-stack/gh-stack \ + --repo github/gh-stack \ + --signer-workflow github/gh-stack/.github/workflows/release.yml \ + --source-ref refs/tags/v0.1.0 \ + --source-digest a1b4a3d4d0bcde9ec3a78ab99b2d63af121857a9 \ + --signer-digest a1b4a3d4d0bcde9ec3a78ab99b2d63af121857a9 \ + --format json \ + | jq -e ' + any( + .[].verificationResult.statement.subject[]; + .name == "linux-amd64" + and .digest.sha256 == + "358552dd7dce0a46ce153fe196270cec482b84f080947890aad4061a8d44bc0b" + ) + ' + windows: name: Windows cross-build images needs: changes diff --git a/README.md b/README.md index 9d116a6..1d0375b 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,10 @@ packages and all four variants remain matched. ## Local validation +The isolated `gh extension list` smoke requires `GH_TOKEN` to be exported from +an authenticated developer session; the token is passed only to that disposable +runtime container and is never supplied to the image build. + ```sh docker build --check --file linux/Dockerfile . docker build --check --file windows/Dockerfile . @@ -79,7 +83,17 @@ docker build --file windows/Dockerfile \ --tag atrinik-windows-check . docker run --rm atrinik-linux-build clang --version -docker run --rm atrinik-linux-build gh --version +docker run --rm --user ubuntu --env HOME=/home/ubuntu \ + atrinik-linux-build gh version +docker run --rm --user ubuntu --env HOME=/home/ubuntu \ + --env GH_TOKEN atrinik-linux-build gh extension list +docker run --rm --user ubuntu --env HOME=/home/ubuntu \ + atrinik-linux-build gh stack --version +docker run --rm --user ubuntu --env HOME=/home/ubuntu \ + atrinik-linux-build gh stack --help +docker run --rm --user ubuntu --env HOME=/home/ubuntu \ + atrinik-linux-build sha256sum \ + /home/ubuntu/.local/share/gh/extensions/gh-stack/gh-stack docker run --rm atrinik-linux-build actionlint --version docker run --rm atrinik-linux-build devcontainer --version docker run --rm atrinik-linux-build go version @@ -194,6 +208,26 @@ 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 broad Linux image installs the official GitHub CLI 2.97.0 archive, pinned +by its published SHA-256, as the sole `gh` executable. It also installs the +official `github/gh-stack` extension v0.1.0 for the non-root `ubuntu` user with +`gh extension install github/gh-stack --pin v0.1.0`. The image validates the +extension's pinned manifest, ordinary `gh stack` dispatch, help output, and +the attested `linux-amd64` binary SHA-256 +`358552dd7dce0a46ce153fe196270cec482b84f080947890aad4061a8d44bc0b`. +Both projects are MIT licensed; their pinned license texts are installed under +`/usr/local/share/licenses/`. + +The upstream release attestation ties `github/gh-stack`'s +`.github/workflows/release.yml`, `refs/tags/v0.1.0`, and source commit +`a1b4a3d4d0bcde9ec3a78ab99b2d63af121857a9` to that asset digest. Pull-request +validation verifies those coordinates with `gh attestation verify` after the +image build, passing the workflow token only to the disposable validation +container at runtime. No GitHub credential enters the Dockerfile, build +arguments, image layers, or published image. Upgrades require reviewed version, +checksum, source, license, and attestation changes; do not run +`gh extension upgrade stack --force` as a runtime substitute. + 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, diff --git a/linux/Dockerfile b/linux/Dockerfile index 07b71ad..c3de42d 100644 --- a/linux/Dockerfile +++ b/linux/Dockerfile @@ -28,7 +28,6 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ flex \ fzf \ gdb \ - gh \ git \ iproute2 \ jq \ @@ -79,6 +78,13 @@ ARG ACTIONLINT_SHA256=8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b3 ARG BUF_VERSION=1.72.0 ARG BUF_SHA256=8720830e26a733da55bb89bcd3cb44849c0965fc0c44fb5d691cccdc64dca5af ARG DEVCONTAINER_CLI_VERSION=0.88.0 +ARG GH_VERSION=2.97.0 +ARG GH_SHA256=a2c9b8497e1f85b1ad0dfcb78b5a622e098801b8e461e459e88e1ee12f018112 +ARG GH_STACK_VERSION=0.1.0 +ARG GH_STACK_SHA256_AMD64=358552dd7dce0a46ce153fe196270cec482b84f080947890aad4061a8d44bc0b +ARG GH_STACK_SOURCE_COMMIT=a1b4a3d4d0bcde9ec3a78ab99b2d63af121857a9 +ARG GH_STACK_LICENSE_SHA256=2510b446bc1f0cf9702453075d20cd88631e20e5642658edb7325d9c1eb534f7 +ARG GIT_VERSION=2.53.0 ARG GO_VERSION=1.26.5 ARG GO_SHA256=5c2c3b16caefa1d968a94c1daca04a7ca301a496d9b086e17ad77bb81393f053 ARG NODE_VERSION=24.18.1 @@ -155,6 +161,25 @@ RUN curl --fail --location --silent --show-error \ && tar -xzf /tmp/trivy.tar.gz -C /usr/local/bin trivy \ && rm -f /tmp/syft.tar.gz /tmp/trivy.tar.gz +# Install one checksum-pinned GitHub CLI from its immutable v2.97.0 release. +# The archive carries cli/cli's MIT license; Ubuntu's distro package is omitted +# above so SBOM and executable ownership remain unambiguous. +RUN curl --fail --location --silent --show-error \ + "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_amd64.tar.gz" \ + --output /tmp/gh.tar.gz \ + && echo "${GH_SHA256} /tmp/gh.tar.gz" | sha256sum -c - \ + && tar -xzf /tmp/gh.tar.gz -C /tmp \ + && install -m 0755 \ + "/tmp/gh_${GH_VERSION}_linux_amd64/bin/gh" /usr/local/bin/gh \ + && install -D -m 0644 \ + "/tmp/gh_${GH_VERSION}_linux_amd64/LICENSE" \ + /usr/local/share/licenses/gh/LICENSE \ + && rm -rf /tmp/gh.tar.gz "/tmp/gh_${GH_VERSION}_linux_amd64" \ + && test "$(command -v gh)" = /usr/local/bin/gh \ + && test ! -e /usr/bin/gh \ + && test "$(gh version | sed -n '1s/^gh version \([^ ]*\) .*/\1/p')" \ + = "${GH_VERSION}" + # Ubuntu does not currently package SDL3_mixer. Build the checksum-pinned # release with static libogg, libopus, and libopusfile inputs so OPUS, WAV, # and built-in Vorbis remain available without host codec libraries. @@ -212,6 +237,28 @@ RUN install -d -o "${USERNAME}" -g "${USERNAME}" "/home/${USERNAME}/.codex" \ > "/etc/sudoers.d/${USERNAME}" \ && chmod 0440 "/etc/sudoers.d/${USERNAME}" +# gh-stack v0.1.0 is a non-immutable release tag, so retain the supported pin +# while independently enforcing its attested linux-amd64 asset digest. The +# source commit is the verified release-workflow signer/source digest for +# github/gh-stack/.github/workflows/release.yml at refs/tags/v0.1.0. +RUN HOME="/home/${USERNAME}" runuser -u "${USERNAME}" -- \ + gh extension install github/gh-stack --pin "v${GH_STACK_VERSION}" \ + && echo "${GH_STACK_SHA256_AMD64} /home/${USERNAME}/.local/share/gh/extensions/gh-stack/gh-stack" \ + | sha256sum -c - \ + && grep -Fx 'owner: github' \ + "/home/${USERNAME}/.local/share/gh/extensions/gh-stack/manifest.yml" \ + && grep -Fx 'name: gh-stack' \ + "/home/${USERNAME}/.local/share/gh/extensions/gh-stack/manifest.yml" \ + && grep -Fx "tag: v${GH_STACK_VERSION}" \ + "/home/${USERNAME}/.local/share/gh/extensions/gh-stack/manifest.yml" \ + && grep -Fx 'ispinned: true' \ + "/home/${USERNAME}/.local/share/gh/extensions/gh-stack/manifest.yml" \ + && curl --fail --location --silent --show-error \ + "https://raw.githubusercontent.com/github/gh-stack/${GH_STACK_SOURCE_COMMIT}/LICENSE" \ + --output /usr/local/share/licenses/gh-stack.LICENSE \ + && echo "${GH_STACK_LICENSE_SHA256} /usr/local/share/licenses/gh-stack.LICENSE" \ + | sha256sum -c - + RUN clang --version \ && test "$(go env GOVERSION)" = "go${GO_VERSION}" \ && test "$(rustc --version | cut -d' ' -f2)" = "${RUST_VERSION}" \ @@ -222,7 +269,17 @@ RUN clang --version \ && test "$(protoc-gen-go --version)" \ = "protoc-gen-go v${PROTOC_GEN_GO_VERSION}" \ && test "$(protoc-gen-prost --version)" = "${PROTOC_GEN_PROST_VERSION}" \ - && gh --version \ + && test "$(runuser -u "${USERNAME}" -- env HOME="/home/${USERNAME}" \ + gh version | sed -n '1s/^gh version \([^ ]*\) .*/\1/p')" \ + = "${GH_VERSION}" \ + && test "$(runuser -u "${USERNAME}" -- env HOME="/home/${USERNAME}" \ + git version)" = "git version ${GIT_VERSION}" \ + && test "$(runuser -u "${USERNAME}" -- env HOME="/home/${USERNAME}" \ + gh stack --version)" = "gh stack version ${GH_STACK_VERSION}" \ + && runuser -u "${USERNAME}" -- env HOME="/home/${USERNAME}" \ + gh stack --help >/dev/null \ + && echo "${GH_STACK_SHA256_AMD64} /home/${USERNAME}/.local/share/gh/extensions/gh-stack/gh-stack" \ + | runuser -u "${USERNAME}" -- sha256sum -c - \ && llvm-config --version \ && syft version \ && trivy --version \ From f96fdade70435ba9826099552a09f0909db327ba Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Wed, 12 Aug 2026 19:17:32 +0000 Subject: [PATCH 2/2] fix(linux): harden gh-stack verification --- .github/workflows/validate.yml | 50 ++++++++++++++++++++++------------ README.md | 19 +++++++------ linux/Dockerfile | 36 ++++++++++++++++++++---- 3 files changed, 73 insertions(+), 32 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 47cc943..b6d1f4a 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -4,7 +4,6 @@ on: pull_request: permissions: - attestations: read contents: read concurrency: @@ -93,6 +92,9 @@ jobs: if: needs.changes.outputs.linux == 'true' runs-on: ubuntu-26.04 timeout-minutes: 60 + permissions: + attestations: read + contents: read steps: - name: Check out image sources uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -112,18 +114,20 @@ jobs: cache-to: type=gha,mode=max,scope=linux-build-image,ignore-error=true - name: Verify non-root gh-stack contract - env: - GH_TOKEN: ${{ github.token }} run: | docker run --rm \ --user ubuntu \ --env HOME=/home/ubuntu \ - --env GH_TOKEN \ + --env GH_TOKEN=unused \ atrinik-linux-validation:ci \ sh -eu -c ' test "$(gh version | sed -n "1s/^gh version \([^ ]*\) .*/\1/p")" = 2.97.0 test "$(git version)" = "git version 2.53.0" test "$(gh extension list)" = "gh stack$(printf "\t")github/gh-stack$(printf "\t")v0.1.0" + grep -Fx "owner: github" /home/ubuntu/.local/share/gh/extensions/gh-stack/manifest.yml + grep -Fx "name: gh-stack" /home/ubuntu/.local/share/gh/extensions/gh-stack/manifest.yml + grep -Fx "tag: v0.1.0" /home/ubuntu/.local/share/gh/extensions/gh-stack/manifest.yml + grep -Fx "ispinned: true" /home/ubuntu/.local/share/gh/extensions/gh-stack/manifest.yml test "$(gh stack --version)" = "gh stack version 0.1.0" gh stack --help >/dev/null echo "358552dd7dce0a46ce153fe196270cec482b84f080947890aad4061a8d44bc0b /home/ubuntu/.local/share/gh/extensions/gh-stack/gh-stack" | sha256sum -c - @@ -133,19 +137,31 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - docker run --rm \ - --user ubuntu \ - --env HOME=/home/ubuntu \ - --env GH_TOKEN \ - atrinik-linux-validation:ci \ - gh attestation verify \ - /home/ubuntu/.local/share/gh/extensions/gh-stack/gh-stack \ - --repo github/gh-stack \ - --signer-workflow github/gh-stack/.github/workflows/release.yml \ - --source-ref refs/tags/v0.1.0 \ - --source-digest a1b4a3d4d0bcde9ec3a78ab99b2d63af121857a9 \ - --signer-digest a1b4a3d4d0bcde9ec3a78ab99b2d63af121857a9 \ - --format json \ + install -d "${RUNNER_TEMP}/gh-stack-attestation" + gh release download v2.97.0 \ + --repo cli/cli \ + --pattern gh_2.97.0_linux_amd64.tar.gz \ + --dir "${RUNNER_TEMP}/gh-stack-attestation" + gh release download v0.1.0 \ + --repo github/gh-stack \ + --pattern linux-amd64 \ + --dir "${RUNNER_TEMP}/gh-stack-attestation" + cat <