Continuously verify stable container aliases

This commit is contained in:
pulse-triage[bot] 2026-08-30 13:50:47 +01:00
parent 161e322ee0
commit 7d9e77ff32
7 changed files with 268 additions and 6 deletions

View file

@ -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.

View file

@ -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

View file

@ -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

1
.gitignore vendored
View file

@ -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

View file

@ -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) {

View file

@ -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()

View file

@ -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 <stable-tag> <server-digest> <control-plane-digest> [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"