| 1 | #!/usr/bin/env python3 |
| 2 | """Hermetic tests for the FEAT-015 migration-gate CI wiring. |
| 3 | |
| 4 | Verifies `.github/workflows/ci.yml` keeps both migration checker commands |
| 5 | (self-tests then live scan) under the same `heavy` condition as the existing |
| 6 | command-contract boundary step, and that the boundary step itself remains |
| 7 | intact. |
| 8 | """ |
| 9 | |
| 10 | from __future__ import annotations |
| 11 | |
| 12 | import unittest |
| 13 | from pathlib import Path |
| 14 | |
| 15 | ROOT = Path(__file__).resolve().parents[1] |
| 16 | CI_PATH = ROOT / ".github" / "workflows" / "ci.yml" |
| 17 | |
| 18 | |
| 19 | def load_ci() -> str: |
| 20 | return CI_PATH.read_text(encoding="utf-8") |
| 21 | |
| 22 | |
| 23 | def boundary_step_block(ci: str) -> str: |
| 24 | """Extract the 'Check command-contract prototype boundary' step block.""" |
| 25 | marker = "Check command-contract prototype boundary" |
| 26 | start = ci.index(marker) |
| 27 | step_start = ci.rindex("- name:", 0, start) |
| 28 | # The step ends at the next "- name:" after the marker. |
| 29 | next_step = ci.index("- name:", start + len(marker)) |
| 30 | return ci[step_start:next_step] |
| 31 | |
| 32 | |
| 33 | def migration_step_block(ci: str) -> str: |
| 34 | marker = "Check command migration manifest" |
| 35 | start = ci.index(marker) |
| 36 | step_start = ci.rindex("- name:", 0, start) |
| 37 | next_step = ci.index("- name:", start + len(marker)) |
| 38 | return ci[step_start:next_step] |
| 39 | |
| 40 | |
| 41 | class CiWiringTests(unittest.TestCase): |
| 42 | def test_boundary_step_still_present(self) -> None: |
| 43 | ci = load_ci() |
| 44 | self.assertIn("Check command-contract prototype boundary", ci) |
| 45 | block = boundary_step_block(ci) |
| 46 | self.assertIn("test_check_command_crate_boundaries.py", block) |
| 47 | self.assertIn("check-command-crate-boundaries.py", block) |
| 48 | |
| 49 | def test_migration_self_test_present(self) -> None: |
| 50 | ci = load_ci() |
| 51 | block = migration_step_block(ci) |
| 52 | self.assertIn("test_check_command_migration_manifest.py", block) |
| 53 | |
| 54 | def test_migration_live_scan_present(self) -> None: |
| 55 | ci = load_ci() |
| 56 | block = migration_step_block(ci) |
| 57 | self.assertIn("check-command-migration-manifest.py", block) |
| 58 | |
| 59 | def test_migration_live_scan_receives_fetched_baseline(self) -> None: |
| 60 | block = migration_step_block(load_ci()) |
| 61 | self.assertIn("PR_BASE_SHA", block) |
| 62 | self.assertIn("PUSH_BEFORE_SHA", block) |
| 63 | self.assertIn("git fetch --no-tags origin", block) |
| 64 | self.assertNotIn( |
| 65 | "--depth", |
| 66 | block, |
| 67 | "baseline fetch must preserve the full ancestry used by later CI range checks", |
| 68 | ) |
| 69 | self.assertIn('--baseline-ref "${baseline}"', block) |
| 70 | |
| 71 | def test_migration_commands_are_ordered_self_test_first(self) -> None: |
| 72 | ci = load_ci() |
| 73 | block = migration_step_block(ci) |
| 74 | self.assertLess( |
| 75 | block.index("test_check_command_migration_manifest.py"), |
| 76 | block.index("check-command-migration-manifest.py"), |
| 77 | "checker self-tests must run before the live migration scan", |
| 78 | ) |
| 79 | |
| 80 | def test_migration_step_uses_heavy_condition(self) -> None: |
| 81 | ci = load_ci() |
| 82 | block = migration_step_block(ci) |
| 83 | self.assertIn("needs.changes.outputs.heavy == 'true'", block) |
| 84 | |
| 85 | def test_boundary_step_condition_matches_migration_step(self) -> None: |
| 86 | ci = load_ci() |
| 87 | boundary = boundary_step_block(ci) |
| 88 | migration = migration_step_block(ci) |
| 89 | self.assertIn("needs.changes.outputs.heavy == 'true'", boundary) |
| 90 | self.assertEqual( |
| 91 | "needs.changes.outputs.heavy == 'true'" in boundary, |
| 92 | "needs.changes.outputs.heavy == 'true'" in migration, |
| 93 | ) |
| 94 | |
| 95 | def test_migration_step_does_not_remove_boundary_step(self) -> None: |
| 96 | ci = load_ci() |
| 97 | # Both steps must coexist (the migration step is added beside, never |
| 98 | # replacing, the boundary step). |
| 99 | self.assertLess( |
| 100 | ci.index("Check command-contract prototype boundary"), |
| 101 | ci.index("Check command migration manifest"), |
| 102 | ) |
| 103 | |
| 104 | def test_main_ci_concurrency_is_keyed_by_sha(self) -> None: |
| 105 | ci = load_ci() |
| 106 | self.assertIn("github.workflow, github.sha", ci) |
| 107 | self.assertIn("ci-pr-{0}", ci) |
| 108 | self.assertNotIn( |
| 109 | "github.event.pull_request.number || github.ref", |
| 110 | ci, |
| 111 | "main must not share one concurrency group per ref — pending runs get cancelled", |
| 112 | ) |
| 113 | self.assertIn("name: Safety gate", ci) |
| 114 | self.assertIn("Hermetic safety and authorization tests", ci) |
| 115 | |
| 116 | def test_safety_gate_is_hermetic_for_config_home(self) -> None: |
| 117 | ci = load_ci() |
| 118 | start = ci.index("Hermetic safety and authorization tests") |
| 119 | next_step = ci.index("- name:", start + 1) |
| 120 | block = ci[start:next_step] |
| 121 | self.assertIn( |
| 122 | "sh scripts/with-hermetic-test-home.sh cargo test -p codewhale-tui " |
| 123 | "--lib --locked -- auto_review authority sandbox", block |
| 124 | ) |
| 125 | self.assertIn( |
| 126 | "sh scripts/with-hermetic-test-home.sh cargo test -p codewhale-execpolicy --locked", |
| 127 | block, |
| 128 | ) |
| 129 | self.assertNotIn("CODEWHALE_HOME:", block) |
| 130 | self.assertIn("sh scripts/with-hermetic-test-home.test.sh", ci) |
| 131 | |
| 132 | def test_valid_wiring_passes_all_assertions(self) -> None: |
| 133 | # The live workflow must satisfy every structural invariant above. |
| 134 | ci = load_ci() |
| 135 | self.assertIn("test_check_command_migration_manifest.py", ci) |
| 136 | self.assertIn("check-command-migration-manifest.py", ci) |
| 137 | self.assertIn("needs.changes.outputs.heavy == 'true'", ci) |
| 138 | |
| 139 | |
| 140 | if __name__ == "__main__": |
| 141 | unittest.main() |
| 142 |