diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0baae85..a03b43f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,17 @@ jobs: exit 1 fi + - name: Verify exact-digest policy + run: | + digest="sha256:$(printf '%064d' 0)" + test "$(sh scripts/verify-exact-digest-runtime.sh ghcr.io/ploos-as/soju-web "$digest")" = "ghcr.io/ploos-as/soju-web@$digest" + for invalid in latest v0.1.0 sha256:deadbeef "sha256:$(printf '%064d' 0)X"; do + if sh scripts/verify-exact-digest-runtime.sh ghcr.io/ploos-as/soju-web "$invalid"; then + echo "invalid digest unexpectedly accepted: $invalid" >&2 + exit 1 + fi + done + - name: Set up QEMU uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5dc69f3..9b68773 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,6 +69,42 @@ jobs: provenance: mode=max sbom: true + - name: Qualify published image by exact digest + env: + IMAGE_DIGEST: ${{ steps.push.outputs.digest }} + run: | + set -eu + case "$IMAGE_DIGEST" in + sha256:[0-9a-f][0-9a-f]*) ;; + *) echo "invalid published image digest: $IMAGE_DIGEST" >&2; exit 1 ;; + esac + test "${#IMAGE_DIGEST}" -eq 71 + image_ref="${IMAGE_NAME}@${IMAGE_DIGEST}" + docker pull "$image_ref" + resolved="$(docker image inspect "$image_ref" --format '{{index .RepoDigests 0}}')" + test "$resolved" = "$image_ref" + revision="$(docker image inspect "$image_ref" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')" + version="$(docker image inspect "$image_ref" --format '{{index .Config.Labels "org.opencontainers.image.version"}}')" + test "$revision" = "$GITHUB_SHA" + test "$version" = "$GITHUB_REF_NAME" + test "$(docker run --rm --entrypoint id "$image_ref" -u)" = "1000" + test "$(docker run --rm --entrypoint id "$image_ref" -g)" = "1000" + cid=$(docker run -d --rm \ + -e SOJU_WEB_ADMIN_PASSWORD=release-qualification-password \ + -e SOJU_WEB_SESSION_SECRET=release-qualification-session-secret-0123456789 \ + -p 127.0.0.1:18080:8080 "$image_ref") + trap 'docker logs "$cid" 2>&1 || true; docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT + for i in $(seq 1 30); do + if curl -fsS http://127.0.0.1:18080/healthz | grep -qx ok; then + healthy=true + break + fi + sleep 1 + done + test "${healthy:-false}" = true + docker rm -f "$cid" + trap - EXIT + - name: Attest image uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3 with: diff --git a/docs/M11_3_EXACT_DIGEST_RUNTIME.md b/docs/M11_3_EXACT_DIGEST_RUNTIME.md new file mode 100644 index 0000000..bb344d6 --- /dev/null +++ b/docs/M11_3_EXACT_DIGEST_RUNTIME.md @@ -0,0 +1,9 @@ +# M11.3 exact-digest runtime qualification + +Release qualification is bound to the immutable OCI digest emitted by the successful multi-platform build/push step. + +The release workflow constructs only `ghcr.io/ploos-as/soju-web@sha256:<64 lowercase hex>` for qualification. It pulls that exact reference, verifies Docker resolves the same RepoDigest, checks the OCI revision and version labels against the release commit/tag, verifies UID/GID 1000, and starts the exact-digest image to require a successful `/healthz` response. + +Mutable release tags are not used as the runtime qualification target. Attestation and GitHub Release creation remain downstream of this runtime gate, so a failed exact-digest qualification prevents release completion. + +CI separately exercises the digest-reference validator with accepted and rejected inputs before any release tag is created. diff --git a/scripts/test-exact-digest-runtime.sh b/scripts/test-exact-digest-runtime.sh new file mode 100644 index 0000000..73b7217 --- /dev/null +++ b/scripts/test-exact-digest-runtime.sh @@ -0,0 +1,20 @@ +#!/bin/sh +set -eu + +digest="sha256:$(printf '%064d' 0)" +expected="ghcr.io/ploos-as/soju-web@$digest" +actual=$(sh scripts/verify-exact-digest-runtime.sh ghcr.io/ploos-as/soju-web "$digest") +test "$actual" = "$expected" + +for invalid in \ + latest \ + v0.1.0 \ + sha256:deadbeef \ + "sha256:$(printf '%064d' 0)X" \ + "sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +do + if sh scripts/verify-exact-digest-runtime.sh ghcr.io/ploos-as/soju-web "$invalid" >/dev/null 2>&1; then + echo "invalid digest unexpectedly accepted: $invalid" >&2 + exit 1 + fi +done diff --git a/scripts/verify-exact-digest-runtime.sh b/scripts/verify-exact-digest-runtime.sh new file mode 100644 index 0000000..3a68384 --- /dev/null +++ b/scripts/verify-exact-digest-runtime.sh @@ -0,0 +1,22 @@ +#!/bin/sh +set -eu + +image_name=${1:-ghcr.io/ploos-as/soju-web} +digest=${2:-} + +case "$digest" in + sha256:[0-9a-f][0-9a-f]*) ;; + *) echo "invalid digest: $digest" >&2; exit 1 ;; +esac + +if [ "${#digest}" -ne 71 ]; then + echo "digest must be sha256 plus exactly 64 lowercase hex characters" >&2 + exit 1 +fi + +hex=${digest#sha256:} +case "$hex" in + *[!0-9a-f]*) echo "digest contains non-hex characters" >&2; exit 1 ;; +esac + +printf '%s@%s\n' "$image_name" "$digest"