diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index 1048b4189..9d87792e1 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -85,18 +85,26 @@ jobs: # Archive layout/name live in the ONE canonical script the local # artifact-flow smoke lane also runs (venue-parity contract). - name: Archive release binary (canonical package-release.sh) + env: + VERSION: ${{ inputs.version }} run: scripts/package-release.sh ${{ matrix.goos }} ${{ matrix.goarch }} - name: Attest release binary provenance if: ${{ inputs.attest }} uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: - subject-path: codebase-memory-mcp-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz + # *.mcpb matches nothing on the glibc-dynamic linux targets — the + # canonical packager builds bundles only where static/one-click holds. + subject-path: | + codebase-memory-mcp-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz + *.mcpb - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: binaries-${{ matrix.goos }}-${{ matrix.goarch }} - path: "*.tar.gz" + path: | + *.tar.gz + *.mcpb build-windows: runs-on: windows-latest @@ -133,18 +141,24 @@ jobs: # artifact-flow smoke lane also runs (venue-parity contract). - name: Archive release binary (canonical package-release.sh) shell: msys2 {0} + env: + VERSION: ${{ inputs.version }} run: scripts/package-release.sh windows amd64 CC=clang CXX=clang++ - name: Attest release binary provenance if: ${{ inputs.attest }} uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: - subject-path: codebase-memory-mcp-windows-amd64.zip + subject-path: | + codebase-memory-mcp-windows-amd64.zip + *.mcpb - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: binaries-windows-amd64 - path: "*.zip" + path: | + *.zip + *.mcpb build-windows-arm64: # Native ARM64 Windows binary via the CLANGARM64 toolchain on the @@ -183,18 +197,24 @@ jobs: - name: Archive release binary (canonical package-release.sh) shell: msys2 {0} + env: + VERSION: ${{ inputs.version }} run: scripts/package-release.sh windows arm64 CC=clang CXX=clang++ - name: Attest release binary provenance if: ${{ inputs.attest }} uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: - subject-path: codebase-memory-mcp-windows-arm64.zip + subject-path: | + codebase-memory-mcp-windows-arm64.zip + *.mcpb - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: binaries-windows-arm64 - path: "*.zip" + path: | + *.zip + *.mcpb build-linux-portable: # Fully static Linux binaries (gcc -static on Ubuntu). @@ -235,15 +255,21 @@ jobs: ldd build/c/codebase-memory-mcp 2>&1 | grep -q "not a dynamic executable" || ldd build/c/codebase-memory-mcp 2>&1 | grep -q "statically linked" - name: Archive release binary (canonical package-release.sh) + env: + VERSION: ${{ inputs.version }} run: scripts/package-release.sh linux ${{ matrix.arch }}-portable - name: Attest release binary provenance if: ${{ inputs.attest }} uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: - subject-path: codebase-memory-mcp-linux-${{ matrix.arch }}-portable.tar.gz + subject-path: | + codebase-memory-mcp-linux-${{ matrix.arch }}-portable.tar.gz + *.mcpb - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: binaries-linux-${{ matrix.arch }}-portable - path: "*.tar.gz" + path: | + *.tar.gz + *.mcpb diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0328e26a6..1d8cd4027 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,16 +129,25 @@ jobs: merge-multiple: true - name: List artifacts - run: ls -la *.tar.gz *.zip + run: ls -la *.tar.gz *.zip *.mcpb - name: Generate checksums - run: sha256sum *.tar.gz *.zip > checksums.txt + run: sha256sum *.tar.gz *.zip *.mcpb > checksums.txt - name: Attest checksum provenance uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: subject-path: checksums.txt + # publish-mcp-registry reads the *.mcpb sha256 lines from here — a + # same-run workflow artifact, not a draft-release download, so that + # job keeps contents: read and no gh dependency. + - name: Preserve checksums for the registry job + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-checksums + path: checksums.txt + # SBOM content lives in the canonical scripts/ci entry, not inline YAML # (venue-parity contract): vendored versions are reviewable there. - name: Generate SBOM @@ -155,7 +164,7 @@ jobs: - name: Sign artifacts run: | - for f in *.tar.gz *.zip checksums.txt; do + for f in *.tar.gz *.zip *.mcpb checksums.txt; do cosign sign-blob --yes --bundle "${f}.bundle" "$f" done @@ -187,6 +196,7 @@ jobs: files: | *.tar.gz *.zip + *.mcpb checksums.txt sbom.json *.bundle @@ -214,11 +224,14 @@ jobs: run: | ARCHIVE_DIR="$RUNNER_TEMP/release-archives" mkdir -p "$ARCHIVE_DIR" binaries - gh release download "$VERSION" --dir "$ARCHIVE_DIR" --repo "$GITHUB_REPOSITORY" --pattern '*.tar.gz' --pattern '*.zip' + gh release download "$VERSION" --dir "$ARCHIVE_DIR" --repo "$GITHUB_REPOSITORY" --pattern '*.tar.gz' --pattern '*.zip' --pattern '*.mcpb' + # 14 = 8 archives + 6 MCPB bundles; runtime 42 = 8×3 archive + # sidecars + 6×3 bundle members (manifest.json, LICENSE, notices). + # The bundle binaries dedupe to the archive objects byte-for-byte. scripts/ci/extract-release-archives.sh "$ARCHIVE_DIR" binaries \ - --expect-archives=8 \ - --expect-binaries=8 \ - --expect-runtime-files=24 + --expect-archives=14 \ + --expect-binaries=14 \ + --expect-runtime-files=42 - name: Security audits on all unique extracted release objects run: | @@ -418,6 +431,20 @@ jobs: jq --arg v "$VERSION" '.version = $v | (.packages[].version) = $v' \ server.json > server.tmp && mv server.tmp server.json echo "server.json pinned to $VERSION" + + # The MCPB entries need each bundle's sha256; checksums.txt comes from + # the release-draft job as a same-run artifact (not a draft-release + # download — that would need contents beyond read plus gh). + - name: Fetch release checksums + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-checksums + + - name: Append MCPB package entries to server.json + env: + RELEASE_VERSION: ${{ inputs.version }} + run: | + scripts/ci/gen-mcpb-registry-entries.sh server.json checksums.txt "$RELEASE_VERSION" cat server.json - name: Install mcp-publisher diff --git a/scripts/ci/extract-release-archives.sh b/scripts/ci/extract-release-archives.sh index 72120f67c..be439194a 100755 --- a/scripts/ci/extract-release-archives.sh +++ b/scripts/ci/extract-release-archives.sh @@ -33,6 +33,7 @@ from __future__ import annotations import csv import hashlib +import json import os import pathlib import re @@ -71,9 +72,20 @@ UNIX_TARGETS = ( "linux-arm64-portable", ) WINDOWS_TARGETS = ("windows-amd64", "windows-arm64") +# MCPB bundles exist for darwin/windows and the STATIC linux builds only — +# the same eligibility rule scripts/package-release.sh encodes. +MCPB_TARGETS = ( + "darwin-amd64", + "darwin-arm64", + "linux-amd64-portable", + "linux-arm64-portable", + "windows-amd64", + "windows-arm64", +) CANONICAL_ARCHIVES = frozenset( [f"codebase-memory-mcp-{target}.tar.gz" for target in UNIX_TARGETS] + [f"codebase-memory-mcp-{target}.zip" for target in WINDOWS_TARGETS] + + [f"codebase-memory-mcp-{target}.mcpb" for target in MCPB_TARGETS] ) # One composition ships; the association column is retained so the schema stays # stable for the gate and release-notes consumers. @@ -308,15 +320,26 @@ def validate_namespace(archive_name: str, names: Iterable[str]) -> Dict[str, str if len(names_list) != len(set(names_list)): duplicate = next(name for name in names_list if names_list.count(name) > 1) raise ContractError(f"duplicate archive member in {archive_name}: {duplicate}") - windows = archive_name.endswith(".zip") - binary = "codebase-memory-mcp.exe" if windows else "codebase-memory-mcp" - installer = "install.ps1" if windows else "install.sh" - fixed = { - binary: "binary", - "LICENSE": "runtime", - installer: "runtime", - "THIRD_PARTY_NOTICES.md": "runtime", - } + # The archive name is already validated against CANONICAL_ARCHIVES, so + # platform detection by name is sound for every container kind. + windows = "-windows-" in archive_name + if archive_name.endswith(".mcpb"): + binary = "server/codebase-memory-mcp.exe" if windows else "server/codebase-memory-mcp" + fixed = { + "manifest.json": "runtime", + binary: "binary", + "server/LICENSE": "runtime", + "server/THIRD_PARTY_NOTICES.md": "runtime", + } + else: + binary = "codebase-memory-mcp.exe" if windows else "codebase-memory-mcp" + installer = "install.ps1" if windows else "install.sh" + fixed = { + binary: "binary", + "LICENSE": "runtime", + installer: "runtime", + "THIRD_PARTY_NOTICES.md": "runtime", + } name_set = set(names_list) extras = name_set - set(fixed) if extras: @@ -324,11 +347,43 @@ def validate_namespace(archive_name: str, names: Iterable[str]) -> Dict[str, str if name_set != set(fixed): missing = sorted(set(fixed) - name_set) raise ContractError( - f"member namespace mismatch in {archive_name}: expected exactly 4 root files; missing={missing}" + f"member namespace mismatch in {archive_name}: expected exactly 4 members; missing={missing}" ) return fixed +def validate_mcpb_manifest(path: pathlib.Path, *, archive_name: str) -> None: + """A structurally broken bundle must fail the matrix, not ship. + + The namespace check above proves manifest.json EXISTS; this proves it + actually describes the binary the bundle carries. Full schema validation + belongs to MCPB hosts — the gate pins only what a wrong build would break. + """ + with zipfile.ZipFile(path, "r") as archive: + try: + manifest = json.loads(archive.read("manifest.json")) + except (json.JSONDecodeError, UnicodeDecodeError) as error: + raise ContractError(f"manifest.json in {archive_name} is not valid JSON: {error}") + if not isinstance(manifest, dict): + raise ContractError(f"manifest.json in {archive_name} must be a JSON object") + if not manifest.get("version"): + raise ContractError(f"manifest.json in {archive_name} lacks a version") + server = manifest.get("server") + if not isinstance(server, dict) or server.get("type") != "binary": + raise ContractError(f"manifest.json in {archive_name} must declare a binary server") + entry = server.get("entry_point") + if entry not in set(archive.namelist()): + raise ContractError( + f"manifest entry_point is not a member of {archive_name}: {entry}" + ) + mcp_config = server.get("mcp_config") + command = mcp_config.get("command") if isinstance(mcp_config, dict) else None + if not isinstance(command, str) or not command.endswith(entry): + raise ContractError( + f"manifest mcp_config.command does not target the entry_point in {archive_name}" + ) + + def add_association( rows: List[Dict[str, object]], scan_object: ScanObject, @@ -582,6 +637,8 @@ def main(argv: Sequence[str]) -> None: total_members += member_total if total_members > MAX_TOTAL_MEMBER_BYTES: raise ContractError("release matrix exceeds total uncompressed byte ceiling") + if archive_name.endswith(".mcpb"): + validate_mcpb_manifest(archive_object.path, archive_name=archive_name) counts["archives"] += 1 counts["binaries"] += sum(kind == "binary" for kind in kinds.values()) counts["runtime_files"] += sum(kind == "runtime" for kind in kinds.values()) diff --git a/scripts/ci/gen-mcpb-registry-entries.sh b/scripts/ci/gen-mcpb-registry-entries.sh new file mode 100644 index 000000000..d39c145fd --- /dev/null +++ b/scripts/ci/gen-mcpb-registry-entries.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# gen-mcpb-registry-entries.sh — append MCPB package entries to server.json. +# +# The npm/PyPI entries are static repo content, but MCPB entries carry a +# per-release fileSha256, so they can only exist at publish time. This script +# derives them from the same checksums.txt the draft release ships (and +# attests), never by re-hashing anything itself: one checksum authority. +# +# Usage: gen-mcpb-registry-entries.sh +# +# release-tag the GitHub release tag, v-prefixed (v0.11.0); it forms the +# download URL. The package version field drops the prefix. +# +# Idempotent: existing mcpb entries are replaced, never duplicated, so a +# re-run of the registry job cannot grow the manifest. A checksums file +# without a single .mcpb line is a hard failure — publishing a manifest that +# silently un-lists the bundles would hide a broken artifact chain +# (the #1522 silent-empty-results lesson). +set -euo pipefail + +if [ "$#" -ne 3 ]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +command -v python3 >/dev/null 2>&1 || { + echo "FAIL: python3 is required to generate MCPB registry entries" >&2 + exit 1 +} + +python3 - "$@" <<'PY' +import json +import pathlib +import re +import sys + +server_json = pathlib.Path(sys.argv[1]) +checksums = pathlib.Path(sys.argv[2]) +tag = sys.argv[3] + +if not tag: + print("FAIL: empty release tag", file=sys.stderr) + raise SystemExit(1) +for path in (server_json, checksums): + if not path.is_file(): + print(f"FAIL: missing {path}", file=sys.stderr) + raise SystemExit(1) + +manifest = json.loads(server_json.read_text(encoding="utf-8")) +repo_url = (manifest.get("repository") or {}).get("url") +if not repo_url: + print(f"FAIL: {server_json} has no repository.url to build download URLs from", + file=sys.stderr) + raise SystemExit(1) +repo_url = repo_url.rstrip("/") +version = tag[1:] if tag.startswith("v") else tag + +entries = [] +for line in checksums.read_text(encoding="utf-8").splitlines(): + parts = line.split() + if len(parts) != 2 or not parts[1].endswith(".mcpb"): + continue + sha, name = parts + if not re.fullmatch(r"[0-9a-f]{64}", sha): + print(f"FAIL: malformed sha256 for {name} in {checksums}", file=sys.stderr) + raise SystemExit(1) + entries.append((name, sha)) + +if not entries: + print(f"FAIL: no .mcpb lines in {checksums}", file=sys.stderr) + raise SystemExit(1) + +packages = [package for package in manifest.get("packages", []) + if package.get("registryType") != "mcpb"] +for name, sha in sorted(entries): + packages.append({ + "registryType": "mcpb", + "identifier": f"{repo_url}/releases/download/{tag}/{name}", + "version": version, + "fileSha256": sha, + "transport": {"type": "stdio"}, + }) + print(f"mcpb entry: {name} ({sha})") +manifest["packages"] = packages + +server_json.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") +print(f"appended {len(entries)} mcpb package entries to {server_json} for {tag}") +PY diff --git a/scripts/package-release.sh b/scripts/package-release.sh index 8be6edbf7..744d79df8 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -31,11 +31,18 @@ Make passthrough (VAR=VAL, forwarded to the build): Environment: BUILD_DIR build tree to archive from (default build/c). + VERSION release version stamped into the MCPB manifest (v-prefix + accepted; defaults to 0.0.0-dev outside a release build). Archive contents (defined here, canonical) — ONE executable, no sidecars: unix: codebase-memory-mcp LICENSE install.sh THIRD_PARTY_NOTICES.md (.tar.gz) windows: codebase-memory-mcp.exe LICENSE install.ps1 THIRD_PARTY_NOTICES.md (.zip) +MCPB bundle (.mcpb, a zip) — darwin/windows targets plus the STATIC linux +builds; same staged binary, for MCP-Registry one-click-install hosts: + manifest.json server/codebase-memory-mcp[.exe] server/LICENSE + server/THIRD_PARTY_NOTICES.md + Only one build variant ships: the binary carries the graph UI and the agent integration templates inside itself, so an extracted archive is immediately complete — no adjacent data file has to resolve for `install` to work. @@ -194,3 +201,70 @@ else codebase-memory-mcp LICENSE install.sh THIRD_PARTY_NOTICES.md echo "=== package-release: $OUT_DIR/$NAME.tar.gz ===" fi + +# ── MCPB bundle (registryType "mcpb" in the MCP Registry) ───────────────── +# Repackages the SAME staged binary the archive above ships — already +# stripped, re-signed and composition-gated — so the VirusTotal scan set +# dedupes the executable to the archive's object; only manifest.json is a +# new scan member. The installer script is deliberately absent: an MCPB +# host manages install/update itself. +build_mcpb_bundle() { + local out="$OUT_DIR/$NAME.mcpb" + local stage="$PACK_DIR/.mcpb-stage" + local entry="server/$STAGED_BINARY_NAME" + local platform + case "$GOOS" in + darwin) platform="darwin" ;; + windows) platform="win32" ;; + linux) platform="linux" ;; + esac + command -v zip >/dev/null 2>&1 || { + echo "package-release: zip is required to build $NAME.mcpb" >&2 + return 1 + } + mkdir -p "$stage/server" + cp "$STAGED_BINARY" "$stage/$entry" + cp "$PACK_DIR/LICENSE" "$PACK_DIR/THIRD_PARTY_NOTICES.md" "$stage/server/" + local mcpb_version="${VERSION:-0.0.0-dev}" + mcpb_version="${mcpb_version#v}" + # ${__dirname} is the MCPB host's substitution variable, not shell — + # hence the escapes. Hosts append .exe themselves where needed, but the + # manifest names the actual member so non-normalizing hosts also work. + cat >"$stage/manifest.json" <"$FIX/stub.c" <<'EOF' +#include +static const char keep[] = "codebase-memory-mcp OMIT_LOAD_EXTENSION"; +int main(void) { puts(keep); return 0; } +EOF +"${CC:-cc}" -O0 -o "$BUILD_DIR/codebase-memory-mcp" "$FIX/stub.c" + +package() { # goos goarch out-subdir [VERSION value] + local goos="$1" goarch="$2" out="$FIX/$3" + mkdir -p "$out" + if [ "$#" -ge 4 ]; then + VERSION="$4" STRIP=true BUILD_DIR="$BUILD_DIR" \ + bash "$ROOT/scripts/package-release.sh" "$goos" "$goarch" --out-dir "$out" + else + env -u VERSION STRIP=true BUILD_DIR="$BUILD_DIR" \ + bash "$ROOT/scripts/package-release.sh" "$goos" "$goarch" --out-dir "$out" + fi +} + +echo "--- packaging the four target families from one stub build tree" +package darwin arm64 darwin v0.0.0-contract >/dev/null +package windows amd64 windows v0.0.0-contract >/dev/null +package linux amd64-portable portable v0.0.0-contract >/dev/null +package linux amd64 plainlinux v0.0.0-contract >/dev/null +package darwin arm64 unversioned >/dev/null + +python3 - "$FIX" <<'PY' +import json +import pathlib +import stat +import sys +import zipfile + +fix = pathlib.Path(sys.argv[1]) +failures = [] + + +def fail(message): + failures.append(message) + + +def check_bundle(path, *, binary, platform, version): + if not path.is_file(): + fail(f"missing bundle: {path.name} in {path.parent.name}/") + return + with zipfile.ZipFile(path) as bundle: + names = sorted(bundle.namelist()) + expected = sorted(["manifest.json", binary, "server/LICENSE", + "server/THIRD_PARTY_NOTICES.md"]) + if names != expected: + fail(f"{path.name}: member set {names} != {expected}") + return + info = bundle.getinfo(binary) + mode = (info.external_attr >> 16) & 0xFFFF + if not mode & stat.S_IXUSR: + fail(f"{path.name}: {binary} lost its executable bit (mode {oct(mode)})") + manifest = json.loads(bundle.read("manifest.json")) + if manifest.get("version") != version: + fail(f"{path.name}: manifest version {manifest.get('version')!r} != {version!r}") + server = manifest.get("server") or {} + if server.get("type") != "binary": + fail(f"{path.name}: server.type must be 'binary'") + if server.get("entry_point") != binary: + fail(f"{path.name}: entry_point {server.get('entry_point')!r} != {binary!r}") + command = (server.get("mcp_config") or {}).get("command") + if command != "${__dirname}/" + binary: + fail(f"{path.name}: mcp_config.command {command!r} does not target the bundled binary") + platforms = (manifest.get("compatibility") or {}).get("platforms") + if platforms != [platform]: + fail(f"{path.name}: compatibility.platforms {platforms!r} != {[platform]!r}") + + +check_bundle(fix / "darwin" / "codebase-memory-mcp-darwin-arm64.mcpb", + binary="server/codebase-memory-mcp", platform="darwin", + version="0.0.0-contract") +check_bundle(fix / "windows" / "codebase-memory-mcp-windows-amd64.mcpb", + binary="server/codebase-memory-mcp.exe", platform="win32", + version="0.0.0-contract") +check_bundle(fix / "portable" / "codebase-memory-mcp-linux-amd64-portable.mcpb", + binary="server/codebase-memory-mcp", platform="linux", + version="0.0.0-contract") + +# Without VERSION the manifest must say so loudly, not invent a release. +check_bundle(fix / "unversioned" / "codebase-memory-mcp-darwin-arm64.mcpb", + binary="server/codebase-memory-mcp", platform="darwin", + version="0.0.0-dev") + +# Eligibility is a fence, not a default: the glibc-dynamic linux build gets +# an archive but NO bundle. +plain = fix / "plainlinux" +if not (plain / "codebase-memory-mcp-linux-amd64.tar.gz").is_file(): + fail("plain linux target must still produce its tar.gz") +mcpbs = list(plain.glob("*.mcpb")) +if mcpbs: + fail(f"glibc-dynamic linux target must not produce a bundle: {[p.name for p in mcpbs]}") + +if failures: + print("MCPB BUNDLE CONTRACT VIOLATED:") + for message in failures: + print(f" - {message}") + sys.exit(1) + +print("mcpb bundle contract OK (darwin/windows/static-linux bundles exact, " + "manifest binds the bundled binary, exec bit preserved, version " + "stamped with 0.0.0-dev fallback, glibc-dynamic linux excluded)") +PY diff --git a/tests/test_mcpb_registry_entries_contract.sh b/tests/test_mcpb_registry_entries_contract.sh new file mode 100644 index 000000000..15af2eb5c --- /dev/null +++ b/tests/test_mcpb_registry_entries_contract.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# Contract: scripts/ci/gen-mcpb-registry-entries.sh turns checksums.txt into +# the MCPB package entries the MCP Registry publishes. +# +# These entries are the only place clients learn a bundle's sha256 — clients +# verify downloads against it, so a wrong hash bricks one-click install and a +# silently EMPTY entry set publishes a manifest that quietly un-lists the +# bundles (#1522's lesson: empty results must be loud). The real repo +# server.json is the fixture, so schema drift there surfaces here. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +GEN="$ROOT/scripts/ci/gen-mcpb-registry-entries.sh" +FIX="$(mktemp -d "${TMPDIR:-/tmp}/cbm-mcpb-entries.XXXXXX")" +trap 'rm -rf "$FIX"' EXIT + +# "$BASH" by explicit argv — on native Windows a bare "bash" resolves to the +# WSL stub (same trap the extractor contract documents). +python3 - "$ROOT" "$FIX" "$BASH" <<'PY' +import json +import pathlib +import shutil +import subprocess +import sys + +root = pathlib.Path(sys.argv[1]) +fix = pathlib.Path(sys.argv[2]) +bash_executable = sys.argv[3] +generator = root / "scripts" / "ci" / "gen-mcpb-registry-entries.sh" + +SHA_A = "a" * 64 +SHA_B = "b" * 64 +failures = [] + + +def fail(message): + failures.append(message) + + +def fresh_fixture(): + shutil.copy(root / "server.json", fix / "server.json") + (fix / "checksums.txt").write_text( + f"{SHA_B} codebase-memory-mcp-windows-amd64.zip\n" + f"{SHA_B} codebase-memory-mcp-windows-amd64.mcpb\n" + f"{SHA_A} codebase-memory-mcp-darwin-arm64.mcpb\n", + encoding="utf-8", + ) + + +def run(checksums_name="checksums.txt"): + return subprocess.run( + [bash_executable, str(generator), str(fix / "server.json"), + str(fix / checksums_name), "v9.9.9"], + capture_output=True, text=True, + ) + + +def mcpb_packages(): + manifest = json.loads((fix / "server.json").read_text(encoding="utf-8")) + return (manifest, + [p for p in manifest.get("packages", []) + if p.get("registryType") == "mcpb"]) + + +# ── happy path ────────────────────────────────────────────────────────────── +fresh_fixture() +result = run() +if result.returncode != 0: + fail(f"generator failed on a valid fixture: {result.stderr[-300:]}") +manifest, mcpb = mcpb_packages() + +kinds = [p.get("registryType") for p in manifest.get("packages", [])] +if kinds[:2] != ["npm", "pypi"]: + fail(f"npm/pypi entries must survive, in order, ahead of mcpb: {kinds}") +if len(mcpb) != 2: + fail(f"expected 2 mcpb entries (only .mcpb lines count), got {len(mcpb)}") +else: + first = mcpb[0] + wanted_url = ("https://github.com/DeusData/codebase-memory-mcp/releases/" + "download/v9.9.9/codebase-memory-mcp-darwin-arm64.mcpb") + if first.get("identifier") != wanted_url: + fail(f"darwin identifier wrong: {first.get('identifier')}") + if first.get("version") != "9.9.9": + fail(f"version must drop the v prefix: {first.get('version')}") + if first.get("fileSha256") != SHA_A: + fail(f"fileSha256 wrong: {first.get('fileSha256')}") + if (first.get("transport") or {}).get("type") != "stdio": + fail(f"transport must be stdio: {first.get('transport')}") + # The registry requires the identifier to satisfy its "contains mcp" rule + # and clients resolve it as a release asset URL — pin both for every entry. + for package in mcpb: + url = package.get("identifier", "") + if not (url.startswith("https://github.com/") + and "/releases/download/v9.9.9/" in url + and url.endswith(".mcpb")): + fail(f"mcpb identifier must be a release-asset URL: {url}") + +# ── idempotency: a registry-job re-run must not grow the manifest ─────────── +result = run() +if result.returncode != 0: + fail(f"re-run failed: {result.stderr[-300:]}") +_, mcpb = mcpb_packages() +if len(mcpb) != 2: + fail(f"re-run duplicated mcpb entries: {len(mcpb)}") + +# ── fail-closed: zero bundles, malformed hash ─────────────────────────────── +fresh_fixture() +(fix / "checksums-none.txt").write_text( + f"{SHA_B} codebase-memory-mcp-windows-amd64.zip\n", encoding="utf-8") +result = run("checksums-none.txt") +if result.returncode == 0: + fail("a checksums file without .mcpb lines must be fatal, not an empty publish") +elif "no .mcpb lines" not in result.stderr: + fail(f"zero-bundle failure does not name the contract: {result.stderr[-200:]}") + +fresh_fixture() +with (fix / "checksums.txt").open("a", encoding="utf-8") as handle: + handle.write("deadbeef codebase-memory-mcp-darwin-amd64.mcpb\n") +result = run() +if result.returncode == 0: + fail("a malformed sha256 must be fatal") +elif "malformed sha256" not in result.stderr: + fail(f"malformed-sha failure does not name the contract: {result.stderr[-200:]}") + +if failures: + print("MCPB REGISTRY ENTRIES CONTRACT VIOLATED:") + for message in failures: + print(f" - {message}") + sys.exit(1) + +print("mcpb registry entries contract OK (entries exact against the live " + "server.json, idempotent, fail-closed on zero bundles and malformed hashes)") +PY diff --git a/tests/test_release_archive_extractor_contract.sh b/tests/test_release_archive_extractor_contract.sh index b04402217..8005a0efd 100644 --- a/tests/test_release_archive_extractor_contract.sh +++ b/tests/test_release_archive_extractor_contract.sh @@ -35,8 +35,11 @@ extractor = root / "scripts" / "ci" / "extract-release-archives.sh" UNIX = ("linux-amd64", "linux-arm64", "darwin-amd64", "darwin-arm64", "linux-amd64-portable", "linux-arm64-portable") WINDOWS = ("windows-amd64", "windows-arm64") +MCPB = ("darwin-amd64", "darwin-arm64", "linux-amd64-portable", + "linux-arm64-portable", "windows-amd64", "windows-arm64") ARCHIVES = tuple(f"codebase-memory-mcp-{t}.tar.gz" for t in UNIX) + \ - tuple(f"codebase-memory-mcp-{t}.zip" for t in WINDOWS) + tuple(f"codebase-memory-mcp-{t}.zip" for t in WINDOWS) + \ + tuple(f"codebase-memory-mcp-{t}.mcpb" for t in MCPB) # Deliberately byte-IDENTICAL across every archive: the extractor must collapse # them to one scan object each, or the gate pays to scan the same bytes 8 times @@ -53,12 +56,37 @@ def fail(message): failures.append(message) +def target_of(archive): + return archive.rsplit(".", 1)[0].removesuffix(".tar") + + +def make_manifest(entry_point): + return ( + '{"manifest_version": "0.3", "name": "codebase-memory-mcp",' + ' "version": "0.0.0-test", "server": {"type": "binary",' + f' "entry_point": "{entry_point}",' + ' "mcp_config": {"command": "${__dirname}/' + entry_point + '", "args": []}}}' + ).encode() + + def members(archive): - windows = archive.endswith(".zip") + windows = "-windows-" in archive + # Binary bytes keyed by TARGET, not archive: production repackages the + # SAME staged binary into the archive and the .mcpb, and the dedup + # assertion below depends on that. + binary_bytes = b"binary bytes of " + target_of(archive).encode() + if archive.endswith(".mcpb"): + binary = "server/codebase-memory-mcp.exe" if windows else "server/codebase-memory-mcp" + return { + "manifest.json": make_manifest(binary), + binary: binary_bytes, + "server/LICENSE": SHARED_LICENSE, + "server/THIRD_PARTY_NOTICES.md": SHARED_NOTICES, + } binary = "codebase-memory-mcp.exe" if windows else "codebase-memory-mcp" installer = "install.ps1" if windows else "install.sh" return { - binary: b"binary bytes of " + archive.encode(), # unique per archive + binary: binary_bytes, "LICENSE": SHARED_LICENSE, installer: SHARED_PS1 if windows else SHARED_SH, "THIRD_PARTY_NOTICES.md": SHARED_NOTICES, @@ -72,7 +100,7 @@ def write_archive(directory, archive, extra=None, drop=None): if extra: entries[extra] = b"unexpected\n" path = directory / archive - if archive.endswith(".zip"): + if archive.endswith((".zip", ".mcpb")): with zipfile.ZipFile(path, "w") as zf: for name, data in entries.items(): zf.writestr(name, data) @@ -119,8 +147,8 @@ def read_manifest(path, marker): # ── 1. The happy path: exact matrix in, exact bundle out ──────────────────── good = build_matrix(fixtures / "good" / "archives") out = fixtures / "good" / "scan" -result = run_extractor(good, out, "--expect-archives=8", "--expect-binaries=8", - "--expect-runtime-files=24") +result = run_extractor(good, out, "--expect-archives=14", "--expect-binaries=14", + "--expect-runtime-files=42") if result.returncode != 0: fail(f"exact release matrix was rejected: {result.stdout[-600:]}{result.stderr[-600:]}") else: @@ -132,9 +160,9 @@ else: "cbm-release-scan-associations-v3") set_meta, scan_set = read_manifest(out / "scan-set.tsv", "cbm-release-scan-set-v2") - # Every member of every archive is covered — 8 archives x 4 members. - if len(assoc) != 32: - fail(f"association manifest must cover all 32 extracted members, got {len(assoc)}") + # Every member of every archive is covered — 14 containers x 4 members. + if len(assoc) != 56: + fail(f"association manifest must cover all 56 extracted members, got {len(assoc)}") # An archive container must never be scanned as if it were a member. if any(row["member"] in ARCHIVES for row in assoc): @@ -144,12 +172,18 @@ else: objects = {row["scan_path"] for row in assoc} if len(scan_set) != len(objects): fail(f"scan-set rows ({len(scan_set)}) differ from distinct objects ({len(objects)})") - licences = {row["scan_path"] for row in assoc if row["member"] == "LICENSE"} + licences = {row["scan_path"] for row in assoc + if row["member"] in ("LICENSE", "server/LICENSE")} if len(licences) != 1: - fail(f"8 byte-identical LICENSE members must map to ONE scan object, got {len(licences)}") + fail(f"14 byte-identical LICENSE members must map to ONE scan object, got {len(licences)}") + # 14 binary MEMBERS but 8 distinct byte sequences: each .mcpb repackages + # its source archive's binary, and the gate must not scan those bytes twice. binaries = {row["scan_path"] for row in assoc if row["kind"] == "binary"} if len(binaries) != 8: fail(f"8 distinct binaries must stay 8 scan objects, got {len(binaries)}") + binary_members = [row for row in assoc if row["kind"] == "binary"] + if len(binary_members) != 14: + fail(f"14 binary members must all be associated, got {len(binary_members)}") # The counts the gate reads back must agree with the rows. if assoc_meta.get("associations") != len(assoc): @@ -183,9 +217,48 @@ for label, kwargs, expect in ( short = fixtures / "short" / "archives" short.mkdir(parents=True) write_archive(short, ARCHIVES[0]) -result = run_extractor(short, fixtures / "short" / "scan", "--expect-archives=8") +result = run_extractor(short, fixtures / "short" / "scan", "--expect-archives=14") if result.returncode == 0: - fail("extractor accepted a 1-archive matrix under --expect-archives=8") + fail("extractor accepted a 1-archive matrix under --expect-archives=14") + +# ── 3. MCPB manifest contract — a structurally broken bundle must not ship ── +BROKEN_MCPB = "codebase-memory-mcp-darwin-arm64.mcpb" + + +def rewrite_mcpb(directory, manifest_bytes): + entries = members(BROKEN_MCPB) + entries["manifest.json"] = manifest_bytes + with zipfile.ZipFile(directory / BROKEN_MCPB, "w") as zf: + for name, data in entries.items(): + zf.writestr(name, data) + + +# Case directories use SHORT slugs: the staged scan-object name embeds the +# fixture path plus a 64-hex digest, and a descriptive directory name pushes +# the total past Windows' 260-char MAX_PATH — os.replace then fails before +# the contract error under test can fire. +for label, slug, manifest_bytes, expect in ( + ("unparseable manifest.json", "m1", b"{not json", "not valid JSON"), + ("manifest without a version", "m2", + make_manifest("server/codebase-memory-mcp").replace(b'"version": "0.0.0-test", ', b""), + "lacks a version"), + ("manifest entry_point outside the bundle", "m3", + make_manifest("server/other-binary"), + "not a member"), + ("manifest command not targeting the entry_point", "m4", + make_manifest("server/codebase-memory-mcp").replace( + b'${__dirname}/server/codebase-memory-mcp', b"/usr/bin/env"), + "does not target the entry_point"), +): + case = fixtures / slug + bad = build_matrix(case / "archives") + rewrite_mcpb(case / "archives", manifest_bytes) + result = run_extractor(bad, case / "scan") + if result.returncode == 0: + fail(f"extractor accepted a bundle with {label}") + elif expect not in (result.stdout + result.stderr): + fail(f"{label} was rejected without naming the contract: " + f"{(result.stdout + result.stderr)[-300:]}") if failures: print("RELEASE ARCHIVE EXTRACTOR CONTRACT VIOLATED:") @@ -194,6 +267,7 @@ if failures: sys.exit(1) print("release archive extractor contract OK " - "(8-archive matrix, 32 member associations, dedup exact, fail-closed on " - "surplus/missing members and short matrices)") + "(14-container matrix incl. 6 MCPB bundles, 56 member associations, " + "dedup exact incl. bundle/archive binary collapse, fail-closed on " + "surplus/missing members, short matrices and broken MCPB manifests)") PY