From 7a4fd5c736e5e70dbf0ecdf7e510017830713ee0 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Tue, 28 Jul 2026 11:31:05 +0100 Subject: [PATCH] gate upstream auto-merge configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Conformance now verifies live native auto-merge and required status checks across every configured projection. Disclosure-Reviewed: reviewed — public-safe only, private facet kept out of this commit --- .github/workflows/boatstack-publish.yml | 2 +- .github/workflows/interlock-publish.yml | 2 +- .github/workflows/pitot-publish.yml | 2 +- .github/workflows/value-map-publish.yml | 2 +- labkit/src/labkit/doctor.py | 21 +++++++++ labkit/src/labkit/gen_workflows.py | 4 +- labkit/src/labkit/project.py | 16 ++++++- labkit/tests/test_labkit_doctor.py | 22 ++++++++- labkit/tests/test_labkit_project.py | 47 +++++++++++++++++++ .../.labkit/generated/sync-upstream.yml | 2 +- ...026-07-28-native-auto-merge-conformance.md | 5 ++ .../.labkit/generated/sync-upstream.yml | 2 +- .../.labkit/generated/sync-upstream.yml | 2 +- .../2026-07-28-bounded-legacy-adoption.md | 5 ++ .../.labkit/generated/sync-upstream.yml | 2 +- 15 files changed, 123 insertions(+), 13 deletions(-) create mode 100644 labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-28-native-auto-merge-conformance.md create mode 100644 labs/15-pitot/pitot-distribution/release-notes/2026-07-28-bounded-legacy-adoption.md diff --git a/.github/workflows/boatstack-publish.yml b/.github/workflows/boatstack-publish.yml index ff2106fbd..427a53b27 100644 --- a/.github/workflows/boatstack-publish.yml +++ b/.github/workflows/boatstack-publish.yml @@ -21,7 +21,7 @@ jobs: if: github.repository == 'operatorstack/intelligence-flow' runs-on: ubuntu-latest steps: - - name: Create publisher token + - name: Create Operator Stack Publisher token id: app-token uses: actions/create-github-app-token@v3 with: diff --git a/.github/workflows/interlock-publish.yml b/.github/workflows/interlock-publish.yml index 548282c62..9ec5a3f82 100644 --- a/.github/workflows/interlock-publish.yml +++ b/.github/workflows/interlock-publish.yml @@ -21,7 +21,7 @@ jobs: if: github.repository == 'operatorstack/intelligence-flow' runs-on: ubuntu-latest steps: - - name: Create publisher token + - name: Create Operator Stack Publisher token id: app-token uses: actions/create-github-app-token@v3 with: diff --git a/.github/workflows/pitot-publish.yml b/.github/workflows/pitot-publish.yml index 473c115be..43ea739ab 100644 --- a/.github/workflows/pitot-publish.yml +++ b/.github/workflows/pitot-publish.yml @@ -21,7 +21,7 @@ jobs: if: github.repository == 'operatorstack/intelligence-flow' runs-on: ubuntu-latest steps: - - name: Create publisher token + - name: Create Operator Stack Publisher token id: app-token uses: actions/create-github-app-token@v3 with: diff --git a/.github/workflows/value-map-publish.yml b/.github/workflows/value-map-publish.yml index f7752850d..839f09b01 100644 --- a/.github/workflows/value-map-publish.yml +++ b/.github/workflows/value-map-publish.yml @@ -21,7 +21,7 @@ jobs: if: github.repository == 'operatorstack/intelligence-flow' runs-on: ubuntu-latest steps: - - name: Create publisher token + - name: Create Operator Stack Publisher token id: app-token uses: actions/create-github-app-token@v3 with: diff --git a/labkit/src/labkit/doctor.py b/labkit/src/labkit/doctor.py index 19c166599..8a2f008ea 100644 --- a/labkit/src/labkit/doctor.py +++ b/labkit/src/labkit/doctor.py @@ -71,6 +71,17 @@ def _fetch_remote_workflow(target_slug: str, name: str) -> Optional[str]: return None +def _fetch_remote_json(endpoint: str) -> Optional[object]: + """Fetch one GitHub API document via gh, returning None on access/error.""" + result = subprocess.run(["gh", "api", endpoint], capture_output=True, text=True) + if result.returncode != 0: + return None + try: + return json.loads(result.stdout) + except ValueError: + return None + + def check_remote(config: PublishConfig) -> list[str]: """Diff the live public control planes against the generated canonical copies.""" problems: list[str] = [] @@ -85,6 +96,16 @@ 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") + settings = _fetch_remote_json(f"repos/{config.target_slug}") + if not isinstance(settings, dict) or settings.get("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: + problems.append(f"{config.target_slug}: main has no required status checks") return problems diff --git a/labkit/src/labkit/gen_workflows.py b/labkit/src/labkit/gen_workflows.py index f7ff33f63..44e87353a 100644 --- a/labkit/src/labkit/gen_workflows.py +++ b/labkit/src/labkit/gen_workflows.py @@ -84,7 +84,7 @@ def render_publish_dispatcher(config: PublishConfig) -> str: if: github.repository == '{config.source.repo}' runs-on: ubuntu-latest steps: - - name: Create publisher token + - name: Create Operator Stack Publisher token id: app-token uses: actions/create-github-app-token@v3 with: @@ -185,7 +185,7 @@ def render_sync_upstream(config: PublishConfig) -> str: sync: runs-on: ubuntu-latest steps: - - name: Create publisher token + - name: Create Operator Stack Publisher token id: app-token uses: actions/create-github-app-token@v3 with: diff --git a/labkit/src/labkit/project.py b/labkit/src/labkit/project.py index ce1470cf7..fc1ceb265 100644 --- a/labkit/src/labkit/project.py +++ b/labkit/src/labkit/project.py @@ -314,15 +314,27 @@ def _legacy_manifest_equivalent(repo: Path, projection: Projection, value: dict) if set(value) != {"schema_version", "files"} or value.get("schema_version") != 1: return False owned = value.get("files") - if not isinstance(owned, dict) or set(owned) != set(projection.files): + if not isinstance(owned, dict): return False + # Existing projected destinations must either be absent (safe creation) or + # be owned by the legacy manifest with bytes equal to the new projection. for relative, content in projection.files.items(): expected = sha256_bytes(content) target = repo / relative - if owned.get(relative) != expected or not target.is_file(): + if not target.exists(): + continue + if not target.is_file() or owned.get(relative) != expected: return False if sha256_bytes(target.read_bytes()) != expected: return False + # A legacy-owned path removed by the new projection may be deleted only + # while its bytes still match the ownership proof. + for relative, old_hash in owned.items(): + if relative in projection.files: + continue + target = repo / relative + if target.exists() and (not target.is_file() or sha256_bytes(target.read_bytes()) != old_hash): + return False return True diff --git a/labkit/tests/test_labkit_doctor.py b/labkit/tests/test_labkit_doctor.py index a8e41f9cc..84717d4af 100644 --- a/labkit/tests/test_labkit_doctor.py +++ b/labkit/tests/test_labkit_doctor.py @@ -6,7 +6,7 @@ from labkit import load_config from labkit.config import PublishConfig -from labkit.doctor import check_config, check_projection, main +from labkit.doctor import check_config, check_projection, check_remote, main from labkit.gen_workflows import generated_artifacts from labkit.project import build_projection, write_repo @@ -121,3 +121,23 @@ def test_check_projection_flags_stale_source_commit(tmp_path): def test_doctor_repo_requires_project(tmp_path): (tmp_path / "labs").mkdir() assert main(["--repo-root", str(tmp_path), "--repo", str(tmp_path)]) == 1 + + +def test_remote_requires_native_auto_merge_and_protected_checks(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") + + monkeypatch.setattr("labkit.doctor._fetch_remote_workflow", lambda *_args: sync) + monkeypatch.setattr( + "labkit.doctor._fetch_remote_json", + lambda endpoint: ( + {"allow_auto_merge": False} + if endpoint == f"repos/{config.target_slug}" + else {"contexts": [], "checks": []} + ), + ) + + problems = check_remote(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 diff --git a/labkit/tests/test_labkit_project.py b/labkit/tests/test_labkit_project.py index bc8d39bc6..371b2cd82 100644 --- a/labkit/tests/test_labkit_project.py +++ b/labkit/tests/test_labkit_project.py @@ -203,6 +203,53 @@ def test_equivalent_legacy_surface_manifest_is_adopted_automatically(tmp_path): assert adopted["source"]["repository"] == "operatorstack/intelligence-flow" +def test_equivalent_legacy_manifest_allows_absent_new_projection_files(tmp_path): + lab = _make_lab(tmp_path) + (lab / "NEW.md").write_text("new projected file\n") + config = _projection_config( + [ + {"kind": "copy", "source": "README.md", "dest": "README.md"}, + {"kind": "copy", "source": "NEW.md", "dest": "NEW.md"}, + ], + generator="demo:gen", + ) + proj = build_projection(config, lab, "a" * 40) + repo = tmp_path / "public" + repo.mkdir() + (repo / "README.md").write_bytes(proj.files["README.md"]) + (repo / "UPSTREAM.json").write_text(json.dumps({ + "schema_version": 1, + "files": {"README.md": sha256_bytes(proj.files["README.md"])}, + })) + + assert collisions(repo, proj, adopt=False) == [] + assert write_repo(repo, proj, adopt=False) == 0 + assert (repo / "NEW.md").read_bytes() == proj.files["NEW.md"] + + +def test_legacy_manifest_growth_fails_closed_on_unowned_destination(tmp_path): + lab = _make_lab(tmp_path) + (lab / "NEW.md").write_text("new projected file\n") + config = _projection_config( + [ + {"kind": "copy", "source": "README.md", "dest": "README.md"}, + {"kind": "copy", "source": "NEW.md", "dest": "NEW.md"}, + ], + generator="demo:gen", + ) + proj = build_projection(config, lab, "a" * 40) + repo = tmp_path / "public" + repo.mkdir() + (repo / "README.md").write_bytes(proj.files["README.md"]) + (repo / "NEW.md").write_text("downstream-owned\n") + (repo / "UPSTREAM.json").write_text(json.dumps({ + "schema_version": 1, + "files": {"README.md": sha256_bytes(proj.files["README.md"])}, + })) + + assert sorted(collisions(repo, proj, adopt=False)) == ["NEW.md", "UPSTREAM.json"] + + def test_legacy_manifest_adoption_fails_closed_on_any_hash_mismatch(tmp_path): lab = _make_lab(tmp_path) config = _projection_config([{"kind": "copy", "source": "README.md", "dest": "README.md"}], diff --git a/labs/12-product-engineering-loop/.labkit/generated/sync-upstream.yml b/labs/12-product-engineering-loop/.labkit/generated/sync-upstream.yml index 2c9d9ee9b..6d4c4c3de 100644 --- a/labs/12-product-engineering-loop/.labkit/generated/sync-upstream.yml +++ b/labs/12-product-engineering-loop/.labkit/generated/sync-upstream.yml @@ -25,7 +25,7 @@ jobs: sync: runs-on: ubuntu-latest steps: - - name: Create publisher token + - name: Create Operator Stack Publisher token id: app-token uses: actions/create-github-app-token@v3 with: diff --git a/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-28-native-auto-merge-conformance.md b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-28-native-auto-merge-conformance.md new file mode 100644 index 000000000..92d7dbdc7 --- /dev/null +++ b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-28-native-auto-merge-conformance.md @@ -0,0 +1,5 @@ +### Native auto-merge policy is now conformance-checked + +Boatstack's publisher workflow identifies the Operator Stack Publisher +explicitly, and fleet doctor checks now fail when native auto-merge or required +status checks are missing from the live repository. diff --git a/labs/14-product-value-projection/.labkit/generated/sync-upstream.yml b/labs/14-product-value-projection/.labkit/generated/sync-upstream.yml index 8e447e160..08625cd2d 100644 --- a/labs/14-product-value-projection/.labkit/generated/sync-upstream.yml +++ b/labs/14-product-value-projection/.labkit/generated/sync-upstream.yml @@ -25,7 +25,7 @@ jobs: sync: runs-on: ubuntu-latest steps: - - name: Create publisher token + - name: Create Operator Stack Publisher token id: app-token uses: actions/create-github-app-token@v3 with: diff --git a/labs/15-pitot/.labkit/generated/sync-upstream.yml b/labs/15-pitot/.labkit/generated/sync-upstream.yml index 34ac90405..65c782807 100644 --- a/labs/15-pitot/.labkit/generated/sync-upstream.yml +++ b/labs/15-pitot/.labkit/generated/sync-upstream.yml @@ -25,7 +25,7 @@ jobs: sync: runs-on: ubuntu-latest steps: - - name: Create publisher token + - name: Create Operator Stack Publisher token id: app-token uses: actions/create-github-app-token@v3 with: diff --git a/labs/15-pitot/pitot-distribution/release-notes/2026-07-28-bounded-legacy-adoption.md b/labs/15-pitot/pitot-distribution/release-notes/2026-07-28-bounded-legacy-adoption.md new file mode 100644 index 000000000..517527650 --- /dev/null +++ b/labs/15-pitot/pitot-distribution/release-notes/2026-07-28-bounded-legacy-adoption.md @@ -0,0 +1,5 @@ +### Legacy projection growth can now be adopted safely + +Pitot can migrate an equivalent legacy manifest when newly projected +destinations are absent. Existing and stale paths remain protected by exact +hash proofs, so downstream-owned or modified files still fail closed. diff --git a/labs/21-interlock/.labkit/generated/sync-upstream.yml b/labs/21-interlock/.labkit/generated/sync-upstream.yml index f97e49504..dc22cb17b 100644 --- a/labs/21-interlock/.labkit/generated/sync-upstream.yml +++ b/labs/21-interlock/.labkit/generated/sync-upstream.yml @@ -25,7 +25,7 @@ jobs: sync: runs-on: ubuntu-latest steps: - - name: Create publisher token + - name: Create Operator Stack Publisher token id: app-token uses: actions/create-github-app-token@v3 with: