feat(release): ship MCPB bundles and publish them to the MCP Registry (#1246)
Every release now carries .mcpb one-click-install bundles alongside the archives, and the MCP Registry entry lists them with per-file sha256: - package-release.sh (canonical) builds codebase-memory-mcp-<target>.mcpb for darwin/windows and the STATIC linux builds — manifest.json + the same staged (stripped, gated) binary + LICENSE + THIRD_PARTY_NOTICES.md. The glibc-dynamic linux targets stay archive-only: a dynamic binary defeats the one-click promise. - _build.yml / release-draft: bundles flow through provenance attestation, checksums.txt, cosign signing and the release asset list; checksums.txt is also preserved as a same-run artifact for the registry job. - verify: the canonical scan matrix grows to 14 containers; MCPB manifests are validated (parse, binary server, entry_point member, command binds the entry point). Bundle binaries dedupe to the archive scan objects, so the VT gate gains only the three distinct manifest.json files. - publish-mcp-registry: gen-mcpb-registry-entries.sh appends one mcpb package entry per bundle (release-asset URL + fileSha256 from the attested checksums) to server.json before mcp-publisher runs. Idempotent; a checksums file without bundles is a hard failure. - contracts: Step 0o pins the bundle shape at its producer on every leg, Step 0p pins the registry entries against the live server.json, and the extractor contract covers the 14-container matrix incl. broken-manifest fail-closed cases. The linux test image gains zip for the packager. Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 <server.json> <checksums.txt> <release-tag>
|
||||
#
|
||||
# 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 <server.json> <checksums.txt> <release-tag>" >&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
|
||||
@@ -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" <<EOF
|
||||
{
|
||||
"manifest_version": "0.3",
|
||||
"name": "codebase-memory-mcp",
|
||||
"display_name": "Codebase Memory",
|
||||
"version": "$mcpb_version",
|
||||
"description": "Codebase knowledge graph for AI agents — 159 languages, sub-ms queries, 99% fewer tokens.",
|
||||
"author": { "name": "DeusData", "url": "https://github.com/DeusData" },
|
||||
"repository": { "type": "git", "url": "https://github.com/DeusData/codebase-memory-mcp" },
|
||||
"homepage": "https://deusdata.github.io/codebase-memory-mcp/",
|
||||
"license": "MIT",
|
||||
"server": {
|
||||
"type": "binary",
|
||||
"entry_point": "$entry",
|
||||
"mcp_config": {
|
||||
"command": "\${__dirname}/$entry",
|
||||
"args": []
|
||||
}
|
||||
},
|
||||
"compatibility": {
|
||||
"platforms": ["$platform"]
|
||||
}
|
||||
}
|
||||
EOF
|
||||
rm -f "$out"
|
||||
(
|
||||
cd "$stage"
|
||||
zip -q -X "$out" \
|
||||
manifest.json "$entry" server/LICENSE server/THIRD_PARTY_NOTICES.md
|
||||
)
|
||||
echo "=== package-release: $out ==="
|
||||
}
|
||||
|
||||
# MCPB eligibility: every darwin/windows target, but only the STATIC linux
|
||||
# builds — a glibc-dynamic binary defeats the one-click-install promise.
|
||||
case "$GOOS/$GOARCH" in
|
||||
darwin/* | windows/* | linux/*-portable) build_mcpb_bundle || exit 2 ;;
|
||||
esac
|
||||
|
||||
@@ -242,6 +242,12 @@ bash "$ROOT/tests/test_vt_release_notes_contract.sh"
|
||||
echo "=== Step 0n: VirusTotal gate policy contract ==="
|
||||
bash "$ROOT/tests/test_vt_gate_policy_contract.sh"
|
||||
|
||||
echo "=== Step 0o: MCPB bundle contract (#1246) ==="
|
||||
bash "$ROOT/tests/test_mcpb_bundle_contract.sh"
|
||||
|
||||
echo "=== Step 0p: MCPB registry entries contract (#1246) ==="
|
||||
bash "$ROOT/tests/test_mcpb_registry_entries_contract.sh"
|
||||
|
||||
# Verify compiler supports target arch
|
||||
verify_compiler "$CC"
|
||||
|
||||
|
||||
@@ -12,7 +12,9 @@ FROM ubuntu:noble@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebff
|
||||
|
||||
# Minimal: gcc + zlib only. sqlite3 is vendored (compiled from source with ASan).
|
||||
# curl + zsh mirror the GitHub runner images: the self-update tests shell out
|
||||
# to curl, and the shell-activation tests exercise zsh rc files.
|
||||
# to curl, and the shell-activation tests exercise zsh rc files. zip backs
|
||||
# scripts/package-release.sh (.mcpb bundles), which the MCPB bundle contract
|
||||
# exercises on every leg.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc g++ make \
|
||||
zlib1g-dev \
|
||||
@@ -22,6 +24,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
zsh \
|
||||
ccache \
|
||||
zip \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env bash
|
||||
# Contract: scripts/package-release.sh builds the MCPB bundles the MCP
|
||||
# Registry advertises (#1246) — exactly where eligible, never elsewhere.
|
||||
#
|
||||
# A broken bundle is worse than none: an mcpb registry entry points hosts at
|
||||
# one-click install, so a wrong member set, a manifest that mis-names the
|
||||
# entry point, or a lost executable bit fails AT THE USER, on a machine we
|
||||
# never see. This contract pins the bundle's shape at its single canonical
|
||||
# producer, for every target family, on every leg.
|
||||
#
|
||||
# The stub is a REAL compiled executable, so the composition gate runs
|
||||
# genuinely: format detection, the ELF segment checks (-z separate-code) and
|
||||
# the needle scans. The gate's two positive needles — the artifact canary and
|
||||
# the SQLite OMIT_LOAD_EXTENSION marker — live in .rodata. STRIP=true no-ops
|
||||
# the strip stage only because this contract packages FOREIGN goos targets
|
||||
# from one host, and a host strip tool cannot legitimately serve them all;
|
||||
# the real strip runs with the real binary in the artifact-flow smoke lane
|
||||
# (scripts/ci/smoke-artifact.sh) on every leg.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
FIX="$(mktemp -d "${TMPDIR:-/tmp}/cbm-mcpb-contract.XXXXXX")"
|
||||
trap 'rm -rf "$FIX"' EXIT
|
||||
|
||||
BUILD_DIR="$FIX/build"
|
||||
mkdir -p "$BUILD_DIR"
|
||||
cat >"$FIX/stub.c" <<'EOF'
|
||||
#include <stdio.h>
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user