diff --git a/.github/workflows/kmp-contract.yml b/.github/workflows/kmp-contract.yml index 1b83323..b21f211 100644 --- a/.github/workflows/kmp-contract.yml +++ b/.github/workflows/kmp-contract.yml @@ -23,23 +23,50 @@ jobs: with: path: marketplace + - name: Exercise release-ref regression fixtures + working-directory: marketplace + run: python3 scripts/ci/test-kmp-release-ref.py + - name: Resolve immutable KMP source id: source working-directory: marketplace shell: bash - run: echo "ref=$(python3 scripts/ci/kmp-contract.py --print-source-ref)" >> "${GITHUB_OUTPUT}" + run: | + ref="$(python3 scripts/ci/kmp-contract.py --print-source-ref --allow-unpublished-tag)" + if git ls-remote --exit-code --tags https://github.com/underpass-ai/kmp.git \ + "refs/tags/${ref}" >/dev/null 2>&1; then + echo "checkout_ref=${ref}" >> "${GITHUB_OUTPUT}" + echo "published=true" >> "${GITHUB_OUTPUT}" + else + echo "checkout_ref=main" >> "${GITHUB_OUTPUT}" + echo "published=false" >> "${GITHUB_OUTPUT}" + fi + echo "ref=${ref}" >> "${GITHUB_OUTPUT}" # This is the operation Claude Code's git-subdir installer performs. # Keeping it literal prevents a commit SHA (fetchable by actions/checkout # but invalid for git clone --branch) from passing the marketplace gate. - name: Clone the KMP source exactly as Claude Code does + if: steps.source.outputs.published == 'true' run: >- git clone --depth 1 --branch "${{ steps.source.outputs.ref }}" https://github.com/underpass-ai/kmp.git kmp-source + # Before publication there is deliberately no cloneable release tag. + # KMP main is the only commit that release.sh may tag, so reviewing that + # exact tree keeps the public catalog on the previous installable release. + - name: Clone the reviewed unpublished KMP source + if: steps.source.outputs.published == 'false' + run: >- + git clone --depth 1 + --branch "${{ steps.source.outputs.checkout_ref }}" + https://github.com/underpass-ai/kmp.git + kmp-source + - name: Verify catalogs, copy and exact plugin tree run: >- python3 marketplace/scripts/ci/kmp-contract.py + --allow-unpublished-tag --source-root kmp-source/plugins/kmp diff --git a/scripts/ci/kmp-contract.py b/scripts/ci/kmp-contract.py index ce905a6..4d7b42e 100644 --- a/scripts/ci/kmp-contract.py +++ b/scripts/ci/kmp-contract.py @@ -74,13 +74,17 @@ def mirrored_version() -> str: return versions.pop() -def annotated_tag_commit(ref: str) -> str: +def release_commit( + ref: str, + repository: str = KMP_REMOTE, + allow_unpublished_tag: bool = False, +) -> tuple[str, bool]: result = subprocess.run( [ "git", "ls-remote", "--tags", - KMP_REMOTE, + repository, f"refs/tags/{ref}", f"refs/tags/{ref}^{{}}", ], @@ -98,14 +102,30 @@ def annotated_tag_commit(ref: str) -> str: refs[fields[1]] = fields[0] tag_ref = f"refs/tags/{ref}" if tag_ref not in refs: - raise SystemExit(f"KMP source tag {ref} does not exist") + if not allow_unpublished_tag: + raise SystemExit(f"KMP source tag {ref} does not exist") + main = subprocess.run( + ["git", "ls-remote", repository, "refs/heads/main"], + check=False, + capture_output=True, + text=True, + ) + fields = main.stdout.split() + commit = fields[0] if main.returncode == 0 and fields else "" + if not COMMIT_SHA.fullmatch(commit): + detail = main.stderr.strip() or "main ref was not found" + raise SystemExit(f"could not resolve unpublished KMP source {repository}: {detail}") + return commit, False peeled = refs.get(f"{tag_ref}^{{}}") if peeled is None: raise SystemExit(f"KMP source tag {ref} must be annotated") - return peeled + return peeled, True -def claude_source() -> tuple[dict[str, object], str, str]: +def claude_source( + allow_unpublished_tag: bool = False, + repository: str = KMP_REMOTE, +) -> tuple[dict[str, object], str, str, bool]: entry = kmp_entry(CLAUDE_LISTING) source = entry.get("source") if not isinstance(source, dict): @@ -118,7 +138,8 @@ def claude_source() -> tuple[dict[str, object], str, str]: if ref != expected_ref: raise SystemExit(f"Claude kmp source must pin clonable immutable release tag {expected_ref}") verify_description("Claude marketplace entry", entry.get("description")) - return entry, ref, annotated_tag_commit(ref) + commit, published = release_commit(ref, repository, allow_unpublished_tag) + return entry, ref, commit, published def verify_description(label: str, value: object) -> None: @@ -159,8 +180,14 @@ def verify_tree(source_root: pathlib.Path) -> None: ) -def verify_contract(source_root: pathlib.Path) -> None: - _, ref, expected_commit = claude_source() +def verify_contract( + source_root: pathlib.Path, + allow_unpublished_tag: bool = False, + repository: str = KMP_REMOTE, +) -> None: + _, ref, expected_commit, published = claude_source( + allow_unpublished_tag, repository + ) codex = kmp_entry(CODEX_LISTING) if codex.get("source") != EXPECTED_CODEX_SOURCE: raise SystemExit("Codex kmp entry no longer resolves the reviewed plugins/kmp snapshot") @@ -211,21 +238,37 @@ def verify_contract(source_root: pathlib.Path) -> None: if RETIRED_COUNT.search(readme): raise SystemExit("marketplace README still contains retired whole-surface copy") - print(f"KMP marketplace contract passed: {versions.pop()}, 13 tools, source {ref}") + source_state = "published annotated tag" if published else "unpublished tag bound to KMP main" + print( + f"KMP marketplace contract passed: {versions.pop()}, 13 tools, " + f"source {ref} ({source_state})" + ) def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--source-root", type=pathlib.Path) parser.add_argument("--print-source-ref", action="store_true") + parser.add_argument("--allow-unpublished-tag", action="store_true") + parser.add_argument( + "--kmp-repository", + default=KMP_REMOTE, + help="override the KMP Git remote for contract tests", + ) args = parser.parse_args() - _, ref, _ = claude_source() + _, ref, _, _ = claude_source( + args.allow_unpublished_tag, args.kmp_repository + ) if args.print_source_ref: print(ref) return if args.source_root is None: parser.error("--source-root is required unless --print-source-ref is used") - verify_contract(args.source_root) + verify_contract( + args.source_root, + args.allow_unpublished_tag, + args.kmp_repository, + ) if __name__ == "__main__": diff --git a/scripts/ci/test-kmp-release-ref.py b/scripts/ci/test-kmp-release-ref.py new file mode 100644 index 0000000..54c0b48 --- /dev/null +++ b/scripts/ci/test-kmp-release-ref.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Regression fixtures for the pre-tag and annotated-tag marketplace contract.""" + +from __future__ import annotations + +import importlib.util +import pathlib +import subprocess +import tempfile + + +SCRIPT = pathlib.Path(__file__).with_name("kmp-contract.py") +SPEC = importlib.util.spec_from_file_location("kmp_contract", SCRIPT) +if SPEC is None or SPEC.loader is None: + raise SystemExit(f"could not load {SCRIPT}") +CONTRACT = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(CONTRACT) + + +def run(*args: object, cwd: pathlib.Path | None = None) -> None: + subprocess.run( + [str(argument) for argument in args], + cwd=cwd, + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + +with tempfile.TemporaryDirectory(prefix="kmp-marketplace-ref-") as raw_fixture: + fixture = pathlib.Path(raw_fixture) + remote = fixture / "remote.git" + work = fixture / "work" + run("git", "init", "--bare", remote) + run("git", "init", "--initial-branch=main", work) + run("git", "config", "user.name", "KMP contract", cwd=work) + run("git", "config", "user.email", "kmp-contract@example.invalid", cwd=work) + (work / "README.md").write_text("release-ref fixture\n", encoding="utf-8") + run("git", "add", "README.md", cwd=work) + run("git", "commit", "-m", "fixture: candidate", cwd=work) + commit = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=work, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + run("git", "remote", "add", "origin", remote.as_uri(), cwd=work) + run("git", "push", "origin", "main", cwd=work) + + try: + CONTRACT.release_commit("v0.5.1", remote.as_uri()) + except SystemExit as error: + if "does not exist" not in str(error): + raise + else: + raise SystemExit("strict marketplace contract accepted an unpublished tag") + + observed, published = CONTRACT.release_commit( + "v0.5.1", remote.as_uri(), allow_unpublished_tag=True + ) + if observed != commit or published: + raise SystemExit("pre-tag marketplace contract did not bind remote main") + + run("git", "tag", "v0.5.1", cwd=work) + run("git", "push", "origin", "refs/tags/v0.5.1", cwd=work) + try: + CONTRACT.release_commit( + "v0.5.1", remote.as_uri(), allow_unpublished_tag=True + ) + except SystemExit as error: + if "must be annotated" not in str(error): + raise + else: + raise SystemExit("marketplace contract accepted a lightweight release tag") + + run("git", "push", "origin", ":refs/tags/v0.5.1", cwd=work) + run("git", "tag", "-d", "v0.5.1", cwd=work) + run("git", "tag", "-a", "v0.5.1", "-m", "Release v0.5.1", cwd=work) + run("git", "push", "origin", "refs/tags/v0.5.1", cwd=work) + observed, published = CONTRACT.release_commit( + "v0.5.1", remote.as_uri(), allow_unpublished_tag=True + ) + if observed != commit or not published: + raise SystemExit("annotated release tag did not peel to the reviewed commit") + +print("KMP release-ref regressions passed: unpublished main, lightweight refusal, annotated tag")