diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index bfc74a0ca..0322900b1 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -78,6 +78,62 @@ jobs: --diagnostic release-continuity-evidence/release-diagnostic.json \ --github-output "${GITHUB_OUTPUT}" + - name: Reject orphaned stable publication tags + id: frontier + if: ${{ !cancelled() && steps.release.outputs.referenceable == 'true' }} + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + evidence=release-continuity-evidence + gh api --paginate --slurp \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/${REPOSITORY}/git/matching-refs/tags/v?per_page=100" \ + > "${evidence}/stable-refs.json" + gh api --paginate --slurp \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/${REPOSITORY}/releases?per_page=100" \ + > "${evidence}/releases.json" + + fetch_registry_tags() { + local registry_name="$1" auth_url="$2" registry_url="$3" image="$4" output="$5" + local token + token="$(curl -fsSL --retry 4 --retry-delay 2 --retry-all-errors "${auth_url}")" + token="$(jq -er '.token | select(type == "string" and length > 0)' <<<"${token}")" + curl -fsSL --retry 4 --retry-delay 2 --retry-all-errors \ + -H "Authorization: Bearer ${token}" \ + "${registry_url}/v2/${image}/tags/list?n=10000" \ + | jq --arg name "${registry_name}/${image}" '.name = $name' > "${output}" + } + owner="${GITHUB_REPOSITORY_OWNER,,}" + for image in pulse pulse-control-plane; do + file_image="${image//-/_}" + fetch_registry_tags \ + ghcr.io \ + "https://ghcr.io/token?service=ghcr.io&scope=repository:${owner}/${image}:pull" \ + https://ghcr.io "${owner}/${image}" \ + "${evidence}/ghcr-${file_image}-tags.json" + done + # The server image is public on both registries. The control-plane + # image is publicly enumerable only on GHCR, so an unauthenticated + # continuity audit must not depend on Docker Hub credentials. + fetch_registry_tags \ + docker.io \ + "https://auth.docker.io/token?service=registry.docker.io&scope=repository:rcourtman/pulse:pull" \ + https://registry-1.docker.io rcourtman/pulse \ + "${evidence}/docker-pulse-tags.json" + python3 scripts/release_control/release_continuity.py frontier \ + --release-json "${evidence}/release.json" \ + --stable-refs-json "${evidence}/stable-refs.json" \ + --releases-json "${evidence}/releases.json" \ + --registry-tags-json "${evidence}/ghcr-pulse-tags.json" \ + --registry-tags-json "${evidence}/docker-pulse-tags.json" \ + --registry-tags-json "${evidence}/ghcr-pulse_control_plane-tags.json" \ + --diagnostic "${evidence}/frontier-diagnostic.json" + - name: Bind the release activation marker id: activation # A structurally valid tag/id/SHA remains safe lookup data even when @@ -114,7 +170,7 @@ jobs: - name: Verify immutable release and build provenance id: packet - if: ${{ always() && steps.release.outcome == 'success' && steps.activation.outcome == 'success' && github.event.schedule != '17 */6 * * *' }} + if: ${{ always() && steps.release.outcome == 'success' && steps.frontier.outcome == 'success' && steps.activation.outcome == 'success' && github.event.schedule != '17 */6 * * *' }} env: GH_TOKEN: ${{ github.token }} REPOSITORY: ${{ github.repository }} @@ -127,7 +183,7 @@ jobs: - name: Authenticate every published release asset id: assets - if: ${{ always() && steps.release.outcome == 'success' && steps.activation.outcome == 'success' && github.event.schedule != '17 */6 * * *' }} + if: ${{ always() && steps.release.outcome == 'success' && steps.frontier.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 }} @@ -136,7 +192,7 @@ jobs: - name: Verify exact-version container identities id: containers - if: ${{ always() && steps.release.outcome == 'success' && steps.activation.outcome == 'success' && github.event.schedule != '17 */6 * * *' }} + if: ${{ always() && steps.release.outcome == 'success' && steps.frontier.outcome == 'success' && steps.activation.outcome == 'success' && github.event.schedule != '17 */6 * * *' }} env: GH_TOKEN: ${{ github.token }} REPOSITORY: ${{ github.repository }} @@ -158,7 +214,7 @@ jobs: - name: Verify stable container discovery aliases id: aliases - if: ${{ always() && steps.release.outcome == 'success' && steps.activation.outcome == 'success' && github.event.schedule != '17 */6 * * *' }} + if: ${{ always() && steps.release.outcome == 'success' && steps.frontier.outcome == 'success' && steps.activation.outcome == 'success' && github.event.schedule != '17 */6 * * *' }} env: TAG: ${{ steps.release.outputs.tag }} EXPECTED_SERVER_DIGEST: ${{ steps.activation.outputs.server_image_digest }} @@ -171,7 +227,7 @@ jobs: - name: Verify exact-version Helm identity id: helm - if: ${{ always() && steps.release.outcome == 'success' && steps.activation.outcome == 'success' && github.event.schedule != '17 */6 * * *' }} + if: ${{ always() && steps.release.outcome == 'success' && steps.frontier.outcome == 'success' && steps.activation.outcome == 'success' && github.event.schedule != '17 */6 * * *' }} env: GH_TOKEN: ${{ github.token }} REPOSITORY: ${{ github.repository }} @@ -202,6 +258,7 @@ jobs: 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 }} + FRONTIER_RESULT: ${{ steps.frontier.outcome }} ACTIVATION_RESULT: ${{ steps.activation.outcome }} PACKET_RESULT: ${{ steps.packet.outcome }} ASSET_RESULT: ${{ steps.assets.outcome }} @@ -216,7 +273,7 @@ jobs: if [ "${TRIGGER_SCHEDULE}" = '17 */6 * * *' ]; then mode=release_lock fi - for diagnostic in release activation; do + for diagnostic in release frontier activation; do path="release-continuity-evidence/${diagnostic}-diagnostic.json" if [ ! -s "${path}" ]; then jq -n \ @@ -227,6 +284,7 @@ jobs: done jq -n \ --slurpfile release_diagnostic release-continuity-evidence/release-diagnostic.json \ + --slurpfile frontier_diagnostic release-continuity-evidence/frontier-diagnostic.json \ --slurpfile activation_diagnostic release-continuity-evidence/activation-diagnostic.json \ --arg schema_version "1" \ --arg checked_at "${checked_at}" \ @@ -248,6 +306,7 @@ jobs: --arg control_plane_image_digest "${CONTROL_PLANE_IMAGE_DIGEST}" \ --arg helm_chart_digest "${HELM_CHART_DIGEST}" \ --arg release_result "${RELEASE_RESULT}" \ + --arg frontier_result "${FRONTIER_RESULT}" \ --arg activation_result "${ACTIVATION_RESULT}" \ --arg packet_result "${PACKET_RESULT}" \ --arg asset_result "${ASSET_RESULT}" \ @@ -281,10 +340,12 @@ jobs: }, diagnostics: { release_identity: $release_diagnostic[0], + stable_publication_frontier: $frontier_diagnostic[0], activation_binding: $activation_diagnostic[0] }, checks: { release_resolution: $release_result, + stable_publication_frontier: $frontier_result, activation_binding: $activation_result, immutable_packet_and_provenance: $packet_result, authenticated_assets: $asset_result, diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index e82ebb35e..b45fb4c66 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -161,12 +161,23 @@ func TestSecurityScanRevalidatesLatestStableDelivery(t *testing.T) { `"repos/${REPOSITORY}/releases/latest"`, `scripts/release_control/release_continuity.py release`, "release-diagnostic.json", + "Reject orphaned stable publication tags", + `"repos/${REPOSITORY}/git/matching-refs/tags/v?per_page=100"`, + `"repos/${REPOSITORY}/releases?per_page=100"`, + "https://ghcr.io/token?service=ghcr.io&scope=repository:", + "https://auth.docker.io/token?service=registry.docker.io&scope=repository:", + `"${registry_url}/v2/${image}/tags/list?n=10000"`, + `--registry-tags-json "${evidence}/ghcr-pulse-tags.json"`, + `--registry-tags-json "${evidence}/docker-pulse-tags.json"`, + `scripts/release_control/release_continuity.py frontier`, + "frontier-diagnostic.json", "Bind the release activation marker", `!cancelled()`, `steps.release.outputs.referenceable == 'true'`, `scripts/release_control/release_continuity.py activation`, "activation-diagnostic.json", `steps.activation.outcome == 'success'`, + `steps.frontier.outcome == 'success'`, `./scripts/verify-github-release-integrity.sh`, `./scripts/validate-published-release.sh`, `PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}`, @@ -177,6 +188,7 @@ func TestSecurityScanRevalidatesLatestStableDelivery(t *testing.T) { `stable_container_aliases: $alias_result`, `activation_binding: $activation_result`, `release_identity: $release_diagnostic[0]`, + `stable_publication_frontier: $frontier_diagnostic[0]`, `CONVERGENCE_RUN_ID: ${{ github.event.workflow_run.id }}`, `TRIGGER_SCHEDULE: ${{ github.event.schedule }}`, `mode=release_lock`, @@ -229,6 +241,7 @@ func TestSecurityScanRevalidatesLatestStableDelivery(t *testing.T) { step := workflowStepBlock(t, workflowJobBlock(t, workflow, "release-continuity"), stepName) for _, admission := range []string{ `steps.release.outcome == 'success'`, + `steps.frontier.outcome == 'success'`, `steps.activation.outcome == 'success'`, } { if !strings.Contains(step, admission) { diff --git a/scripts/release_control/release_continuity.py b/scripts/release_control/release_continuity.py index 79d4e89f1..47657ab40 100644 --- a/scripts/release_control/release_continuity.py +++ b/scripts/release_control/release_continuity.py @@ -29,6 +29,10 @@ IMMUTABLE_REPLACEMENT_ACTION = ( "Do not edit or repair the advertised release in place; restore the last known-good " "stable target if needed, then publish a corrected replacement through convergence." ) +ORPHAN_REPLACEMENT_ACTION = ( + "Do not advertise or repair the orphaned version in place; inspect the failed " + "release run and publish a corrected replacement through convergence." +) @dataclass(frozen=True) @@ -86,6 +90,27 @@ RELEASE_RULES = { } +FRONTIER_RULES = { + "frontier_payload_invalid": ( + "GitHub did not return complete stable-tag and release inventories.", + "Inspect the matching-refs and releases API responses before retrying.", + ), + "stable_tag_without_release": ( + "A stable source tag newer than the advertised release has no GitHub Release packet.", + ORPHAN_REPLACEMENT_ACTION, + ), + "newer_stable_release_not_advertised": ( + "A published stable release is newer than the advertised latest release.", + "Inspect the latest-release selection and customer aliases; do not move them without " + "an immutable activation packet and successful convergence.", + ), + "registry_stable_tag_beyond_latest": ( + "A public container registry exposes a stable version newer than the advertised release.", + ORPHAN_REPLACEMENT_ACTION, + ), +} + + ACTIVATION_RULES = { "activation_asset_invalid": ( "The release does not list one uploaded, digest-bound activation marker.", @@ -270,6 +295,185 @@ def release_is_referenceable(payload: Any, failures: list[Violation]) -> bool: ) +def stable_version(tag: str) -> tuple[int, int, int]: + if STABLE_TAG.fullmatch(tag) is None: + raise ValueError(f"invalid stable tag {tag!r}") + parts = tuple(int(part) for part in tag.removeprefix("v").split(".")) + return (parts[0], parts[1], parts[2]) + + +def inventory_list(payload: Any) -> list[Any] | None: + """Flatten gh api's ordinary or --slurp pagination representation.""" + + if not isinstance(payload, list): + return None + if payload and all(isinstance(page, list) for page in payload): + return [item for page in payload for item in page] + return payload + + +def frontier_violations( + latest_release: Any, + stable_refs_payload: Any, + releases_payload: Any, + registry_payloads: list[Any], +) -> tuple[list[Violation], dict[str, Any]]: + refs = inventory_list(stable_refs_payload) + releases = inventory_list(releases_payload) + latest_tag = latest_release.get("tag_name") if isinstance(latest_release, dict) else None + identity: dict[str, Any] = { + "advertised_tag": latest_tag, + "newer_stable_tags": "", + "orphaned_stable_tags": "", + "unadvertised_published_stable_tags": "", + "registry_stable_tags_beyond_latest": "", + } + if ( + not isinstance(latest_tag, str) + or STABLE_TAG.fullmatch(latest_tag) is None + or refs is None + or releases is None + ): + return ( + [ + violation( + "frontier_payload_invalid", + "$", + "stable latest release plus tag and release arrays", + { + "latest_release": type(latest_release).__name__, + "stable_refs": type(stable_refs_payload).__name__, + "releases": type(releases_payload).__name__, + }, + FRONTIER_RULES, + ) + ], + identity, + ) + + stable_tags: set[str] = set() + for ref in refs: + if not isinstance(ref, dict): + return ( + [ + violation( + "frontier_payload_invalid", + "stable_refs", + "array of Git ref objects", + ref, + FRONTIER_RULES, + ) + ], + identity, + ) + ref_name = ref.get("ref") + if isinstance(ref_name, str): + tag = ref_name.removeprefix("refs/tags/") + if STABLE_TAG.fullmatch(tag): + stable_tags.add(tag) + + releases_by_tag: dict[str, dict[str, Any]] = {} + for release in releases: + if not isinstance(release, dict): + return ( + [ + violation( + "frontier_payload_invalid", + "releases", + "array of GitHub Release objects", + release, + FRONTIER_RULES, + ) + ], + identity, + ) + release_tag = release.get("tag_name") + if isinstance(release_tag, str) and STABLE_TAG.fullmatch(release_tag): + releases_by_tag[release_tag] = release + + latest_version = stable_version(latest_tag) + newer_tags = sorted( + (tag for tag in stable_tags if stable_version(tag) > latest_version), + key=stable_version, + ) + orphaned = [tag for tag in newer_tags if tag not in releases_by_tag] + unadvertised = [ + tag + for tag in newer_tags + if tag in releases_by_tag + and releases_by_tag[tag].get("draft") is False + and isinstance(releases_by_tag[tag].get("published_at"), str) + and bool(releases_by_tag[tag]["published_at"]) + ] + registry_tags: dict[str, set[str]] = {} + for registry in registry_payloads: + if ( + not isinstance(registry, dict) + or not isinstance(registry.get("name"), str) + or not isinstance(registry.get("tags"), list) + or not all(isinstance(tag, str) for tag in registry["tags"]) + ): + return ( + [ + violation( + "frontier_payload_invalid", + "registries", + "array of registry tag-list objects", + registry, + FRONTIER_RULES, + ) + ], + identity, + ) + registry_name = registry["name"] + for raw_tag in registry["tags"]: + tag = raw_tag if raw_tag.startswith("v") else f"v{raw_tag}" + if STABLE_TAG.fullmatch(tag) and stable_version(tag) > latest_version: + registry_tags.setdefault(tag, set()).add(registry_name) + identity.update( + { + "newer_stable_tags": ",".join(newer_tags), + "orphaned_stable_tags": ",".join(orphaned), + "unadvertised_published_stable_tags": ",".join(unadvertised), + "registry_stable_tags_beyond_latest": ",".join( + sorted(registry_tags, key=stable_version) + ), + } + ) + + failures = [ + violation( + "stable_tag_without_release", + f"refs/tags/{tag}", + "one GitHub Release object", + None, + FRONTIER_RULES, + ) + for tag in orphaned + ] + failures.extend( + violation( + "newer_stable_release_not_advertised", + f"releases/{tag}", + f"not newer than {latest_tag}", + tag, + FRONTIER_RULES, + ) + for tag in unadvertised + ) + failures.extend( + violation( + "registry_stable_tag_beyond_latest", + f"registries/{tag}", + f"no public stable container tag newer than {latest_tag}", + ",".join(sorted(registry_tags[tag])), + FRONTIER_RULES, + ) + for tag in sorted(registry_tags, key=stable_version) + ) + return failures, identity + + def activation_violations( payload: Any, expected_release: dict[str, Any], activation_bytes: bytes ) -> list[Violation]: @@ -541,6 +745,29 @@ def validate_activation(args: argparse.Namespace) -> int: return 0 +def validate_frontier(args: argparse.Namespace) -> int: + try: + latest_release = read_json(args.release_json) + stable_refs = read_json(args.stable_refs_json) + releases = read_json(args.releases_json) + registries = [read_json(path) for path in args.registry_tags_json] + except ValueError as exc: + failures = [ + violation( + "frontier_payload_invalid", "$", "valid JSON inventories", str(exc), FRONTIER_RULES + ) + ] + identity: dict[str, Any] = {} + else: + failures, identity = frontier_violations(latest_release, stable_refs, releases, registries) + + write_diagnostic(args.diagnostic, "stable_publication_frontier", identity, failures) + if failures: + report_failures(failures, FRONTIER_RULES) + return 1 + return 0 + + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) commands = parser.add_subparsers(dest="command", required=True) @@ -555,6 +782,13 @@ def parse_args() -> argparse.Namespace: 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) + + frontier = commands.add_parser("frontier") + frontier.add_argument("--release-json", type=Path, required=True) + frontier.add_argument("--stable-refs-json", type=Path, required=True) + frontier.add_argument("--releases-json", type=Path, required=True) + frontier.add_argument("--registry-tags-json", type=Path, action="append", required=True) + frontier.add_argument("--diagnostic", type=Path, required=True) return parser.parse_args() @@ -562,7 +796,9 @@ def main() -> int: args = parse_args() if args.command == "release": return validate_release(args) - return validate_activation(args) + if args.command == "activation": + return validate_activation(args) + return validate_frontier(args) if __name__ == "__main__": diff --git a/scripts/release_control/release_continuity_test.py b/scripts/release_control/release_continuity_test.py index 1ee15c567..7eb08892f 100644 --- a/scripts/release_control/release_continuity_test.py +++ b/scripts/release_control/release_continuity_test.py @@ -67,6 +67,9 @@ class ReleaseContinuityTest(unittest.TestCase): command: str, release: object, activation: object | None = None, + stable_refs: object | None = None, + releases: object | None = None, + registries: list[object] | None = None, ) -> tuple[subprocess.CompletedProcess[str], dict[str, object], str]: with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -83,13 +86,32 @@ class ReleaseContinuityTest(unittest.TestCase): str(release_path), "--diagnostic", str(diagnostic), - "--github-output", - str(output), ] + if command != "frontier": + args.extend(["--github-output", str(output)]) if command == "activation": activation_path = root / "activation.json" activation_path.write_bytes(encoded(activation)) args.extend(["--activation-json", str(activation_path)]) + if command == "frontier": + stable_refs_path = root / "stable-refs.json" + releases_path = root / "releases.json" + stable_refs_path.write_text(json.dumps(stable_refs), encoding="utf-8") + releases_path.write_text(json.dumps(releases), encoding="utf-8") + args.extend( + [ + "--stable-refs-json", + str(stable_refs_path), + "--releases-json", + str(releases_path), + ] + ) + for index, registry in enumerate( + registries or [{"name": "registry.example/pulse", "tags": []}] + ): + registry_path = root / f"registry-{index}.json" + registry_path.write_text(json.dumps(registry), encoding="utf-8") + args.extend(["--registry-tags-json", str(registry_path)]) result = subprocess.run( args, cwd=ROOT, @@ -272,6 +294,102 @@ class ReleaseContinuityTest(unittest.TestCase): ], ) + def test_frontier_accepts_tags_at_or_behind_advertised_release(self) -> None: + release = valid_release() + result, diagnostic, output = self.run_command( + "frontier", + release, + stable_refs=[ + {"ref": "refs/tags/v6.4.1"}, + {"ref": "refs/tags/v6.4.2"}, + {"ref": "refs/tags/v6.4.3-rc.1"}, + ], + releases=[release], + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(diagnostic["status"], "success") + self.assertEqual(diagnostic["identity"]["newer_stable_tags"], "") + self.assertEqual(output, "") + + def test_frontier_rejects_newer_stable_tag_without_release_packet(self) -> None: + release = valid_release() + result, diagnostic, _ = self.run_command( + "frontier", + release, + stable_refs=[[{"ref": "refs/tags/v6.4.2"}, {"ref": "refs/tags/v6.4.3"}]], + releases=[[release]], + ) + self.assertEqual(result.returncode, 1) + self.assertEqual( + [item["code"] for item in diagnostic["violations"]], + ["stable_tag_without_release"], + ) + self.assertEqual(diagnostic["identity"]["orphaned_stable_tags"], "v6.4.3") + self.assertIn("orphaned version", result.stderr) + + def test_frontier_rejects_public_registry_version_beyond_latest(self) -> None: + release = valid_release() + result, diagnostic, _ = self.run_command( + "frontier", + release, + stable_refs=[{"ref": "refs/tags/v6.4.2"}], + releases=[release], + registries=[ + { + "name": "docker.io/rcourtman/pulse", + "tags": ["latest", "6.4", "6.4.2", "v6.4.3"], + }, + { + "name": "ghcr.io/rcourtman/pulse", + "tags": ["v6.4.3", "6.4.3-rc.1"], + }, + ], + ) + self.assertEqual(result.returncode, 1) + self.assertEqual( + [item["code"] for item in diagnostic["violations"]], + ["registry_stable_tag_beyond_latest"], + ) + self.assertEqual( + diagnostic["identity"]["registry_stable_tags_beyond_latest"], + "v6.4.3", + ) + self.assertIn("docker.io/rcourtman/pulse", diagnostic["violations"][0]["actual"]) + self.assertIn("ghcr.io/rcourtman/pulse", diagnostic["violations"][0]["actual"]) + + def test_frontier_rejects_published_stable_release_beyond_latest(self) -> None: + release = valid_release() + newer = {**release, "id": 67890, "tag_name": "v6.4.3"} + result, diagnostic, _ = self.run_command( + "frontier", + release, + stable_refs=[{"ref": "refs/tags/v6.4.3"}], + releases=[release, newer], + ) + self.assertEqual(result.returncode, 1) + self.assertEqual( + [item["code"] for item in diagnostic["violations"]], + ["newer_stable_release_not_advertised"], + ) + + def test_frontier_allows_in_progress_draft_beyond_latest(self) -> None: + release = valid_release() + draft = { + **release, + "id": 67890, + "tag_name": "v6.4.3", + "draft": True, + "published_at": None, + } + result, diagnostic, _ = self.run_command( + "frontier", + release, + stable_refs=[{"ref": "refs/tags/v6.4.3"}], + releases=[release, draft], + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(diagnostic["status"], "success") + if __name__ == "__main__": unittest.main()