diff --git a/.github/workflows/canonical-governance.yml b/.github/workflows/canonical-governance.yml index 2e29f99c6..d00d70457 100644 --- a/.github/workflows/canonical-governance.yml +++ b/.github/workflows/canonical-governance.yml @@ -180,6 +180,9 @@ jobs: - name: Run immutable release setting unit tests run: python3 scripts/release_control/check_github_release_immutability_test.py + - name: Run stable release continuity diagnostic unit tests + run: python3 scripts/release_control/release_continuity_test.py + - name: Run status audit unit tests run: python3 scripts/release_control/status_audit_test.py diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 56d1dd6ad..9dca0c005 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -61,59 +61,56 @@ jobs: set -euo pipefail mkdir -p release-continuity-evidence release_json=release-continuity-evidence/release.json + + if ! gh api \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/${REPOSITORY}/releases/latest" > "${release_json}"; then + python3 scripts/release_control/release_continuity.py release \ + --release-json "${release_json}" \ + --diagnostic release-continuity-evidence/release-diagnostic.json \ + --github-output "${GITHUB_OUTPUT}" || true + exit 1 + fi + + python3 scripts/release_control/release_continuity.py release \ + --release-json "${release_json}" \ + --diagnostic release-continuity-evidence/release-diagnostic.json \ + --github-output "${GITHUB_OUTPUT}" + + - name: Bind the release activation marker + id: activation + if: ${{ steps.release.outcome == 'success' }} + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + release_json=release-continuity-evidence/release.json marker=release-continuity-evidence/release-activation.json - gh api \ - -H 'Accept: application/vnd.github+json' \ - -H 'X-GitHub-Api-Version: 2026-03-10' \ - "repos/${REPOSITORY}/releases/latest" > "${release_json}" - if ! jq -e ' - (.id | type == "number") and - (.tag_name | type == "string" and test("^v[0-9]+\\.[0-9]+\\.[0-9]+$")) and - (.target_commitish | type == "string" and test("^[0-9a-f]{40}$")) and - .draft == false and .prerelease == false and .immutable == true and - (.published_at | type == "string" and length > 0) - ' "${release_json}" >/dev/null; then - echo "::error::Advertised latest release failed the immutable stable identity contract. It must be a published, non-draft vX.Y.Z release locked immutable and bound to a 40-character source commit; inspect release.json in the continuity evidence." + if ! gh release download "${TAG}" \ + --repo "${REPOSITORY}" \ + --pattern release-activation.json \ + --dir release-continuity-evidence; then + python3 scripts/release_control/release_continuity.py activation \ + --release-json "${release_json}" \ + --activation-json "${marker}" \ + --diagnostic release-continuity-evidence/activation-diagnostic.json \ + --github-output "${GITHUB_OUTPUT}" || true exit 1 fi - tag="$(jq -r '.tag_name' "${release_json}")" - release_id="$(jq -r '.id' "${release_json}")" - source_sha="$(jq -r '.target_commitish' "${release_json}")" - gh release download "${tag}" \ - --repo "${REPOSITORY}" \ - --pattern release-activation.json \ - --dir release-continuity-evidence - - if ! jq -e \ - --arg tag "${tag}" \ - --arg release_id "${release_id}" \ - --arg source_sha "${source_sha}" \ - '.schema_version == 1 and .tag == $tag and - .release_id == $release_id and .target_commitish == $source_sha and - (.source_release_run_id | test("^[0-9]+$")) and - (.convergence_run_id | test("^[0-9]+$")) and - (.r2_prefix | type == "string" and length > 0) and - (.server_image_digest | test("^sha256:[0-9a-f]{64}$")) and - (.control_plane_image_digest | test("^sha256:[0-9a-f]{64}$")) and - (.helm_chart_digest | test("^sha256:[0-9a-f]{64}$"))' \ - "${marker}" >/dev/null; then - echo "::error::release-activation.json does not bind the advertised stable tag, release, source commit, convergence runs, and published artifact digests." - exit 1 - fi - - echo "tag=${tag}" >> "${GITHUB_OUTPUT}" - echo "release_id=${release_id}" >> "${GITHUB_OUTPUT}" - echo "source_sha=${source_sha}" >> "${GITHUB_OUTPUT}" - echo "activation_sha256=$(sha256sum "${marker}" | awk '{print $1}')" >> "${GITHUB_OUTPUT}" - echo "server_image_digest=$(jq -r '.server_image_digest' "${marker}")" >> "${GITHUB_OUTPUT}" - echo "control_plane_image_digest=$(jq -r '.control_plane_image_digest' "${marker}")" >> "${GITHUB_OUTPUT}" - echo "helm_chart_digest=$(jq -r '.helm_chart_digest' "${marker}")" >> "${GITHUB_OUTPUT}" + python3 scripts/release_control/release_continuity.py activation \ + --release-json "${release_json}" \ + --activation-json "${marker}" \ + --diagnostic release-continuity-evidence/activation-diagnostic.json \ + --github-output "${GITHUB_OUTPUT}" - name: Verify immutable release and build provenance id: packet - if: ${{ always() && steps.release.outcome == 'success' && github.event.schedule != '17 */6 * * *' }} + if: ${{ always() && steps.release.outcome == 'success' && steps.activation.outcome == 'success' && github.event.schedule != '17 */6 * * *' }} env: GH_TOKEN: ${{ github.token }} REPOSITORY: ${{ github.repository }} @@ -126,7 +123,7 @@ jobs: - name: Authenticate every published release asset id: assets - if: ${{ always() && steps.release.outcome == 'success' && github.event.schedule != '17 */6 * * *' }} + if: ${{ always() && steps.release.outcome == 'success' && steps.activation.outcome == 'success' && github.event.schedule != '17 */6 * * *' }} env: PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} REPOSITORY: ${{ github.repository }} @@ -135,14 +132,14 @@ jobs: - name: Verify exact-version container identities id: containers - if: ${{ always() && steps.release.outcome == 'success' && github.event.schedule != '17 */6 * * *' }} + if: ${{ always() && steps.release.outcome == 'success' && steps.activation.outcome == 'success' && github.event.schedule != '17 */6 * * *' }} env: GH_TOKEN: ${{ github.token }} REPOSITORY: ${{ github.repository }} TAG: ${{ steps.release.outputs.tag }} SOURCE_SHA: ${{ steps.release.outputs.source_sha }} - EXPECTED_SERVER_DIGEST: ${{ steps.release.outputs.server_image_digest }} - EXPECTED_CONTROL_PLANE_DIGEST: ${{ steps.release.outputs.control_plane_image_digest }} + EXPECTED_SERVER_DIGEST: ${{ steps.activation.outputs.server_image_digest }} + EXPECTED_CONTROL_PLANE_DIGEST: ${{ steps.activation.outputs.control_plane_image_digest }} run: | set -euo pipefail proof="$(./scripts/verify-release-container-images.sh "${TAG}" "${SOURCE_SHA}" "${REPOSITORY}")" @@ -157,11 +154,11 @@ jobs: - name: Verify stable container discovery aliases id: aliases - if: ${{ always() && steps.release.outcome == 'success' && github.event.schedule != '17 */6 * * *' }} + if: ${{ always() && steps.release.outcome == 'success' && steps.activation.outcome == 'success' && github.event.schedule != '17 */6 * * *' }} 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 }} + EXPECTED_SERVER_DIGEST: ${{ steps.activation.outputs.server_image_digest }} + EXPECTED_CONTROL_PLANE_DIGEST: ${{ steps.activation.outputs.control_plane_image_digest }} REGISTRY_OWNER: ${{ github.repository_owner }} run: >- ./scripts/verify-stable-container-aliases.sh @@ -170,13 +167,13 @@ jobs: - name: Verify exact-version Helm identity id: helm - if: ${{ always() && steps.release.outcome == 'success' && github.event.schedule != '17 */6 * * *' }} + if: ${{ always() && steps.release.outcome == 'success' && steps.activation.outcome == 'success' && github.event.schedule != '17 */6 * * *' }} env: GH_TOKEN: ${{ github.token }} REPOSITORY: ${{ github.repository }} TAG: ${{ steps.release.outputs.tag }} SOURCE_SHA: ${{ steps.release.outputs.source_sha }} - EXPECTED_HELM_DIGEST: ${{ steps.release.outputs.helm_chart_digest }} + EXPECTED_HELM_DIGEST: ${{ steps.activation.outputs.helm_chart_digest }} run: >- ./scripts/verify-release-helm-chart.sh "${TAG}" "${SOURCE_SHA}" "${REPOSITORY}" "${EXPECTED_HELM_DIGEST}" @@ -196,11 +193,12 @@ jobs: TAG: ${{ steps.release.outputs.tag }} RELEASE_ID: ${{ steps.release.outputs.release_id }} SOURCE_SHA: ${{ steps.release.outputs.source_sha }} - ACTIVATION_SHA256: ${{ steps.release.outputs.activation_sha256 }} - SERVER_IMAGE_DIGEST: ${{ steps.release.outputs.server_image_digest }} - CONTROL_PLANE_IMAGE_DIGEST: ${{ steps.release.outputs.control_plane_image_digest }} - HELM_CHART_DIGEST: ${{ steps.release.outputs.helm_chart_digest }} + ACTIVATION_SHA256: ${{ steps.activation.outputs.activation_sha256 }} + SERVER_IMAGE_DIGEST: ${{ steps.activation.outputs.server_image_digest }} + CONTROL_PLANE_IMAGE_DIGEST: ${{ steps.activation.outputs.control_plane_image_digest }} + HELM_CHART_DIGEST: ${{ steps.activation.outputs.helm_chart_digest }} RELEASE_RESULT: ${{ steps.release.outcome }} + ACTIVATION_RESULT: ${{ steps.activation.outcome }} PACKET_RESULT: ${{ steps.packet.outcome }} ASSET_RESULT: ${{ steps.assets.outcome }} CONTAINER_RESULT: ${{ steps.containers.outcome }} @@ -214,7 +212,18 @@ jobs: if [ "${TRIGGER_SCHEDULE}" = '17 */6 * * *' ]; then mode=release_lock fi + for diagnostic in release activation; do + path="release-continuity-evidence/${diagnostic}-diagnostic.json" + if [ ! -s "${path}" ]; then + jq -n \ + --arg check "${diagnostic}" \ + '{schema_version: 1, check: $check, status: "not_run", identity: {}, violations: []}' \ + > "${path}" + fi + done jq -n \ + --slurpfile release_diagnostic release-continuity-evidence/release-diagnostic.json \ + --slurpfile activation_diagnostic release-continuity-evidence/activation-diagnostic.json \ --arg schema_version "1" \ --arg checked_at "${checked_at}" \ --arg repository "${REPOSITORY}" \ @@ -235,6 +244,7 @@ jobs: --arg control_plane_image_digest "${CONTROL_PLANE_IMAGE_DIGEST}" \ --arg helm_chart_digest "${HELM_CHART_DIGEST}" \ --arg release_result "${RELEASE_RESULT}" \ + --arg activation_result "${ACTIVATION_RESULT}" \ --arg packet_result "${PACKET_RESULT}" \ --arg asset_result "${ASSET_RESULT}" \ --arg container_result "${CONTAINER_RESULT}" \ @@ -265,8 +275,13 @@ jobs: control_plane_image_digest: $control_plane_image_digest, helm_chart_digest: $helm_chart_digest }, + diagnostics: { + release_identity: $release_diagnostic[0], + activation_binding: $activation_diagnostic[0] + }, checks: { release_resolution: $release_result, + activation_binding: $activation_result, immutable_packet_and_provenance: $packet_result, authenticated_assets: $asset_result, container_identities: $container_result, diff --git a/.gitignore b/.gitignore index 7bcbe1398..bc5d7772d 100644 --- a/.gitignore +++ b/.gitignore @@ -253,6 +253,8 @@ scripts/release_control/* !scripts/release_control/relay_registration_reconnect_drain_proof.py !scripts/release_control/registry_audit.py !scripts/release_control/registry_audit_test.py +!scripts/release_control/release_continuity.py +!scripts/release_control/release_continuity_test.py !scripts/release_control/release_promotion_policy_support.py !scripts/release_control/release_promotion_policy_support_test.py !scripts/release_control/release_promotion_policy_test.py diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 2abc5ae46..6fa3a6438 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -159,25 +159,29 @@ func TestSecurityScanRevalidatesLatestStableDelivery(t *testing.T) { "Latest stable release continuity", "docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5", `"repos/${REPOSITORY}/releases/latest"`, - `.draft == false and .prerelease == false and .immutable == true`, - "Advertised latest release failed the immutable stable identity contract", - `.release_id == $release_id and .target_commitish == $source_sha`, - "release-activation.json does not bind the advertised stable tag", + `scripts/release_control/release_continuity.py release`, + "release-diagnostic.json", + "Bind the release activation marker", + `scripts/release_control/release_continuity.py activation`, + "activation-diagnostic.json", + `steps.activation.outcome == 'success'`, `./scripts/verify-github-release-integrity.sh`, `./scripts/validate-published-release.sh`, `PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}`, `./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 }}`, + `EXPECTED_SERVER_DIGEST: ${{ steps.activation.outputs.server_image_digest }}`, + `EXPECTED_CONTROL_PLANE_DIGEST: ${{ steps.activation.outputs.control_plane_image_digest }}`, `./scripts/verify-stable-container-aliases.sh`, `stable_container_aliases: $alias_result`, + `activation_binding: $activation_result`, + `release_identity: $release_diagnostic[0]`, `CONVERGENCE_RUN_ID: ${{ github.event.workflow_run.id }}`, `TRIGGER_SCHEDULE: ${{ github.event.schedule }}`, `mode=release_lock`, `mode: $mode`, `release_convergence_run: {`, `./scripts/verify-release-helm-chart.sh`, - `EXPECTED_HELM_DIGEST: ${{ steps.release.outputs.helm_chart_digest }}`, + `EXPECTED_HELM_DIGEST: ${{ steps.activation.outputs.helm_chart_digest }}`, "continuity-evidence.json", "retention-days: 90", } diff --git a/scripts/release_control/release_continuity.py b/scripts/release_control/release_continuity.py new file mode 100644 index 000000000..00ce397e6 --- /dev/null +++ b/scripts/release_control/release_continuity.py @@ -0,0 +1,458 @@ +#!/usr/bin/env python3 +"""Validate and explain the public stable-release continuity identity.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +STABLE_TAG = re.compile(r"^v[0-9]+\.[0-9]+\.[0-9]+$") +SOURCE_SHA = re.compile(r"^[0-9a-f]{40}$") +RUN_ID = re.compile(r"^[0-9]+$") +SHA256 = re.compile(r"^sha256:[0-9a-f]{64}$") +IMMUTABLE_REPLACEMENT_ACTION = ( + "Do not edit the immutable release; restore the last known-good stable target if " + "needed, then publish a corrected replacement through convergence." +) + + +@dataclass(frozen=True) +class Violation: + code: str + field: str + expected: str + actual: Any + action: str + + def as_dict(self) -> dict[str, Any]: + return { + "code": self.code, + "field": self.field, + "expected": self.expected, + "actual": diagnostic_value(self.actual), + "action": self.action, + } + + +RELEASE_RULES = { + "release_payload_invalid": ( + "GitHub did not return one release object.", + "Inspect the releases/latest API response and API availability before retrying.", + ), + "release_id_invalid": ( + "The release id is absent or malformed.", + "Do not activate the release; inspect how the release was created.", + ), + "stable_tag_invalid": ( + "The advertised release is not a stable vX.Y.Z tag.", + "Restore the latest stable pointer to an exact stable release.", + ), + "source_identity_invalid": ( + "The release is not bound to a full lowercase source commit.", + "Publish from an exact 40-character source commit.", + ), + "release_is_draft": ( + "The advertised release is still a draft.", + "Keep drafts outside the stable channel until convergence completes.", + ), + "release_is_prerelease": ( + "The advertised release is marked as a prerelease.", + "Keep prereleases outside the stable latest-release pointer.", + ), + "release_mutable": ( + "GitHub reports immutable=false for the advertised stable release.", + "Publish a replacement through the immutable-release-gated pipeline; " + "never repair the packet in place.", + ), + "publication_time_invalid": ( + "The advertised release has no publication timestamp.", + "Do not treat the release as activated until GitHub reports publication.", + ), +} + + +ACTIVATION_RULES = { + "activation_payload_invalid": ( + "The activation marker is not one JSON object.", + IMMUTABLE_REPLACEMENT_ACTION, + ), + "activation_schema_invalid": ( + "The activation marker schema is unsupported.", + IMMUTABLE_REPLACEMENT_ACTION, + ), + "activation_tag_mismatch": ( + "The activation marker names a different release tag.", + IMMUTABLE_REPLACEMENT_ACTION, + ), + "activation_release_mismatch": ( + "The activation marker names a different GitHub release id.", + IMMUTABLE_REPLACEMENT_ACTION, + ), + "activation_source_mismatch": ( + "The activation marker names a different source commit.", + IMMUTABLE_REPLACEMENT_ACTION, + ), + "source_run_invalid": ( + "The activation marker has no valid source release run id.", + IMMUTABLE_REPLACEMENT_ACTION, + ), + "convergence_run_invalid": ( + "The activation marker has no valid convergence run id.", + IMMUTABLE_REPLACEMENT_ACTION, + ), + "delivery_prefix_invalid": ( + "The activation marker has no customer-delivery prefix.", + IMMUTABLE_REPLACEMENT_ACTION, + ), + "server_digest_invalid": ( + "The activation marker has no valid server image digest.", + IMMUTABLE_REPLACEMENT_ACTION, + ), + "control_plane_digest_invalid": ( + "The activation marker has no valid control-plane image digest.", + IMMUTABLE_REPLACEMENT_ACTION, + ), + "helm_digest_invalid": ( + "The activation marker has no valid Helm chart digest.", + IMMUTABLE_REPLACEMENT_ACTION, + ), +} + + +def diagnostic_value(value: Any) -> Any: + if value is None or isinstance(value, (bool, int, float)): + return value + if isinstance(value, str): + return value if len(value) <= 160 else value[:157] + "..." + return f"<{type(value).__name__}>" + + +def read_json(path: Path) -> Any: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise ValueError(f"cannot read JSON from {path}: {exc}") from exc + + +def violation( + code: str, field: str, expected: str, actual: Any, rules: dict[str, tuple[str, str]] +) -> Violation: + _, action = rules[code] + return Violation(code, field, expected, actual, action) + + +def release_violations(payload: Any) -> list[Violation]: + if not isinstance(payload, dict): + return [ + violation( + "release_payload_invalid", + "$", + "object", + payload, + RELEASE_RULES, + ) + ] + + failures: list[Violation] = [] + release_id = payload.get("id") + if not isinstance(release_id, int) or isinstance(release_id, bool) or release_id <= 0: + failures.append( + violation("release_id_invalid", "id", "positive integer", release_id, RELEASE_RULES) + ) + tag = payload.get("tag_name") + if not isinstance(tag, str) or STABLE_TAG.fullmatch(tag) is None: + failures.append( + violation("stable_tag_invalid", "tag_name", "vX.Y.Z", tag, RELEASE_RULES) + ) + source = payload.get("target_commitish") + if not isinstance(source, str) or SOURCE_SHA.fullmatch(source) is None: + failures.append( + violation( + "source_identity_invalid", + "target_commitish", + "40 lowercase hexadecimal characters", + source, + RELEASE_RULES, + ) + ) + if payload.get("draft") is not False: + failures.append( + violation("release_is_draft", "draft", "false", payload.get("draft"), RELEASE_RULES) + ) + if payload.get("prerelease") is not False: + failures.append( + violation( + "release_is_prerelease", + "prerelease", + "false", + payload.get("prerelease"), + RELEASE_RULES, + ) + ) + if payload.get("immutable") is not True: + failures.append( + violation( + "release_mutable", "immutable", "true", payload.get("immutable"), RELEASE_RULES + ) + ) + published_at = payload.get("published_at") + if not isinstance(published_at, str) or not published_at: + failures.append( + violation( + "publication_time_invalid", + "published_at", + "non-empty timestamp", + published_at, + RELEASE_RULES, + ) + ) + return failures + + +def release_identity(payload: dict[str, Any]) -> dict[str, Any]: + return { + "id": payload["id"], + "tag": payload["tag_name"], + "source_sha": payload["target_commitish"], + "draft": payload["draft"], + "prerelease": payload["prerelease"], + "immutable": payload["immutable"], + "published_at": payload["published_at"], + } + + +def activation_violations( + payload: Any, expected_release: dict[str, Any] +) -> list[Violation]: + if not isinstance(payload, dict): + return [ + violation( + "activation_payload_invalid", + "$", + "object", + payload, + ACTIVATION_RULES, + ) + ] + + failures: list[Violation] = [] + checks = ( + ("activation_schema_invalid", "schema_version", 1, "integer 1"), + ( + "activation_tag_mismatch", + "tag", + expected_release["tag_name"], + expected_release["tag_name"], + ), + ( + "activation_release_mismatch", + "release_id", + str(expected_release["id"]), + str(expected_release["id"]), + ), + ( + "activation_source_mismatch", + "target_commitish", + expected_release["target_commitish"], + expected_release["target_commitish"], + ), + ) + for code, field, expected, expected_description in checks: + actual = payload.get(field) + if actual != expected or (field == "schema_version" and isinstance(actual, bool)): + failures.append( + violation(code, field, str(expected_description), actual, ACTIVATION_RULES) + ) + + for code, field in ( + ("source_run_invalid", "source_release_run_id"), + ("convergence_run_invalid", "convergence_run_id"), + ): + actual = payload.get(field) + if not isinstance(actual, str) or RUN_ID.fullmatch(actual) is None: + failures.append( + violation(code, field, "decimal run id string", actual, ACTIVATION_RULES) + ) + + prefix = payload.get("r2_prefix") + if not isinstance(prefix, str) or not prefix: + failures.append( + violation( + "delivery_prefix_invalid", + "r2_prefix", + "non-empty string", + prefix, + ACTIVATION_RULES, + ) + ) + + for code, field in ( + ("server_digest_invalid", "server_image_digest"), + ("control_plane_digest_invalid", "control_plane_image_digest"), + ("helm_digest_invalid", "helm_chart_digest"), + ): + actual = payload.get(field) + if not isinstance(actual, str) or SHA256.fullmatch(actual) is None: + failures.append( + violation(code, field, "sha256:<64 lowercase hex>", actual, ACTIVATION_RULES) + ) + return failures + + +def write_diagnostic( + path: Path, + check: str, + identity: dict[str, Any], + failures: list[Violation], +) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + result = { + "schema_version": 1, + "check": check, + "status": "failure" if failures else "success", + "identity": {key: diagnostic_value(value) for key, value in identity.items()}, + "violations": [item.as_dict() for item in failures], + } + path.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def append_outputs(path: Path, outputs: dict[str, str]) -> None: + with path.open("a", encoding="utf-8") as handle: + for key, value in outputs.items(): + if "\n" in value or "\r" in value: + raise ValueError(f"output {key} contains a line break") + handle.write(f"{key}={value}\n") + + +def report_failures(failures: list[Violation], rules: dict[str, tuple[str, str]]) -> None: + for item in failures: + message, action = rules[item.code] + print( + f"::error title=Stable release continuity [{item.code}]::{message} {action}", + file=sys.stderr, + ) + + +def validate_release(args: argparse.Namespace) -> int: + try: + payload = read_json(args.release_json) + except ValueError as exc: + payload = None + failures = [ + violation("release_payload_invalid", "$", "object", str(exc), RELEASE_RULES) + ] + else: + failures = release_violations(payload) + + identity = release_identity(payload) if isinstance(payload, dict) and not failures else { + key: payload.get(source) if isinstance(payload, dict) else None + for key, source in ( + ("id", "id"), + ("tag", "tag_name"), + ("source_sha", "target_commitish"), + ("draft", "draft"), + ("prerelease", "prerelease"), + ("immutable", "immutable"), + ("published_at", "published_at"), + ) + } + write_diagnostic(args.diagnostic, "stable_release_identity", identity, failures) + if failures: + report_failures(failures, RELEASE_RULES) + return 1 + + append_outputs( + args.github_output, + { + "tag": payload["tag_name"], + "release_id": str(payload["id"]), + "source_sha": payload["target_commitish"], + }, + ) + return 0 + + +def validate_activation(args: argparse.Namespace) -> int: + try: + release = read_json(args.release_json) + release_failures = release_violations(release) + if release_failures or not isinstance(release, dict): + raise ValueError("release identity did not pass validation") + activation = read_json(args.activation_json) + except ValueError as exc: + activation = None + failures = [ + violation("activation_payload_invalid", "$", "object", str(exc), ACTIVATION_RULES) + ] + identity: dict[str, Any] = {} + else: + failures = activation_violations(activation, release) + identity = ( + { + key: activation.get(key) + for key in ( + "schema_version", + "tag", + "release_id", + "target_commitish", + "source_release_run_id", + "convergence_run_id", + "r2_prefix", + "server_image_digest", + "control_plane_image_digest", + "helm_chart_digest", + ) + } + if isinstance(activation, dict) + else {} + ) + write_diagnostic(args.diagnostic, "release_activation_binding", identity, failures) + if failures: + report_failures(failures, ACTIVATION_RULES) + return 1 + + append_outputs( + args.github_output, + { + "activation_sha256": hashlib.sha256(args.activation_json.read_bytes()).hexdigest(), + "server_image_digest": activation["server_image_digest"], + "control_plane_image_digest": activation["control_plane_image_digest"], + "helm_chart_digest": activation["helm_chart_digest"], + }, + ) + return 0 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + + release = commands.add_parser("release") + release.add_argument("--release-json", type=Path, required=True) + release.add_argument("--diagnostic", type=Path, required=True) + release.add_argument("--github-output", type=Path, required=True) + + activation = commands.add_parser("activation") + activation.add_argument("--release-json", type=Path, required=True) + activation.add_argument("--activation-json", type=Path, required=True) + activation.add_argument("--diagnostic", type=Path, required=True) + activation.add_argument("--github-output", type=Path, required=True) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + if args.command == "release": + return validate_release(args) + return validate_activation(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release_control/release_continuity_test.py b/scripts/release_control/release_continuity_test.py new file mode 100644 index 000000000..d6e2ad309 --- /dev/null +++ b/scripts/release_control/release_continuity_test.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / "scripts" / "release_control" / "release_continuity.py" +SOURCE_SHA = "a" * 40 +DIGEST = "sha256:" + "b" * 64 + + +def valid_release() -> dict[str, object]: + return { + "id": 12345, + "tag_name": "v6.4.2", + "target_commitish": SOURCE_SHA, + "draft": False, + "prerelease": False, + "immutable": True, + "published_at": "2026-08-31T17:00:00Z", + } + + +def valid_activation() -> dict[str, object]: + return { + "schema_version": 1, + "tag": "v6.4.2", + "release_id": "12345", + "target_commitish": SOURCE_SHA, + "source_release_run_id": "1001", + "convergence_run_id": "1002", + "r2_prefix": "releases/v6.4.2", + "server_image_digest": DIGEST, + "control_plane_image_digest": DIGEST, + "helm_chart_digest": DIGEST, + } + + +class ReleaseContinuityTest(unittest.TestCase): + def run_command( + self, + command: str, + release: object, + activation: object | None = None, + ) -> tuple[subprocess.CompletedProcess[str], dict[str, object], str]: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + release_path = root / "release.json" + release_path.write_text(json.dumps(release), encoding="utf-8") + diagnostic = root / "diagnostic.json" + output = root / "github-output" + output.touch() + args = [ + sys.executable, + str(SCRIPT), + command, + "--release-json", + str(release_path), + "--diagnostic", + str(diagnostic), + "--github-output", + str(output), + ] + if command == "activation": + activation_path = root / "activation.json" + activation_path.write_text(json.dumps(activation), encoding="utf-8") + args.extend(["--activation-json", str(activation_path)]) + result = subprocess.run( + args, + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + return ( + result, + json.loads(diagnostic.read_text(encoding="utf-8")), + output.read_text(encoding="utf-8"), + ) + + def test_accepts_exact_immutable_stable_release(self) -> None: + result, diagnostic, output = self.run_command("release", valid_release()) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(diagnostic["status"], "success") + self.assertEqual(diagnostic["violations"], []) + self.assertIn("tag=v6.4.2\n", output) + self.assertIn("release_id=12345\n", output) + self.assertIn(f"source_sha={SOURCE_SHA}\n", output) + + def test_mutable_release_fails_with_one_actionable_reason(self) -> None: + release = valid_release() + release["immutable"] = False + result, diagnostic, output = self.run_command("release", release) + self.assertEqual(result.returncode, 1) + self.assertEqual(output, "") + self.assertEqual(diagnostic["status"], "failure") + self.assertEqual( + [item["code"] for item in diagnostic["violations"]], + ["release_mutable"], + ) + self.assertIn("immutable=false", result.stderr) + self.assertIn("never repair the packet in place", result.stderr) + + def test_reports_every_release_identity_violation(self) -> None: + result, diagnostic, output = self.run_command( + "release", + { + "id": True, + "tag_name": "v6.4.2-rc.1\nforged", + "target_commitish": "main", + "draft": True, + "prerelease": True, + "immutable": None, + "published_at": "", + }, + ) + self.assertEqual(result.returncode, 1) + self.assertEqual(output, "") + self.assertEqual( + {item["code"] for item in diagnostic["violations"]}, + { + "release_id_invalid", + "stable_tag_invalid", + "source_identity_invalid", + "release_is_draft", + "release_is_prerelease", + "release_mutable", + "publication_time_invalid", + }, + ) + self.assertNotIn("forged", result.stderr) + + def test_accepts_exact_activation_binding_and_emits_digests(self) -> None: + result, diagnostic, output = self.run_command( + "activation", valid_release(), valid_activation() + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(diagnostic["status"], "success") + self.assertRegex(output, r"activation_sha256=[0-9a-f]{64}\n") + self.assertIn(f"server_image_digest={DIGEST}\n", output) + self.assertIn(f"control_plane_image_digest={DIGEST}\n", output) + self.assertIn(f"helm_chart_digest={DIGEST}\n", output) + + def test_activation_mismatches_are_classified_without_outputs(self) -> None: + activation = valid_activation() + activation.update( + { + "schema_version": True, + "tag": "v6.4.1", + "release_id": 12345, + "target_commitish": "c" * 40, + "source_release_run_id": "", + "convergence_run_id": 1002, + "r2_prefix": "", + "server_image_digest": "latest", + "control_plane_image_digest": None, + "helm_chart_digest": "sha256:ABC", + } + ) + result, diagnostic, output = self.run_command( + "activation", valid_release(), activation + ) + self.assertEqual(result.returncode, 1) + self.assertEqual(output, "") + self.assertEqual( + {item["code"] for item in diagnostic["violations"]}, + { + "activation_schema_invalid", + "activation_tag_mismatch", + "activation_release_mismatch", + "activation_source_mismatch", + "source_run_invalid", + "convergence_run_invalid", + "delivery_prefix_invalid", + "server_digest_invalid", + "control_plane_digest_invalid", + "helm_digest_invalid", + }, + ) + self.assertTrue( + all( + "publish a corrected replacement" in item["action"] + for item in diagnostic["violations"] + ) + ) + + +if __name__ == "__main__": + unittest.main()