diff --git a/.github/workflows/interlock-drift.yml b/.github/workflows/interlock-drift.yml index 701b370f0..29486063c 100644 --- a/.github/workflows/interlock-drift.yml +++ b/.github/workflows/interlock-drift.yml @@ -87,7 +87,7 @@ jobs: - uses: astral-sh/setup-uv@v7 - name: Assert public == project(main) and control planes in sync env: - GH_TOKEN: ${{ github.token }} # gh api reads public interlock content for --remote + GH_TOKEN: ${{ github.token }} # --remote reads public workflow content, not admin settings run: | git clone --depth 1 https://github.com/operatorstack/interlock "$RUNNER_TEMP/pub" uv run --project labkit python -m labkit doctor \ diff --git a/labkit/README.md b/labkit/README.md index 279e7a906..698c8c2e5 100644 --- a/labkit/README.md +++ b/labkit/README.md @@ -45,7 +45,7 @@ python -m labkit | `project --config --repo --source-commit --write` | Project a lab into a public-repo checkout (add `--adopt` on first import). | | `project --config --check` | Verify the committed in-lab surface manifest (surface style). | | `gen [--repo-root ] [--check]` | (Re)generate the dispatcher + staged `sync-upstream.yml` for every lab; `--check` fails on drift. | -| `doctor [--repo-root ] [--remote]` | Validate every config, fail on generated drift, assert each verify gate exists; `--remote` diffs live public control planes via `gh`. | +| `doctor [--repo-root ] [--remote] [--remote-governance]` | Validate every config, fail on generated drift, assert each verify gate exists; `--remote` diffs live public workflow control planes, while `--remote-governance` verifies auto-merge and required checks with an Administration-read token. | | `publish init ` | Scaffold `publish.config.json` + workflows + a first release note for a new lab. | | `release-notes ` | The shared append-only release-note policy. | diff --git a/labkit/src/labkit/doctor.py b/labkit/src/labkit/doctor.py index 8a2f008ea..adfa17b23 100644 --- a/labkit/src/labkit/doctor.py +++ b/labkit/src/labkit/doctor.py @@ -10,10 +10,16 @@ 3. **Verify gate exists** — the bespoke ``-lab.yml`` is present (it is hand-maintained, not generated, so we only assert its existence). -With ``--remote`` it also fetches each public repo's live -``.github/workflows/sync-upstream.yml`` via ``gh`` and diffs it against the -generated canonical copy — catching the current blind spot where a public repo's -control plane silently drifts from the monorepo. +With ``--remote`` it also fetches each public repo's live workflow control +plane via ``gh`` and diffs it against the generated canonical copy — catching +the current blind spot where a public repo's control plane silently drifts from +the monorepo. + +``--remote-governance`` is the privileged companion check for native +auto-merge and required status checks. GitHub does not expose those settings to +the source repository's ``github.token``, even for a public target repository, +so governance is a separate capability boundary. An unreadable setting fails +as unreadable rather than being misreported as disabled. With ``--repo --project `` it additionally asserts the public repo carries *exactly* ``project(main)`` — every projected byte, including the @@ -96,15 +102,29 @@ def check_remote(config: PublishConfig) -> list[str]: problems.append(f"{config.target_slug}: could not read live verify.yml (not installed?)") elif live_verify != render_verify_automerge(config): problems.append(f"{config.target_slug}: live verify.yml differs from the generated copy") + return problems + + +def check_remote_governance(config: PublishConfig) -> list[str]: + """Verify target settings that require Administration-read capability.""" + problems: list[str] = [] settings = _fetch_remote_json(f"repos/{config.target_slug}") - if not isinstance(settings, dict) or settings.get("allow_auto_merge") is not True: + if not isinstance(settings, dict) or "allow_auto_merge" not in settings: + problems.append( + f"{config.target_slug}: could not verify native auto-merge " + "(token needs Administration: read)" + ) + elif settings["allow_auto_merge"] is not True: problems.append(f"{config.target_slug}: native auto-merge is not enabled") protection = _fetch_remote_json( f"repos/{config.target_slug}/branches/main/protection/required_status_checks" ) - contexts = protection.get("contexts") if isinstance(protection, dict) else None - checks = protection.get("checks") if isinstance(protection, dict) else None - if not contexts and not checks: + if not isinstance(protection, dict): + problems.append( + f"{config.target_slug}: could not verify main required status checks " + "(token needs Administration: read)" + ) + elif not protection.get("contexts") and not protection.get("checks"): problems.append(f"{config.target_slug}: main has no required status checks") return problems @@ -153,6 +173,14 @@ def main(argv: Optional[list[str]] = None) -> int: parser = argparse.ArgumentParser(description="Validate labkit configs and check drift") parser.add_argument("--repo-root", type=Path, default=None) parser.add_argument("--remote", action="store_true", help="also diff live public control planes via gh") + parser.add_argument( + "--remote-governance", + action="store_true", + help=( + "also verify target auto-merge and required checks via gh " + "(requires Administration: read)" + ), + ) parser.add_argument( "--repo", type=Path, @@ -206,6 +234,8 @@ def main(argv: Optional[list[str]] = None) -> int: problems = check_config(config, root) if args.remote: problems += check_remote(config) + if args.remote_governance: + problems += check_remote_governance(config) if args.repo is not None: repo = args.repo.resolve() if not repo.is_dir(): diff --git a/labkit/tests/test_labkit_doctor.py b/labkit/tests/test_labkit_doctor.py index 84717d4af..38b498c4f 100644 --- a/labkit/tests/test_labkit_doctor.py +++ b/labkit/tests/test_labkit_doctor.py @@ -6,8 +6,14 @@ from labkit import load_config from labkit.config import PublishConfig -from labkit.doctor import check_config, check_projection, check_remote, main -from labkit.gen_workflows import generated_artifacts +from labkit.doctor import ( + check_config, + check_projection, + check_remote, + check_remote_governance, + main, +) +from labkit.gen_workflows import generated_artifacts, render_verify_automerge from labkit.project import build_projection, write_repo REPO_ROOT = Path(__file__).resolve().parents[2] @@ -123,12 +129,26 @@ def test_doctor_repo_requires_project(tmp_path): assert main(["--repo-root", str(tmp_path), "--repo", str(tmp_path)]) == 1 -def test_remote_requires_native_auto_merge_and_protected_checks(monkeypatch): +def test_remote_does_not_cross_the_governance_capability_boundary(monkeypatch): config = load_config(REPO_ROOT / "labs/14-product-value-projection/publish.config.json") canonical = generated_artifacts(config, REPO_ROOT) sync = next(content for path, content in canonical.items() if path.name == "sync-upstream.yml") + verify = render_verify_automerge(config) - monkeypatch.setattr("labkit.doctor._fetch_remote_workflow", lambda *_args: sync) + monkeypatch.setattr( + "labkit.doctor._fetch_remote_workflow", + lambda _target, name: sync if name == "sync-upstream.yml" else verify, + ) + monkeypatch.setattr( + "labkit.doctor._fetch_remote_json", + lambda *_args: (_ for _ in ()).throw(AssertionError("governance read crossed --remote")), + ) + + assert check_remote(config) == [] + + +def test_remote_governance_requires_native_auto_merge_and_protected_checks(monkeypatch): + config = load_config(REPO_ROOT / "labs/14-product-value-projection/publish.config.json") monkeypatch.setattr( "labkit.doctor._fetch_remote_json", lambda endpoint: ( @@ -138,6 +158,18 @@ def test_remote_requires_native_auto_merge_and_protected_checks(monkeypatch): ), ) - problems = check_remote(config) + problems = check_remote_governance(config) assert f"{config.target_slug}: native auto-merge is not enabled" in problems assert f"{config.target_slug}: main has no required status checks" in problems + + +def test_remote_governance_does_not_report_unreadable_settings_as_disabled(monkeypatch): + config = load_config(REPO_ROOT / "labs/14-product-value-projection/publish.config.json") + monkeypatch.setattr("labkit.doctor._fetch_remote_json", lambda *_args: None) + + problems = check_remote_governance(config) + + assert any("could not verify native auto-merge" in problem for problem in problems) + assert any("could not verify main required status checks" in problem for problem in problems) + assert not any("native auto-merge is not enabled" in problem for problem in problems) + assert not any("main has no required status checks" in problem for problem in problems)