diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8680de0..2824bfd 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -61,6 +61,29 @@ jobs: - name: List artefacts run: ls -l dist/ + # Envelope-sign the bundles before upload when signing material is + # configured as repo secrets (PEM contents, not paths): + # MCPB_SIGNING_CERT, MCPB_SIGNING_KEY, MCPB_SIGNING_INTERMEDIATES + # 'make sign' regenerates each .sha256 to match the signed bytes, and + # soft-skips with a notice when the secrets are not set. + - name: Sign bundles (optional) + env: + MCPB_SIGNING_CERT: ${{ secrets.MCPB_SIGNING_CERT }} + MCPB_SIGNING_KEY: ${{ secrets.MCPB_SIGNING_KEY }} + MCPB_SIGNING_INTERMEDIATES: ${{ secrets.MCPB_SIGNING_INTERMEDIATES }} + run: | + if [ -n "$MCPB_SIGNING_CERT" ] && [ -n "$MCPB_SIGNING_KEY" ]; then + dir="$(mktemp -d)" + printf '%s\n' "$MCPB_SIGNING_CERT" > "$dir/cert.pem" + printf '%s\n' "$MCPB_SIGNING_KEY" > "$dir/key.pem" + export MCPB_SIGN_CERT="$dir/cert.pem" MCPB_SIGN_KEY="$dir/key.pem" + if [ -n "$MCPB_SIGNING_INTERMEDIATES" ]; then + printf '%s\n' "$MCPB_SIGNING_INTERMEDIATES" > "$dir/intermediates.pem" + export MCPB_SIGN_INTERMEDIATES="$dir/intermediates.pem" + fi + fi + make sign + # 'make publish' uses gh release upload --clobber, so re-running this # workflow (or overlapping with a manual local publish) is safe. - name: Upload to release diff --git a/CLAUDE.md b/CLAUDE.md index ff9a2d4..67e6e14 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,7 +89,7 @@ Three workflows in [.github/workflows/](.github/workflows/): - **[build.yml](.github/workflows/build.yml)** - reusable. Builds each bundle with `make one TARGET= VERSION=` on a runner that can execute the embedded binary (`ubuntu-latest`, `ubuntu-24.04-arm`, `macos-latest`), runs `scripts/smoke-test.py` against it, and uploads `dist/` artefacts. The windows-x64 bundle builds on ubuntu (packaging has no Windows dependency) and is smoke-tested on `windows-latest` from the artifact. The Gemini agent check runs on linux-x64 only and soft-skips without `GEMINI_API_KEY`. - **[ci.yml](.github/workflows/ci.yml)** - on PRs to main and pushes to main. Reads `stackql_release` from [release.yaml](release.yaml) and calls build.yml. Nothing is published. -- **[publish.yml](.github/workflows/publish.yml)** - on pushing a `v*` tag. Fails fast if the tag does not exactly match `stackql_release` in release.yaml, rebuilds and smoke-tests everything, then runs `make publish` to upload all bundles + `.sha256` files to the matching `stackql/stackql` release. Requires the `STACKQL_RELEASE_TOKEN` repo secret (fine-grained PAT with `contents:write` on `stackql/stackql` - the default `GITHUB_TOKEN` cannot upload cross-repo). +- **[publish.yml](.github/workflows/publish.yml)** - on pushing a `v*` tag. Fails fast if the tag does not exactly match `stackql_release` in release.yaml, rebuilds and smoke-tests everything, optionally envelope-signs the bundles (runs `make sign` when the `MCPB_SIGNING_CERT`/`MCPB_SIGNING_KEY` PEM-content secrets are set; soft-skips otherwise), then runs `make publish` to upload all bundles + `.sha256` files to the matching `stackql/stackql` release. Requires the `STACKQL_RELEASE_TOKEN` repo secret (fine-grained PAT with `contents:write` on `stackql/stackql` - the default `GITHUB_TOKEN` cannot upload cross-repo). Signing happens before checksumming: the signature is appended to the bundle bytes, so `make sign` regenerates each `.sha256`. `mcpb verify` is broken in the current upstream CLI (node-forge cannot verify PKCS#7); the scripts treat it as advisory and assert the appended `MCPB_SIG_END` block instead. The release sequence: upstream `stackql/stackql` release publishes the core assets -> raise a PR here bumping `stackql_release` in release.yaml (CI proves the bundles build and pass smoke tests against the real release assets) -> merge -> push the matching tag (e.g. `v0.10.500`) -> publish.yml attaches the `.mcpb` assets to the upstream release. @@ -142,7 +142,7 @@ The end goal is signed, verifiable, functional MCP binary assets distributed thr 1. **Mach-O / Authenticode signatures on the embedded binary** - applied upstream during the stackql release build. Windows: Authenticode-signed `stackql.exe`. macOS: Developer ID Application signature embedded in the universal `stackql` binary inside the `.pkg`, plus Apple notarisation keyed to the binary's cdhash. Linux: no platform-level signing, by convention. 2. **SHA-256 on the bundle envelope** - written by `package.sh` next to every `.mcpb`. Published with the bundle and pinned in the official MCP Registry `server.json`. Anyone installing the bundle can verify the bytes. -3. **MCPB envelope signature (`mcpb sign`)** - currently *not applied*. The hooks are in `package.sh` (`MCPB_SELF_SIGN`, `MCPB_SIGN_CERT`/`MCPB_SIGN_KEY`/`MCPB_SIGN_INTERMEDIATES`) and remain dormant until envelope signing is wired up. +3. **MCPB envelope signature (`mcpb sign`)** - wired but inactive until signing material is configured. `make sign` (scripts/sign.sh) signs `dist/*.mcpb` in place and regenerates checksums; the publish workflow calls it and soft-skips unless the `MCPB_SIGNING_CERT`/`MCPB_SIGNING_KEY` secrets are set. The same `MCPB_SELF_SIGN`/`MCPB_SIGN_CERT`/`MCPB_SIGN_KEY`/`MCPB_SIGN_INTERMEDIATES` hooks remain in `package.sh` for sign-at-build. 4. **Anthropic Desktop Extensions directory listing** - the editorial "vetted by Claude" signal that users see in Claude Desktop's Browse Extensions UI. Submission is via the review form at `claude.com/docs/connectors/building/submission`; requirements (privacy policy, logo, screenshots) are in [listings.md](listings.md). 5. **Official MCP Registry entry** - canonical metadata pointing at the GitHub release assets and pinning their SHA-256. diff --git a/Makefile b/Makefile index 88c5829..fc5b524 100644 --- a/Makefile +++ b/Makefile @@ -48,7 +48,7 @@ ASSETS := stackql_linux_amd64.zip \ stackql_windows_amd64.zip \ stackql_darwin_multiarch.pkg -.PHONY: all check-version check-target download package one signed publish \ +.PHONY: all check-version check-target download package one signed sign publish \ server-json registry-publish clean clean-bin \ linux-x64 linux-arm64 windows-x64 darwin-universal \ list help @@ -66,6 +66,9 @@ help: @echo " fail hard if the bundle is not produced" @echo " (use on a Mac for darwin-universal)" @echo " make signed VERSION=X.Y.Z build with MCPB_SELF_SIGN=true" + @echo " make sign envelope-sign dist/*.mcpb in place and" + @echo " regenerate .sha256 (MCPB_SELF_SIGN=true" + @echo " or MCPB_SIGN_CERT + MCPB_SIGN_KEY)" @echo " make publish VERSION=X.Y.Z upload dist/* to the stackql/stackql" @echo " release matching v" @echo " make server-json VERSION=X.Y.Z render registry/server.json (pins" @@ -136,6 +139,15 @@ package: check-version signed: check-version MCPB_SELF_SIGN=true $(PACKAGE) --version $(VERSION) +# Envelope-sign whatever is already in dist/ and regenerate the .sha256 +# files (the signature is appended to the bundle, so checksums must be +# recomputed). Same env contract as package.sh: MCPB_SELF_SIGN=true, or +# MCPB_SIGN_CERT + MCPB_SIGN_KEY (+ optional MCPB_SIGN_INTERMEDIATES). +# No-ops with a notice when no signing material is configured, so CI can +# call it unconditionally before 'make publish'. +sign: + bash scripts/sign.sh + # Render registry/server.json from the template, pinning the four per-platform # SHA-256s read from dist/*.sha256. Fails hard if any sha file is missing - # run this on a machine that has gathered all four bundles (typically the diff --git a/README.md b/README.md index 5dce6e8..797036c 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,8 @@ The sequence: One-time setup: add a repo secret `STACKQL_RELEASE_TOKEN` - a fine-grained PAT (or GitHub App token) with `contents:write` on `stackql/stackql`. The default `GITHUB_TOKEN` cannot upload assets to another repo. Optionally add `GEMINI_API_KEY` to enable the agent smoke test on the linux-x64 job; without it that step soft-skips. +Optional envelope signing: if the repo secrets `MCPB_SIGNING_CERT` and `MCPB_SIGNING_KEY` (PEM contents, plus optional `MCPB_SIGNING_INTERMEDIATES`) are set, the publish job runs `make sign` to `mcpb sign` every bundle and regenerate its `.sha256` before upload. Without the secrets the step prints a notice and skips, and unsigned bundles ship as before. Note `mcpb verify` in the current CLI is broken upstream (node-forge cannot verify PKCS#7, so every signed bundle reports as unsigned); `make sign` treats it as advisory and asserts the appended signature block instead. + Steps 3 and 4 of the local runbook below (MCP Registry publish and aggregator listings) are still manual after a CI publish. ## Release runbook (local fallback) @@ -274,6 +276,9 @@ make VERSION=X.Y.Z build a single target from current bin/ state make one TARGET= VERSION=X.Y.Z download just one target's artefact and build that one bundle (use on a Mac for darwin) make signed VERSION=X.Y.Z build with MCPB_SELF_SIGN=true (testing only) +make sign envelope-sign dist/*.mcpb in place and regenerate + .sha256 (MCPB_SELF_SIGN=true or MCPB_SIGN_CERT + + MCPB_SIGN_KEY; no-ops with a notice when unset) make publish VERSION=X.Y.Z upload dist/* to the stackql/stackql release make server-json VERSION=X.Y.Z render registry/server.json (pins 4 SHAs) make registry-publish VERSION=X.Y.Z render + publish to the Official MCP Registry diff --git a/scripts/package.sh b/scripts/package.sh index 72ce27c..a0da0a4 100755 --- a/scripts/package.sh +++ b/scripts/package.sh @@ -67,12 +67,29 @@ sha_file() { ) } +verify_bundle() { + # 'mcpb verify' is currently broken upstream (the CLI calls node-forge's + # p7.verify, which is not implemented, so every signed bundle reports as + # unsigned). Treat its result as advisory and assert the appended + # signature block directly. + local f="$1" + if mcpb verify "$f"; then + return 0 + fi + if tail -c 64 "$f" | grep -aq "MCPB_SIG_END"; then + echo " warn: 'mcpb verify' failed but the signature block is present (known upstream CLI bug)" + return 0 + fi + echo " error: no signature block found after signing $(basename "$f")" >&2 + return 1 +} + sign_bundle() { local f="$1" if [ "${MCPB_SELF_SIGN:-false}" = "true" ]; then echo " signing bundle (self-signed): $(basename "$f")" mcpb sign "$f" --self-signed - mcpb verify "$f" || true + verify_bundle "$f" elif [ -n "${MCPB_SIGN_CERT:-}" ] && [ -n "${MCPB_SIGN_KEY:-}" ]; then echo " signing bundle (production cert): $(basename "$f")" if [ -n "${MCPB_SIGN_INTERMEDIATES:-}" ]; then @@ -81,7 +98,7 @@ sign_bundle() { else mcpb sign "$f" --cert "$MCPB_SIGN_CERT" --key "$MCPB_SIGN_KEY" fi - mcpb verify "$f" + verify_bundle "$f" else echo " bundle signing skipped (set MCPB_SELF_SIGN=true or MCPB_SIGN_CERT + MCPB_SIGN_KEY)" fi diff --git a/scripts/sign.sh b/scripts/sign.sh new file mode 100755 index 0000000..7df8542 --- /dev/null +++ b/scripts/sign.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# +# sign.sh - envelope-sign existing dist/*.mcpb bundles and regenerate their +# .sha256 companions (the signature is appended to the bundle bytes, so the +# checksum must be recomputed after signing). +# +# Same env contract as package.sh: +# self-signed (testing): +# MCPB_SELF_SIGN=true scripts/sign.sh +# production cert: +# MCPB_SIGN_CERT=cert.pem MCPB_SIGN_KEY=key.pem \ +# [MCPB_SIGN_INTERMEDIATES="intermediate-ca.pem root-ca.pem"] \ +# scripts/sign.sh +# +# With neither configured, prints a notice and exits 0 so CI can call it +# unconditionally as a publish step. +# +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DIST_DIR="${DIST_DIR:-$ROOT_DIR/dist}" + +if command -v mcpb >/dev/null 2>&1; then + mcpb() { command mcpb "$@"; } +else + mcpb() { npx --yes @anthropic-ai/mcpb "$@"; } +fi + +sha_file() { + # Write " " so the checksum matches the released filename. + local f="$1" dir base + dir="$(dirname "$f")"; base="$(basename "$f")" + ( cd "$dir" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$base" > "${base}.sha256" + else + shasum -a 256 "$base" > "${base}.sha256" + fi + cat "${base}.sha256" + ) +} + +verify_bundle() { + # 'mcpb verify' is currently broken upstream (the CLI calls node-forge's + # p7.verify, which is not implemented, so every signed bundle reports as + # unsigned). Treat its result as advisory and assert the appended + # signature block directly. + local f="$1" + if mcpb verify "$f"; then + return 0 + fi + if tail -c 64 "$f" | grep -aq "MCPB_SIG_END"; then + echo " warn: 'mcpb verify' failed but the signature block is present (known upstream CLI bug)" + return 0 + fi + echo " error: no signature block found after signing $(basename "$f")" >&2 + return 1 +} + +if [ "${MCPB_SELF_SIGN:-false}" != "true" ] && { [ -z "${MCPB_SIGN_CERT:-}" ] || [ -z "${MCPB_SIGN_KEY:-}" ]; }; then + echo "bundle signing skipped (set MCPB_SELF_SIGN=true or MCPB_SIGN_CERT + MCPB_SIGN_KEY)" + exit 0 +fi + +shopt -s nullglob +bundles=( "$DIST_DIR"/stackql-mcp-*.mcpb ) +if [ ${#bundles[@]} -eq 0 ]; then + echo "error: no bundles found in $DIST_DIR" >&2 + exit 1 +fi + +for f in "${bundles[@]}"; do + if [ "${MCPB_SELF_SIGN:-false}" = "true" ]; then + echo "==> signing (self-signed): $(basename "$f")" + mcpb sign "$f" --self-signed + else + echo "==> signing (production cert): $(basename "$f")" + if [ -n "${MCPB_SIGN_INTERMEDIATES:-}" ]; then + # shellcheck disable=SC2086 + mcpb sign "$f" --cert "$MCPB_SIGN_CERT" --key "$MCPB_SIGN_KEY" --intermediate $MCPB_SIGN_INTERMEDIATES + else + mcpb sign "$f" --cert "$MCPB_SIGN_CERT" --key "$MCPB_SIGN_KEY" + fi + fi + verify_bundle "$f" + sha_file "$f" +done + +echo "signed ${#bundles[@]} bundle(s) and regenerated checksums."