Bind container promotion to attested digests

This commit is contained in:
pulse-triage[bot]
2026-08-29 22:47:12 +01:00
parent 6d4ee48000
commit e67e4a7c5f
10 changed files with 476 additions and 60 deletions
@@ -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
+28 -4
View File
@@ -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}")"
+80 -45
View File
@@ -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
+48
View File
@@ -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."
+12 -1
View File
@@ -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()
+86
View File
@@ -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