From 9ff3758e6098e329723bb786cb9f35f54a542c4d Mon Sep 17 00:00:00 2001 From: Gnani Rahul Date: Tue, 14 Jul 2026 22:22:38 -0500 Subject: [PATCH] feat(e9): publish signed hub OCI image GSTACK-Checkpoint: 2026-07-14/e9-hub-oci-publication#1 Signed-off-by: Gnani Rahul --- .github/workflows/release.yml | 109 ++++++++++++ Makefile | 4 +- README.md | 17 +- docs/RELEASE.md | 62 +++++-- hack/verify-release-hub-image.sh | 94 ++++++++++ sessions/2026-07-14-e9-hub-oci-publication.md | 64 +++++++ .../scripts/release_hub_image_policy_test.sh | 160 ++++++++++++++++++ tools/internal/releasepack/policy_test.go | 109 +++++++++++- 8 files changed, 599 insertions(+), 20 deletions(-) create mode 100755 hack/verify-release-hub-image.sh create mode 100644 sessions/2026-07-14-e9-hub-oci-publication.md create mode 100644 tests/scripts/release_hub_image_policy_test.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 29b313a..3cd2f16 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,6 +17,7 @@ env: GORELEASER_VERSION: "v2.17.0" SYFT_VERSION: "v1.46.0" COSIGN_VERSION: "v3.0.6" + HUB_IMAGE: ghcr.io/ardurai/sith-hub jobs: release: @@ -26,6 +27,7 @@ jobs: contents: write id-token: write attestations: write + packages: write steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -97,6 +99,104 @@ jobs: - name: Sign Homebrew formula run: cosign sign-blob --yes --bundle=dist/sith.rb.sigstore.json dist/sith.rb + - name: Stage immutable hub image context + env: + IMAGE_CONTEXT: ${{ runner.temp }}/sith-hub-image + run: | + set -euo pipefail + install -d "$IMAGE_CONTEXT/bin/linux/amd64" "$IMAGE_CONTEXT/bin/linux/arm64" + tar -xzf "dist/sith_${VERSION}_linux_amd64.tar.gz" -C "$IMAGE_CONTEXT/bin/linux/amd64" sith + tar -xzf "dist/sith_${VERSION}_linux_arm64.tar.gz" -C "$IMAGE_CONTEXT/bin/linux/arm64" sith + install -m 0644 Containerfile "$IMAGE_CONTEXT/Containerfile" + test -x "$IMAGE_CONTEXT/bin/linux/amd64/sith" + test -x "$IMAGE_CONTEXT/bin/linux/arm64/sith" + + - name: Set up QEMU for hub image platforms + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 + + - name: Set up Buildx for hub image platforms + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - name: Authenticate to the GitHub Container Registry + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Guard hub image tag against overwrite + env: + HUB_TAG: ${{ env.HUB_IMAGE }}:${{ github.ref_name }} + run: | + set -euo pipefail + if inspection="$(docker manifest inspect "$HUB_TAG" 2>&1)"; then + echo "::error::hub image tag already exists; immutable release tags cannot be overwritten" + exit 1 + fi + case "$inspection" in + *"manifest unknown"*|*"no such manifest"*) ;; + *) + echo "::error::could not establish whether the hub image tag exists" + exit 1 + ;; + esac + + - name: Publish immutable multi-platform hub image + id: hub_image + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + with: + context: ${{ runner.temp }}/sith-hub-image + file: ${{ runner.temp }}/sith-hub-image/Containerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ env.HUB_IMAGE }}:${{ github.ref_name }} + provenance: false + sbom: false + + - name: Sign and verify published hub image + env: + HUB_DIGEST: ${{ steps.hub_image.outputs.digest }} + run: | + set -euo pipefail + test -n "$HUB_DIGEST" + image="${HUB_IMAGE}@${HUB_DIGEST}" + identity="https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release.yml@refs/tags/${GITHUB_REF_NAME}" + cosign sign --yes "$image" + cosign verify \ + --certificate-identity "$identity" \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ + "$image" >/dev/null + printf '%s\n' "$image" > "dist/sith_${VERSION}_hub.image" + cosign sign-blob --yes \ + --bundle="dist/sith_${VERSION}_hub.image.sigstore.json" \ + "dist/sith_${VERSION}_hub.image" + + - name: Generate hub image SPDX SBOM + env: + HUB_DIGEST: ${{ steps.hub_image.outputs.digest }} + run: | + set -euo pipefail + test -n "$HUB_DIGEST" + syft "${HUB_IMAGE}@${HUB_DIGEST}" \ + --output "spdx-json=dist/sith_${VERSION}_hub.spdx.json" + + - name: Attest hub image build provenance + id: hub_provenance + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + with: + subject-name: ${{ env.HUB_IMAGE }} + subject-digest: ${{ steps.hub_image.outputs.digest }} + push-to-registry: true + + - name: Attest hub image SBOM + id: hub_sbom + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + with: + subject-name: ${{ env.HUB_IMAGE }} + subject-digest: ${{ steps.hub_image.outputs.digest }} + sbom-path: dist/sith_${{ env.VERSION }}_hub.spdx.json + push-to-registry: true + - name: Generate SLSA build provenance id: provenance uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 @@ -139,6 +239,8 @@ jobs: SBOM_DARWIN_ARM64_BUNDLE: ${{ steps.sbom_darwin_arm64.outputs.bundle-path }} SBOM_LINUX_AMD64_BUNDLE: ${{ steps.sbom_linux_amd64.outputs.bundle-path }} SBOM_LINUX_ARM64_BUNDLE: ${{ steps.sbom_linux_arm64.outputs.bundle-path }} + HUB_PROVENANCE_BUNDLE: ${{ steps.hub_provenance.outputs.bundle-path }} + HUB_SBOM_BUNDLE: ${{ steps.hub_sbom.outputs.bundle-path }} run: | set -euo pipefail install -m 0644 "$PROVENANCE_BUNDLE" "dist/sith_${VERSION}_provenance.sigstore.json" @@ -146,9 +248,16 @@ jobs: install -m 0644 "$SBOM_DARWIN_ARM64_BUNDLE" "dist/sith_${VERSION}_darwin_arm64.sbom.sigstore.json" install -m 0644 "$SBOM_LINUX_AMD64_BUNDLE" "dist/sith_${VERSION}_linux_amd64.sbom.sigstore.json" install -m 0644 "$SBOM_LINUX_ARM64_BUNDLE" "dist/sith_${VERSION}_linux_arm64.sbom.sigstore.json" + install -m 0644 "$HUB_PROVENANCE_BUNDLE" "dist/sith_${VERSION}_hub.provenance.sigstore.json" + install -m 0644 "$HUB_SBOM_BUNDLE" "dist/sith_${VERSION}_hub.sbom.sigstore.json" gh release upload "$GITHUB_REF_NAME" \ dist/sith.rb \ dist/sith.rb.sigstore.json \ + "dist/sith_${VERSION}_hub.image" \ + "dist/sith_${VERSION}_hub.image.sigstore.json" \ + "dist/sith_${VERSION}_hub.spdx.json" \ + "dist/sith_${VERSION}_hub.provenance.sigstore.json" \ + "dist/sith_${VERSION}_hub.sbom.sigstore.json" \ "dist/sith_${VERSION}_provenance.sigstore.json" \ "dist/sith_${VERSION}_darwin_amd64.sbom.sigstore.json" \ "dist/sith_${VERSION}_darwin_arm64.sbom.sigstore.json" \ diff --git a/Makefile b/Makefile index b00ab5f..31b6457 100644 --- a/Makefile +++ b/Makefile @@ -63,6 +63,7 @@ test-scripts: ## Run focused safety tests for operator-facing shell harnesses bash tests/scripts/release_tag_identity_guide_test.sh bash tests/scripts/release_tag_policy_test.sh bash tests/scripts/release_pr_gate_policy_test.sh + bash tests/scripts/release_hub_image_policy_test.sh perf: ## Enforce the warm-cache TUI p95 latency budget without race overhead go test -count=1 -run '^TestWarmViewP95UnderOneHundredMilliseconds$$' ./internal/tui @@ -142,7 +143,7 @@ run: build ## Build then run sith version ci: fmt-check vet lint vuln test test-scripts perf e2e build ## Run the full CI gate locally -release-check: ## Build and verify the reproducible multi-platform release snapshot twice +release-check: ## Build, verify, and package the reproducible multi-platform release snapshot twice @command -v "$(GORELEASER)" >/dev/null || { echo "goreleaser is required" >&2; exit 1; } @command -v syft >/dev/null || { echo "syft is required" >&2; exit 1; } @set -e; tmp="$$(mktemp -d)"; trap 'rm -rf "$$tmp"' EXIT; \ @@ -155,6 +156,7 @@ release-check: ## Build and verify the reproducible multi-platform release snaps "$(GORELEASER)" release --snapshot --clean --skip=sign; \ go run ./tools/releasecheck verify --dist dist; \ go run ./tools/releasecheck formula --dist dist --output dist/sith.rb; \ + DOCKER_BIN="$(DOCKER)" hack/verify-release-hub-image.sh --dist dist; \ go run ./tools/releasecheck digests --dist dist > "$$tmp/second.sha256"; \ diff -u "$$tmp/first.sha256" "$$tmp/second.sha256" diff --git a/README.md b/README.md index 84bbdbd..a7f05ed 100644 --- a/README.md +++ b/README.md @@ -270,14 +270,17 @@ The no-network setting applies only to those isolated image checks. A deployed h allowlisted egress to its configured runtime dependencies, including its database and, when enabled, the pinned OIDC discovery and JWKS endpoints. -This is not a published image reference. The fail-closed [`charts/sith-hub`](charts/sith-hub) -chart requires an explicit immutable `repository@sha256:...` image reference and refuses tags, -especially `latest`; it invokes `sith hub migrate` in a separate short-lived Job before the -non-owner hub Deployment starts. Its defaults intentionally cannot install until a release-bound -hub image and operator-provided Secret references exist; it never renders secret material. The +Hub OCI images are published only by a completed, signed release tag. The release attaches the +exact immutable digest as `sith__hub.image`; follow the +[release verification guide](docs/RELEASE.md#verify-a-hub-oci-image) before supplying it to the +fail-closed [`charts/sith-hub`](charts/sith-hub) chart. The chart requires an explicit +`repository@sha256:...` image reference and refuses tags, especially `latest`; it invokes `sith hub +migrate` in a separate short-lived Job before the non-owner hub Deployment starts. Its defaults +intentionally cannot install until an operator provides that digest and the existing Secret +references; it never renders secret material. Older releases can lack this image artifact. The chart permits only fixed `light` and `heavy` resource profiles, which retain identical security, -credential, and RBAC controls. This first F9.3a profile slice does not claim a public image, -in-chart database, or HA; those parent-F9.3 topology and custody capabilities need later evidence. +credential, and RBAC controls. This first F9.3a profile slice does not claim in-chart database or +HA topology; those parent-F9.3 capabilities need later evidence. `sith serve --mcp` exposes `fleet.inventory`, `fleet.health`, `fleet.correlate`, and `fleet.cve-search` over MCP Streamable HTTP. All four tools are cache-only and carry diff --git a/docs/RELEASE.md b/docs/RELEASE.md index f98f9b2..ce409aa 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -4,7 +4,9 @@ Sith releases are immutable, tag-driven builds from `main`. Stable releases use the beta channel uses `vMAJOR.MINOR.PATCH-beta.N` and never replaces the latest stable release. The release job creates a draft, builds four archives with GoReleaser, emits an SPDX 2.3 SBOM for each archive with Syft, signs the archives, SBOMs, and checksum manifest with keyless Cosign, and creates GitHub SLSA provenance plus -one SBOM attestation per platform. The draft becomes public only after every step succeeds. +one SBOM attestation per platform. It also publishes the tag's multi-architecture hub image by its +manifest digest, signs it with keyless Cosign, and creates separate provenance and SPDX SBOM +attestations for that digest. The draft becomes public only after every step succeeds. The workflow follows the primary guidance for [GitHub artifact attestations](https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations/use-artifact-attestations), [GoReleaser reproducible Go builds](https://goreleaser.com/customization/builds/builders/go/#reproducible-builds), @@ -29,6 +31,45 @@ URLs or hashes. The release workflow signs the formula itself. The tap's own rep verifies that signature and the signed checksum manifest before importing the formula, so the Sith release token never needs cross-repository write access. +## Verify a hub OCI image + +Hub images are published only by a signed Sith release tag and must be consumed by immutable +manifest digest. A release includes a signed `sith__hub.image` file whose only line is the +digest address; do not substitute the convenient version tag or add `latest` to a Helm value. + +```bash +tag=vX.Y.Z # use a tag released after hub-image publication is enabled +version=${tag#v} +gh release download "$tag" --repo ArdurAI/sith \ + --pattern "sith_${version}_hub.image" \ + --dir "sith-$version" +image=$(cat "sith-$version/sith_${version}_hub.image") +case "$image" in ghcr.io/ardurai/sith-hub@sha256:*) ;; *) exit 1 ;; esac +``` + +Verify the keyless image signature against the exact tag workflow identity, then verify GitHub +provenance and the SPDX SBOM attestation. These commands require registry access; the later air-gap +workflow consumes mirrored, pre-verified material rather than weakening this verification boundary. + +```bash +identity="https://github.com/ArdurAI/sith/.github/workflows/release.yml@refs/tags/${tag}" +cosign verify \ + --certificate-identity "$identity" \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ + "$image" +gh attestation verify "oci://$image" \ + --repo ArdurAI/sith \ + --signer-workflow ArdurAI/sith/.github/workflows/release.yml +gh attestation verify "oci://$image" \ + --repo ArdurAI/sith \ + --signer-workflow ArdurAI/sith/.github/workflows/release.yml \ + --predicate-type https://spdx.dev/Document/v2.3 +``` + +The existing Helm chart remains fail-closed: it accepts this digest and names of pre-materialized +runtime and migration Secrets only. It neither creates secret data nor supplies a KMS provider, +database, ingress, or mutable image reference. + ## Verify a release Set the release and platform, then download its assets: @@ -98,14 +139,15 @@ No network is an isolated image-check constraint, not the operational hub policy must allow only narrowly scoped egress to configured runtime dependencies, including the database and, where enabled, the pinned OIDC discovery and JWKS endpoints. -No OCI image is published by this repository yet. Consumers must not infer a mutable image tag -from a release archive. The [`charts/sith-hub`](../charts/sith-hub) chart accepts only an explicit -immutable `repository@sha256:...` reference, and its defaults intentionally fail until an operator -provides that reference and existing Secret names. Image publishing/signing/attestation will be -added as a separate release-boundary change before any public deployment guidance. Its fixed -`light` and `heavy` profiles alter only the reviewed resource envelope; both preserve the same -digest, Secret-reference, migration, RBAC, and workload-hardening contract. This first F9.3a -slice is not a claim of the parent feature's future in-chart database, HA, or cloud-KMS topology. +Hub OCI images are published only as a release-boundary artifact, never by a pull request or local +build. Consumers must not infer a mutable image tag from a release archive. The +[`charts/sith-hub`](../charts/sith-hub) chart accepts only an explicit immutable +`repository@sha256:...` reference, and its defaults intentionally fail until an operator provides +that reference and existing Secret names. Older releases can lack the hub-image assets; use the +digest address attached to a release that includes them. Its fixed `light` and `heavy` profiles +alter only the reviewed resource envelope; both preserve the same digest, Secret-reference, +migration, RBAC, and workload-hardening contract. This first F9.3a slice is not a claim of the +parent feature's future in-chart database, HA, or cloud-KMS topology. ## Maintainer release procedure @@ -190,6 +232,6 @@ Homebrew token is stored in Sith. ## Cost and operational notes The incremental cost is GitHub-hosted runner time for four cross-builds, two snapshot builds on -each PR, Syft scans, and five attestations on a tag. Fulcio and Rekor use Sigstore's public-good +each PR, Syft scans, and seven attestations on a tag. Fulcio and Rekor use Sigstore's public-good service for this public repository. Releases create no runtime cloud service, NAT egress path, or persistent signing infrastructure. diff --git a/hack/verify-release-hub-image.sh b/hack/verify-release-hub-image.sh new file mode 100755 index 0000000..595366f --- /dev/null +++ b/hack/verify-release-hub-image.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash + +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +usage() { + printf 'usage: %s --dist \n' "${0##*/}" >&2 + exit 2 +} + +if [[ "$#" != 2 || "$1" != "--dist" ]]; then + usage +fi + +readonly REPOSITORY_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +readonly DIST_DIRECTORY="$(cd "$2" && pwd -P)" +readonly DOCKER_BIN="${DOCKER_BIN:-docker}" + +command -v "$DOCKER_BIN" >/dev/null +command -v python3 >/dev/null + +shopt -s nullglob +amd64_archives=("${DIST_DIRECTORY}"/sith_*_linux_amd64.tar.gz) +arm64_archives=("${DIST_DIRECTORY}"/sith_*_linux_arm64.tar.gz) +if [[ "${#amd64_archives[@]}" != 1 || "${#arm64_archives[@]}" != 1 ]]; then + printf 'expected exactly one Linux archive per supported architecture in %s\n' "$DIST_DIRECTORY" >&2 + exit 1 +fi + +context_directory="$(mktemp -d)" +builder="sith-release-hub-${RANDOM}-$$" +cleanup() { + "$DOCKER_BIN" buildx rm --force "$builder" >/dev/null 2>&1 || true + rm -rf "$context_directory" +} +trap cleanup EXIT +install -d -m 0755 "$context_directory/bin/linux/amd64" "$context_directory/bin/linux/arm64" +tar -xzf "${amd64_archives[0]}" -C "$context_directory/bin/linux/amd64" sith +tar -xzf "${arm64_archives[0]}" -C "$context_directory/bin/linux/arm64" sith +install -m 0644 "$REPOSITORY_ROOT/Containerfile" "$context_directory/Containerfile" +test -x "$context_directory/bin/linux/amd64/sith" +test -x "$context_directory/bin/linux/arm64/sith" + +oci_layout="$context_directory/sith-hub.oci" +"$DOCKER_BIN" buildx create --driver docker-container --name "$builder" >/dev/null +"$DOCKER_BIN" buildx inspect --builder "$builder" --bootstrap >/dev/null +"$DOCKER_BIN" buildx build \ + --builder "$builder" \ + --platform linux/amd64,linux/arm64 \ + --provenance=false \ + --sbom=false \ + --file "$context_directory/Containerfile" \ + --output "type=oci,dest=${oci_layout}" \ + "$context_directory" + +oci_directory="$context_directory/oci-layout" +install -d -m 0755 "$oci_directory" +tar -xf "$oci_layout" -C "$oci_directory" +python3 - "$oci_directory" <<'PY' +import json +import sys +from pathlib import Path + +root = Path(sys.argv[1]) +index_media_types = { + "application/vnd.oci.image.index.v1+json", + "application/vnd.docker.distribution.manifest.list.v2+json", +} + +def indexed_platforms(index): + platforms = set() + for manifest in index.get("manifests", []): + if manifest.get("mediaType") in index_media_types: + algorithm, digest = manifest["digest"].split(":", 1) + with (root / "blobs" / algorithm / digest).open(encoding="utf-8") as nested_file: + platforms.update(indexed_platforms(json.load(nested_file))) + continue + platform = manifest.get("platform", {}) + os_name = platform.get("os") + architecture = platform.get("architecture") + if os_name is not None and architecture is not None: + platforms.add((os_name, architecture)) + return platforms + +with (root / "index.json").open(encoding="utf-8") as index_file: + platforms = indexed_platforms(json.load(index_file)) + +expected = {("linux", "amd64"), ("linux", "arm64")} +if platforms != expected: + raise SystemExit(f"OCI layout platforms = {sorted(platforms)!r}, want {sorted(expected)!r}") +PY + +printf 'release hub OCI layout verified from release archives\n' diff --git a/sessions/2026-07-14-e9-hub-oci-publication.md b/sessions/2026-07-14-e9-hub-oci-publication.md new file mode 100644 index 0000000..ddf6955 --- /dev/null +++ b/sessions/2026-07-14-e9-hub-oci-publication.md @@ -0,0 +1,64 @@ +# Session — 2026-07-14 — e9-hub-oci-publication + +**Builder:** Gnani Rahul · **Branch:** `gnanirahulnutakki/feat/e9-hub-oci-publication` +**Slice(s):** E9 / [#169](https://github.com/ArdurAI/sith/issues/169) · **Status:** ready-for-commit + +--- + +[G] Goal: publish the Helm-installable Sith hub only as a release-bound, immutable +multi-architecture OCI image, with verifiable signature, provenance, and SBOM evidence. + +[S] Scope: one GHCR repository (`ghcr.io/ardurai/sith-hub`), release-tag-only +`linux/amd64` and `linux/arm64` manifest publication, keyless Cosign signing, GitHub provenance +and SPDX SBOM attestations, release-attached digest evidence, and fail-closed Helm documentation. +No mutable tags, KMS, database, ingress, additional hub capabilities, registry credential in the +repository, or release rewrite path are introduced. + +[A] Action: the tag workflow stages only the two GoReleaser Linux archives, builds and pushes the +manifest under the exact release tag, fails closed if that tag exists or cannot be inspected, then +signs and verifies the manifest digest with the exact GitHub Actions workflow identity. It writes +the digest address, its signed blob bundle, SPDX JSON, and provenance/SBOM bundles to the release. + +[A] Action: added a non-publishing `hack/verify-release-hub-image.sh` release gate. It creates a +temporary BuildKit builder without changing the developer default, assembles the OCI layout from +the actual release archives, disables Buildx-generated metadata to match production, recursively +walks the OCI index, requires exactly `linux/amd64` and `linux/arm64`, and removes the builder and +temporary files on every exit path. + +[A] Action: documented digest-only verification in `docs/RELEASE.md` and `README.md`. Consumers +verify the Cosign identity plus GitHub provenance and the SPDX predicate from the exact immutable +release digest before passing it to the chart; older releases may not contain hub-image assets. + +[T] Test: final `make ci` passed formatting, vet, golangci-lint (0 findings), `govulncheck` +(no vulnerabilities), race suites, all safety/policy scripts, binary E2E, and build. Final +`make release-check` passed two reproducible four-platform snapshots, archive verification, +formula rendering, and the archive-derived multi-platform OCI-layout check. The release guard was +also verified against GHCR's actual missing-manifest response. + +[T] Test: `make e2e-oci` passed the hardened native and cross-architecture OCI contract. +`make e2e-kind KIND=/Volumes/EXTENDED/MacData/tools/bin/kind` passed the two real-cluster fleet +and hardened image contract in 154.272 seconds. `make e2e-isolation` passed forced-RLS PostgreSQL +coverage and the fixed 50,000-execution cross-workspace fuzz campaign. + +[R] Review: the first CodeRabbit uncommitted review reported eight substantive boundary gaps. The +diff now scopes package permission, signing, and each image attestation to their active workflow +blocks; requires the guide's signer workflow; checks the active `release-check` and Buildx command +blocks; rejects mutable hub image references; and guards existing or ambiguous registry tags. A +second review added exact platform parsing, bounded release-job inspection, and guard-before-push +ordering; all three were corrected. Its request to call local `make release-check` from the tag +workflow was rejected: that would run two redundant snapshot releases, not validate the actual +tag build, and materially increase release time. The tag workflow already verifies its own real +GoReleaser distribution before image publication. The final CodeRabbit review reported zero +findings after the metadata-equivalence policy was added. + +[S] Security: GitHub queues immediately before the final review were Dependabot 0, code scanning +0, and secret scanning 0. No kubeconfig, credential, token, or local cluster data enters the +workflow, release assets, documentation examples, or session record. + +[C] Checkpoint #1: final closeout rechecked Dependabot 0, code scanning 0, and secret scanning 0; +no Kind clusters remained; the temporary release-check builder was removed; and Docker's safe +dangling-image prune reclaimed 1.202 GB. Final `make ci` and `make release-check` remained green. + +[C] Checkpoint #2: create the SSH-signed DCO/GSTACK commit, push and land the PR into `dev`, verify +exact post-merge CI, then publish and verify the release tag and GHCR manifest before closing #169 +and updating #27/#39. diff --git a/tests/scripts/release_hub_image_policy_test.sh b/tests/scripts/release_hub_image_policy_test.sh new file mode 100644 index 0000000..91d78d7 --- /dev/null +++ b/tests/scripts/release_hub_image_policy_test.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +workflow="${repo_root}/.github/workflows/release.yml" +guide="${repo_root}/docs/RELEASE.md" +makefile="${repo_root}/Makefile" +verifier="${repo_root}/hack/verify-release-hub-image.sh" +workflow_contents="$(<"$workflow")" + +assert_contains() { + local text="$1" + local needle="$2" + local description="$3" + + if [[ "$text" != *"$needle"* ]]; then + printf '[release-hub-image] FAIL: %s\n' "$description" >&2 + exit 1 + fi + printf '[release-hub-image] PASS: %s\n' "$description" +} + +workflow_step() { + local name="$1" + awk -v name="$name" ' + $0 == " - name: " name { inside = 1 } + inside && $0 ~ /^ - name: / && $0 != " - name: " name { exit } + inside { print } + ' "$workflow" +} + +workflow_job() { + local name="$1" + awk -v name="$name" ' + $0 == " " name ":" { inside = 1 } + inside && $0 ~ /^ [[:alnum:]_-]+:$/ && $0 != " " name ":" { exit } + inside { print } + ' "$workflow" +} + +make_target() { + local name="$1" + awk -v name="$name" ' + $0 ~ "^" name ":" { inside = 1 } + inside && $0 ~ /^[[:alnum:]_-]+:/ && $0 !~ "^" name ":" { exit } + inside { print } + ' "$makefile" +} + +assert_text_contains() { + local text="$1" + local needle="$2" + local description="$3" + + if [[ "$text" != *"$needle"* ]]; then + printf '[release-hub-image] FAIL: %s\n' "$description" >&2 + exit 1 + fi + printf '[release-hub-image] PASS: %s\n' "$description" +} + +release_job="$(workflow_job 'release')" +assert_contains "$workflow_contents" 'HUB_IMAGE: ghcr.io/ardurai/sith-hub' 'uses one fixed GHCR hub image name' +assert_text_contains "$release_job" 'packages: write' 'grants package publication permission to the release job' +for assertion in \ + 'docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130|pins QEMU setup action' \ + 'docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f|pins Buildx setup action' \ + 'docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9|pins registry login action' \ + 'docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8|pins image build action' \ + 'tar -xzf "dist/sith_${VERSION}_linux_amd64.tar.gz"|stages the released linux amd64 binary' \ + 'tar -xzf "dist/sith_${VERSION}_linux_arm64.tar.gz"|stages the released linux arm64 binary' \ + 'dist/sith_${VERSION}_hub.image|attaches the digest address to the release' \ + 'dist/sith_${VERSION}_hub.provenance.sigstore.json|attaches image provenance evidence to the release' \ + 'dist/sith_${VERSION}_hub.sbom.sigstore.json|attaches image SBOM evidence to the release'; do + needle="${assertion%%|*}" + description="${assertion#*|}" + assert_contains "$release_job" "$needle" "$description" +done + +publish_step="$(workflow_step 'Publish immutable multi-platform hub image')" +platforms="$(awk '/^[[:space:]]+platforms:/{ print $2 }' <<<"$publish_step")" +if [[ "$platforms" != 'linux/amd64,linux/arm64' ]]; then + printf '[release-hub-image] FAIL: publish step platforms = %q, want linux/amd64,linux/arm64\n' "$platforms" >&2 + exit 1 +fi +printf '[release-hub-image] PASS: publishes exactly the two supported Linux platforms\n' +assert_contains "$publish_step" 'push: true' 'publishes the release image before release publication' +assert_contains "$publish_step" 'tags: ${{ env.HUB_IMAGE }}:${{ github.ref_name }}' 'uses the exact release tag without a latest tag' +assert_contains "$publish_step" 'provenance: false' 'uses the explicit GitHub provenance attestation path' +assert_contains "$publish_step" 'sbom: false' 'uses the explicit SPDX SBOM attestation path' + +signing_step="$(workflow_step 'Sign and verify published hub image')" +assert_text_contains "$signing_step" 'HUB_DIGEST: ${{ steps.hub_image.outputs.digest }}' 'derives the signing digest from the pushed manifest' +assert_text_contains "$signing_step" 'image="${HUB_IMAGE}@${HUB_DIGEST}"' 'constructs the signed image from the pushed manifest digest' +assert_text_contains "$signing_step" 'cosign sign --yes "$image"' 'keylessly signs that manifest digest' + +for attestation in 'Attest hub image build provenance' 'Attest hub image SBOM'; do + attestation_step="$(workflow_step "$attestation")" + assert_text_contains "$attestation_step" 'subject-name: ${{ env.HUB_IMAGE }}' "$attestation uses the tag-free image name" + assert_text_contains "$attestation_step" 'subject-digest: ${{ steps.hub_image.outputs.digest }}' "$attestation uses the pushed manifest digest" +done + +guard_step="$(workflow_step 'Guard hub image tag against overwrite')" +assert_text_contains "$guard_step" 'docker manifest inspect "$HUB_TAG"' 'checks the exact release tag before publication' +assert_text_contains "$guard_step" 'could not establish whether the hub image tag exists' 'fails closed on registry inspection errors' +guard_line="$(grep -n -F ' - name: Guard hub image tag against overwrite' "$workflow" | head -n 1 | cut -d: -f1)" +publish_line="$(grep -n -F ' - name: Publish immutable multi-platform hub image' "$workflow" | head -n 1 | cut -d: -f1)" +if [[ -z "$guard_line" || -z "$publish_line" || "$guard_line" -ge "$publish_line" ]]; then + printf '[release-hub-image] FAIL: immutable-tag guard must run before image publication\n' >&2 + exit 1 +fi +printf '[release-hub-image] PASS: immutable-tag guard runs before image publication\n' + +release_check="$(make_target 'release-check')" +if [[ "$release_check" != *'hack/verify-release-hub-image.sh --dist dist'* ]]; then + printf '[release-hub-image] FAIL: release check does not build the OCI layout from release archives\n' >&2 + exit 1 +fi +printf '[release-hub-image] PASS: release check builds the OCI layout from release archives\n' + +buildx_command="$(awk '/"\$DOCKER_BIN" buildx build/{ inside = 1 } inside { print } inside && /"\$context_directory"$/{ exit }' "$verifier")" +if [[ "$buildx_command" != *'--builder "$builder"'* || "$buildx_command" != *'--provenance=false'* || "$buildx_command" != *'--sbom=false'* ]]; then + printf '[release-hub-image] FAIL: release archive validation does not use an isolated OCI-capable builder\n' >&2 + exit 1 +fi +printf '[release-hub-image] PASS: release archive validation uses an isolated OCI-capable builder\n' + +if [[ "$buildx_command" != *'--provenance=false'* || "$publish_step" != *'provenance: false'* ]]; then + printf '[release-hub-image] FAIL: release archive validation provenance setting does not match release publish step\n' >&2 + exit 1 +fi +if [[ "$buildx_command" != *'--sbom=false'* || "$publish_step" != *'sbom: false'* ]]; then + printf '[release-hub-image] FAIL: release archive validation SBOM setting does not match release publish step\n' >&2 + exit 1 +fi +printf '[release-hub-image] PASS: release archive validation matches release OCI metadata settings\n' + +verification_block="$(awk '/^identity=/{ inside = 1 } inside { print } inside && /^```$/{ exit }' "$guide")" +assert_text_contains "$verification_block" 'cosign verify' 'verifies the complete immutable image reference with Cosign' +assert_text_contains "$verification_block" 'gh attestation verify "oci://$image"' 'verifies attestations for the same immutable image reference' +assert_text_contains "$verification_block" '--signer-workflow ArdurAI/sith/.github/workflows/release.yml' 'pins the attestation signer workflow' + +if grep -Eq 'ghcr\.io/ardurai/sith-hub:latest' "$workflow" "$guide"; then + printf '[release-hub-image] FAIL: hub image workflow or guide permits a mutable latest tag\n' >&2 + exit 1 +fi +printf '[release-hub-image] PASS: no mutable latest hub image tag appears in workflow or guide\n' + +for needle in \ + 'ghcr.io/ardurai/sith-hub@sha256:' \ + 'cosign verify' \ + 'gh attestation verify "oci://$image"' \ + 'https://spdx.dev/Document/v2.3'; do + if ! grep -Fq -- "$needle" "$guide"; then + printf '[release-hub-image] FAIL: release guide missing %s\n' "$needle" >&2 + exit 1 + fi +done +printf '[release-hub-image] PASS: release guide documents digest, signature, provenance, and SBOM verification\n' diff --git a/tools/internal/releasepack/policy_test.go b/tools/internal/releasepack/policy_test.go index d755f12..29d4b2d 100644 --- a/tools/internal/releasepack/policy_test.go +++ b/tools/internal/releasepack/policy_test.go @@ -20,19 +20,61 @@ func TestReleasePolicyIsFailClosed(t *testing.T) { for _, want := range []string{ "id-token: write", "attestations: write", + "packages: write", "persist-credentials: false", "release tag must point to a commit reachable from main", "release tag must be annotated", "release tag must carry a signature verified by GitHub", "cosign sign-blob --yes --bundle=dist/sith.rb.sigstore.json dist/sith.rb", + "HUB_IMAGE: ghcr.io/ardurai/sith-hub", + "tags: ${{ env.HUB_IMAGE }}:${{ github.ref_name }}", + "cosign sign --yes \"$image\"", `gh release edit "$GITHUB_REF_NAME" --draft=false --latest`, } { if !strings.Contains(release, want) { t.Errorf("release workflow does not enforce %q", want) } } - if count := strings.Count(release, "uses: actions/attest@"); count != 5 { - t.Errorf("release workflow has %d attestation steps, want one provenance and four SBOM attestations", count) + releaseJob := releaseWorkflowJob(release, "release") + if !strings.Contains(releaseJob, "packages: write") { + t.Error("release job does not grant the package publication permission") + } + guardStep := releaseWorkflowStep(release, "Guard hub image tag against overwrite") + for _, want := range []string{ + `HUB_TAG: ${{ env.HUB_IMAGE }}:${{ github.ref_name }}`, + `docker manifest inspect "$HUB_TAG"`, + "hub image tag already exists; immutable release tags cannot be overwritten", + "could not establish whether the hub image tag exists", + } { + if !strings.Contains(guardStep, want) { + t.Errorf("hub image overwrite guard does not enforce %q", want) + } + } + signingStep := releaseWorkflowStep(release, "Sign and verify published hub image") + for _, want := range []string{ + "HUB_DIGEST: ${{ steps.hub_image.outputs.digest }}", + `image="${HUB_IMAGE}@${HUB_DIGEST}"`, + `cosign sign --yes "$image"`, + } { + if !strings.Contains(signingStep, want) { + t.Errorf("hub image signing step does not enforce %q", want) + } + } + if count := strings.Count(release, "uses: actions/attest@"); count != 7 { + t.Errorf("release workflow has %d attestation steps, want archive provenance, four archive SBOMs, image provenance, and image SBOM", count) + } + for _, name := range []string{"Attest hub image build provenance", "Attest hub image SBOM"} { + step := releaseWorkflowStep(release, name) + for _, want := range []string{ + "uses: actions/attest@", + "subject-name: ${{ env.HUB_IMAGE }}", + "subject-digest: ${{ steps.hub_image.outputs.digest }}", + "push-to-registry: true", + } { + if !strings.Contains(step, want) { + t.Errorf("%s does not enforce %q", name, want) + } + } } for _, forbidden := range []string{"pull_request_target:", "workflow_run:", "HOMEBREW_TAP_TOKEN", "PERSONAL_AUTH_TOKEN"} { if strings.Contains(release, forbidden) { @@ -88,6 +130,30 @@ func TestReleaseGuidePinsSPDXPredicateVersion(t *testing.T) { } } +func TestReleaseGuideRequiresImmutableHubImageVerification(t *testing.T) { + t.Parallel() + guide := readRepositoryFile(t, repositoryRoot(t), "docs/RELEASE.md") + if !strings.Contains(guide, `case "$image" in ghcr.io/ardurai/sith-hub@sha256:*)`) { + t.Fatal("release guide does not require a complete immutable hub image digest") + } + verification := markdownCodeBlockAfter(guide, "identity=\"https://github.com/ArdurAI/sith/.github/workflows/release.yml") + for _, want := range []string{ + "cosign verify", + `gh attestation verify "oci://$image"`, + "--signer-workflow ArdurAI/sith/.github/workflows/release.yml", + } { + if !strings.Contains(verification, want) { + t.Errorf("release guide does not require immutable hub image verification %q", want) + } + } + if count := strings.Count(verification, "$image"); count != 3 { + t.Errorf("release guide uses the immutable image reference %d times, want cosign plus two attestations", count) + } + if strings.Contains(guide, "ghcr.io/ardurai/sith-hub:latest") { + t.Error("release guide permits a mutable hub image tag") + } +} + func TestInstallDocsUseFormulaScopedHomebrewTrust(t *testing.T) { t.Parallel() root := repositoryRoot(t) @@ -102,6 +168,45 @@ func TestInstallDocsUseFormulaScopedHomebrewTrust(t *testing.T) { } } +func releaseWorkflowJob(workflow, name string) string { + marker := "\n " + name + ":\n" + start := strings.Index(workflow, marker) + if start < 0 { + return "" + } + job := workflow[start:] + headers := regexp.MustCompile(`(?m)^ [[:alnum:]_-]+:\n`).FindAllStringIndex(job, -1) + if len(headers) > 1 { + return job[:headers[1][0]] + } + return job +} + +func releaseWorkflowStep(workflow, name string) string { + marker := "\n - name: " + name + "\n" + start := strings.Index(workflow, marker) + if start < 0 { + return "" + } + step := workflow[start:] + if next := strings.Index(step[len(marker):], "\n - name: "); next >= 0 { + return step[:len(marker)+next] + } + return step +} + +func markdownCodeBlockAfter(contents, marker string) string { + start := strings.Index(contents, marker) + if start < 0 { + return "" + } + block := contents[start:] + if end := strings.Index(block, "\n```"); end >= 0 { + return block[:end] + } + return block +} + func repositoryRoot(t *testing.T) string { t.Helper() _, filename, _, ok := runtime.Caller(0)