Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/interlock-drift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
2 changes: 1 addition & 1 deletion labkit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ python -m labkit <cmd>
| `project --config <cfg> --repo <dir> --source-commit <sha> --write` | Project a lab into a public-repo checkout (add `--adopt` on first import). |
| `project --config <cfg> --check` | Verify the committed in-lab surface manifest (surface style). |
| `gen [--repo-root <root>] [--check]` | (Re)generate the dispatcher + staged `sync-upstream.yml` for every lab; `--check` fails on drift. |
| `doctor [--repo-root <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 <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 <lab>` | Scaffold `publish.config.json` + workflows + a first release note for a new lab. |
| `release-notes <check-policy\|validate\|preflight>` | The shared append-only release-note policy. |

Expand Down
46 changes: 38 additions & 8 deletions labkit/src/labkit/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,16 @@
3. **Verify gate exists** — the bespoke ``<proj>-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 <checkout> --project <name>`` it additionally asserts the public
repo carries *exactly* ``project(main)`` — every projected byte, including the
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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():
Expand Down
42 changes: 37 additions & 5 deletions labkit/tests/test_labkit_doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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: (
Expand All @@ -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)
Loading