From e67e4a7c5f76f21550118e08e5ba48ae8505b22d Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 22:47:12 +0100 Subject: [PATCH] Bind container promotion to attested digests --- .github/workflows/canonical-governance.yml | 3 + .github/workflows/create-release.yml | 32 +++- .github/workflows/promote-floating-tags.yml | 125 ++++++++++------ .github/workflows/publish-docker.yml | 48 ++++++ .../workflows/recover-release-activation.yml | 46 +++++- .github/workflows/release-convergence.yml | 13 +- .../subsystems/deployment-installability.md | 30 +++- .../release_promotion_policy_test.py | 15 ++ .../verify_release_container_images_test.py | 138 ++++++++++++++++++ scripts/verify-release-container-images.sh | 86 +++++++++++ 10 files changed, 476 insertions(+), 60 deletions(-) create mode 100644 scripts/release_control/verify_release_container_images_test.py create mode 100755 scripts/verify-release-container-images.sh diff --git a/.github/workflows/canonical-governance.yml b/.github/workflows/canonical-governance.yml index b674e6cb6..de7d5bd77 100644 --- a/.github/workflows/canonical-governance.yml +++ b/.github/workflows/canonical-governance.yml @@ -252,6 +252,9 @@ jobs: - name: Run release promotion policy unit tests run: python3 scripts/release_control/release_promotion_policy_test.py + - name: Run release container identity verifier unit tests + run: python3 scripts/release_control/verify_release_container_images_test.py + - name: Run immutable release integrity unit tests run: python3 scripts/release_control/verify_github_release_integrity_test.py diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 7de3e5128..c2154d137 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -1546,6 +1546,7 @@ jobs: needs: - prepare - create_release + - publish_docker - release_readiness - dispatch_release_convergence - stage_private_pro_runtime @@ -1570,6 +1571,8 @@ jobs: IS_PRERELEASE: ${{ needs.prepare.outputs.is_prerelease }} CONVERGENCE_RUN_ID: ${{ needs.dispatch_release_convergence.outputs.run_id }} R2_PREFIX: ${{ needs.stage_private_pro_runtime.outputs.r2_prefix }} + SERVER_IMAGE_DIGEST: ${{ needs.publish_docker.outputs.server_digest }} + CONTROL_PLANE_IMAGE_DIGEST: ${{ needs.publish_docker.outputs.control_plane_digest }} run: | set -euo pipefail release_json=$(mktemp) @@ -1594,10 +1597,14 @@ jobs: --arg release_id "${RELEASE_ID}" \ --arg source_release_run_id "${GITHUB_RUN_ID}" \ --arg r2_prefix "${R2_PREFIX}" \ + --arg server_image_digest "${SERVER_IMAGE_DIGEST}" \ + --arg control_plane_image_digest "${CONTROL_PLANE_IMAGE_DIGEST}" \ '.schema_version == 1 and .tag == $tag and .target_commitish == $target_commitish and .release_id == $release_id and .source_release_run_id == $source_release_run_id and - (.convergence_run_id | test("^[0-9]+$")) and .r2_prefix == $r2_prefix' \ + (.convergence_run_id | test("^[0-9]+$")) and .r2_prefix == $r2_prefix and + .server_image_digest == $server_image_digest and + .control_plane_image_digest == $control_plane_image_digest' \ "${verified_marker}" >/dev/null marker_convergence_run_id="$(jq -r '.convergence_run_id' "${verified_marker}")" @@ -1743,6 +1750,11 @@ jobs: # Close the dispatch-to-commit race before staging the exact marker. require_viable_convergence_owner + if [[ ! "${SERVER_IMAGE_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]] || \ + [[ ! "${CONTROL_PLANE_IMAGE_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "::error::Verified public container digests are required before release activation." + exit 1 + fi jq -n \ --arg tag "${TAG}" \ --arg target_commitish "${EXPECTED_COMMIT}" \ @@ -1750,6 +1762,8 @@ jobs: --arg source_release_run_id "${GITHUB_RUN_ID}" \ --arg convergence_run_id "${CONVERGENCE_RUN_ID}" \ --arg r2_prefix "${R2_PREFIX}" \ + --arg server_image_digest "${SERVER_IMAGE_DIGEST}" \ + --arg control_plane_image_digest "${CONTROL_PLANE_IMAGE_DIGEST}" \ '{ schema_version: 1, tag: $tag, @@ -1757,7 +1771,9 @@ jobs: release_id: $release_id, source_release_run_id: $source_release_run_id, convergence_run_id: $convergence_run_id, - r2_prefix: $r2_prefix + r2_prefix: $r2_prefix, + server_image_digest: $server_image_digest, + control_plane_image_digest: $control_plane_image_digest }' > "${activation_marker}" gh release upload "${TAG}" \ "${activation_marker}" --clobber \ @@ -1838,7 +1854,9 @@ jobs: --arg source_release_run_id "${GITHUB_RUN_ID}" \ --arg convergence_run_id "${CONVERGENCE_RUN_ID}" \ --arg r2_prefix "${R2_PREFIX}" \ - '.schema_version == 1 and .tag == $tag and .target_commitish == $target_commitish and .release_id == $release_id and .source_release_run_id == $source_release_run_id and .convergence_run_id == $convergence_run_id and .r2_prefix == $r2_prefix' \ + --arg server_image_digest "${SERVER_IMAGE_DIGEST}" \ + --arg control_plane_image_digest "${CONTROL_PLANE_IMAGE_DIGEST}" \ + '.schema_version == 1 and .tag == $tag and .target_commitish == $target_commitish and .release_id == $release_id and .source_release_run_id == $source_release_run_id and .convergence_run_id == $convergence_run_id and .r2_prefix == $r2_prefix and .server_image_digest == $server_image_digest and .control_plane_image_digest == $control_plane_image_digest' \ "${verified_marker}" >/dev/null trap - ERR rm -f "$release_json" "$publish_payload" "$quarantine_payload" \ @@ -1888,6 +1906,8 @@ jobs: CONVERGENCE_RUN_ID: ${{ needs.dispatch_release_convergence.outputs.run_id }} CONVERGENCE_RUN_URL: ${{ needs.dispatch_release_convergence.outputs.run_url }} R2_PREFIX: ${{ needs.stage_private_pro_runtime.outputs.r2_prefix }} + SERVER_IMAGE_DIGEST: ${{ needs.publish_docker.outputs.server_digest }} + CONTROL_PLANE_IMAGE_DIGEST: ${{ needs.publish_docker.outputs.control_plane_digest }} run: | set -euo pipefail require_result() { @@ -1928,10 +1948,14 @@ jobs: --arg release_id "${RELEASE_ID}" \ --arg source_release_run_id "${GITHUB_RUN_ID}" \ --arg r2_prefix "${R2_PREFIX}" \ + --arg server_image_digest "${SERVER_IMAGE_DIGEST}" \ + --arg control_plane_image_digest "${CONTROL_PLANE_IMAGE_DIGEST}" \ '.schema_version == 1 and .tag == $tag and .target_commitish == $target_commitish and .release_id == $release_id and .source_release_run_id == $source_release_run_id and - (.convergence_run_id | test("^[0-9]+$")) and .r2_prefix == $r2_prefix' \ + (.convergence_run_id | test("^[0-9]+$")) and .r2_prefix == $r2_prefix and + .server_image_digest == $server_image_digest and + .control_plane_image_digest == $control_plane_image_digest' \ "${marker}" >/dev/null marker_convergence_run_id="$(jq -r '.convergence_run_id' "${marker}")" recovery_run_id="$(jq -r '.activation_recovery_run_id // ""' "${marker}")" diff --git a/.github/workflows/promote-floating-tags.yml b/.github/workflows/promote-floating-tags.yml index ae7e8ccec..a2183bfeb 100644 --- a/.github/workflows/promote-floating-tags.yml +++ b/.github/workflows/promote-floating-tags.yml @@ -20,6 +20,18 @@ on: description: "Is this a prerelease?" required: true type: boolean + source_sha: + description: "Exact source commit asserted by the image provenance." + required: true + type: string + server_digest: + description: "Activation-committed Pulse server image digest." + required: true + type: string + control_plane_digest: + description: "Activation-committed Pulse control-plane image digest." + required: true + type: string permissions: contents: read @@ -94,34 +106,33 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Wait for exact-version images to be available + - name: Reverify activation-committed image identities env: TAG: ${{ steps.extract.outputs.tag }} + SOURCE_SHA: ${{ inputs.source_sha }} + SERVER_DIGEST: ${{ inputs.server_digest }} + CONTROL_PLANE_DIGEST: ${{ inputs.control_plane_digest }} + GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - for image in pulse pulse-control-plane; do - echo "Waiting for rcourtman/${image}:${TAG} to be available..." - available=false - for attempt in $(seq 1 30); do - if docker manifest inspect "rcourtman/${image}:${TAG}" > /dev/null 2>&1; then - echo "Image rcourtman/${image}:${TAG} is available." - available=true - break - fi - echo "Attempt ${attempt}/30 - ${image} image not yet available, waiting 10s..." - sleep 10 - done - if [ "$available" != "true" ]; then - echo "::error::Timeout waiting for rcourtman/${image}:${TAG}" - exit 1 - fi - done + expected_proof="$(printf 'server_digest=%s\ncontrol_plane_digest=%s' \ + "${SERVER_DIGEST}" "${CONTROL_PLANE_DIGEST}")" + actual_proof="$(./scripts/verify-release-container-images.sh \ + "${TAG}" "${SOURCE_SHA}" "${GITHUB_REPOSITORY}")" + if [ "${actual_proof}" != "${expected_proof}" ]; then + echo "::error::Exact-version container identities no longer match the immutable activation marker." + printf 'Expected:\n%s\nObserved:\n%s\n' "${expected_proof}" "${actual_proof}" >&2 + exit 1 + fi + echo "[OK] Exact-version tags still resolve to the activation-committed, attested digests." - name: Promote Pulse server image tags env: TAG: ${{ steps.extract.outputs.tag }} PRERELEASE: ${{ steps.extract.outputs.prerelease }} OWNER: ${{ github.repository_owner }} + SERVER_DIGEST: ${{ inputs.server_digest }} + CONTROL_PLANE_DIGEST: ${{ inputs.control_plane_digest }} run: | set -euo pipefail VERSION="${TAG#v}" @@ -132,38 +143,62 @@ jobs: HIGHEST_STABLE=$(git tag -l 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1) for image in pulse pulse-control-plane; do + if [ "${image}" = "pulse" ]; then + expected_digest="${SERVER_DIGEST}" + else + expected_digest="${CONTROL_PLANE_DIGEST}" + fi + docker_source="docker.io/rcourtman/${image}@${expected_digest}" + ghcr_source="ghcr.io/${OWNER}/${image}@${expected_digest}" + docker_targets=() + ghcr_targets=() if [ "$PRERELEASE" = "true" ]; then - echo "Promoting :rc for ${image} from ${TAG}." + echo "Promoting :rc for ${image} from ${expected_digest}." + docker_targets+=("rcourtman/${image}:rc") + ghcr_targets+=("ghcr.io/${OWNER}/${image}:rc") docker buildx imagetools create \ - -t "rcourtman/${image}:rc" \ - "rcourtman/${image}:${TAG}" + -t "${docker_targets[0]}" \ + "${docker_source}" docker buildx imagetools create \ - -t "ghcr.io/${OWNER}/${image}:rc" \ - "ghcr.io/${OWNER}/${image}:${TAG}" - continue + -t "${ghcr_targets[0]}" \ + "${ghcr_source}" + else + docker_targets=( + "rcourtman/${image}:${MAJOR_MINOR}" + "rcourtman/${image}:${MAJOR}" + ) + ghcr_targets=( + "ghcr.io/${OWNER}/${image}:${MAJOR_MINOR}" + "ghcr.io/${OWNER}/${image}:${MAJOR}" + ) + if [ "$TAG" = "$HIGHEST_STABLE" ]; then + docker_targets+=("rcourtman/${image}:latest") + ghcr_targets+=("ghcr.io/${OWNER}/${image}:latest") + echo "Promoting stable aliases for ${image} from ${expected_digest}, including :latest." + else + echo "Promoting stable aliases for ${image} from ${expected_digest} without :latest (highest stable is ${HIGHEST_STABLE})." + fi + docker_hub_tags=() + ghcr_tags=() + for target in "${docker_targets[@]}"; do docker_hub_tags+=(-t "$target"); done + for target in "${ghcr_targets[@]}"; do ghcr_tags+=(-t "$target"); done + docker buildx imagetools create \ + "${docker_hub_tags[@]}" \ + "${docker_source}" + docker buildx imagetools create \ + "${ghcr_tags[@]}" \ + "${ghcr_source}" fi - docker_hub_tags=( - -t "rcourtman/${image}:${MAJOR_MINOR}" - -t "rcourtman/${image}:${MAJOR}" - ) - ghcr_tags=( - -t "ghcr.io/${OWNER}/${image}:${MAJOR_MINOR}" - -t "ghcr.io/${OWNER}/${image}:${MAJOR}" - ) - if [ "$TAG" = "$HIGHEST_STABLE" ]; then - docker_hub_tags+=( -t "rcourtman/${image}:latest" ) - ghcr_tags+=( -t "ghcr.io/${OWNER}/${image}:latest" ) - echo "Promoting stable aliases for ${image} from ${TAG}, including :latest." - else - echo "Promoting stable aliases for ${image} from ${TAG} without :latest (highest stable is ${HIGHEST_STABLE})." - fi - docker buildx imagetools create \ - "${docker_hub_tags[@]}" \ - "rcourtman/${image}:${TAG}" - docker buildx imagetools create \ - "${ghcr_tags[@]}" \ - "ghcr.io/${OWNER}/${image}:${TAG}" + for target in "${docker_targets[@]}" "${ghcr_targets[@]}"; do + observed_digest="$(docker buildx imagetools inspect "${target}" \ + --format '{{json .Manifest}}' | jq -er '.digest')" + if [ "${observed_digest}" != "${expected_digest}" ]; then + echo "::error::Promoted alias ${target} resolved to ${observed_digest}, expected ${expected_digest}." + exit 1 + fi + echo "[OK] ${target} -> ${expected_digest}" + done done - name: Promotion summary diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index eaea1a8ea..fa4cdf86a 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -20,6 +20,13 @@ on: description: 'Exact source commit bound to the candidate payload' required: true type: string + outputs: + server_digest: + description: 'Verified multi-registry digest for the Pulse server image' + value: ${{ jobs.verify.outputs.server_digest }} + control_plane_digest: + description: 'Verified multi-registry digest for the Pulse control-plane image' + value: ${{ jobs.verify.outputs.control_plane_digest }} concurrency: group: docker-publish-${{ inputs.tag }} @@ -217,3 +224,44 @@ jobs: echo " - rcourtman/pulse-control-plane:${{ steps.version.outputs.version }}" fi echo "Floating aliases are promoted separately at the activation barrier." + + verify: + name: Verify exact image identities and provenance + needs: publish + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + packages: read + outputs: + server_digest: ${{ steps.proof.outputs.server_digest }} + control_plane_digest: ${{ steps.proof.outputs.control_plane_digest }} + steps: + - name: Checkout release verification control + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + - name: Log in to Docker Hub + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Log in to GHCR + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Verify exact tags and signed provenance + id: proof + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + ./scripts/verify-release-container-images.sh \ + "${{ inputs.tag }}" "${{ inputs.source_sha }}" \ + "${{ github.repository }}" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/recover-release-activation.yml b/.github/workflows/recover-release-activation.yml index 65b069c48..24b20dc8f 100644 --- a/.github/workflows/recover-release-activation.yml +++ b/.github/workflows/recover-release-activation.yml @@ -23,6 +23,7 @@ concurrency: permissions: actions: write contents: write + packages: read jobs: recover_activation: @@ -175,6 +176,32 @@ jobs: --validate-body-file "${release_body}" echo "[OK] GitHub's stored RC packet still matches the immutable source-run manifest." + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + - name: Log in to Docker Hub + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Log in to GHCR + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Revalidate exact container identities and provenance + id: container_proof + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + ./scripts/verify-release-container-images.sh \ + "${{ inputs.tag }}" "${{ steps.qualify.outputs.source_sha }}" \ + "${{ github.repository }}" >> "$GITHUB_OUTPUT" + - name: Dispatch a fresh durable convergence owner id: dispatch env: @@ -238,6 +265,8 @@ jobs: IS_PRERELEASE: ${{ steps.qualify.outputs.is_prerelease }} R2_PREFIX: ${{ steps.qualify.outputs.r2_prefix }} CONVERGENCE_RUN_ID: ${{ steps.dispatch.outputs.run_id }} + SERVER_IMAGE_DIGEST: ${{ steps.container_proof.outputs.server_digest }} + CONTROL_PLANE_IMAGE_DIGEST: ${{ steps.container_proof.outputs.control_plane_digest }} run: | set -euo pipefail release_json=$(mktemp) @@ -334,6 +363,11 @@ jobs: jq -n '{draft: true, make_latest: "false"}' > "${quarantine_payload}" require_viable_convergence_owner + if [[ ! "${SERVER_IMAGE_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]] || \ + [[ ! "${CONTROL_PLANE_IMAGE_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "::error::Verified public container digests are required before recovered activation." + exit 1 + fi jq -n \ --arg tag "${TAG}" \ --arg target_commitish "${EXPECTED_COMMIT}" \ @@ -342,6 +376,8 @@ jobs: --arg convergence_run_id "${CONVERGENCE_RUN_ID}" \ --arg r2_prefix "${R2_PREFIX}" \ --arg activation_recovery_run_id "${GITHUB_RUN_ID}" \ + --arg server_image_digest "${SERVER_IMAGE_DIGEST}" \ + --arg control_plane_image_digest "${CONTROL_PLANE_IMAGE_DIGEST}" \ '{ schema_version: 1, tag: $tag, @@ -350,7 +386,9 @@ jobs: source_release_run_id: $source_release_run_id, convergence_run_id: $convergence_run_id, r2_prefix: $r2_prefix, - activation_recovery_run_id: $activation_recovery_run_id + activation_recovery_run_id: $activation_recovery_run_id, + server_image_digest: $server_image_digest, + control_plane_image_digest: $control_plane_image_digest }' > "${activation_marker}" gh release upload "${TAG}" \ "${activation_marker}" --clobber \ @@ -412,11 +450,15 @@ jobs: --arg convergence_run_id "${CONVERGENCE_RUN_ID}" \ --arg r2_prefix "${R2_PREFIX}" \ --arg activation_recovery_run_id "${GITHUB_RUN_ID}" \ + --arg server_image_digest "${SERVER_IMAGE_DIGEST}" \ + --arg control_plane_image_digest "${CONTROL_PLANE_IMAGE_DIGEST}" \ '.schema_version == 1 and .tag == $tag and .target_commitish == $target_commitish and .release_id == $release_id and .source_release_run_id == $source_release_run_id and .convergence_run_id == $convergence_run_id and .r2_prefix == $r2_prefix and - .activation_recovery_run_id == $activation_recovery_run_id' \ + .activation_recovery_run_id == $activation_recovery_run_id and + .server_image_digest == $server_image_digest and + .control_plane_image_digest == $control_plane_image_digest' \ "${verified_marker}" >/dev/null trap - ERR echo "[OK] Recovered, immutably committed, and attested ${TAG}; convergence run ${CONVERGENCE_RUN_ID} owns customer rollout." diff --git a/.github/workflows/release-convergence.yml b/.github/workflows/release-convergence.yml index 0f8f85797..701226c61 100644 --- a/.github/workflows/release-convergence.yml +++ b/.github/workflows/release-convergence.yml @@ -53,6 +53,8 @@ jobs: outputs: activation_owner_run_id: ${{ steps.marker.outputs.activation_owner_run_id }} activation_marker_sha256: ${{ steps.marker.outputs.activation_marker_sha256 }} + server_image_digest: ${{ steps.marker.outputs.server_image_digest }} + control_plane_image_digest: ${{ steps.marker.outputs.control_plane_image_digest }} steps: - name: Checkout release integrity control uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -107,7 +109,7 @@ jobs: --arg release_id "${EXPECTED_RELEASE_ID}" \ --arg source_run_id "${EXPECTED_SOURCE_RUN_ID}" \ --arg r2_prefix "${EXPECTED_R2_PREFIX}" \ - '.schema_version == 1 and .tag == $tag and .target_commitish == $commit and .release_id == $release_id and .source_release_run_id == $source_run_id and (.convergence_run_id | test("^[0-9]+$")) and .r2_prefix == $r2_prefix' \ + '.schema_version == 1 and .tag == $tag and .target_commitish == $commit and .release_id == $release_id and .source_release_run_id == $source_run_id and (.convergence_run_id | test("^[0-9]+$")) and .r2_prefix == $r2_prefix and (.server_image_digest | test("^sha256:[0-9a-f]{64}$")) and (.control_plane_image_digest | test("^sha256:[0-9a-f]{64}$"))' \ "${marker}" >/dev/null; then rm -f "${marker}" echo "::error::Public activation marker for ${TAG} does not match the expected immutable release identity." @@ -136,8 +138,12 @@ jobs: exit 1 fi activation_marker_sha256="$(sha256sum "${marker}" | awk '{print $1}')" + server_image_digest="$(jq -r '.server_image_digest' "${marker}")" + control_plane_image_digest="$(jq -r '.control_plane_image_digest' "${marker}")" echo "activation_owner_run_id=${activation_owner_run_id}" >> "$GITHUB_OUTPUT" echo "activation_marker_sha256=${activation_marker_sha256}" >> "$GITHUB_OUTPUT" + echo "server_image_digest=${server_image_digest}" >> "$GITHUB_OUTPUT" + echo "control_plane_image_digest=${control_plane_image_digest}" >> "$GITHUB_OUTPUT" rm -f "${marker}" echo "[OK] ${TAG} crossed the immutable, attested activation commit point." exit 0 @@ -198,6 +204,8 @@ jobs: desired_tag: ${{ steps.admit.outputs.desired_tag }} owner_asset_name: ${{ steps.owner.outputs.owner_asset_name }} owner_asset_sha256: ${{ steps.owner.outputs.owner_asset_sha256 }} + server_image_digest: ${{ needs.await_activation_commit.outputs.server_image_digest }} + control_plane_image_digest: ${{ needs.await_activation_commit.outputs.control_plane_image_digest }} steps: - name: Checkout release control uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -333,6 +341,9 @@ jobs: with: tag: ${{ inputs.tag }} prerelease: ${{ inputs.prerelease }} + source_sha: ${{ inputs.target_commitish }} + server_digest: ${{ needs.acquire_customer_promotion_lease.outputs.server_image_digest }} + control_plane_digest: ${{ needs.acquire_customer_promotion_lease.outputs.control_plane_image_digest }} publish_helm_pages: name: Converge Helm Pages diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index b7eff5425..e82c24383 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -146,6 +146,10 @@ failing status and requires an explicit corrective release path. The activation marker is part of that complete draft packet: its stored digest must be checked before publication, and customer convergence is forbidden until GitHub reports the release immutable and its signed release attestation verifies. +The marker must also retain the verified server and provider control-plane image +digests. Normal and activation-recovery publication both fail closed without +those identities, and convergence may forward only the values read from the +immutable marker to public-container alias promotion. The accelerated exact-SHA release worker must preserve release-gate fidelity under its own resource envelope. Bounded frontend static checks and integration @@ -295,6 +299,8 @@ release-latency optimization. 96. `.github/scripts/setup-demo-ssh.sh` 97. `scripts/trigger-stable-patch.sh` 98. `scripts/verify-github-release-integrity.sh` +99. `scripts/verify-release-container-images.sh` +100. `scripts/release_control/verify_release_container_images_test.py` ## Shared Boundaries @@ -630,7 +636,7 @@ artifact-selection behaviour. ## Extension Points 1. Add or change deployment-type detection, update planning, or apply behavior through `internal/updates/` -2. Add or change release-build metadata injection, Docker build-context allowlists, release artifact assembly, governed promotion metadata resolution, artifact release-line validation, post-install live-runtime claim proof, the canonical version file, operator-facing release packet content, model-selected visual release-note capture, prerelease feedback intake wording, historical published-release integrity backfill, release asset validation status publication, download endpoint checksum/signature header proof, end-to-end install.sh smoke against staged or published release assets, or the canonical in-repo v6 upgrade guide through `scripts/build-release.sh`, `scripts/build-release-binaries.sh`, `scripts/release_build_targets.sh`, `scripts/run-release-backend-tests.sh`, `scripts/shard_go_tests.py`, `scripts/release_asset_common.sh`, `scripts/backfill-release-assets.sh`, `scripts/release_ldflags.sh`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/capture-release-note-visuals.sh`, `scripts/release-preflight-worker.sh`, `scripts/run-release-preflight.sh`, `scripts/verify-github-release-integrity.sh`, `scripts/release_control/capture_release_note_visuals.mjs`, `scripts/release_control/release_note_visuals.py`, `scripts/release_control/live_runtime_proof.py`, `scripts/release_control/live_runtime_proof_test.py`, `scripts/release_control/mobile_release_gate.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/resolve_release_promotion.py`, `scripts/release_control/validate_artifact_release_line.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `pulse-enterprise:scripts/build-pro-binaries.sh`, `pulse-enterprise:scripts/build-pro-release.sh`, `pulse-enterprise:scripts/validate-pro-release-line.sh`, `.dockerignore`, `Dockerfile`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/UPGRADE_v6.md`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/validate-release.sh`, `scripts/validate-published-release.sh`, the operator dispatch helpers `scripts/trigger-release.sh` and `scripts/trigger-release-dry-run.sh`, and the governed release workflows `.github/workflows/backfill-release-assets.yml`, `.github/workflows/build-release-candidate.yml`, `.github/workflows/create-release.yml`, `.github/workflows/deploy-demo-server.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/install-sh-smoke.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/promote-private-pro-runtime.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/release-convergence.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/retry-release-convergence.yml`, `.github/workflows/update-demo-server.yml`, `.github/workflows/validate-release-assets.yml`, and `pulse-enterprise:.github/workflows/build-pro-release.yml` +2. Add or change release-build metadata injection, Docker build-context allowlists, release artifact assembly, governed promotion metadata resolution, artifact release-line validation, post-install live-runtime claim proof, the canonical version file, operator-facing release packet content, model-selected visual release-note capture, prerelease feedback intake wording, historical published-release integrity backfill, release asset validation status publication, download endpoint checksum/signature header proof, end-to-end install.sh smoke against staged or published release assets, or the canonical in-repo v6 upgrade guide through `scripts/build-release.sh`, `scripts/build-release-binaries.sh`, `scripts/release_build_targets.sh`, `scripts/run-release-backend-tests.sh`, `scripts/shard_go_tests.py`, `scripts/release_asset_common.sh`, `scripts/backfill-release-assets.sh`, `scripts/release_ldflags.sh`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/capture-release-note-visuals.sh`, `scripts/release-preflight-worker.sh`, `scripts/run-release-preflight.sh`, `scripts/verify-github-release-integrity.sh`, `scripts/verify-release-container-images.sh`, `scripts/release_control/verify_release_container_images_test.py`, `scripts/release_control/capture_release_note_visuals.mjs`, `scripts/release_control/release_note_visuals.py`, `scripts/release_control/live_runtime_proof.py`, `scripts/release_control/live_runtime_proof_test.py`, `scripts/release_control/mobile_release_gate.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/resolve_release_promotion.py`, `scripts/release_control/validate_artifact_release_line.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `pulse-enterprise:scripts/build-pro-binaries.sh`, `pulse-enterprise:scripts/build-pro-release.sh`, `pulse-enterprise:scripts/validate-pro-release-line.sh`, `.dockerignore`, `Dockerfile`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/UPGRADE_v6.md`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/validate-release.sh`, `scripts/validate-published-release.sh`, the operator dispatch helpers `scripts/trigger-release.sh` and `scripts/trigger-release-dry-run.sh`, and the governed release workflows `.github/workflows/backfill-release-assets.yml`, `.github/workflows/build-release-candidate.yml`, `.github/workflows/create-release.yml`, `.github/workflows/deploy-demo-server.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/install-sh-smoke.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/promote-private-pro-runtime.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/recover-release-activation.yml`, `.github/workflows/release-convergence.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/retry-release-convergence.yml`, `.github/workflows/update-demo-server.yml`, `.github/workflows/validate-release-assets.yml`, and `pulse-enterprise:.github/workflows/build-pro-release.yml` The governed release-build surface also includes `scripts/prepare-release-container-context.sh` for exact-candidate container assembly. @@ -791,9 +797,12 @@ artifact-selection behaviour. promotion. The exact-version server and provider control-plane image builds are independent consumers of the same immutable container payload and must publish and attest in separate matrix jobs. Each matrix leg independently - verifies the exact checkout and candidate manifest; the reusable workflow - succeeds only after both legs finish, so parallel assembly cannot weaken the - readiness join or the shared source-SHA boundary. + verifies the exact checkout and candidate manifest. After both legs finish, + the reusable workflow must resolve the `v`-prefixed and unprefixed tags on + Docker Hub and GHCR to one digest per image, verify each registry's keyless + provenance against the exact source SHA and reusable signer workflow, and + export those two digests. Parallel assembly therefore cannot weaken the + readiness join or leave activation trusting a mutable registry tag. The backend runner must compile the race-enabled `internal/api` test binary once, enumerate every top-level test from that exact binary, and generate a deterministic manifest proving a complete, disjoint partition. Each @@ -1204,7 +1213,7 @@ artifact-selection behaviour. the helper must wait for the Organization selector to hold the requested org before a scenario navigates onward, so an interrupted org-list bootstrap cannot fall back to `default` and mask the scoped UI under test. -6. Add or change governed release-promotion workflow inputs, operator-facing promotion metadata, the canonical version file, prerelease feedback intake prompts, artifact publication lineage enforcement, release note or changelog packet composition, stable-promotion rehearsal summaries, or the optional exact-SHA external-worker acceleration through `.github/workflows/create-release.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/promote-private-pro-runtime.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/release-convergence.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/retry-release-convergence.yml`, `.github/workflows/update-demo-server.yml`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release-preflight-worker.sh`, `scripts/run-release-preflight.sh`, `scripts/release_control/mobile_release_gate.py`, `scripts/release_control/mobile_release_gate_test.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/validate_artifact_release_line.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `scripts/trigger-release.sh`, and `scripts/trigger-release-dry-run.sh` +6. Add or change governed release-promotion workflow inputs, operator-facing promotion metadata, the canonical version file, prerelease feedback intake prompts, artifact publication lineage enforcement, release note or changelog packet composition, stable-promotion rehearsal summaries, or the optional exact-SHA external-worker acceleration through `.github/workflows/create-release.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/promote-private-pro-runtime.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/recover-release-activation.yml`, `.github/workflows/release-convergence.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/retry-release-convergence.yml`, `.github/workflows/update-demo-server.yml`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release-preflight-worker.sh`, `scripts/run-release-preflight.sh`, `scripts/release_control/mobile_release_gate.py`, `scripts/release_control/mobile_release_gate_test.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/validate_artifact_release_line.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `scripts/trigger-release.sh`, and `scripts/trigger-release-dry-run.sh` That release-promotion boundary also owns prerelease note packet lineage: shipped RC notes must remain historically accurate, the top-level `docs/RELEASE_NOTES.md` index must continue to point at the current shipped @@ -1636,9 +1645,14 @@ artifact-selection behaviour. publisher must publish only immutable version tags; `promote-floating-tags` is the sole owner of `rc`, `latest`, major, and major/minor aliases for both `pulse` and `pulse-control-plane` on Docker Hub and GHCR. It must expose - `workflow_call` inputs (`tag`, `prerelease`), refuse a draft or quarantined - GitHub release, and depend on successful activation in the create-release - wiring so every alias points at an already-public, verified release. + `workflow_call` inputs for the tag, channel, source SHA, and both + activation-committed image digests; refuse a draft or quarantined GitHub + release; and depend on successful activation in the create-release wiring. + Immediately before mutation it must re-resolve all exact-version tags and + reverify both registries' provenance against those committed identities. + Alias creation must use registry-specific `image@sha256:...` sources rather + than dereferencing the mutable version tag again, and every resulting alias + must resolve back to the expected digest before convergence succeeds. Generated chart docs are part of the packaged release artifact, not a disposable byproduct: when the stable candidate version changes, the checked in `deploy/helm/pulse/README.md` output must be regenerated from the same diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index 21fea0673..79c5543af 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -397,6 +397,9 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("r2_prefix: $r2_prefix", activation) self.assertIn(".r2_prefix == $r2_prefix", activation) self.assertIn(".r2_prefix == $r2_prefix", convergence) + for digest_field in ("server_image_digest", "control_plane_image_digest"): + self.assertIn(digest_field, activation) + self.assertIn(digest_field, convergence) self.assertIn("committed=true", activation) self.assertIn("Immutably committed, attested, and publicly verified ${TAG}", activation) self.assertIn("Draft activation marker digest does not match", activation) @@ -1968,6 +1971,10 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertNotIn("subject-name: docker.io/rcourtman/pulse-agent", publish) self.assertNotIn("subject-name: ghcr.io/${{ github.repository_owner }}/pulse-agent", publish) self.assertIn("create-storage-record: false", publish) + self.assertIn("server_digest:", publish) + self.assertIn("control_plane_digest:", publish) + self.assertIn("Verify exact image identities and provenance", publish) + self.assertIn("verify-release-container-images.sh", publish) self.assertIn("target: runtime_prebuilt", publish) self.assertIn("target: control_plane_prebuilt", publish) self.assertIn("Verify exact-candidate container payload", publish) @@ -1975,6 +1982,14 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertNotIn("PULSE_UPDATE_SIGNING_KEY", publish) self.assertNotIn("provenance: false", publish) self.assertIn("validate_artifact_release_line.py", promote) + self.assertIn("source_sha:", promote) + self.assertIn("server_digest:", promote) + self.assertIn("control_plane_digest:", promote) + self.assertIn("verify-release-container-images.sh", promote) + self.assertIn('docker_source="docker.io/rcourtman/${image}@${expected_digest}"', promote) + self.assertIn('ghcr_source="ghcr.io/${OWNER}/${image}@${expected_digest}"', promote) + self.assertNotIn('"rcourtman/${image}:${TAG}"', promote) + self.assertNotIn('"ghcr.io/${OWNER}/${image}:${TAG}"', promote) self.assertIn("control_plane.py --branch-for-version", demo) self.assertIn("demo-stable", demo) self.assertIn("Refusing prerelease tag", demo) diff --git a/scripts/release_control/verify_release_container_images_test.py b/scripts/release_control/verify_release_container_images_test.py new file mode 100644 index 000000000..007937e95 --- /dev/null +++ b/scripts/release_control/verify_release_container_images_test.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import os +from pathlib import Path +import subprocess +import tempfile +import textwrap +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / "scripts" / "verify-release-container-images.sh" +SOURCE_SHA = "a" * 40 +DIGEST = "sha256:" + "b" * 64 + + +class VerifyReleaseContainerImagesTests(unittest.TestCase): + def run_verifier( + self, + *, + overrides: dict[str, str] | None = None, + gh_exit: int = 0, + tag: str = "v6.4.1", + source_sha: str = SOURCE_SHA, + ) -> tuple[subprocess.CompletedProcess[str], str]: + with tempfile.TemporaryDirectory() as temp: + temp_path = Path(temp) + bin_path = temp_path / "bin" + bin_path.mkdir() + gh_log = temp_path / "gh.log" + digest_file = temp_path / "digests" + values = { + "docker.io/rcourtman/pulse:v6.4.1": DIGEST, + "docker.io/rcourtman/pulse:6.4.1": DIGEST, + "ghcr.io/rcourtman/pulse:v6.4.1": DIGEST, + "ghcr.io/rcourtman/pulse:6.4.1": DIGEST, + "docker.io/rcourtman/pulse-control-plane:v6.4.1": DIGEST, + "docker.io/rcourtman/pulse-control-plane:6.4.1": DIGEST, + "ghcr.io/rcourtman/pulse-control-plane:v6.4.1": DIGEST, + "ghcr.io/rcourtman/pulse-control-plane:6.4.1": DIGEST, + } + values.update(overrides or {}) + digest_file.write_text( + "\n".join(f"{reference} {digest}" for reference, digest in values.items()) + + "\n", + encoding="utf-8", + ) + (bin_path / "docker").write_text( + textwrap.dedent( + """\ + #!/bin/sh + reference="$4" + digest=$(awk -v ref="$reference" '$1 == ref { print $2 }' "$DIGEST_FILE") + [ -n "$digest" ] || exit 1 + printf '{"digest":"%s"}\n' "$digest" + """ + ), + encoding="utf-8", + ) + (bin_path / "gh").write_text( + textwrap.dedent( + """\ + #!/bin/sh + printf '%s\n' "$*" >> "$GH_LOG" + exit "$GH_EXIT" + """ + ), + encoding="utf-8", + ) + for command in (bin_path / "docker", bin_path / "gh"): + command.chmod(0o755) + + env = os.environ.copy() + env.update( + { + "PATH": f"{bin_path}:{env['PATH']}", + "DIGEST_FILE": str(digest_file), + "GH_LOG": str(gh_log), + "GH_EXIT": str(gh_exit), + } + ) + result = subprocess.run( + [str(SCRIPT), tag, source_sha, "rcourtman/Pulse"], + cwd=ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + return result, gh_log.read_text(encoding="utf-8") if gh_log.exists() else "" + + def test_emits_digest_proof_after_verifying_both_registries(self) -> None: + result, calls = self.run_verifier() + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + result.stdout.splitlines(), + [f"server_digest={DIGEST}", f"control_plane_digest={DIGEST}"], + ) + self.assertEqual(len(calls.splitlines()), 4) + self.assertIn(f"oci://docker.io/rcourtman/pulse@{DIGEST}", calls) + self.assertIn(f"oci://ghcr.io/rcourtman/pulse-control-plane@{DIGEST}", calls) + self.assertIn("--repo rcourtman/Pulse", calls) + self.assertIn("--bundle-from-oci", calls) + self.assertIn( + "--signer-workflow github.com/rcourtman/Pulse/.github/workflows/publish-docker.yml", + calls, + ) + self.assertIn(f"--source-digest {SOURCE_SHA}", calls) + + def test_rejects_a_moved_exact_version_tag_before_attestation(self) -> None: + changed = "sha256:" + "c" * 64 + result, calls = self.run_verifier( + overrides={"ghcr.io/rcourtman/pulse:6.4.1": changed} + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("do not resolve to one digest", result.stderr) + self.assertEqual(calls, "") + + def test_rejects_an_unverifiable_attestation(self) -> None: + result, calls = self.run_verifier(gh_exit=1) + + self.assertNotEqual(result.returncode, 0) + self.assertEqual(len(calls.splitlines()), 1) + + def test_rejects_invalid_release_identity_without_registry_calls(self) -> None: + result, calls = self.run_verifier(source_sha="main") + + self.assertNotEqual(result.returncode, 0) + self.assertIn("Invalid release source SHA", result.stderr) + self.assertEqual(calls, "") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify-release-container-images.sh b/scripts/verify-release-container-images.sh new file mode 100755 index 000000000..80a1f6642 --- /dev/null +++ b/scripts/verify-release-container-images.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash + +# Resolve every exact-version public container tag to one digest per image and +# verify the GitHub build-provenance attestation for that digest. The two +# machine-readable output lines are intentionally stable so release workflows +# can carry the verified identities across the activation boundary. + +set -euo pipefail + +if [ "$#" -lt 2 ] || [ "$#" -gt 3 ]; then + echo "Usage: $0 [owner/repo]" >&2 + exit 2 +fi + +TAG="$1" +SOURCE_SHA="$2" +REPOSITORY="${3:-${GITHUB_REPOSITORY:-rcourtman/Pulse}}" +OWNER="${REPOSITORY%%/*}" +VERSION="${TAG#v}" +SIGNER_WORKFLOW="github.com/${REPOSITORY}/.github/workflows/publish-docker.yml" + +if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-((rc|alpha|beta)\.[0-9]+))?$ ]]; then + echo "Invalid release tag: ${TAG}" >&2 + exit 1 +fi +if [[ ! "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "Invalid release source SHA: ${SOURCE_SHA}" >&2 + exit 1 +fi +if [[ ! "$REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then + echo "Invalid GitHub repository: ${REPOSITORY}" >&2 + exit 1 +fi + +for command in docker gh jq; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "${command} is required to verify release container images." >&2 + exit 1 + fi +done + +resolve_digest() { + local reference="$1" + local manifest + manifest="$(docker buildx imagetools inspect "$reference" --format '{{json .Manifest}}')" + jq -er '.digest | select(type == "string" and test("^sha256:[0-9a-f]{64}$"))' <<<"$manifest" +} + +verify_image() { + local image="$1" + local output_name="$2" + local docker_name="docker.io/rcourtman/${image}" + local ghcr_name="ghcr.io/${OWNER}/${image}" + local docker_tag_digest docker_version_digest ghcr_tag_digest ghcr_version_digest + + docker_tag_digest="$(resolve_digest "${docker_name}:${TAG}")" + docker_version_digest="$(resolve_digest "${docker_name}:${VERSION}")" + ghcr_tag_digest="$(resolve_digest "${ghcr_name}:${TAG}")" + ghcr_version_digest="$(resolve_digest "${ghcr_name}:${VERSION}")" + + if [ "$docker_tag_digest" != "$docker_version_digest" ] || \ + [ "$docker_tag_digest" != "$ghcr_tag_digest" ] || \ + [ "$docker_tag_digest" != "$ghcr_version_digest" ]; then + echo "Exact-version ${image} tags do not resolve to one digest:" >&2 + printf ' %s:%s = %s\n' "$docker_name" "$TAG" "$docker_tag_digest" >&2 + printf ' %s:%s = %s\n' "$docker_name" "$VERSION" "$docker_version_digest" >&2 + printf ' %s:%s = %s\n' "$ghcr_name" "$TAG" "$ghcr_tag_digest" >&2 + printf ' %s:%s = %s\n' "$ghcr_name" "$VERSION" "$ghcr_version_digest" >&2 + return 1 + fi + + for subject in "$docker_name" "$ghcr_name"; do + gh attestation verify "oci://${subject}@${docker_tag_digest}" \ + --repo "$REPOSITORY" \ + --bundle-from-oci \ + --signer-workflow "$SIGNER_WORKFLOW" \ + --source-digest "$SOURCE_SHA" \ + >/dev/null + done + + printf '%s=%s\n' "$output_name" "$docker_tag_digest" + echo "[OK] ${image} exact-version tags and provenance resolve to ${docker_tag_digest}." >&2 +} + +verify_image pulse server_digest +verify_image pulse-control-plane control_plane_digest