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:
Martin Vogel
2026-08-11 16:59:29 +02:00
parent b377c62a4e
commit b6a5d2c35b
10 changed files with 661 additions and 41 deletions
+34 -8
View File
@@ -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
+34 -7
View File
@@ -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
+67 -10
View File
@@ -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())
+88
View File
@@ -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
+74
View File
@@ -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
+6
View File
@@ -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"
+4 -1
View File
@@ -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/*
+132
View File
@@ -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