diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 3c694c662..6df9ad0b6 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -22,9 +22,11 @@ the generated shell program. Workflows triggered by `pull_request` cannot reference confidential repository secrets. Canonical governance therefore keeps its pull-request checks local to the public checkout. `canonical-private-governance.yml` performs cross-repo -status, control-plane, subsystem-registry, mobile compatibility, and -repo-governance checks only after a push to `main`, so unmerged pull-request -code cannot replace the instructions that receive `WORKFLOW_PAT`. +status, control-plane, subsystem-registry, subsystem-contract, mobile +compatibility, and repo-governance checks only after a push to `main`, so +unmerged pull-request code cannot replace the instructions that receive +`WORKFLOW_PAT`. The public job still audits contract structure and every public +path; only private path existence is deferred to that credential-isolated job. `PULSE_LICENSE_PUBLIC_KEY` is the sole explicit PR exception because that legacy secret value is intentionally non-confidential. @@ -39,9 +41,10 @@ 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. Six-hour lock-watch evidence explicitly records its narrower +those identities. Every run requests 90-day retention for a machine-readable +evidence packet, including partial outcomes when a check fails; GitHub applies +the repository's configured retention maximum and reports any clamp in the +workflow warning. Six-hour lock-watch evidence explicitly records its narrower `release_lock` mode and skipped full-surface checks. 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 86e920dbf..87ca43f5f 100644 --- a/.github/workflows/canonical-governance.yml +++ b/.github/workflows/canonical-governance.yml @@ -136,7 +136,9 @@ jobs: run: go test ./internal/mutationregistry ./internal/ai/tools -run 'Test(EveryRegisteredMutationHasDisposition|InfrastructureAPIRoutesResolveToRegistry|TransportCommandCatalogsResolveToRegistry|PatrolJobRegistrationResolvesToRegistry|RuntimeCandidateAuditNegativeFixtures|ActionRouteMethodAuthorityIsExactAndLookalikesFailClosed|NonAdmittingTransportMessagesCannotCarryDispatchAuthority|UnknownTransportLookalikeFailsClosed|RegisteredModelMutationSchemasResolveToClosedRegistry|RetiredMutationAliasesCannotShadowExtensions)' -count=1 - name: Run contract audit - run: python3 scripts/release_control/contract_audit.py --check + # Cross-repository path existence is checked with the credential-isolated + # sibling checkouts after merge. Keep PR validation scoped to public truth. + run: python3 scripts/release_control/contract_audit.py --check --repo-scope pulse - name: Run canonical completion guard unit tests run: python3 scripts/release_control/canonical_completion_guard_test.py diff --git a/.github/workflows/canonical-private-governance.yml b/.github/workflows/canonical-private-governance.yml index 428ab6856..881697f70 100644 --- a/.github/workflows/canonical-private-governance.yml +++ b/.github/workflows/canonical-private-governance.yml @@ -109,6 +109,9 @@ jobs: - name: Run complete cross-repository registry audit run: python3 scripts/release_control/registry_audit.py --check + - name: Run complete cross-repository contract audit + run: python3 scripts/release_control/contract_audit.py --check + - name: Run repo governance guardrail tests env: PULSE_REPO_ROOT_PULSE: ${{ github.workspace }}/repos/pulse diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index cef32523e..33e5f174f 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -66,13 +66,16 @@ jobs: -H 'Accept: application/vnd.github+json' \ -H 'X-GitHub-Api-Version: 2026-03-10' \ "repos/${REPOSITORY}/releases/latest" > "${release_json}" - 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 + 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." + exit 1 + fi tag="$(jq -r '.tag_name' "${release_json}")" release_id="$(jq -r '.id' "${release_json}")" @@ -82,19 +85,22 @@ jobs: --pattern release-activation.json \ --dir release-continuity-evidence - 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 + 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}" diff --git a/internal/repoctl/canonical_development_protocol_test.go b/internal/repoctl/canonical_development_protocol_test.go index 17ad16fd7..56dc41bb0 100644 --- a/internal/repoctl/canonical_development_protocol_test.go +++ b/internal/repoctl/canonical_development_protocol_test.go @@ -1826,6 +1826,7 @@ func TestCanonicalCompletionGuardIsWiredIntoPreCommit(t *testing.T) { "Contract Metadata", "audit_contract_payload", "contract metadata", + "--repo-scope", "--check", }) @@ -1868,7 +1869,7 @@ func TestCanonicalGovernanceRunsInCI(t *testing.T) { "python3 scripts/release_control/canonical_completion_guard.py --files-from-stdin", "python3 scripts/release_control/browser_verification_guard.py", "python3 scripts/release_control/registry_audit.py --check --repo-scope pulse", - "python3 scripts/release_control/contract_audit.py --check", + "python3 scripts/release_control/contract_audit.py --check --repo-scope pulse", "python3 scripts/release_control/readiness_assertion_guard.py --active-target --proof-type automated", "python3 scripts/release_control/readiness_assertion_guard.py --active-target --proof-type hybrid", "go test ./internal/repoctl -count=1", @@ -1907,6 +1908,7 @@ func TestCanonicalGovernanceRunsInCI(t *testing.T) { "python3 scripts/release_control/status_audit.py --check", "python3 scripts/release_control/control_plane_audit.py --check", "python3 scripts/release_control/registry_audit.py --check", + "python3 scripts/release_control/contract_audit.py --check", "go test ./internal/repoctl -count=1", }) if strings.Contains(privateWorkflow, "pull_request:") || strings.Contains(privateWorkflow, "workflow_dispatch:") { diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index c3f2ce0b5..ad6b191b0 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -160,7 +160,9 @@ func TestSecurityScanRevalidatesLatestStableDelivery(t *testing.T) { "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/verify-github-release-integrity.sh`, `./scripts/validate-published-release.sh`, `PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}`, diff --git a/scripts/release_control/contract_audit.py b/scripts/release_control/contract_audit.py index 72fb9ac5c..a4131c511 100644 --- a/scripts/release_control/contract_audit.py +++ b/scripts/release_control/contract_audit.py @@ -170,11 +170,15 @@ def validate_repo_path_token( errors: list[str], repo_roots: dict[str, Path], default_repo_id: str, + audited_repo_ids: set[str] | None = None, ) -> None: resolved = resolve_repo_path_token(token, repo_roots=repo_roots, default_repo_id=default_repo_id) if resolved is None: errors.append(f"{rel} {heading} contains non-clean repo-relative path {token!r}") return + repo_id = token.split(":", 1)[0] if ":" in token else default_repo_id + if audited_repo_ids is not None and repo_id not in audited_repo_ids: + return if not resolved.exists(): errors.append(f"{rel} {heading} references missing path {token!r}") return @@ -214,6 +218,7 @@ def audit_contract_text( *, repo_roots: dict[str, Path], default_repo_id: str, + audited_repo_ids: set[str] | None = None, ) -> tuple[dict[str, Any], list[str]]: errors: list[str] = [] path_references: list[dict[str, str]] = [] @@ -270,6 +275,7 @@ def audit_contract_text( errors=errors, repo_roots=repo_roots, default_repo_id=default_repo_id, + audited_repo_ids=audited_repo_ids, ) path_references.append({"heading": heading, "path": token}) if heading == "## Extension Points": @@ -283,6 +289,7 @@ def audit_contract_text( errors=errors, repo_roots=repo_roots, default_repo_id=default_repo_id, + audited_repo_ids=audited_repo_ids, ) path_references.append({"heading": heading, "path": token}) @@ -339,6 +346,7 @@ def audit_contract_payload( status_payload: dict[str, Any], contract_texts: dict[str, str], repo_root: Path | None = None, + audited_repo_ids: set[str] | None = None, ) -> dict[str, Any]: errors: list[str] = [] warnings: list[str] = [] @@ -388,6 +396,7 @@ def audit_contract_payload( contract_texts[rel], repo_roots=repo_roots, default_repo_id=default_repo_id, + audited_repo_ids=audited_repo_ids, ) errors.extend(parse_errors) metadata = parsed.get("metadata") @@ -513,6 +522,7 @@ def audit_contract_payload( errors=errors, repo_roots=repo_roots, default_repo_id=default_repo_id, + audited_repo_ids=audited_repo_ids, ) actual_shared_paths.append(shared_path) if shared_path in seen_shared_paths: @@ -590,6 +600,16 @@ def parse_args(argv: list[str]) -> argparse.Namespace: action="store_true", help="Read subsystem contracts from the git index instead of the working tree.", ) + parser.add_argument( + "--repo-scope", + action="append", + default=[], + metavar="REPO_ID", + help=( + "Audit paths for one available repository. Repeat to audit multiple " + "repositories; the default audits the complete active profile." + ), + ) return parser.parse_args(argv) @@ -615,11 +635,33 @@ def render_pretty(report: dict[str, Any]) -> str: def main(argv: list[str] | None = None) -> int: args = parse_args(list(argv or [])) + status_payload = load_status_payload(staged=args.staged) + active_repos = [ + repo_id + for repo_id in status_payload.get("scope", {}).get("active_repos", []) + if isinstance(repo_id, str) and repo_id.strip() + ] or [canonical_repo_id(REPO_ROOT)] + local_repo = canonical_repo_id(REPO_ROOT) + requested_repos = args.repo_scope or active_repos + scope_errors: list[str] = [] + if len(requested_repos) != len(set(requested_repos)): + scope_errors.append("--repo-scope must not contain duplicate repository ids") + unknown_repos = sorted(set(requested_repos) - set(active_repos), key=str.casefold) + if unknown_repos: + scope_errors.append( + "--repo-scope contains repositories outside the active profile: " + + ", ".join(unknown_repos) + ) + if local_repo not in requested_repos: + scope_errors.append(f"--repo-scope must include the local control-plane repository {local_repo!r}") + report = audit_contract_payload( registry_payload=load_registry_payload(staged=args.staged), - status_payload=load_status_payload(staged=args.staged), + status_payload=status_payload, contract_texts=tracked_contract_files(staged=args.staged), + audited_repo_ids=set(requested_repos) if args.repo_scope else None, ) + report["errors"] = scope_errors + report["errors"] output = render_pretty(report) if args.pretty else json.dumps(report, indent=2, sort_keys=True) print(output) if args.check and report["errors"]: diff --git a/scripts/release_control/contract_audit_test.py b/scripts/release_control/contract_audit_test.py index f7ee2090e..1e59d2563 100644 --- a/scripts/release_control/contract_audit_test.py +++ b/scripts/release_control/contract_audit_test.py @@ -20,10 +20,75 @@ class ContractAuditTest(unittest.TestCase): env=env, ) + def cross_repo_contract_fixture( + self, + private_path: str = "pulse-mobile:src/relay/client.ts", + ) -> tuple[dict, dict, dict[str, str]]: + registry_payload = { + "subsystems": [ + { + "id": "relay-runtime", + "lane": "L7", + "contract": "docs/release-control/v6/internal/subsystems/relay-runtime.md", + } + ] + } + status_payload = { + "scope": {"active_repos": ["pulse", "pulse-mobile"]}, + "lanes": [{"id": "L7"}], + } + contract_texts = { + "docs/release-control/v6/internal/subsystems/relay-runtime.md": f"""# Relay Runtime Contract + +## Contract Metadata + +```json +{{ + "subsystem_id": "relay-runtime", + "lane": "L7", + "contract_file": "docs/release-control/v6/internal/subsystems/relay-runtime.md", + "status_file": "docs/release-control/v6/internal/status.json", + "registry_file": "docs/release-control/v6/internal/subsystems/registry.json", + "dependency_subsystem_ids": [] +}} +``` + +## Purpose + +Own relay runtime truth. + +## Canonical Files + +1. `{private_path}` + +## Shared Boundaries + +1. None. + +## Extension Points + +1. Add mobile relay reconnect behavior through `pulse-mobile:src/relay/` + +## Forbidden Paths + +1. Ad hoc mobile relay reconnect state. + +## Completion Obligations + +1. Keep mobile relay runtime changes tied to tests. + +## Current State + +Cross-repo relay ownership is explicit. +""", + } + return registry_payload, status_payload, contract_texts + def test_parse_args_accepts_staged_flag(self) -> None: - args = parse_args(["--check", "--staged"]) + args = parse_args(["--check", "--staged", "--repo-scope", "pulse"]) self.assertTrue(args.check) self.assertTrue(args.staged) + self.assertEqual(args.repo_scope, ["pulse"]) def test_audit_contract_payload_accepts_valid_contracts(self) -> None: registry_payload = { @@ -314,66 +379,7 @@ Canonical alert identity is live runtime truth. ) def test_audit_contract_payload_accepts_cross_repo_contract_paths(self) -> None: - registry_payload = { - "subsystems": [ - { - "id": "relay-runtime", - "lane": "L7", - "contract": "docs/release-control/v6/internal/subsystems/relay-runtime.md", - } - ] - } - status_payload = { - "scope": { - "active_repos": ["pulse", "pulse-mobile"], - }, - "lanes": [{"id": "L7"}], - } - contract_texts = { - "docs/release-control/v6/internal/subsystems/relay-runtime.md": """# Relay Runtime Contract - -## Contract Metadata - -```json -{ - "subsystem_id": "relay-runtime", - "lane": "L7", - "contract_file": "docs/release-control/v6/internal/subsystems/relay-runtime.md", - "status_file": "docs/release-control/v6/internal/status.json", - "registry_file": "docs/release-control/v6/internal/subsystems/registry.json", - "dependency_subsystem_ids": [] -} -``` - -## Purpose - -Own relay runtime truth. - -## Canonical Files - -1. `pulse-mobile:src/relay/client.ts` - -## Shared Boundaries - -1. None. - -## Extension Points - -1. Add mobile relay reconnect behavior through `pulse-mobile:src/relay/` - -## Forbidden Paths - -1. Ad hoc mobile relay reconnect state. - -## Completion Obligations - -1. Keep mobile relay runtime changes tied to tests. - -## Current State - -Cross-repo relay ownership is explicit. -""", - } + registry_payload, status_payload, contract_texts = self.cross_repo_contract_fixture() report = audit_contract_payload( registry_payload=registry_payload, @@ -383,6 +389,38 @@ Cross-repo relay ownership is explicit. self.assertEqual(report["errors"], []) + def test_scoped_audit_defers_missing_private_paths_to_private_governance(self) -> None: + missing_path = "pulse-mobile:src/relay/definitely-missing.ts" + registry_payload, status_payload, contract_texts = self.cross_repo_contract_fixture(missing_path) + + full_report = audit_contract_payload( + registry_payload=registry_payload, + status_payload=status_payload, + contract_texts=contract_texts, + ) + scoped_report = audit_contract_payload( + registry_payload=registry_payload, + status_payload=status_payload, + contract_texts=contract_texts, + audited_repo_ids={"pulse"}, + ) + + self.assertIn(f"references missing path '{missing_path}'", "\n".join(full_report["errors"])) + self.assertEqual(scoped_report["errors"], []) + + def test_scoped_audit_still_rejects_unclean_private_path_tokens(self) -> None: + unclean_path = "pulse-mobile:../client.ts" + registry_payload, status_payload, contract_texts = self.cross_repo_contract_fixture(unclean_path) + + report = audit_contract_payload( + registry_payload=registry_payload, + status_payload=status_payload, + contract_texts=contract_texts, + audited_repo_ids={"pulse"}, + ) + + self.assertIn(f"contains non-clean repo-relative path '{unclean_path}'", "\n".join(report["errors"])) + def test_audit_contract_payload_isolates_local_paths_and_resolves_siblings_from_linked_worktree(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: workspace = Path(tmpdir) / "workspace"