mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Bind container promotion to attested digests
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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}")"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
Executable
+86
@@ -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 <tag> <source-sha> [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
|
||||
Reference in New Issue
Block a user