From 7d9e77ff320ce9072754a7034452afe89127ec42 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:50:47 +0100 Subject: [PATCH] Continuously verify stable container aliases --- .github/workflows/README.md | 15 ++- .github/workflows/canonical-governance.yml | 3 + .github/workflows/security-scan.yml | 45 +++++++ .gitignore | 1 + .../installtests/build_release_assets_test.go | 12 ++ .../verify_stable_container_aliases_test.py | 124 ++++++++++++++++++ scripts/verify-stable-container-aliases.sh | 74 +++++++++++ 7 files changed, 268 insertions(+), 6 deletions(-) create mode 100644 scripts/release_control/verify_stable_container_aliases_test.py create mode 100755 scripts/verify-stable-container-aliases.sh diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 0832d3328a..f23433a127 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -19,12 +19,15 @@ data and must never be interpolated into the generated shell program. ## Release Continuity -The weekly `security-scan.yml` backstop also verifies the latest advertised -stable release from the public surfaces customers use. It binds the immutable -GitHub release and activation marker to one source commit, authenticates every -checksummed release asset and SSH signature, re-verifies release and build -attestations, and requires the exact Docker Hub, GHCR, and OCI Helm identities -to remain equal to the digests committed at activation. Every run retains a +The weekly `security-scan.yml` backstop and an immediate read-back after every +stable release convergence verify the latest advertised stable release from +the public surfaces customers use. They bind the immutable +GitHub release and activation marker to one source commit, authenticate every +checksummed release asset and SSH signature, re-verify release and build +attestations, and require the exact Docker Hub, GHCR, and OCI Helm identities +to remain equal to the digests committed at activation. Stable Docker Hub and +GHCR discovery aliases (`latest`, major, and major-minor) must also retain +those identities. Every run retains a machine-readable evidence packet for 90 days, including partial outcomes when a check fails. The job is read-only and requires the public `PULSE_UPDATE_SIGNING_PUBLIC_KEY` repository variable. diff --git a/.github/workflows/canonical-governance.yml b/.github/workflows/canonical-governance.yml index 774e78b806..eca10d4b14 100644 --- a/.github/workflows/canonical-governance.yml +++ b/.github/workflows/canonical-governance.yml @@ -256,6 +256,9 @@ jobs: - name: Run release container identity verifier unit tests run: python3 scripts/release_control/verify_release_container_images_test.py + - name: Run stable container alias verifier unit tests + run: python3 scripts/release_control/verify_stable_container_aliases_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/security-scan.yml b/.github/workflows/security-scan.yml index 4c2e0dd62a..61246048aa 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -10,6 +10,9 @@ on: schedule: - cron: '30 5 * * 1' # weekly, Monday 05:30 UTC, before the 06:00 triage run workflow_dispatch: + workflow_run: + workflows: [Release Convergence] + types: [completed] permissions: contents: read @@ -17,6 +20,10 @@ permissions: jobs: release-continuity: name: Latest stable release continuity + # Stable convergence gets an immediate independent read-back. Prerelease + # convergence is excluded because this job intentionally follows the + # advertised stable channel; the weekly and manual backstops always run. + if: ${{ github.event_name != 'workflow_run' || !contains(github.event.workflow_run.display_title, '-') }} runs-on: ubuntu-24.04 timeout-minutes: 60 steps: @@ -135,6 +142,19 @@ jobs: exit 1 fi + - name: Verify stable container discovery aliases + id: aliases + if: ${{ always() && steps.release.outcome == 'success' }} + env: + TAG: ${{ steps.release.outputs.tag }} + EXPECTED_SERVER_DIGEST: ${{ steps.release.outputs.server_image_digest }} + EXPECTED_CONTROL_PLANE_DIGEST: ${{ steps.release.outputs.control_plane_image_digest }} + REGISTRY_OWNER: ${{ github.repository_owner }} + run: >- + ./scripts/verify-stable-container-aliases.sh + "${TAG}" "${EXPECTED_SERVER_DIGEST}" "${EXPECTED_CONTROL_PLANE_DIGEST}" + "${REGISTRY_OWNER}" + - name: Verify exact-version Helm identity id: helm if: ${{ always() && steps.release.outcome == 'success' }} @@ -154,6 +174,11 @@ jobs: REPOSITORY: ${{ github.repository }} RUN_ID: ${{ github.run_id }} RUN_ATTEMPT: ${{ github.run_attempt }} + TRIGGER_EVENT: ${{ github.event_name }} + CONVERGENCE_RUN_ID: ${{ github.event.workflow_run.id }} + CONVERGENCE_RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }} + CONVERGENCE_CONCLUSION: ${{ github.event.workflow_run.conclusion }} + CONVERGENCE_DISPLAY_TITLE: ${{ github.event.workflow_run.display_title }} TAG: ${{ steps.release.outputs.tag }} RELEASE_ID: ${{ steps.release.outputs.release_id }} SOURCE_SHA: ${{ steps.release.outputs.source_sha }} @@ -165,6 +190,7 @@ jobs: PACKET_RESULT: ${{ steps.packet.outcome }} ASSET_RESULT: ${{ steps.assets.outcome }} CONTAINER_RESULT: ${{ steps.containers.outcome }} + ALIAS_RESULT: ${{ steps.aliases.outcome }} HELM_RESULT: ${{ steps.helm.outcome }} run: | set -euo pipefail @@ -176,6 +202,11 @@ jobs: --arg repository "${REPOSITORY}" \ --arg run_id "${RUN_ID}" \ --arg run_attempt "${RUN_ATTEMPT}" \ + --arg trigger_event "${TRIGGER_EVENT}" \ + --arg convergence_run_id "${CONVERGENCE_RUN_ID}" \ + --arg convergence_run_attempt "${CONVERGENCE_RUN_ATTEMPT}" \ + --arg convergence_conclusion "${CONVERGENCE_CONCLUSION}" \ + --arg convergence_display_title "${CONVERGENCE_DISPLAY_TITLE}" \ --arg tag "${TAG}" \ --arg release_id "${RELEASE_ID}" \ --arg source_sha "${SOURCE_SHA}" \ @@ -187,12 +218,22 @@ jobs: --arg packet_result "${PACKET_RESULT}" \ --arg asset_result "${ASSET_RESULT}" \ --arg container_result "${CONTAINER_RESULT}" \ + --arg alias_result "${ALIAS_RESULT}" \ --arg helm_result "${HELM_RESULT}" \ '{ schema_version: ($schema_version | tonumber), checked_at: $checked_at, repository: $repository, workflow_run: {id: $run_id, attempt: $run_attempt}, + trigger: { + event: $trigger_event, + release_convergence_run: { + id: $convergence_run_id, + attempt: $convergence_run_attempt, + conclusion: $convergence_conclusion, + display_title: $convergence_display_title + } + }, release: { tag: $tag, id: $release_id, @@ -207,6 +248,7 @@ jobs: immutable_packet_and_provenance: $packet_result, authenticated_assets: $asset_result, container_identities: $container_result, + stable_container_aliases: $alias_result, helm_identity: $helm_result } }' > release-continuity-evidence/continuity-evidence.json @@ -223,6 +265,7 @@ jobs: container-lifecycle: name: Container trust and support window + if: ${{ github.event_name != 'workflow_run' }} runs-on: ubuntu-24.04 steps: - name: Checkout repository @@ -242,6 +285,7 @@ jobs: govulncheck: name: Go Vulnerability Scan (${{ matrix.directory }}) + if: ${{ github.event_name != 'workflow_run' }} runs-on: ubuntu-24.04 strategy: fail-fast: false @@ -269,6 +313,7 @@ jobs: npm-audit: name: npm Dependency Audit (${{ matrix.name }}) + if: ${{ github.event_name != 'workflow_run' }} runs-on: ubuntu-24.04 strategy: fail-fast: false diff --git a/.gitignore b/.gitignore index bf0075b1df..321ee3a124 100644 --- a/.gitignore +++ b/.gitignore @@ -278,6 +278,7 @@ scripts/release_control/* !scripts/release_control/subsystem_lookup.py !scripts/release_control/subsystem_lookup_test.py !scripts/release_control/verify_github_release_integrity_test.py +!scripts/release_control/verify_stable_container_aliases_test.py !scripts/release_control/ssh_host_key_policy_test.py !scripts/release_control/work_claim.py !scripts/release_control/work_claim_test.py diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index cefc329e38..61ab179d24 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -151,6 +151,9 @@ func TestSecurityScanRevalidatesLatestStableDelivery(t *testing.T) { } workflow := string(content) required := []string{ + "workflow_run:", + "workflows: [Release Convergence]", + `github.event_name != 'workflow_run' || !contains(github.event.workflow_run.display_title, '-')`, "release-continuity:", "Latest stable release continuity", "docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5", @@ -163,6 +166,10 @@ func TestSecurityScanRevalidatesLatestStableDelivery(t *testing.T) { `./scripts/verify-release-container-images.sh`, `EXPECTED_SERVER_DIGEST: ${{ steps.release.outputs.server_image_digest }}`, `EXPECTED_CONTROL_PLANE_DIGEST: ${{ steps.release.outputs.control_plane_image_digest }}`, + `./scripts/verify-stable-container-aliases.sh`, + `stable_container_aliases: $alias_result`, + `CONVERGENCE_RUN_ID: ${{ github.event.workflow_run.id }}`, + `release_convergence_run: {`, `./scripts/verify-release-helm-chart.sh`, `EXPECTED_HELM_DIGEST: ${{ steps.release.outputs.helm_chart_digest }}`, "continuity-evidence.json", @@ -176,6 +183,11 @@ func TestSecurityScanRevalidatesLatestStableDelivery(t *testing.T) { if strings.Contains(workflowJobBlock(t, workflow, "release-continuity"), "contents: write") { t.Fatal("scheduled release continuity check must remain read-only") } + for _, jobName := range []string{"container-lifecycle", "govulncheck", "npm-audit"} { + if !strings.Contains(workflowJobBlock(t, workflow, jobName), `github.event_name != 'workflow_run'`) { + t.Fatalf("%s must not run for the post-convergence continuity trigger", jobName) + } + } } func TestProPackagingBuildsFrontendEmbedWithoutTransferringBundle(t *testing.T) { diff --git a/scripts/release_control/verify_stable_container_aliases_test.py b/scripts/release_control/verify_stable_container_aliases_test.py new file mode 100644 index 0000000000..8fab1e4a3f --- /dev/null +++ b/scripts/release_control/verify_stable_container_aliases_test.py @@ -0,0 +1,124 @@ +#!/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-stable-container-aliases.sh" +SERVER_DIGEST = "sha256:" + "a" * 64 +CONTROL_PLANE_DIGEST = "sha256:" + "b" * 64 + + +class VerifyStableContainerAliasesTests(unittest.TestCase): + def run_verifier( + self, + *, + overrides: dict[str, str] | None = None, + tag: str = "v6.4.1", + owner: str = "rcourtman", + ) -> tuple[subprocess.CompletedProcess[str], list[str]]: + with tempfile.TemporaryDirectory() as temp: + temp_path = Path(temp) + bin_path = temp_path / "bin" + bin_path.mkdir() + digest_file = temp_path / "digests" + call_log = temp_path / "calls" + values: dict[str, str] = {} + for image, digest in ( + ("pulse", SERVER_DIGEST), + ("pulse-control-plane", CONTROL_PLANE_DIGEST), + ): + for registry in ("docker.io/rcourtman", f"ghcr.io/{owner}"): + for alias in ("6.4", "6", "latest"): + values[f"{registry}/{image}:{alias}"] = 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" + printf '%s\n' "$reference" >> "$CALL_LOG" + 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 / "docker").chmod(0o755) + + env = os.environ.copy() + env.update( + { + "PATH": f"{bin_path}:{env['PATH']}", + "DIGEST_FILE": str(digest_file), + "CALL_LOG": str(call_log), + } + ) + result = subprocess.run( + [ + str(SCRIPT), + tag, + SERVER_DIGEST, + CONTROL_PLANE_DIGEST, + owner, + ], + cwd=ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + calls = call_log.read_text(encoding="utf-8").splitlines() if call_log.exists() else [] + return result, calls + + def test_verifies_every_stable_alias_in_both_registries(self) -> None: + result, calls = self.run_verifier() + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(len(calls), 12) + self.assertIn("docker.io/rcourtman/pulse:latest", calls) + self.assertIn("ghcr.io/rcourtman/pulse-control-plane:6.4", calls) + + def test_rejects_a_moved_alias(self) -> None: + moved = "sha256:" + "c" * 64 + result, calls = self.run_verifier( + overrides={"ghcr.io/rcourtman/pulse:latest": moved} + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("resolved to", result.stderr) + self.assertIn("ghcr.io/rcourtman/pulse:latest", calls) + + def test_rejects_prerelease_without_registry_calls(self) -> None: + result, calls = self.run_verifier(tag="v6.4.2-rc.1") + self.assertNotEqual(result.returncode, 0) + self.assertIn("Invalid stable release tag", result.stderr) + self.assertEqual(calls, []) + + def test_rejects_invalid_digest_without_registry_calls(self) -> None: + result = subprocess.run( + [str(SCRIPT), "v6.4.2", "latest", CONTROL_PLANE_DIGEST], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("Invalid server image digest", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify-stable-container-aliases.sh b/scripts/verify-stable-container-aliases.sh new file mode 100755 index 0000000000..96ed4a53c8 --- /dev/null +++ b/scripts/verify-stable-container-aliases.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash + +# Verify that every stable floating container alias still selects the exact +# image digest committed by release activation. Exact-version provenance is +# checked separately by verify-release-container-images.sh; this check covers +# the mutable discovery paths customers can continue to pull after promotion. + +set -euo pipefail + +if [ "$#" -lt 3 ] || [ "$#" -gt 4 ]; then + echo "Usage: $0 [owner]" >&2 + exit 2 +fi + +TAG="$1" +SERVER_DIGEST="$2" +CONTROL_PLANE_DIGEST="$3" +OWNER="${4:-rcourtman}" +VERSION="${TAG#v}" + +if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Invalid stable release tag: ${TAG}" >&2 + exit 1 +fi +if [[ ! "$SERVER_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "Invalid server image digest: ${SERVER_DIGEST}" >&2 + exit 1 +fi +if [[ ! "$CONTROL_PLANE_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "Invalid control-plane image digest: ${CONTROL_PLANE_DIGEST}" >&2 + exit 1 +fi +if [[ ! "$OWNER" =~ ^[A-Za-z0-9_.-]+$ ]]; then + echo "Invalid registry owner: ${OWNER}" >&2 + exit 1 +fi + +for command in docker jq; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "${command} is required to verify stable container aliases." >&2 + exit 1 + fi +done + +IFS='.' read -r MAJOR MINOR _PATCH <<<"$VERSION" +ALIASES=("${MAJOR}.${MINOR}" "${MAJOR}" latest) + +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_aliases() { + local image="$1" + local expected_digest="$2" + local registry alias reference observed_digest + + for registry in "docker.io/rcourtman" "ghcr.io/${OWNER}"; do + for alias in "${ALIASES[@]}"; do + reference="${registry}/${image}:${alias}" + observed_digest="$(resolve_digest "$reference")" + if [ "$observed_digest" != "$expected_digest" ]; then + echo "Stable alias ${reference} resolved to ${observed_digest}, expected ${expected_digest}." >&2 + return 1 + fi + echo "[OK] ${reference} -> ${expected_digest}" >&2 + done + done +} + +verify_aliases pulse "$SERVER_DIGEST" +verify_aliases pulse-control-plane "$CONTROL_PLANE_DIGEST"