From 6a676a02aa24fe240f0fc6cd60f0fc79379f9b65 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 13 Mar 2026 07:02:45 -0700 Subject: [PATCH 01/22] mcp: remove stale darwin-x64 package, fix Windows target triple Delete the gitignored mcp-darwin-x64 platform package directory (local cleanup) and change the Windows target triple from x86_64-pc-windows-msvc to x86_64-pc-windows-gnu to match the cross-compilation toolchain. --- src/simlin-mcp/bin/simlin-mcp.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/simlin-mcp/bin/simlin-mcp.js b/src/simlin-mcp/bin/simlin-mcp.js index 952c45082..4cb2cc102 100755 --- a/src/simlin-mcp/bin/simlin-mcp.js +++ b/src/simlin-mcp/bin/simlin-mcp.js @@ -30,7 +30,7 @@ const PLATFORM_MAP = { }, "win32-x64": { package: "@simlin/mcp-win32-x64", - triple: "x86_64-pc-windows-msvc", + triple: "x86_64-pc-windows-gnu", }, }; From dc4a1403d2362b313f00d1b8c355b41fb861aa32 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 13 Mar 2026 07:04:02 -0700 Subject: [PATCH 02/22] mcp: add publishConfig and repository to wrapper package.json Required for publishing scoped packages to the public npm registry. The publishConfig.access field enables `npm publish` without the --access=public flag, and the repository field links the package to the monorepo source. --- src/simlin-mcp/package.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/simlin-mcp/package.json b/src/simlin-mcp/package.json index 880bbae6a..ab884ff6f 100644 --- a/src/simlin-mcp/package.json +++ b/src/simlin-mcp/package.json @@ -15,5 +15,13 @@ "@simlin/mcp-win32-x64": "0.1.0" }, "license": "Apache-2.0", - "type": "module" + "type": "module", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/bpowers/simlin.git", + "directory": "src/simlin-mcp" + } } From 63349842f4cecfdb705294b7e240231c37c089b7 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 13 Mar 2026 07:05:32 -0700 Subject: [PATCH 03/22] mcp: include publishConfig and repository in generated platform packages The build-npm-packages.sh template now emits publishConfig.access and repository fields in each platform package.json, required for publishing scoped packages publicly to npm. --- src/simlin-mcp/build-npm-packages.sh | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/simlin-mcp/build-npm-packages.sh b/src/simlin-mcp/build-npm-packages.sh index 3371b0153..64cc299e8 100755 --- a/src/simlin-mcp/build-npm-packages.sh +++ b/src/simlin-mcp/build-npm-packages.sh @@ -41,7 +41,15 @@ for entry in "${PLATFORMS[@]}"; do "os": ["$os"], "cpu": ["$cpu"], "files": ["bin"], - "license": "Apache-2.0" + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/bpowers/simlin.git", + "directory": "src/simlin-mcp" + } } JSON From 44275e3a4146e3e6e45318e230550574ee3f59a7 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 13 Mar 2026 07:07:04 -0700 Subject: [PATCH 04/22] mcp: update build script integration test for publishConfig, repository, and linux-arm64 Rename test from AC5.2 to AC4 to match design plan numbering. Add missing linux-arm64 platform entry so all 4 platforms are verified. Add assertions for publishConfig.access and repository fields that were added to the build script template in the previous commit. --- src/simlin-mcp/tests/build_npm_packages.rs | 34 +++++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/src/simlin-mcp/tests/build_npm_packages.rs b/src/simlin-mcp/tests/build_npm_packages.rs index 0a93c1b49..8521aaa96 100644 --- a/src/simlin-mcp/tests/build_npm_packages.rs +++ b/src/simlin-mcp/tests/build_npm_packages.rs @@ -2,10 +2,11 @@ // Use of this source code is governed by the Apache License, // Version 2.0, that can be found in the LICENSE file. -//! Integration test for build-npm-packages.sh (AC5.2). +//! Integration test for build-npm-packages.sh (AC4). //! //! Runs the shell script in a temporary output directory and validates that each -//! platform package.json contains the correct name, version, os, and cpu fields. +//! platform package.json contains the correct name, version, os, cpu, +//! publishConfig, and repository fields. use std::process::Command; @@ -21,6 +22,11 @@ const PLATFORMS: &[Platform] = &[ os: "darwin", cpu: "arm64", }, + Platform { + suffix: "linux-arm64", + os: "linux", + cpu: "arm64", + }, Platform { suffix: "linux-x64", os: "linux", @@ -46,9 +52,9 @@ fn cargo_version() -> String { panic!("could not parse version from Cargo.toml"); } -// simlin-mcp.AC5.2: build-npm-packages.sh produces correct os/cpu/name/version fields +// simlin-mcp.AC4: build-npm-packages.sh produces correct os/cpu/name/version/publishConfig/repository fields #[test] -fn ac5_2_platform_packages_have_correct_fields() { +fn ac4_platform_packages_have_correct_fields() { let tmp = tempfile::tempdir().expect("failed to create tempdir"); // The script uses SCRIPT_DIR to locate Cargo.toml and to write into npm/. @@ -138,5 +144,25 @@ fn ac5_2_platform_packages_have_correct_fields() { "wrong cpu for platform {}", plat.suffix ); + + // AC4.4: publishConfig and repository + let publish_config = &pkg["publishConfig"]; + assert_eq!( + publish_config["access"], "public", + "missing publishConfig.access for {}", + plat.suffix + ); + + let repo = &pkg["repository"]; + assert_eq!( + repo["type"], "git", + "missing repository.type for {}", + plat.suffix + ); + assert!( + repo["url"].as_str().unwrap_or("").contains("simlin"), + "repository.url should reference simlin for {}", + plat.suffix + ); } } From 12c8473efaea9e00caf976a7b4655576573d4f69 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 13 Mar 2026 07:10:40 -0700 Subject: [PATCH 05/22] mcp: add Dockerfile.cross for local cross-compilation toolchain --- src/simlin-mcp/Dockerfile.cross | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 src/simlin-mcp/Dockerfile.cross diff --git a/src/simlin-mcp/Dockerfile.cross b/src/simlin-mcp/Dockerfile.cross new file mode 100644 index 000000000..3a1989aa5 --- /dev/null +++ b/src/simlin-mcp/Dockerfile.cross @@ -0,0 +1,30 @@ +# Cross-compilation toolchain for simlin-mcp. +# +# Provides Rust + Zig + cargo-zigbuild for building Linux (musl) and +# Windows (mingw) binaries from a single image. Used by scripts/cross-build.sh. + +# Update RUST_VERSION to match the current stable Rust when building. +# Override at build time: docker build --build-arg RUST_VERSION=1.88.0 ... +ARG RUST_VERSION=1.87.0 +ARG ZIG_VERSION=0.15.2 + +FROM rust:${RUST_VERSION}-bookworm + +ARG ZIG_VERSION + +# Install Zig from upstream tarball +RUN ARCH=$(uname -m) && \ + TARBALL="zig-${ARCH}-linux-${ZIG_VERSION}.tar.xz" && \ + DIR="zig-${ARCH}-linux-${ZIG_VERSION}" && \ + curl -L "https://ziglang.org/download/${ZIG_VERSION}/${TARBALL}" \ + | tar -J -x -C /usr/local && \ + ln -s "/usr/local/${DIR}/zig" /usr/local/bin/zig + +RUN cargo install --locked cargo-zigbuild + +RUN rustup target add \ + x86_64-unknown-linux-musl \ + aarch64-unknown-linux-musl \ + x86_64-pc-windows-gnu + +WORKDIR /src From ce4783713113c155301bc590b2622b540737142e Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 13 Mar 2026 07:12:13 -0700 Subject: [PATCH 06/22] mcp: add cross-build.sh for local multi-platform binary builds Shell script that builds all three cross-compilation targets (linux-x64, linux-arm64, win32-x64) via Docker + cargo-zigbuild. Source is mounted read-only; a named volume caches the Cargo target directory for incremental builds. Includes a smoke test that verifies the Linux x64 binary executes. --- src/simlin-mcp/scripts/cross-build.sh | 64 +++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100755 src/simlin-mcp/scripts/cross-build.sh diff --git a/src/simlin-mcp/scripts/cross-build.sh b/src/simlin-mcp/scripts/cross-build.sh new file mode 100755 index 000000000..b8d9c0a91 --- /dev/null +++ b/src/simlin-mcp/scripts/cross-build.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Cross-compile simlin-mcp for Linux (x64, arm64) and Windows (x64). +# +# Builds a Docker toolchain image, then runs cargo-zigbuild inside it with the +# repo source mounted read-only. A named Docker volume caches the Cargo target +# directory so subsequent runs are incremental. +# +# Output: dist//simlin-mcp[.exe] + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MCP_DIR="$(dirname "$SCRIPT_DIR")" +REPO_ROOT="$(cd "$MCP_DIR/../.." && pwd)" + +IMAGE_NAME="simlin-mcp-cross" +CACHE_VOLUME="simlin-mcp-cross-cache" +DIST_DIR="$MCP_DIR/dist" + +echo "==> Building cross-compilation toolchain image..." +docker build -t "$IMAGE_NAME" -f "$MCP_DIR/Dockerfile.cross" "$MCP_DIR/" + +rm -rf "$DIST_DIR" +mkdir -p "$DIST_DIR" + +echo "==> Cross-compiling all targets..." +docker run --rm \ + -v "$REPO_ROOT:/src:ro" \ + -v "$CACHE_VOLUME:/tmp/target" \ + -v "$DIST_DIR:/dist" \ + -e CARGO_TARGET_DIR=/tmp/target \ + "$IMAGE_NAME" \ + bash -c ' + cd /src + for target in x86_64-unknown-linux-musl aarch64-unknown-linux-musl x86_64-pc-windows-gnu; do + echo "--- Building $target ---" + cargo zigbuild -p simlin-mcp --release --target "$target" + mkdir -p "/dist/$target" + if [[ "$target" == *windows* ]]; then + cp "/tmp/target/$target/release/simlin-mcp.exe" "/dist/$target/" + else + cp "/tmp/target/$target/release/simlin-mcp" "/dist/$target/" + fi + done + echo "--- All targets built ---" + ' + +echo "" +echo "Binaries:" +ls -lh "$DIST_DIR"/*/simlin-mcp* + +# Smoke test: verify the Linux x64 binary is a valid static executable and runs +echo "" +echo "==> Smoke test: Linux x64 binary..." +file "$DIST_DIR/x86_64-unknown-linux-musl/simlin-mcp" + +echo "==> Verifying binary executes..." +# simlin-mcp is a stdio MCP server that waits for input, so feed it empty input +# with a timeout. Any exit (including error) proves the binary loads and runs. +echo '' | timeout 2 "$DIST_DIR/x86_64-unknown-linux-musl/simlin-mcp" 2>/dev/null || true +echo "Smoke test passed (binary executed)" + +echo "" +echo "Done. Binaries in $DIST_DIR/" From f78effd453014b70f11e2d963dc69c7a634db964 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 13 Mar 2026 07:13:15 -0700 Subject: [PATCH 07/22] mcp: ignore cross-build dist/ output --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 1ef494828..7ce6f24b1 100644 --- a/.gitignore +++ b/.gitignore @@ -98,3 +98,4 @@ __pycache__/ # simlin-mcp npm packaging artifacts /src/simlin-mcp/vendor /src/simlin-mcp/npm +/src/simlin-mcp/dist From a202066f0b8c7e74f91739eddb68bef615af71ba Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 13 Mar 2026 07:16:59 -0700 Subject: [PATCH 08/22] mcp: fix smoke test to detect binary execution failures The previous smoke test used '|| true' which masked all failures: a segfault, missing binary (exit 127), or non-executable file (exit 126) would all print "Smoke test passed". Fix by capturing the exit code and rejecting fatal exit codes (>= 126, excluding 124 which is timeout killing a running process). Also capture the 'file' command output and grep for "ELF.*executable" so a truncated or wrong-arch binary fails the check rather than silently passing. --- src/simlin-mcp/scripts/cross-build.sh | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/simlin-mcp/scripts/cross-build.sh b/src/simlin-mcp/scripts/cross-build.sh index b8d9c0a91..30aba9301 100755 --- a/src/simlin-mcp/scripts/cross-build.sh +++ b/src/simlin-mcp/scripts/cross-build.sh @@ -52,13 +52,27 @@ ls -lh "$DIST_DIR"/*/simlin-mcp* # Smoke test: verify the Linux x64 binary is a valid static executable and runs echo "" echo "==> Smoke test: Linux x64 binary..." -file "$DIST_DIR/x86_64-unknown-linux-musl/simlin-mcp" +FILE_OUTPUT=$(file "$DIST_DIR/x86_64-unknown-linux-musl/simlin-mcp") +echo "$FILE_OUTPUT" +if ! echo "$FILE_OUTPUT" | grep -q "ELF.*executable"; then + echo "FAIL: binary is not an ELF executable" + exit 1 +fi echo "==> Verifying binary executes..." # simlin-mcp is a stdio MCP server that waits for input, so feed it empty input -# with a timeout. Any exit (including error) proves the binary loads and runs. -echo '' | timeout 2 "$DIST_DIR/x86_64-unknown-linux-musl/simlin-mcp" 2>/dev/null || true -echo "Smoke test passed (binary executed)" +# with a timeout. timeout exits 124 when it kills a running process (expected), +# and the binary itself may exit with a small non-zero code on empty input. +# Fatal failures are: 126 (cannot execute), 127 (not found), >= 128 (signal). +set +e +echo '' | timeout 2 "$DIST_DIR/x86_64-unknown-linux-musl/simlin-mcp" 2>/dev/null +EXIT_CODE=$? +set -e +if [ "$EXIT_CODE" -ge 126 ] && [ "$EXIT_CODE" -ne 124 ]; then + echo "FAIL: binary did not execute properly (exit code $EXIT_CODE)" + exit 1 +fi +echo "Smoke test passed (binary executed, exit code $EXIT_CODE)" echo "" echo "Done. Binaries in $DIST_DIR/" From 1e70d67c939b64edf1fa52c8d74fdb71c1f8a129 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 13 Mar 2026 07:23:02 -0700 Subject: [PATCH 09/22] mcp: add GitHub Actions workflow for npm release Three-job workflow triggered by mcp-v* tags: - validate: checks tag version matches Cargo.toml - build: matrix of 4 platform targets (zigbuild for Linux/Windows, native for macOS arm64) - publish-platform + publish-wrapper: sequential npm publish with OIDC trusted publishing and provenance attestation workflow_dispatch enabled for dry-run testing (publish jobs skip when ref is not a tag). --- .github/workflows/mcp-release.yml | 196 ++++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 .github/workflows/mcp-release.yml diff --git a/.github/workflows/mcp-release.yml b/.github/workflows/mcp-release.yml new file mode 100644 index 000000000..48e31fad1 --- /dev/null +++ b/.github/workflows/mcp-release.yml @@ -0,0 +1,196 @@ +name: MCP npm Release + +on: + push: + tags: + - 'mcp-v*' + workflow_dispatch: + +permissions: + contents: read + +jobs: + validate: + name: Validate version + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v4 + + - name: Extract and validate version + id: version + run: | + CARGO_VERSION=$(grep '^version = ' src/simlin-mcp/Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') + echo "Cargo.toml version: $CARGO_VERSION" + + if [[ "$GITHUB_REF" == refs/tags/mcp-v* ]]; then + TAG_VERSION="${GITHUB_REF_NAME#mcp-v}" + echo "Tag version: $TAG_VERSION" + if [ "$TAG_VERSION" != "$CARGO_VERSION" ]; then + echo "::error::Tag version ($TAG_VERSION) does not match Cargo.toml ($CARGO_VERSION)" + exit 1 + fi + fi + + echo "version=$CARGO_VERSION" >> "$GITHUB_OUTPUT" + + build: + name: Build ${{ matrix.artifact }} + needs: validate + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-musl + os: ubuntu-latest + artifact: mcp-linux-x64 + binary: simlin-mcp + use-zigbuild: true + - target: aarch64-unknown-linux-musl + os: ubuntu-latest + artifact: mcp-linux-arm64 + binary: simlin-mcp + use-zigbuild: true + - target: x86_64-pc-windows-gnu + os: ubuntu-latest + artifact: mcp-win32-x64 + binary: simlin-mcp.exe + use-zigbuild: true + - target: aarch64-apple-darwin + os: macos-latest + artifact: mcp-darwin-arm64 + binary: simlin-mcp + use-zigbuild: false + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Cache Cargo artifacts + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin + ~/.cargo/registry + ~/.cargo/git + target + key: cargo-mcp-${{ matrix.os }}-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + cargo-mcp-${{ matrix.os }}-${{ matrix.target }}- + + - name: Install Zig + if: matrix.use-zigbuild + uses: mlugg/setup-zig@v2 + with: + version: '0.15.2' + + - name: Install cargo-zigbuild + if: matrix.use-zigbuild + run: cargo install --locked cargo-zigbuild@0.22 + + - name: Build (zigbuild) + if: matrix.use-zigbuild + run: cargo zigbuild -p simlin-mcp --release --target ${{ matrix.target }} + + - name: Build (native) + if: ${{ !matrix.use-zigbuild }} + run: cargo build -p simlin-mcp --release --target ${{ matrix.target }} + + - name: Upload binary + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: target/${{ matrix.target }}/release/${{ matrix.binary }} + if-no-files-found: error + retention-days: 1 + + publish-platform: + name: Publish platform packages + needs: [validate, build] + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - run: npm install -g npm@latest + + - uses: actions/download-artifact@v4 + with: + path: artifacts/ + + - name: Prepare platform packages + working-directory: src/simlin-mcp + run: | + bash build-npm-packages.sh + + cp ../../artifacts/mcp-linux-x64/simlin-mcp npm/@simlin/mcp-linux-x64/bin/ + cp ../../artifacts/mcp-linux-arm64/simlin-mcp npm/@simlin/mcp-linux-arm64/bin/ + cp ../../artifacts/mcp-win32-x64/simlin-mcp.exe npm/@simlin/mcp-win32-x64/bin/ + cp ../../artifacts/mcp-darwin-arm64/simlin-mcp npm/@simlin/mcp-darwin-arm64/bin/ + + chmod +x npm/@simlin/mcp-linux-x64/bin/simlin-mcp + chmod +x npm/@simlin/mcp-linux-arm64/bin/simlin-mcp + chmod +x npm/@simlin/mcp-darwin-arm64/bin/simlin-mcp + + - name: Publish @simlin/mcp-linux-x64 + working-directory: src/simlin-mcp/npm/@simlin/mcp-linux-x64 + run: npm publish --provenance --access public + + - name: Publish @simlin/mcp-linux-arm64 + working-directory: src/simlin-mcp/npm/@simlin/mcp-linux-arm64 + run: npm publish --provenance --access public + + - name: Publish @simlin/mcp-win32-x64 + working-directory: src/simlin-mcp/npm/@simlin/mcp-win32-x64 + run: npm publish --provenance --access public + + - name: Publish @simlin/mcp-darwin-arm64 + working-directory: src/simlin-mcp/npm/@simlin/mcp-darwin-arm64 + run: npm publish --provenance --access public + + publish-wrapper: + name: Publish @simlin/mcp + needs: [validate, publish-platform] + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - run: npm install -g npm@latest + + - name: Update wrapper version + working-directory: src/simlin-mcp + run: | + VERSION="${{ needs.validate.outputs.version }}" + jq --arg v "$VERSION" ' + .version = $v | + .optionalDependencies = ( + .optionalDependencies | to_entries | map(.value = $v) | from_entries + ) + ' package.json > package.json.tmp && mv package.json.tmp package.json + + echo "Updated wrapper package.json:" + cat package.json + + - name: Publish @simlin/mcp + working-directory: src/simlin-mcp + run: npm publish --provenance --access public From 3f557e05fc253c6c48b8bced8ed29295bc8df1b3 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 13 Mar 2026 07:30:18 -0700 Subject: [PATCH 10/22] doc: update CLAUDE.md files for mcp-npm-release Add simlin-mcp to the root components table (was missing since the component was first created). Document the npm distribution structure, supported platforms, release trigger, and build scripts in the MCP domain CLAUDE.md. Teach check-docs.py to skip npm scoped package names (tokens starting with @) so backtick-quoted names like @simlin/mcp are not treated as broken file paths. --- CLAUDE.md | 28 ++++++++++++++-------------- scripts/check-docs.py | 3 +++ src/simlin-mcp/CLAUDE.md | 24 ++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dd00afb73..38b77651f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,20 +11,20 @@ For documentation index, see [docs/README.md](/docs/README.md). ## Components -| Component | Language | Description | Docs | -|---------------------|----------|--------------------------------------------------|-------------------------------------------| -| `src/simlin-engine` | Rust | Compiles, type-checks, and simulates SD models | [CLAUDE.md](/src/simlin-engine/CLAUDE.md) | -| `src/libsimlin` | Rust | Flat C FFI to simlin-engine (WASM, CGo, C/C++) | [CLAUDE.md](/src/libsimlin/CLAUDE.md) | -| `src/simlin-mcp` | Rust | MCP server for viewing and editing models | [CLAUDE.md](/src/simlin-mcp/CLAUDE.md) | -| `src/engine` | TypeScript | Promise-based TypeScript API for WASM engine | [CLAUDE.md](/src/engine/CLAUDE.md) | -| `src/core` | TypeScript | Shared data models and common utilities | [CLAUDE.md](/src/core/CLAUDE.md) | -| `src/diagram` | TypeScript | React model editor and visualization toolkit | [CLAUDE.md](/src/diagram/CLAUDE.md) | -| `src/app` | TypeScript | Full-featured SD application | [CLAUDE.md](/src/app/CLAUDE.md) | -| `src/server` | TypeScript | Express.js backend (Firebase Auth, Firestore) | [CLAUDE.md](/src/server/CLAUDE.md) | -| `src/xmutil` | C++/Rust | Vensim-to-XMILE converter (test-only) | -- | -| `src/simlin-cli` | Rust | CLI for simulation/conversion (testing/debugging) | [CLAUDE.md](/src/simlin-cli/CLAUDE.md) | -| `src/pysimlin` | Python/Rust | Python bindings for the simulation engine | [CLAUDE.md](/src/pysimlin/CLAUDE.md) | -| `website` | TypeScript | Rspress-based documentation site | [CLAUDE.md](/website/CLAUDE.md) | +| Component | Language | Description | Docs | +|---------------------|-------------|---------------------------------------------------|-------------------------------------------| +| `src/simlin-engine` | Rust | Compiles, type-checks, and simulates SD models | [CLAUDE.md](/src/simlin-engine/CLAUDE.md) | +| `src/libsimlin` | Rust | Flat C FFI to simlin-engine (WASM, CGo, C/C++) | [CLAUDE.md](/src/libsimlin/CLAUDE.md) | +| `src/simlin-mcp` | Rust/JS | MCP server for AI assistants (`@simlin/mcp` npm) | [CLAUDE.md](/src/simlin-mcp/CLAUDE.md) | +| `src/engine` | TypeScript | Promise-based TypeScript API for WASM engine | [CLAUDE.md](/src/engine/CLAUDE.md) | +| `src/core` | TypeScript | Shared data models and common utilities | [CLAUDE.md](/src/core/CLAUDE.md) | +| `src/diagram` | TypeScript | React model editor and visualization toolkit | [CLAUDE.md](/src/diagram/CLAUDE.md) | +| `src/app` | TypeScript | Full-featured SD application | [CLAUDE.md](/src/app/CLAUDE.md) | +| `src/server` | TypeScript | Express.js backend (Firebase Auth, Firestore) | [CLAUDE.md](/src/server/CLAUDE.md) | +| `src/xmutil` | C++/Rust | Vensim-to-XMILE converter (test-only) | -- | +| `src/simlin-cli` | Rust | CLI for simulation/conversion (testing/debugging) | [CLAUDE.md](/src/simlin-cli/CLAUDE.md) | +| `src/pysimlin` | Python/Rust | Python bindings for the simulation engine | [CLAUDE.md](/src/pysimlin/CLAUDE.md) | +| `website` | TypeScript | Rspress-based documentation site | [CLAUDE.md](/website/CLAUDE.md) | ## Environment Setup diff --git a/scripts/check-docs.py b/scripts/check-docs.py index 40e21d99c..bb074d901 100755 --- a/scripts/check-docs.py +++ b/scripts/check-docs.py @@ -98,6 +98,9 @@ def check_file(file_path: Path, repo_root: Path) -> list[str]: "python ", "ruff ", "mypy ", "pytest ", "--", "RUST_", "DISABLE_")): continue + # Skip npm scoped package names (e.g. @simlin/mcp) + if token.startswith("@"): + continue # Skip glob patterns if "*" in token: continue diff --git a/src/simlin-mcp/CLAUDE.md b/src/simlin-mcp/CLAUDE.md index 406935925..4677e95b8 100644 --- a/src/simlin-mcp/CLAUDE.md +++ b/src/simlin-mcp/CLAUDE.md @@ -12,6 +12,30 @@ MCP (Model Context Protocol) server exposing the Simlin simulation engine as too Tools are defined using `TypedTool` where `I` implements `Deserialize + JsonSchema`. The JSON Schema for the tool input is automatically derived from the Rust type via `schemars`, so the full schema (including nested types like patch operations and variable definitions) is visible to MCP clients. +## npm Distribution + +Published to npm as `@simlin/mcp` with platform-specific binary packages following the Node optional-dependency pattern (same approach as esbuild, turbo, etc.). + +- **Wrapper package**: `@simlin/mcp` -- entry point `bin/simlin-mcp.js` resolves the correct platform binary at runtime +- **Platform packages**: `@simlin/mcp-{darwin-arm64,linux-arm64,linux-x64,win32-x64}` -- each contains a single native binary in `bin/` +- **Version source of truth**: `Cargo.toml` -- `build-npm-packages.sh` reads it; CI validates tag matches +- **Release trigger**: push a `mcp-v*` tag; the `mcp-release.yml` workflow builds, publishes platform packages, then publishes the wrapper + +### Supported Platforms + +| npm package | Rust target | Build method | +|---|---|---| +| `@simlin/mcp-linux-x64` | `x86_64-unknown-linux-musl` | cargo-zigbuild | +| `@simlin/mcp-linux-arm64` | `aarch64-unknown-linux-musl` | cargo-zigbuild | +| `@simlin/mcp-win32-x64` | `x86_64-pc-windows-gnu` | cargo-zigbuild | +| `@simlin/mcp-darwin-arm64` | `aarch64-apple-darwin` | native (macOS runner) | + +### Scripts + +- `build-npm-packages.sh` -- generates platform `package.json` files in `npm/@simlin/mcp-*` +- `scripts/cross-build.sh` -- local cross-compilation via Docker + cargo-zigbuild (outputs to dist/) +- `Dockerfile.cross` -- toolchain image for cross-build.sh + ## Build / Test ```sh From af52c5abf5de3558a84de74ac59a02c2fc5b8394 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 13 Mar 2026 07:38:10 -0700 Subject: [PATCH 11/22] mcp: add missing test coverage from code review feedback Add the tests specified in test-requirements.md that were flagged as missing in the final code review: - ac4_2_js_launcher_windows_triple: reads bin/simlin-mcp.js and asserts the Windows platform entry maps to x86_64-pc-windows-gnu (not msvc) - ac4_4_wrapper_package_json_has_publish_config: reads the committed src/simlin-mcp/package.json and verifies publishConfig.access and repository fields (the existing integration test only covered generated platform packages, not the committed wrapper manifest) - Directory count assertion in ac4_platform_packages_have_correct_fields: after iterating the 4 expected platforms, reads the @simlin directory and asserts exactly 4 subdirectories exist (AC4.3) - tests/mcp_release_workflow.rs: new static YAML validation test file covering AC1.1-AC1.6, AC2.1, AC2.3, AC2.5, AC5.1-AC5.3 by parsing .github/workflows/mcp-release.yml with serde_yaml; catches workflow regressions without requiring a live CI run Also add "Keep in sync" comments between Dockerfile.cross and mcp-release.yml to flag the Zig version as a value that must stay consistent across both files. --- .github/workflows/mcp-release.yml | 1 + Cargo.lock | 20 ++ src/simlin-mcp/Cargo.toml | 1 + src/simlin-mcp/Dockerfile.cross | 1 + src/simlin-mcp/tests/build_npm_packages.rs | 45 ++++ src/simlin-mcp/tests/mcp_release_workflow.rs | 216 +++++++++++++++++++ 6 files changed, 284 insertions(+) create mode 100644 src/simlin-mcp/tests/mcp_release_workflow.rs diff --git a/.github/workflows/mcp-release.yml b/.github/workflows/mcp-release.yml index 48e31fad1..39a347d3d 100644 --- a/.github/workflows/mcp-release.yml +++ b/.github/workflows/mcp-release.yml @@ -87,6 +87,7 @@ jobs: if: matrix.use-zigbuild uses: mlugg/setup-zig@v2 with: + # Keep in sync with src/simlin-mcp/Dockerfile.cross version: '0.15.2' - name: Install cargo-zigbuild diff --git a/Cargo.lock b/Cargo.lock index 050d450c4..17609d6e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2049,6 +2049,19 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha2" version = "0.10.9" @@ -2146,6 +2159,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "serde_yaml", "simlin-engine", "tempfile", "tokio", @@ -2515,6 +2529,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "usvg" version = "0.47.0" diff --git a/src/simlin-mcp/Cargo.toml b/src/simlin-mcp/Cargo.toml index c181e9ff4..6769500d7 100644 --- a/src/simlin-mcp/Cargo.toml +++ b/src/simlin-mcp/Cargo.toml @@ -20,3 +20,4 @@ tokio = { version = "1", features = ["macros", "io-util", "io-std", "sync", "rt- [dev-dependencies] tempfile = "3" +serde_yaml = "0.9" diff --git a/src/simlin-mcp/Dockerfile.cross b/src/simlin-mcp/Dockerfile.cross index 3a1989aa5..908e8a7e4 100644 --- a/src/simlin-mcp/Dockerfile.cross +++ b/src/simlin-mcp/Dockerfile.cross @@ -6,6 +6,7 @@ # Update RUST_VERSION to match the current stable Rust when building. # Override at build time: docker build --build-arg RUST_VERSION=1.88.0 ... ARG RUST_VERSION=1.87.0 +# Keep in sync with .github/workflows/mcp-release.yml ARG ZIG_VERSION=0.15.2 FROM rust:${RUST_VERSION}-bookworm diff --git a/src/simlin-mcp/tests/build_npm_packages.rs b/src/simlin-mcp/tests/build_npm_packages.rs index 8521aaa96..3d89079c1 100644 --- a/src/simlin-mcp/tests/build_npm_packages.rs +++ b/src/simlin-mcp/tests/build_npm_packages.rs @@ -165,4 +165,49 @@ fn ac4_platform_packages_have_correct_fields() { plat.suffix ); } + + // AC4.3: exactly 4 platform directories, no extras + let simlin_dir = tmp.path().join("npm").join("@simlin"); + let dir_count = std::fs::read_dir(&simlin_dir) + .expect("failed to read @simlin directory") + .filter(|e| { + e.as_ref() + .map(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false)) + .unwrap_or(false) + }) + .count(); + assert_eq!( + dir_count, 4, + "expected exactly 4 platform directories under npm/@simlin/, got {dir_count}" + ); +} + +#[test] +fn ac4_2_js_launcher_windows_triple() { + let js_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("bin/simlin-mcp.js"); + let contents = std::fs::read_to_string(&js_path).expect("read simlin-mcp.js"); + assert!( + contents.contains("x86_64-pc-windows-gnu"), + "JS launcher should map Windows to x86_64-pc-windows-gnu" + ); + assert!( + !contents.contains("x86_64-pc-windows-msvc"), + "JS launcher should not reference the msvc triple" + ); +} + +#[test] +fn ac4_4_wrapper_package_json_has_publish_config() { + let pkg_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("package.json"); + let contents = std::fs::read_to_string(&pkg_path).expect("read package.json"); + let pkg: serde_json::Value = serde_json::from_str(&contents).expect("valid JSON"); + assert_eq!(pkg["publishConfig"]["access"], "public"); + assert_eq!(pkg["repository"]["type"], "git"); + assert!( + pkg["repository"]["url"] + .as_str() + .unwrap_or("") + .contains("simlin"), + "repository.url should reference simlin" + ); } diff --git a/src/simlin-mcp/tests/mcp_release_workflow.rs b/src/simlin-mcp/tests/mcp_release_workflow.rs new file mode 100644 index 000000000..3e33214ad --- /dev/null +++ b/src/simlin-mcp/tests/mcp_release_workflow.rs @@ -0,0 +1,216 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +//! Static validation tests for .github/workflows/mcp-release.yml (AC1, AC2, AC5). +//! +//! These tests parse the YAML workflow file and assert structural properties +//! that would otherwise only be detectable via a live CI run. Catching +//! regressions here is cheaper than waiting for a failed release workflow. + +use std::path::Path; + +fn load_workflow() -> serde_yaml::Value { + // CARGO_MANIFEST_DIR is src/simlin-mcp; repo root is two levels up. + let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap(); + let wf_path = repo_root.join(".github/workflows/mcp-release.yml"); + let contents = std::fs::read_to_string(&wf_path).expect("read workflow YAML"); + serde_yaml::from_str(&contents).expect("parse workflow YAML") +} + +fn load_workflow_text() -> String { + let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap(); + let wf_path = repo_root.join(".github/workflows/mcp-release.yml"); + std::fs::read_to_string(&wf_path).expect("read workflow YAML") +} + +// AC1.1 / AC1.6: only mcp-v* tags trigger the workflow (no broader patterns) +#[test] +fn ac1_1_tag_trigger_is_mcp_v_star() { + let wf = load_workflow(); + let tags = wf["on"]["push"]["tags"] + .as_sequence() + .expect("on.push.tags should be a sequence"); + assert_eq!( + tags.len(), + 1, + "expected exactly one tag pattern, got {}", + tags.len() + ); + assert_eq!( + tags[0].as_str().unwrap_or(""), + "mcp-v*", + "tag trigger pattern should be exactly 'mcp-v*'" + ); +} + +// AC1.2 - AC1.5: build matrix has exactly 4 entries with the expected targets/os +#[test] +fn ac1_2_to_1_5_build_matrix_has_all_four_targets() { + let wf = load_workflow(); + let matrix = wf["jobs"]["build"]["strategy"]["matrix"]["include"] + .as_sequence() + .expect("jobs.build.strategy.matrix.include should be a sequence"); + + assert_eq!( + matrix.len(), + 4, + "expected exactly 4 build matrix entries, got {}", + matrix.len() + ); + + let targets: Vec<&str> = matrix + .iter() + .map(|e| e["target"].as_str().expect("matrix entry missing target")) + .collect(); + + assert!( + targets.contains(&"x86_64-unknown-linux-musl"), + "matrix missing x86_64-unknown-linux-musl (AC1.2)" + ); + assert!( + targets.contains(&"aarch64-unknown-linux-musl"), + "matrix missing aarch64-unknown-linux-musl (AC1.3)" + ); + assert!( + targets.contains(&"x86_64-pc-windows-gnu"), + "matrix missing x86_64-pc-windows-gnu (AC1.4)" + ); + assert!( + targets.contains(&"aarch64-apple-darwin"), + "matrix missing aarch64-apple-darwin (AC1.5)" + ); + + // macOS arm64 must use macos-latest runner + let darwin_entry = matrix + .iter() + .find(|e| e["target"].as_str() == Some("aarch64-apple-darwin")) + .expect("aarch64-apple-darwin entry not found"); + assert_eq!( + darwin_entry["os"].as_str().unwrap_or(""), + "macos-latest", + "aarch64-apple-darwin build must run on macos-latest (AC1.5)" + ); +} + +// AC5.1: top-level permissions.contents is "read" +#[test] +fn ac5_1_top_level_permissions_contents_read() { + let wf = load_workflow(); + assert_eq!( + wf["permissions"]["contents"].as_str().unwrap_or(""), + "read", + "top-level permissions.contents should be 'read'" + ); +} + +// AC5.2: only publish-platform and publish-wrapper have id-token: write; +// build and validate jobs must not have it +#[test] +fn ac5_2_only_publish_jobs_have_id_token_write() { + let wf = load_workflow(); + + assert_eq!( + wf["jobs"]["publish-platform"]["permissions"]["id-token"] + .as_str() + .unwrap_or(""), + "write", + "publish-platform must have id-token: write" + ); + assert_eq!( + wf["jobs"]["publish-wrapper"]["permissions"]["id-token"] + .as_str() + .unwrap_or(""), + "write", + "publish-wrapper must have id-token: write" + ); + + // build and validate jobs must not grant id-token + let build_id_token = &wf["jobs"]["build"]["permissions"]["id-token"]; + assert!( + build_id_token.is_null(), + "build job must not have id-token permission" + ); + let validate_id_token = &wf["jobs"]["validate"]["permissions"]["id-token"]; + assert!( + validate_id_token.is_null(), + "validate job must not have id-token permission" + ); +} + +// AC5.3: no long-lived npm tokens in the workflow +#[test] +fn ac5_3_no_npm_token_in_workflow() { + let text = load_workflow_text(); + assert!( + !text.contains("NPM_TOKEN"), + "workflow must not reference NPM_TOKEN" + ); + assert!( + !text.contains("NODE_AUTH_TOKEN"), + "workflow must not reference NODE_AUTH_TOKEN" + ); + assert!( + !text.contains("secrets.NPM"), + "workflow must not reference secrets.NPM" + ); +} + +// AC2.1: publish-wrapper depends on publish-platform +#[test] +fn ac2_1_publish_wrapper_needs_publish_platform() { + let wf = load_workflow(); + let needs = wf["jobs"]["publish-wrapper"]["needs"] + .as_sequence() + .expect("publish-wrapper.needs should be a sequence"); + let need_names: Vec<&str> = needs + .iter() + .map(|n| n.as_str().expect("needs entry should be a string")) + .collect(); + assert!( + need_names.contains(&"publish-platform"), + "publish-wrapper must list publish-platform in its needs (AC2.1)" + ); +} + +// AC2.3: every npm publish invocation includes --provenance +#[test] +fn ac2_3_all_npm_publish_commands_have_provenance() { + let text = load_workflow_text(); + // Find every line that calls `npm publish` and assert --provenance is present. + for (lineno, line) in text.lines().enumerate() { + if line.contains("npm publish") { + assert!( + line.contains("--provenance"), + "line {} calls npm publish without --provenance: {line}", + lineno + 1 + ); + } + } +} + +// AC2.5: chmod +x is present for the 3 non-Windows binaries +#[test] +fn ac2_5_chmod_for_non_windows_binaries() { + let text = load_workflow_text(); + assert!( + text.contains("chmod +x") && text.contains("mcp-linux-x64"), + "workflow must chmod +x the linux-x64 binary (AC2.5)" + ); + assert!( + text.contains("chmod +x") && text.contains("mcp-linux-arm64"), + "workflow must chmod +x the linux-arm64 binary (AC2.5)" + ); + assert!( + text.contains("chmod +x") && text.contains("mcp-darwin-arm64"), + "workflow must chmod +x the darwin-arm64 binary (AC2.5)" + ); +} From 7246375510d014469fcbbf48d87d3c97298119c5 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 13 Mar 2026 07:44:21 -0700 Subject: [PATCH 12/22] doc: add human test plan for MCP npm release --- docs/test-plans/2026-03-12-mcp-npm-release.md | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 docs/test-plans/2026-03-12-mcp-npm-release.md diff --git a/docs/test-plans/2026-03-12-mcp-npm-release.md b/docs/test-plans/2026-03-12-mcp-npm-release.md new file mode 100644 index 000000000..70b696619 --- /dev/null +++ b/docs/test-plans/2026-03-12-mcp-npm-release.md @@ -0,0 +1,93 @@ +# MCP npm Release -- Human Test Plan + +Generated from test-requirements.md after automated coverage validation passed. + +## Prerequisites + +- Docker installed and running (for cross-build tests) +- Access to the GitHub repository with permission to push tags +- Access to npmjs.com with Trusted Publisher configured for the `@simlin` organization +- `cargo test -p simlin-mcp` passing (confirms all automated tests are green) +- Access to both an x64 Linux machine and an arm64 machine (for AC3.3) + +## Phase 1: Cross-Build Script Verification + +| Step | Action | Expected | +|------|--------|----------| +| 1.1 | On a Linux x64 host, run `cd src/simlin-mcp && bash scripts/cross-build.sh` | Script completes without error. Docker builds the toolchain image and produces binaries. | +| 1.2 | Verify output: run `ls -lh dist/` and check for three subdirectories | Three directories exist: `x86_64-unknown-linux-musl/`, `aarch64-unknown-linux-musl/`, `x86_64-pc-windows-gnu/` | +| 1.3 | Verify each binary exists: `file dist/x86_64-unknown-linux-musl/simlin-mcp`, `file dist/aarch64-unknown-linux-musl/simlin-mcp`, `file dist/x86_64-pc-windows-gnu/simlin-mcp.exe` | `file` reports the x64 Linux binary as ELF 64-bit x86-64 (statically linked), the arm64 binary as ELF 64-bit ARM aarch64, and the Windows binary as PE32+ executable x86-64 | +| 1.4 | Run the built-in smoke test: `echo '' \| timeout 2 dist/x86_64-unknown-linux-musl/simlin-mcp` | Binary loads and exits. Exit code is 0 or a timeout (exit 124), NOT a crash/segfault (exit 139) or permission error (exit 126). | +| 1.5 | (AC3.3) On an arm64 host (e.g., Apple Silicon Mac with Docker), run `bash scripts/cross-build.sh` | Same results as steps 1.1-1.3. The Dockerfile correctly detects `aarch64` via `uname -m` and downloads the arm64 Zig toolchain. | + +## Phase 2: CI Workflow Trigger Verification + +| Step | Action | Expected | +|------|--------|----------| +| 2.1 | Push a non-matching tag: `git tag test-ignore-tag && git push origin test-ignore-tag` | Navigate to the repository's Actions tab. Confirm NO "MCP npm Release" workflow run appears for this tag. | +| 2.2 | Remove the test tag: `git push origin :refs/tags/test-ignore-tag && git tag -d test-ignore-tag` | Tag is cleaned up from both local and remote. | +| 2.3 | Push a matching tag: `git tag mcp-v0.1.0 && git push origin mcp-v0.1.0` | Navigate to the repository's Actions tab. Confirm the "MCP npm Release" workflow starts. | +| 2.4 | Monitor the "Validate version" job | Job succeeds. Log shows `Cargo.toml version: 0.1.0` and `Tag version: 0.1.0` matching. | +| 2.5 | Monitor the 4 "Build" matrix jobs | All 4 jobs succeed: mcp-linux-x64, mcp-linux-arm64, mcp-win32-x64, mcp-darwin-arm64. Each uploads an artifact. | + +## Phase 3: Publish Ordering and Registry Verification + +| Step | Action | Expected | +|------|--------|----------| +| 3.1 | In the Actions run, observe the "Publish platform packages" job | It starts only after all 4 build jobs complete (its `needs` includes `build`). | +| 3.2 | Observe the "Publish @simlin/mcp" (wrapper) job | It starts only after "Publish platform packages" completes (its `needs` includes `publish-platform`). | +| 3.3 | After all jobs complete, run: `npm view @simlin/mcp-linux-x64@0.1.0` | Package exists on the registry with the correct version. | +| 3.4 | Run: `npm view @simlin/mcp@0.1.0 optionalDependencies` | Output lists all 4 platform packages (`@simlin/mcp-darwin-arm64`, `@simlin/mcp-linux-arm64`, `@simlin/mcp-linux-x64`, `@simlin/mcp-win32-x64`) each at version `0.1.0`. | + +## Phase 4: Provenance and OIDC Verification + +| Step | Action | Expected | +|------|--------|----------| +| 4.1 | Navigate to `https://www.npmjs.com/package/@simlin/mcp/v/0.1.0` | The package page displays a provenance badge (green checkmark or "Provenance" label in the sidebar). | +| 4.2 | Run: `npm audit signatures @simlin/mcp` | Audit reports valid signatures with no integrity errors. | +| 4.3 | Confirm no `NPM_TOKEN` secret is configured in the repository settings (Settings > Secrets and variables > Actions) | No secret named `NPM_TOKEN`, `NODE_AUTH_TOKEN`, or any `NPM`-prefixed secret exists. The publish succeeded purely via OIDC. | + +## Phase 5: End-to-End Install and Run + +| Step | Action | Expected | +|------|--------|----------| +| 5.1 | On a Linux x64 machine: `npm install -g @simlin/mcp@0.1.0` | Installation succeeds. npm downloads the wrapper package and the `@simlin/mcp-linux-x64` optional dependency. | +| 5.2 | Run: `which simlin-mcp` | Prints a path (e.g., `/usr/local/bin/simlin-mcp`). | +| 5.3 | Run: `echo '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"0.1.0"}},"id":1}' \| simlin-mcp` | The binary starts, reads the JSON-RPC message from stdin, and prints an `initialize` response to stdout containing `serverInfo` and `capabilities`. | +| 5.4 | On a macOS arm64 machine: repeat steps 5.1-5.3, replacing the expected platform package with `@simlin/mcp-darwin-arm64` | Same results: installation succeeds, binary is found, and responds to the initialize message. | +| 5.5 | On a Windows x64 machine: `npm install -g @simlin/mcp@0.1.0` then send the same JSON-RPC initialize message | Installation succeeds with `@simlin/mcp-win32-x64`. Binary runs as `simlin-mcp.exe`. | + +## Full Release Cycle (End-to-End) + +1. Ensure `cargo test -p simlin-mcp` passes locally (all automated tests green). +2. Push tag `mcp-v0.1.0` to the remote. +3. Observe the GitHub Actions workflow triggers (AC1.1), builds all 4 platforms (AC1.2-AC1.5), publishes platform packages first (AC2.1), then publishes the wrapper (AC2.2). +4. Verify provenance badge on npmjs.com (AC2.3) and confirm no NPM_TOKEN secret was used (AC2.4, AC5.3). +5. On a clean machine, `npm install -g @simlin/mcp@0.1.0` and send a JSON-RPC initialize message to the binary (AC2.5). +6. Confirm `npm view @simlin/mcp@0.1.0` shows all 4 optional dependencies at the correct version (AC2.2). + +## Traceability + +| Acceptance Criterion | Automated Test | Manual Step | +|----------------------|----------------|-------------| +| AC1.1 Tag triggers workflow | `mcp_release_workflow.rs::ac1_1_tag_trigger_is_mcp_v_star` | Phase 2, step 2.3 | +| AC1.2 Linux x64 musl | `mcp_release_workflow.rs::ac1_2_to_1_5_build_matrix_has_all_four_targets` | -- | +| AC1.3 Linux arm64 musl | `mcp_release_workflow.rs::ac1_2_to_1_5_build_matrix_has_all_four_targets` | -- | +| AC1.4 Windows x64 PE | `mcp_release_workflow.rs::ac1_2_to_1_5_build_matrix_has_all_four_targets` | -- | +| AC1.5 macOS arm64 | `mcp_release_workflow.rs::ac1_2_to_1_5_build_matrix_has_all_four_targets` | -- | +| AC1.6 Non-matching tags ignored | `mcp_release_workflow.rs::ac1_1_tag_trigger_is_mcp_v_star` | Phase 2, steps 2.1-2.2 | +| AC2.1 Platform before wrapper | `mcp_release_workflow.rs::ac2_1_publish_wrapper_needs_publish_platform` | Phase 3, steps 3.1-3.2 | +| AC2.2 Correct optionalDeps | -- | Phase 3, step 3.4 | +| AC2.3 Provenance | `mcp_release_workflow.rs::ac2_3_all_npm_publish_commands_have_provenance` | Phase 4, step 4.1 | +| AC2.4 OIDC (no stored token) | `mcp_release_workflow.rs::ac5_3_no_npm_token_in_workflow` | Phase 4, step 4.3 | +| AC2.5 Execute permission | `mcp_release_workflow.rs::ac2_5_chmod_for_non_windows_binaries` | Phase 5, step 5.3 | +| AC3.1 cross-build produces 3 binaries | -- | Phase 1, steps 1.1-1.3 | +| AC3.2 Linux x64 binary runs | -- | Phase 1, step 1.4 | +| AC3.3 Works on x64 and arm64 | -- | Phase 1, steps 1.1 + 1.5 | +| AC4.1 darwin-x64 removed | `build_npm_packages.rs::ac4_platform_packages_have_correct_fields` | -- | +| AC4.2 Windows triple is gnu | `build_npm_packages.rs::ac4_2_js_launcher_windows_triple` | -- | +| AC4.3 Exactly 4 platform packages | `build_npm_packages.rs::ac4_platform_packages_have_correct_fields` | -- | +| AC4.4 publishConfig and repository | `build_npm_packages.rs::ac4_platform_packages_have_correct_fields` + `ac4_4_wrapper_package_json_has_publish_config` | -- | +| AC5.1 Build jobs contents:read | `mcp_release_workflow.rs::ac5_1_top_level_permissions_contents_read` | -- | +| AC5.2 Only publish jobs get id-token | `mcp_release_workflow.rs::ac5_2_only_publish_jobs_have_id_token_write` | -- | +| AC5.3 No stored npm tokens | `mcp_release_workflow.rs::ac5_3_no_npm_token_in_workflow` | -- | From 0f1942c5a068d0aa6175fb17cfe20e9dacc32cff Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 13 Mar 2026 14:55:00 -0700 Subject: [PATCH 13/22] doc: add implementation plans for mcp-npm-release --- .../2026-03-12-mcp-npm-release/phase_01.md | 272 ++++++++++++++++ .../2026-03-12-mcp-npm-release/phase_02.md | 230 +++++++++++++ .../2026-03-12-mcp-npm-release/phase_03.md | 306 ++++++++++++++++++ .../2026-03-12-mcp-npm-release/phase_04.md | 240 ++++++++++++++ .../test-requirements.md | 228 +++++++++++++ 5 files changed, 1276 insertions(+) create mode 100644 docs/implementation-plans/2026-03-12-mcp-npm-release/phase_01.md create mode 100644 docs/implementation-plans/2026-03-12-mcp-npm-release/phase_02.md create mode 100644 docs/implementation-plans/2026-03-12-mcp-npm-release/phase_03.md create mode 100644 docs/implementation-plans/2026-03-12-mcp-npm-release/phase_04.md create mode 100644 docs/implementation-plans/2026-03-12-mcp-npm-release/test-requirements.md diff --git a/docs/implementation-plans/2026-03-12-mcp-npm-release/phase_01.md b/docs/implementation-plans/2026-03-12-mcp-npm-release/phase_01.md new file mode 100644 index 000000000..d3c632a56 --- /dev/null +++ b/docs/implementation-plans/2026-03-12-mcp-npm-release/phase_01.md @@ -0,0 +1,272 @@ +# MCP npm Release -- Phase 1: Scaffolding Cleanup + +**Goal:** Remove stale darwin-x64 support, fix Windows target triple, add npm publish configuration to all packages. + +**Architecture:** Infrastructure cleanup -- delete stale files, update platform mappings, add required npm fields for public scoped package publishing. + +**Tech Stack:** Bash, Node.js (ES modules), npm package.json, Rust integration tests + +**Scope:** Phase 1 of 4 from original design + +**Codebase verified:** 2026-03-12 + +--- + +## Acceptance Criteria Coverage + +This phase implements and tests: + +### mcp-npm-release.AC4: Scaffolding is clean +- **mcp-npm-release.AC4.1 Success:** `mcp-darwin-x64` package directory is removed +- **mcp-npm-release.AC4.2 Success:** JS launcher maps Windows to `x86_64-pc-windows-gnu` triple +- **mcp-npm-release.AC4.3 Success:** `build-npm-packages.sh` generates exactly 4 platform packages +- **mcp-npm-release.AC4.4 Success:** All 5 package.json files include `publishConfig.access: "public"` and `repository` + +--- + +## Codebase Verification Notes + +**Important:** Files under `src/simlin-mcp/npm/` are gitignored (`.gitignore` line: `/src/simlin-mcp/npm`). Platform `package.json` files exist on disk but are not tracked by git -- they are generated by `build-npm-packages.sh`. Only the wrapper `package.json` at `src/simlin-mcp/package.json` and the build script are committed. The stale `mcp-darwin-x64` directory is also gitignored. + +Investigation revealed that some design assumptions are already satisfied: +- `build-npm-packages.sh` PLATFORMS array already has exactly 4 entries (darwin-x64 was already removed) +- `bin/simlin-mcp.js` PLATFORM_MAP already has no darwin-x64 entry +- Wrapper `package.json` optionalDependencies already excludes `@simlin/mcp-darwin-x64` + +Remaining work: +- The `npm/@simlin/mcp-darwin-x64/` directory exists on disk (gitignored) and should be deleted as local cleanup +- JS launcher Windows triple is `x86_64-pc-windows-msvc`, must change to `x86_64-pc-windows-gnu` +- Wrapper `package.json` (committed) lacks `publishConfig` and `repository` fields +- `build-npm-packages.sh` template does not emit `publishConfig` or `repository` (affects generated platform packages) +- Existing integration test (`tests/build_npm_packages.rs`) validates 3 of 4 platforms (missing `linux-arm64`), does not check `publishConfig` or `repository`, and uses stale AC5.2 naming + +--- + + +### Task 1: Delete stale darwin-x64 package and fix Windows triple + +**Verifies:** mcp-npm-release.AC4.1, mcp-npm-release.AC4.2 + +**Files:** +- Delete: `src/simlin-mcp/npm/@simlin/mcp-darwin-x64/` (entire directory) +- Modify: `src/simlin-mcp/bin/simlin-mcp.js:33` (change Windows triple) + +**Implementation:** + +Delete the stale darwin-x64 platform package directory (local cleanup only -- this directory is gitignored): + +```bash +rm -rf src/simlin-mcp/npm/@simlin/mcp-darwin-x64/ +``` + +In `src/simlin-mcp/bin/simlin-mcp.js`, change line 33 from: + +```js + triple: "x86_64-pc-windows-msvc", +``` + +to: + +```js + triple: "x86_64-pc-windows-gnu", +``` + +**Verification:** + +```bash +# Confirm directory is gone +test ! -d src/simlin-mcp/npm/@simlin/mcp-darwin-x64 && echo "OK: darwin-x64 removed" + +# Confirm Windows triple is correct +grep -q 'x86_64-pc-windows-gnu' src/simlin-mcp/bin/simlin-mcp.js && echo "OK: Windows triple updated" +``` + +**Commit:** `mcp: remove stale darwin-x64 package, fix Windows target triple` + + + +### Task 2: Add publishConfig and repository to wrapper package.json + +**Verifies:** mcp-npm-release.AC4.4 (wrapper package only -- platform packages are gitignored and handled by the build script template in Task 3) + +**Files:** +- Modify: `src/simlin-mcp/package.json` (wrapper -- the only committed package.json) + +**Implementation:** + +Add `publishConfig` and `repository` fields to `src/simlin-mcp/package.json` (after the `"license"` field): + +```json + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/bpowers/simlin.git", + "directory": "src/simlin-mcp" + } +``` + +The complete wrapper `package.json` should look like: + +```json +{ + "name": "@simlin/mcp", + "version": "0.1.0", + "description": "MCP server for Simlin system dynamics simulation engine", + "bin": { + "simlin-mcp": "bin/simlin-mcp.js" + }, + "files": [ + "bin" + ], + "optionalDependencies": { + "@simlin/mcp-darwin-arm64": "0.1.0", + "@simlin/mcp-linux-arm64": "0.1.0", + "@simlin/mcp-linux-x64": "0.1.0", + "@simlin/mcp-win32-x64": "0.1.0" + }, + "license": "Apache-2.0", + "type": "module", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/bpowers/simlin.git", + "directory": "src/simlin-mcp" + } +} +``` + +**Verification:** + +```bash +jq -e '.publishConfig.access == "public"' src/simlin-mcp/package.json +jq -e '.repository.url' src/simlin-mcp/package.json +``` + +**Commit:** `mcp: add publishConfig and repository to wrapper package.json` + + + +### Task 3: Update build-npm-packages.sh template to emit publishConfig and repository + +**Verifies:** mcp-npm-release.AC4.3, mcp-npm-release.AC4.4 + +**Files:** +- Modify: `src/simlin-mcp/build-npm-packages.sh:36-46` (JSON template) + +**Implementation:** + +Replace the `cat > "$pkg_dir/package.json"` heredoc (lines 36-46) with: + +```bash + cat > "$pkg_dir/package.json" < + + +### Task 4: Update integration test to verify new fields + +**Verifies:** mcp-npm-release.AC4.3, mcp-npm-release.AC4.4 + +**Files:** +- Modify: `src/simlin-mcp/tests/build_npm_packages.rs` + +**Implementation:** + +The existing test validates `name`, `version`, `os`, and `cpu` for generated platform packages. Three updates needed: + +1. Rename the test function from `ac5_2_platform_packages_have_correct_fields` to `ac4_platform_packages_have_correct_fields` and update the module doc comment from `AC5.2` to `AC4` to match the design plan's AC numbering. + +2. Add the missing `linux-arm64` entry to the `PLATFORMS` const so all 4 platforms are verified (AC4.3): + +```rust +const PLATFORMS: &[Platform] = &[ + Platform { + suffix: "darwin-arm64", + os: "darwin", + cpu: "arm64", + }, + Platform { + suffix: "linux-arm64", + os: "linux", + cpu: "arm64", + }, + Platform { + suffix: "linux-x64", + os: "linux", + cpu: "x64", + }, + Platform { + suffix: "win32-x64", + os: "win32", + cpu: "x64", + }, +]; +``` + +3. Add assertions for `publishConfig` and `repository` inside the `for plat in PLATFORMS` loop, after the existing `cpu` assertions: + +```rust + // AC4.4: publishConfig and repository + let publish_config = &pkg["publishConfig"]; + assert_eq!( + publish_config["access"], "public", + "missing publishConfig.access for {}", + plat.suffix + ); + + let repo = &pkg["repository"]; + assert_eq!( + repo["type"], "git", + "missing repository.type for {}", + plat.suffix + ); + assert!( + repo["url"].as_str().unwrap_or("").contains("simlin"), + "repository.url should reference simlin for {}", + plat.suffix + ); +``` + +**Testing:** + +```bash +cargo test -p simlin-mcp --test build_npm_packages +``` + +Expected: All assertions pass for all 4 platforms including the new `publishConfig` and `repository` checks. + +**Commit:** `mcp: update build script integration test for publishConfig, repository, and linux-arm64` + diff --git a/docs/implementation-plans/2026-03-12-mcp-npm-release/phase_02.md b/docs/implementation-plans/2026-03-12-mcp-npm-release/phase_02.md new file mode 100644 index 000000000..65b4d3844 --- /dev/null +++ b/docs/implementation-plans/2026-03-12-mcp-npm-release/phase_02.md @@ -0,0 +1,230 @@ +# MCP npm Release -- Phase 2: Local Cross-Build Tooling + +**Goal:** Dockerfile and script that build Linux (x64, arm64) and Windows (x64) binaries locally via cargo-zigbuild. + +**Architecture:** A toolchain Docker image (`Dockerfile.cross`) provides Rust + Zig + cargo-zigbuild. A shell script builds the image, runs all three cross-compilations in a single container, and copies binaries to `dist/`. Source is mounted read-only; build artifacts use a named Docker volume for incremental builds. + +**Tech Stack:** Docker, cargo-zigbuild 0.22.x, Zig 0.15.x, Rust cross-compilation targets (musl, mingw) + +**Scope:** Phase 2 of 4 from original design + +**Codebase verified:** 2026-03-12 + +--- + +## Acceptance Criteria Coverage + +This phase implements and tests: + +### mcp-npm-release.AC3: Local build works without CI +- **mcp-npm-release.AC3.1 Success:** `cross-build.sh` produces linux-x64, linux-arm64, and win32-x64 binaries via Docker +- **mcp-npm-release.AC3.2 Success:** Linux x64 binary runs on the host (smoke test) +- **mcp-npm-release.AC3.3 Success:** Script works on both x64 and arm64 development hosts + +--- + +## Codebase Verification Notes + +- Workspace root `Cargo.toml` is at repo root with 5 members: `src/libsimlin`, `src/simlin-cli`, `src/simlin-engine`, `src/simlin-mcp`, `src/xmutil` +- `simlin-mcp` depends on `simlin-engine` (path dep) but NOT on `xmutil` -- cross-build only compiles simlin-mcp and simlin-engine +- No existing Dockerfiles in the repo. Existing `.dockerignore` is stale (web-app focused, does not exclude `/target`) +- No `src/simlin-mcp/scripts/` directory exists -- will be created +- No `.cargo/config.toml` exists -- no pre-configured cross-compilation settings +- Release profile: `opt-level = "z"`, `lto = true`, `panic = "abort"`, `strip = true` (produces small stripped binaries) + +**External dependency findings:** +- cargo-zigbuild 0.22.1 is current stable; install via `cargo install --locked cargo-zigbuild` +- Zig 0.15.2 is current stable; tarball naming uses `zig-{ARCH}-linux-{VERSION}.tar.xz` format (changed at 0.14.0) +- Windows GNU cross-compilation: `raw-dylib` / `dlltool` issue fixed in cargo-zigbuild 0.21.2+ (no need for separate mingw-w64 install) +- musl targets produce fully static binaries with cargo-zigbuild (primary use case, well-supported) +- `CARGO_TARGET_DIR` env var redirects build output away from read-only source mount + +--- + + +### Task 1: Create Dockerfile.cross + +**Verifies:** None (infrastructure setup) + +**Files:** +- Create: `src/simlin-mcp/Dockerfile.cross` + +**Implementation:** + +Create `src/simlin-mcp/Dockerfile.cross` with the cross-compilation toolchain: + +```dockerfile +# Cross-compilation toolchain for simlin-mcp. +# +# Provides Rust + Zig + cargo-zigbuild for building Linux (musl) and +# Windows (mingw) binaries from a single image. Used by scripts/cross-build.sh. + +# Update RUST_VERSION to match the current stable Rust when building. +# Override at build time: docker build --build-arg RUST_VERSION=1.88.0 ... +ARG RUST_VERSION=1.87.0 +ARG ZIG_VERSION=0.15.2 + +FROM rust:${RUST_VERSION}-bookworm + +ARG ZIG_VERSION + +# Install Zig from upstream tarball +RUN ARCH=$(uname -m) && \ + TARBALL="zig-${ARCH}-linux-${ZIG_VERSION}.tar.xz" && \ + DIR="zig-${ARCH}-linux-${ZIG_VERSION}" && \ + curl -L "https://ziglang.org/download/${ZIG_VERSION}/${TARBALL}" \ + | tar -J -x -C /usr/local && \ + ln -s "/usr/local/${DIR}/zig" /usr/local/bin/zig + +RUN cargo install --locked cargo-zigbuild + +RUN rustup target add \ + x86_64-unknown-linux-musl \ + aarch64-unknown-linux-musl \ + x86_64-pc-windows-gnu + +WORKDIR /src +``` + +**Verification:** + +```bash +docker build -t simlin-mcp-cross -f src/simlin-mcp/Dockerfile.cross src/simlin-mcp/ +docker run --rm simlin-mcp-cross cargo zigbuild --version +``` + +Expected: Image builds successfully, `cargo zigbuild --version` prints version info. + +**Commit:** `mcp: add Dockerfile.cross for local cross-compilation toolchain` + + + +### Task 2: Create scripts/cross-build.sh + +**Verifies:** mcp-npm-release.AC3.1, mcp-npm-release.AC3.2, mcp-npm-release.AC3.3 + +**Files:** +- Create: `src/simlin-mcp/scripts/cross-build.sh` + +**Implementation:** + +Create `src/simlin-mcp/scripts/cross-build.sh` (must be `chmod +x`): + +```bash +#!/usr/bin/env bash +# Cross-compile simlin-mcp for Linux (x64, arm64) and Windows (x64). +# +# Builds a Docker toolchain image, then runs cargo-zigbuild inside it with the +# repo source mounted read-only. A named Docker volume caches the Cargo target +# directory so subsequent runs are incremental. +# +# Output: dist//simlin-mcp[.exe] + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MCP_DIR="$(dirname "$SCRIPT_DIR")" +REPO_ROOT="$(cd "$MCP_DIR/../.." && pwd)" + +IMAGE_NAME="simlin-mcp-cross" +CACHE_VOLUME="simlin-mcp-cross-cache" +DIST_DIR="$MCP_DIR/dist" + +echo "==> Building cross-compilation toolchain image..." +docker build -t "$IMAGE_NAME" -f "$MCP_DIR/Dockerfile.cross" "$MCP_DIR/" + +rm -rf "$DIST_DIR" +mkdir -p "$DIST_DIR" + +echo "==> Cross-compiling all targets..." +docker run --rm \ + -v "$REPO_ROOT:/src:ro" \ + -v "$CACHE_VOLUME:/tmp/target" \ + -v "$DIST_DIR:/dist" \ + -e CARGO_TARGET_DIR=/tmp/target \ + "$IMAGE_NAME" \ + bash -c ' + cd /src + for target in x86_64-unknown-linux-musl aarch64-unknown-linux-musl x86_64-pc-windows-gnu; do + echo "--- Building $target ---" + cargo zigbuild -p simlin-mcp --release --target "$target" + mkdir -p "/dist/$target" + if [[ "$target" == *windows* ]]; then + cp "/tmp/target/$target/release/simlin-mcp.exe" "/dist/$target/" + else + cp "/tmp/target/$target/release/simlin-mcp" "/dist/$target/" + fi + done + echo "--- All targets built ---" + ' + +echo "" +echo "Binaries:" +ls -lh "$DIST_DIR"/*/simlin-mcp* + +# Smoke test: verify the Linux x64 binary is a valid static executable and runs +echo "" +echo "==> Smoke test: Linux x64 binary..." +file "$DIST_DIR/x86_64-unknown-linux-musl/simlin-mcp" + +echo "==> Verifying binary executes..." +# simlin-mcp is a stdio MCP server that waits for input, so feed it empty input +# with a timeout. Any exit (including error) proves the binary loads and runs. +echo '' | timeout 2 "$DIST_DIR/x86_64-unknown-linux-musl/simlin-mcp" 2>/dev/null || true +echo "Smoke test passed (binary executed)" + +echo "" +echo "Done. Binaries in $DIST_DIR/" +``` + +**Verification:** + +```bash +cd src/simlin-mcp && bash scripts/cross-build.sh +``` + +Expected output: +- `dist/x86_64-unknown-linux-musl/simlin-mcp` exists (static ELF binary) +- `dist/aarch64-unknown-linux-musl/simlin-mcp` exists (static ELF binary, aarch64) +- `dist/x86_64-pc-windows-gnu/simlin-mcp.exe` exists (PE executable) +- `file` command confirms the Linux x64 binary is `ELF 64-bit LSB executable, x86-64, ... statically linked` + +**Commit:** `mcp: add cross-build.sh for local multi-platform binary builds` + + + +### Task 3: Add dist/ to .gitignore + +**Verifies:** None (infrastructure cleanup) + +**Files:** +- Modify: `src/simlin-mcp/.gitignore` (create if not exists) + +**Implementation:** + +The cross-build script outputs binaries to `src/simlin-mcp/dist/`. These must not be committed. Check if `src/simlin-mcp/.gitignore` exists. If not, create it. If it does, append to it. + +The root `.gitignore` already has `/src/simlin-mcp/vendor` but not `/src/simlin-mcp/dist`. Add `dist/` to the simlin-mcp-level gitignore (preferred over root since it's component-specific). + +Content for `src/simlin-mcp/.gitignore` (create or append): + +``` +/dist/ +``` + +Alternatively, add to the root `.gitignore` next to the existing simlin-mcp entries: + +``` +/src/simlin-mcp/dist +``` + +Choose whichever location already has simlin-mcp ignore patterns. Root `.gitignore` already has `/src/simlin-mcp/vendor`, so adding `/src/simlin-mcp/dist` there is consistent. + +**Verification:** + +```bash +git status # dist/ should not appear as untracked +``` + +**Commit:** `mcp: ignore cross-build dist/ output` + diff --git a/docs/implementation-plans/2026-03-12-mcp-npm-release/phase_03.md b/docs/implementation-plans/2026-03-12-mcp-npm-release/phase_03.md new file mode 100644 index 000000000..2ce122590 --- /dev/null +++ b/docs/implementation-plans/2026-03-12-mcp-npm-release/phase_03.md @@ -0,0 +1,306 @@ +# MCP npm Release -- Phase 3: GitHub Actions Workflow + +**Goal:** CI workflow that builds all 4 platform binaries and publishes the 5 npm packages using OIDC trusted publishing. + +**Architecture:** Three-job workflow: `build` (matrix, 4 platforms) produces binary artifacts; `publish-platform` (needs: build) downloads artifacts and publishes 4 platform npm packages; `publish-wrapper` (needs: publish-platform) publishes the `@simlin/mcp` wrapper. OIDC trusted publishing eliminates stored npm tokens. + +**Tech Stack:** GitHub Actions, cargo-zigbuild, Zig, npm OIDC trusted publishing, provenance attestation + +**Scope:** Phase 3 of 4 from original design + +**Codebase verified:** 2026-03-12 + +--- + +## Acceptance Criteria Coverage + +This phase implements and tests: + +### mcp-npm-release.AC1: CI workflow builds all 4 platforms +- **mcp-npm-release.AC1.1 Success:** `mcp-v*` tag push triggers the workflow +- **mcp-npm-release.AC1.2 Success:** Linux x64 musl static binary is produced +- **mcp-npm-release.AC1.3 Success:** Linux arm64 musl static binary is produced +- **mcp-npm-release.AC1.4 Success:** Windows x64 PE binary is produced (mingw cross-compiled) +- **mcp-npm-release.AC1.5 Success:** macOS arm64 binary is produced (native build on macOS runner) +- **mcp-npm-release.AC1.6 Failure:** Workflow does not trigger on non-`mcp-v*` tags + +### mcp-npm-release.AC2: npm packages publish correctly +- **mcp-npm-release.AC2.1 Success:** 4 platform packages publish before the wrapper package +- **mcp-npm-release.AC2.2 Success:** Wrapper `@simlin/mcp` publishes with correct `optionalDependencies` versions +- **mcp-npm-release.AC2.3 Success:** All packages publish with provenance attestation +- **mcp-npm-release.AC2.4 Success:** Authentication uses OIDC (no stored NPM_TOKEN) +- **mcp-npm-release.AC2.5 Edge:** Non-Windows binaries have execute permission after artifact download + +### mcp-npm-release.AC5: Minimal security surface +- **mcp-npm-release.AC5.1 Success:** Build jobs have only `contents: read` permission +- **mcp-npm-release.AC5.2 Success:** Only publish jobs have `id-token: write` permission +- **mcp-npm-release.AC5.3 Success:** No long-lived npm tokens stored in GitHub secrets + +--- + +## Codebase Verification Notes + +- Existing `release.yml` (pysimlin) uses `pysimlin-v*` tag trigger, artifact upload/download v4, and `if: startsWith(github.ref, 'refs/tags/')` guard. Uses stored `PYPI_API_TOKEN` secret (not OIDC). The MCP workflow follows the same tag pattern but uses OIDC instead. +- No reusable workflows or composite actions exist in `.github/` -- the new workflow must be self-contained. +- `ci.yaml` uses `actions/cache@v4` with key `cargo-build-${{ matrix.os }}-${{ hashFiles('**/Cargo.lock') }}`. The MCP workflow should use a distinct prefix. +- Node.js version in CI is 22. +- `macos-latest` runners are Apple Silicon (arm64) -- native `cargo build` for `aarch64-apple-darwin` works without cross-compilation. +- Repository is `bpowers/simlin` (confirmed from git remote). + +**External dependency findings:** +- npm OIDC trusted publishing is GA (since July 2025). Requires npm >= 11.5.1 (current: 11.11.1). Node 22 is fine. +- Critical gotcha: `actions/setup-node` with `registry-url` injects `NODE_AUTH_TOKEN` placeholder that breaks OIDC. Fix: omit `registry-url` (npm defaults to registry.npmjs.org anyway). +- First version of each package must be published manually before OIDC can be configured -- Phase 4 handles this. +- `actions/upload-artifact@v4` does NOT preserve Unix file permissions. Must `chmod +x` binaries after download. +- `mlugg/setup-zig@v2` is the recommended GitHub Action for Zig (caches across runs). +- `--provenance` flag should be passed explicitly to `npm publish` as a safeguard. +- Each of the 5 packages needs its own Trusted Publisher configuration on npmjs.com (all pointing to same workflow file). + +--- + + +### Task 1: Create .github/workflows/mcp-release.yml + +**Verifies:** mcp-npm-release.AC1.1, mcp-npm-release.AC1.2, mcp-npm-release.AC1.3, mcp-npm-release.AC1.4, mcp-npm-release.AC1.5, mcp-npm-release.AC1.6, mcp-npm-release.AC2.1, mcp-npm-release.AC2.2, mcp-npm-release.AC2.3, mcp-npm-release.AC2.4, mcp-npm-release.AC2.5, mcp-npm-release.AC5.1, mcp-npm-release.AC5.2, mcp-npm-release.AC5.3 + +**Files:** +- Create: `.github/workflows/mcp-release.yml` + +**Implementation:** + +Create `.github/workflows/mcp-release.yml` with the complete workflow below. The workflow has three jobs: + +1. **`build`** -- matrix of 4 platform targets. Three use `cargo-zigbuild` on `ubuntu-latest`; macOS arm64 uses native `cargo build` on `macos-latest`. Each uploads the binary as a named artifact. + +2. **`publish-platform`** -- runs after build completes. Downloads all artifacts, runs `build-npm-packages.sh` to generate platform `package.json` files with the correct version (from `Cargo.toml`), copies binaries into platform package `bin/` directories, `chmod +x` non-Windows binaries, then publishes all 4 platform packages with `npm publish --provenance --access public`. + +3. **`publish-wrapper`** -- runs after platform packages are published. Updates the wrapper `package.json` version and `optionalDependencies` versions to match `Cargo.toml`, then publishes with provenance. + +Key design decisions: +- Workflow-level `permissions: contents: read` (AC5.1). Only publish jobs add `id-token: write` (AC5.2). +- No `registry-url` in `actions/setup-node` -- avoids `NODE_AUTH_TOKEN` placeholder that conflicts with OIDC (AC2.4). +- `npm install -g npm@latest` ensures npm >= 11.5.1 for OIDC support. +- Tag format validated against `Cargo.toml` version in a separate `validate` job to fail fast. +- `workflow_dispatch` enabled for dry-run testing (build jobs run, publish jobs skip because `if: startsWith(github.ref, 'refs/tags/')` is false). + +```yaml +name: MCP npm Release + +on: + push: + tags: + - 'mcp-v*' + workflow_dispatch: + +permissions: + contents: read + +jobs: + validate: + name: Validate version + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v4 + + - name: Extract and validate version + id: version + run: | + CARGO_VERSION=$(grep '^version = ' src/simlin-mcp/Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') + echo "Cargo.toml version: $CARGO_VERSION" + + if [[ "$GITHUB_REF" == refs/tags/mcp-v* ]]; then + TAG_VERSION="${GITHUB_REF_NAME#mcp-v}" + echo "Tag version: $TAG_VERSION" + if [ "$TAG_VERSION" != "$CARGO_VERSION" ]; then + echo "::error::Tag version ($TAG_VERSION) does not match Cargo.toml ($CARGO_VERSION)" + exit 1 + fi + fi + + echo "version=$CARGO_VERSION" >> "$GITHUB_OUTPUT" + + build: + name: Build ${{ matrix.artifact }} + needs: validate + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-musl + os: ubuntu-latest + artifact: mcp-linux-x64 + binary: simlin-mcp + use-zigbuild: true + - target: aarch64-unknown-linux-musl + os: ubuntu-latest + artifact: mcp-linux-arm64 + binary: simlin-mcp + use-zigbuild: true + - target: x86_64-pc-windows-gnu + os: ubuntu-latest + artifact: mcp-win32-x64 + binary: simlin-mcp.exe + use-zigbuild: true + - target: aarch64-apple-darwin + os: macos-latest + artifact: mcp-darwin-arm64 + binary: simlin-mcp + use-zigbuild: false + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Cache Cargo artifacts + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin + ~/.cargo/registry + ~/.cargo/git + target + key: cargo-mcp-${{ matrix.os }}-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + cargo-mcp-${{ matrix.os }}-${{ matrix.target }}- + + - name: Install Zig + if: matrix.use-zigbuild + uses: mlugg/setup-zig@v2 + with: + version: '0.15.2' + + - name: Install cargo-zigbuild + if: matrix.use-zigbuild + run: cargo install --locked cargo-zigbuild@0.22 + + - name: Build (zigbuild) + if: matrix.use-zigbuild + run: cargo zigbuild -p simlin-mcp --release --target ${{ matrix.target }} + + - name: Build (native) + if: ${{ !matrix.use-zigbuild }} + run: cargo build -p simlin-mcp --release --target ${{ matrix.target }} + + - name: Upload binary + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: target/${{ matrix.target }}/release/${{ matrix.binary }} + if-no-files-found: error + retention-days: 1 + + publish-platform: + name: Publish platform packages + needs: [validate, build] + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - run: npm install -g npm@latest + + - uses: actions/download-artifact@v4 + with: + path: artifacts/ + + - name: Prepare platform packages + working-directory: src/simlin-mcp + run: | + bash build-npm-packages.sh + + cp ../../artifacts/mcp-linux-x64/simlin-mcp npm/@simlin/mcp-linux-x64/bin/ + cp ../../artifacts/mcp-linux-arm64/simlin-mcp npm/@simlin/mcp-linux-arm64/bin/ + cp ../../artifacts/mcp-win32-x64/simlin-mcp.exe npm/@simlin/mcp-win32-x64/bin/ + cp ../../artifacts/mcp-darwin-arm64/simlin-mcp npm/@simlin/mcp-darwin-arm64/bin/ + + chmod +x npm/@simlin/mcp-linux-x64/bin/simlin-mcp + chmod +x npm/@simlin/mcp-linux-arm64/bin/simlin-mcp + chmod +x npm/@simlin/mcp-darwin-arm64/bin/simlin-mcp + + - name: Publish @simlin/mcp-linux-x64 + working-directory: src/simlin-mcp/npm/@simlin/mcp-linux-x64 + run: npm publish --provenance --access public + + - name: Publish @simlin/mcp-linux-arm64 + working-directory: src/simlin-mcp/npm/@simlin/mcp-linux-arm64 + run: npm publish --provenance --access public + + - name: Publish @simlin/mcp-win32-x64 + working-directory: src/simlin-mcp/npm/@simlin/mcp-win32-x64 + run: npm publish --provenance --access public + + - name: Publish @simlin/mcp-darwin-arm64 + working-directory: src/simlin-mcp/npm/@simlin/mcp-darwin-arm64 + run: npm publish --provenance --access public + + publish-wrapper: + name: Publish @simlin/mcp + needs: [validate, publish-platform] + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - run: npm install -g npm@latest + + - name: Update wrapper version + working-directory: src/simlin-mcp + run: | + VERSION="${{ needs.validate.outputs.version }}" + jq --arg v "$VERSION" ' + .version = $v | + .optionalDependencies = ( + .optionalDependencies | to_entries | map(.value = $v) | from_entries + ) + ' package.json > package.json.tmp && mv package.json.tmp package.json + + echo "Updated wrapper package.json:" + cat package.json + + - name: Publish @simlin/mcp + working-directory: src/simlin-mcp + run: npm publish --provenance --access public +``` + +**Verification:** + +The workflow cannot be fully tested without pushing a tag, but verify structural correctness: + +```bash +# Check YAML syntax +python3 -c "import yaml; yaml.safe_load(open('.github/workflows/mcp-release.yml'))" + +# Verify trigger pattern +grep -A2 'tags:' .github/workflows/mcp-release.yml + +# Verify permissions +grep -A1 'permissions:' .github/workflows/mcp-release.yml +``` + +If `actionlint` is available: +```bash +actionlint .github/workflows/mcp-release.yml +``` + +**Commit:** `mcp: add GitHub Actions workflow for npm release` + diff --git a/docs/implementation-plans/2026-03-12-mcp-npm-release/phase_04.md b/docs/implementation-plans/2026-03-12-mcp-npm-release/phase_04.md new file mode 100644 index 000000000..93428c6ff --- /dev/null +++ b/docs/implementation-plans/2026-03-12-mcp-npm-release/phase_04.md @@ -0,0 +1,240 @@ +# MCP npm Release -- Phase 4: Manual Verification and npm Trusted Publisher Setup + +**Goal:** End-to-end validation with real npm publishes and OIDC configuration. + +**Architecture:** Manual steps: create npm org if needed, bootstrap packages with placeholder publishes, configure Trusted Publisher for each package, then verify the full CI pipeline with a pre-release tag. + +**Tech Stack:** npm CLI, npmjs.com web UI, GitHub Actions, git tags + +**Scope:** Phase 4 of 4 from original design + +**Codebase verified:** 2026-03-12 + +--- + +## Acceptance Criteria Coverage + +This phase verifies all acceptance criteria end-to-end. No new code is written -- this phase validates the infrastructure from Phases 1-3. + +### mcp-npm-release.AC1: CI workflow builds all 4 platforms +- **mcp-npm-release.AC1.1 Success:** `mcp-v*` tag push triggers the workflow + +### mcp-npm-release.AC2: npm packages publish correctly +- **mcp-npm-release.AC2.1 Success:** 4 platform packages publish before the wrapper package +- **mcp-npm-release.AC2.2 Success:** Wrapper `@simlin/mcp` publishes with correct `optionalDependencies` versions +- **mcp-npm-release.AC2.3 Success:** All packages publish with provenance attestation +- **mcp-npm-release.AC2.4 Success:** Authentication uses OIDC (no stored NPM_TOKEN) + +--- + +## External Dependency Findings + +- First version of each package MUST be published manually before OIDC can be configured (npmjs.com hard requirement). +- No CLI or API for Trusted Publisher configuration -- web UI only at `https://www.npmjs.com/package//access`. +- The `@simlin` npm organization must exist on npmjs.com before scoped packages can be published. +- Trusted Publisher fields: owner (`bpowers`), repository (`simlin`), workflow filename (`mcp-release.yml`), environment (leave blank). +- The workflow filename is the bare filename only, not the full `.github/workflows/` path. +- `--access public` is required on first publish of scoped packages (they default to restricted/private). + +--- + + +### Task 1: Ensure @simlin npm organization exists + +**Verifies:** None (prerequisite) + +**Steps:** + +1. Check if the `@simlin` scope exists on npm: + ```bash + npm org ls simlin 2>/dev/null || echo "org does not exist" + ``` + +2. If the organization does not exist, create it: + - Go to `https://www.npmjs.com/org/create` + - Create organization named `simlin` + - This is free for public packages + +3. Verify: + ```bash + npm org ls simlin + ``` + + + +### Task 2: Bootstrap packages with initial manual publish + +**Verifies:** None (prerequisite for OIDC configuration) + +**Steps:** + +The first version of each package must be published manually so that OIDC Trusted Publisher can be configured. Use a pre-release version (`0.0.1-bootstrap.1`) to avoid permanently creating a broken `0.1.0` on npm (published packages are immutable). + +1. Authenticate to npm: + ```bash + npm login + ``` + +2. Ensure all changes from Phases 1-3 are committed and the code is on a branch where `build-npm-packages.sh` produces correct output. + +3. Temporarily set `Cargo.toml` version to the bootstrap version: + ```bash + cd src/simlin-mcp + # Save original version + ORIG_VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') + sed -i 's/^version = ".*"/version = "0.0.1-bootstrap.1"/' Cargo.toml + ``` + +4. Generate platform packages with the bootstrap version: + ```bash + bash build-npm-packages.sh + ``` + +5. Publish each platform package (placeholder -- no real binary needed for this step): + ```bash + cd npm/@simlin/mcp-darwin-arm64 && npm publish --access public && cd - + cd npm/@simlin/mcp-linux-arm64 && npm publish --access public && cd - + cd npm/@simlin/mcp-linux-x64 && npm publish --access public && cd - + cd npm/@simlin/mcp-win32-x64 && npm publish --access public && cd - + ``` + +6. Update wrapper `package.json` version and publish: + ```bash + cd .. # back to src/simlin-mcp + jq '.version = "0.0.1-bootstrap.1" | .optionalDependencies = (.optionalDependencies | to_entries | map(.value = "0.0.1-bootstrap.1") | from_entries)' package.json > package.json.tmp && mv package.json.tmp package.json + npm publish --access public + ``` + +7. Restore original version: + ```bash + sed -i "s/^version = \".*\"/version = \"$ORIG_VERSION\"/" Cargo.toml + git checkout package.json # restore committed wrapper version + ``` + +8. Verify all 5 packages exist: + ```bash + npm view @simlin/mcp version + npm view @simlin/mcp-darwin-arm64 version + npm view @simlin/mcp-linux-arm64 version + npm view @simlin/mcp-linux-x64 version + npm view @simlin/mcp-win32-x64 version + ``` + +9. Deprecate the bootstrap versions so users don't accidentally install them: + ```bash + npm deprecate @simlin/mcp@0.0.1-bootstrap.1 "Bootstrap placeholder -- use latest release" + npm deprecate @simlin/mcp-darwin-arm64@0.0.1-bootstrap.1 "Bootstrap placeholder" + npm deprecate @simlin/mcp-linux-arm64@0.0.1-bootstrap.1 "Bootstrap placeholder" + npm deprecate @simlin/mcp-linux-x64@0.0.1-bootstrap.1 "Bootstrap placeholder" + npm deprecate @simlin/mcp-win32-x64@0.0.1-bootstrap.1 "Bootstrap placeholder" + ``` + +**Note:** These bootstrap versions are placeholders that exist solely to enable OIDC configuration. They are deprecated immediately. The first real release (via CI) publishes the actual version with binaries. + + + +### Task 3: Configure Trusted Publisher for all 5 packages + +**Verifies:** mcp-npm-release.AC2.4 (OIDC authentication), mcp-npm-release.AC5.3 (no stored tokens) + +**Steps:** + +For each of the 5 packages, configure Trusted Publisher on npmjs.com: + +1. Navigate to the package's access settings page. URLs: + - `https://www.npmjs.com/package/@simlin/mcp/access` + - `https://www.npmjs.com/package/@simlin/mcp-darwin-arm64/access` + - `https://www.npmjs.com/package/@simlin/mcp-linux-arm64/access` + - `https://www.npmjs.com/package/@simlin/mcp-linux-x64/access` + - `https://www.npmjs.com/package/@simlin/mcp-win32-x64/access` + +2. In the **Trusted Publisher** section, click **GitHub Actions**. + +3. Fill in the fields (identical for all 5 packages): + - **Organization or user:** `bpowers` + - **Repository:** `simlin` + - **Workflow filename:** `mcp-release.yml` + - **Environment:** (leave blank) + +4. Click **Set up connection** (or equivalent save button). + +5. Repeat for all 5 packages. + +**Verify:** Each package's access page shows the configured Trusted Publisher. + + + +### Task 4: Test the full pipeline with a pre-release tag + +**Verifies:** mcp-npm-release.AC1.1, mcp-npm-release.AC2.1, mcp-npm-release.AC2.2, mcp-npm-release.AC2.3, mcp-npm-release.AC2.4 + +**Steps:** + +1. First, bump the version in `Cargo.toml` to a pre-release version: + ```bash + # In src/simlin-mcp/Cargo.toml, change version to "0.1.1-rc.1" (or next available) + ``` + +2. Commit the version bump: + ```bash + git add src/simlin-mcp/Cargo.toml + git commit -m "mcp: bump version to 0.1.1-rc.1 for release pipeline test" + ``` + +3. Push the branch and create a tag: + ```bash + git push origin mcp-npm-release + git tag mcp-v0.1.1-rc.1 + git push origin mcp-v0.1.1-rc.1 + ``` + +4. Monitor the GitHub Actions workflow: + - Go to `https://github.com/bpowers/simlin/actions` + - Find the "MCP npm Release" workflow run triggered by the tag + - Verify all build matrix jobs succeed (4 platform binaries built) + - Verify `publish-platform` job publishes all 4 platform packages + - Verify `publish-wrapper` job publishes `@simlin/mcp` + +5. Verify published packages: + ```bash + npm view @simlin/mcp@0.1.1-rc.1 + npm view @simlin/mcp-linux-x64@0.1.1-rc.1 + ``` + +6. Check provenance attestation is present: + - Visit `https://www.npmjs.com/package/@simlin/mcp/v/0.1.1-rc.1` + - Look for the "Provenance" badge/section + + + +### Task 5: Verify end-to-end installation and execution + +**Verifies:** All ACs (end-to-end validation) + +**Steps:** + +1. Install the package globally: + ```bash + npm install -g @simlin/mcp@0.1.1-rc.1 + ``` + +2. Verify the correct platform binary was installed: + ```bash + which simlin-mcp + file $(which simlin-mcp) + ``` + +3. Verify the binary runs (simlin-mcp is a stdio MCP server, so it will wait for input): + ```bash + # Send an empty input and check it starts + echo '' | timeout 2 simlin-mcp || true + # A non-zero exit is fine -- it proves the binary loaded and executed + ``` + +4. Clean up: + ```bash + npm uninstall -g @simlin/mcp + ``` + +**Done when:** `npm install -g @simlin/mcp` installs correctly and the binary runs on at least one platform. + diff --git a/docs/implementation-plans/2026-03-12-mcp-npm-release/test-requirements.md b/docs/implementation-plans/2026-03-12-mcp-npm-release/test-requirements.md new file mode 100644 index 000000000..d9833b5ed --- /dev/null +++ b/docs/implementation-plans/2026-03-12-mcp-npm-release/test-requirements.md @@ -0,0 +1,228 @@ +# MCP npm Release -- Test Requirements + +Maps every acceptance criterion to either an automated test or a documented human verification step. Each entry references the implementation phase and task that addresses the criterion, so traceability is maintained from design through implementation to testing. + +--- + +## Automated Tests + +### AC4: Scaffolding is clean + +These criteria are verified by the existing Rust integration test (`src/simlin-mcp/tests/build_npm_packages.rs`), extended in Phase 1 Task 4. The test runs `build-npm-packages.sh` in a temp directory, parses the generated `package.json` files, and asserts field values. This is the only AC group fully testable before any CI infrastructure exists. + +| Criterion | Description | Test Type | Test File | Notes | +|-----------|-------------|-----------|-----------|-------| +| AC4.1 | `mcp-darwin-x64` directory removed | Integration | `src/simlin-mcp/tests/build_npm_packages.rs` | Test verifies exactly 4 platforms are produced (darwin-arm64, linux-arm64, linux-x64, win32-x64). If `build-npm-packages.sh` still referenced darwin-x64 it would produce 5 packages and the count/iteration would fail. The directory itself is gitignored, so its presence on disk is a local artifact -- the meaningful assertion is that the build script no longer generates it. | +| AC4.2 | JS launcher maps Windows to `x86_64-pc-windows-gnu` | Unit | `src/simlin-mcp/tests/build_npm_packages.rs` | Add a test case (or standalone test) that reads `bin/simlin-mcp.js`, parses the `PLATFORM_MAP` object, and asserts the `win32-x64` entry contains `x86_64-pc-windows-gnu`. Alternatively, a grep-based assertion in the existing integration test. See details below. | +| AC4.3 | `build-npm-packages.sh` generates exactly 4 platform packages | Integration | `src/simlin-mcp/tests/build_npm_packages.rs` | Existing test iterates `PLATFORMS` and asserts each `package.json` exists with correct fields. Phase 1 Task 4 adds the missing `linux-arm64` entry, bringing the list to all 4 platforms. An additional assertion should verify that exactly 4 directories exist under `npm/@simlin/` (no extras). | +| AC4.4 | All 5 `package.json` files have `publishConfig.access: "public"` and `repository` | Integration | `src/simlin-mcp/tests/build_npm_packages.rs` | Phase 1 Task 4 adds assertions for `publishConfig.access == "public"` and `repository.type == "git"` on each generated platform `package.json`. A separate assertion should read the committed wrapper `package.json` and verify the same fields. | + +#### AC4.2 test detail + +The JS launcher triple is a string literal in source code, not a build output. Two approaches: + +1. **Source parsing (preferred):** Read `bin/simlin-mcp.js` as a string in the Rust test, search for the `win32-x64` PLATFORM_MAP entry, and assert it contains `x86_64-pc-windows-gnu` (not `msvc`). This is brittle against formatting changes but catches the exact bug the AC targets. + +2. **Node.js test:** A small Node.js script that `import()`s the platform map and asserts the triple. However, this adds a test runner dependency for a single assertion. The Rust string-search approach is simpler and consistent with the existing test file. + +Recommended: add a second `#[test]` function in `build_npm_packages.rs` that reads the JS file and uses a regex or string search for the Windows triple. + +```rust +#[test] +fn ac4_2_js_launcher_windows_triple() { + let js_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("bin/simlin-mcp.js"); + let contents = std::fs::read_to_string(&js_path).expect("read simlin-mcp.js"); + assert!( + contents.contains("x86_64-pc-windows-gnu"), + "JS launcher should map Windows to x86_64-pc-windows-gnu" + ); + assert!( + !contents.contains("x86_64-pc-windows-msvc"), + "JS launcher should not reference the msvc triple" + ); +} +``` + +#### AC4.4 wrapper test detail + +The wrapper `package.json` is a committed file (not generated by the build script), so the existing test does not cover it. Add a test that reads `src/simlin-mcp/package.json` directly: + +```rust +#[test] +fn ac4_4_wrapper_package_json_has_publish_config() { + let pkg_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("package.json"); + let contents = std::fs::read_to_string(&pkg_path).expect("read package.json"); + let pkg: serde_json::Value = serde_json::from_str(&contents).expect("valid JSON"); + assert_eq!(pkg["publishConfig"]["access"], "public"); + assert_eq!(pkg["repository"]["type"], "git"); + assert!(pkg["repository"]["url"].as_str().unwrap_or("").contains("simlin")); +} +``` + +--- + +### AC1: CI workflow builds all 4 platforms + +CI workflow behavior is inherently runtime -- the workflow file is YAML configuration, not directly executable code. However, structural properties of the workflow can be statically validated. + +| Criterion | Description | Test Type | Test File | Notes | +|-----------|-------------|-----------|-----------|-------| +| AC1.1 | `mcp-v*` tag push triggers workflow | Unit (static) | `src/simlin-mcp/tests/mcp_release_workflow.rs` | Parse the YAML file, assert `on.push.tags` contains `mcp-v*`. See details below. | +| AC1.2 | Linux x64 musl binary produced | Unit (static) | `src/simlin-mcp/tests/mcp_release_workflow.rs` | Assert build matrix includes an entry with `target: x86_64-unknown-linux-musl`. | +| AC1.3 | Linux arm64 musl binary produced | Unit (static) | `src/simlin-mcp/tests/mcp_release_workflow.rs` | Assert build matrix includes an entry with `target: aarch64-unknown-linux-musl`. | +| AC1.4 | Windows x64 PE binary produced | Unit (static) | `src/simlin-mcp/tests/mcp_release_workflow.rs` | Assert build matrix includes an entry with `target: x86_64-pc-windows-gnu`. | +| AC1.5 | macOS arm64 binary produced | Unit (static) | `src/simlin-mcp/tests/mcp_release_workflow.rs` | Assert build matrix includes an entry with `target: aarch64-apple-darwin` and `os: macos-latest`. | +| AC1.6 | Non-`mcp-v*` tags do not trigger | Unit (static) | `src/simlin-mcp/tests/mcp_release_workflow.rs` | Assert `on.push.tags` contains only `mcp-v*` (no broader patterns). Also requires human verification (see below). | + +#### Workflow YAML static validation + +Add a dev dependency on `serde_yaml` to `simlin-mcp` and create `tests/mcp_release_workflow.rs`. The test loads `.github/workflows/mcp-release.yml`, deserializes to `serde_yaml::Value`, and asserts structural properties. This catches regressions from edits to the workflow file without requiring a CI run. + +```rust +fn load_workflow() -> serde_yaml::Value { + let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap().parent().unwrap(); + let wf_path = repo_root.join(".github/workflows/mcp-release.yml"); + let contents = std::fs::read_to_string(&wf_path).expect("read workflow YAML"); + serde_yaml::from_str(&contents).expect("parse workflow YAML") +} +``` + +Assertions to implement: +- `on.push.tags` is `["mcp-v*"]` +- `jobs.build.strategy.matrix.include` has exactly 4 entries +- Each entry has the expected `target` and `os` values +- Top-level `permissions.contents` is `"read"` +- `jobs.publish-platform.permissions.id-token` is `"write"` +- `jobs.publish-wrapper.permissions.id-token` is `"write"` +- Build jobs do NOT have `id-token` in their permissions + +This single test file covers AC1.1-AC1.6, AC5.1, and AC5.2. + +--- + +### AC5: Minimal security surface + +| Criterion | Description | Test Type | Test File | Notes | +|-----------|-------------|-----------|-----------|-------| +| AC5.1 | Build jobs have only `contents: read` | Unit (static) | `src/simlin-mcp/tests/mcp_release_workflow.rs` | Assert top-level `permissions.contents` is `"read"` and build job does not override with broader permissions. | +| AC5.2 | Only publish jobs have `id-token: write` | Unit (static) | `src/simlin-mcp/tests/mcp_release_workflow.rs` | Assert `publish-platform` and `publish-wrapper` jobs have `id-token: write`. Assert `build` and `validate` jobs do NOT have `id-token` in their permissions block. | +| AC5.3 | No long-lived npm tokens in secrets | Unit (static) | `src/simlin-mcp/tests/mcp_release_workflow.rs` | Grep the workflow YAML string for `NPM_TOKEN`, `NODE_AUTH_TOKEN`, and `secrets.NPM`. Assert none appear. This catches accidental reintroduction of stored tokens. | + +--- + +## Human Verification + +The following criteria require manual verification because they depend on external service behavior (npm registry, GitHub Actions runtime, Docker) that cannot be validated by static analysis or local tests alone. + +### AC1.1: Tag push triggers workflow (runtime) + +**Justification:** Static YAML parsing confirms the trigger pattern is correct, but only a real tag push proves GitHub Actions actually triggers the workflow. Edge cases (branch protection rules, repository settings, webhook delivery) are outside test scope. + +**Verification approach:** Phase 4 Task 4 -- push a `mcp-v*` tag and observe the workflow appears in the Actions tab. Documented in the implementation plan as the pre-release validation step. + +### AC1.6: Non-`mcp-v*` tags do not trigger (runtime) + +**Justification:** The static test confirms the YAML pattern is `mcp-v*` only. But confirming that GitHub Actions correctly evaluates this pattern against a non-matching tag requires observing that pushing a different tag (e.g., `pysimlin-v*` or `v1.0.0`) does NOT start the workflow. This is low risk -- GitHub's tag filter is well-established -- but cannot be automatically verified. + +**Verification approach:** After Phase 3, push any existing non-`mcp-v*` tag and confirm no "MCP npm Release" workflow run appears. Alternatively, rely on the static test plus GitHub's documented glob semantics. + +### AC2.1: Platform packages publish before wrapper + +**Justification:** The workflow encodes this ordering via `needs: [validate, build]` on `publish-platform` and `needs: [validate, publish-platform]` on `publish-wrapper`. The static YAML test can verify the `needs` dependency chain, but confirming that npm actually receives the platform packages before the wrapper resolves them requires observing a real publish run. + +**Verification approach:** Phase 4 Task 4 -- monitor the workflow run, confirm `publish-platform` completes before `publish-wrapper` starts. After publish, run `npm view @simlin/mcp` and verify `optionalDependencies` point to versions that exist on the registry. + +**Partial automation:** The static workflow test should assert `jobs.publish-wrapper.needs` includes `publish-platform`. This covers the structural guarantee; the human step confirms npm execution. + +### AC2.2: Wrapper publishes with correct optionalDependencies versions + +**Justification:** The workflow's `publish-wrapper` job uses `jq` to set the wrapper version and optionalDependencies versions from `Cargo.toml`. A static test can verify the `jq` command is present in the workflow, but confirming the published package has the correct versions requires inspecting the npm registry after a real publish. + +**Verification approach:** Phase 4 Task 4 -- after the tag-triggered publish, run `npm view @simlin/mcp@ optionalDependencies` and verify all four platform packages are listed with the matching version. + +### AC2.3: Provenance attestation present + +**Justification:** Provenance is generated by the npm CLI at publish time and validated by the registry. There is no way to locally simulate this. The static test can verify `--provenance` appears in every `npm publish` command in the workflow. + +**Verification approach:** Phase 4 Task 4 -- after publish, visit the npm package page (e.g., `https://www.npmjs.com/package/@simlin/mcp/v/`) and confirm the provenance badge is present. Alternatively: `npm audit signatures @simlin/mcp`. + +**Partial automation:** Static workflow test should assert every `npm publish` invocation includes `--provenance`. + +### AC2.4: Authentication uses OIDC (no stored NPM_TOKEN) + +**Justification:** OIDC is a runtime interaction between GitHub Actions, GitHub's OIDC provider, and npm's token validation endpoint. The static test covers the structural requirements (no secrets references, `id-token: write` permission, no `registry-url` in setup-node). But confirming the publish actually authenticates via OIDC -- not a fallback mechanism -- requires observing a successful publish in CI logs. + +**Verification approach:** Phase 4 Task 3 (configure Trusted Publisher on npmjs.com) + Task 4 (observe successful publish without any NPM_TOKEN secret configured in the repository). If publish succeeds with no stored token, OIDC is working. + +**Partial automation:** Static test asserts no `NPM_TOKEN`, `NODE_AUTH_TOKEN`, or `secrets.NPM` in the workflow file (AC5.3 coverage also serves AC2.4). + +### AC2.5: Non-Windows binaries have execute permission after artifact download + +**Justification:** `actions/upload-artifact@v4` strips Unix permissions. The workflow must `chmod +x` after download. The static test can verify `chmod +x` commands are present in the workflow. But confirming the binary is actually executable in the published npm package requires installing and running it. + +**Verification approach:** Phase 4 Task 5 -- install the published package (`npm install -g @simlin/mcp`) and verify the binary runs. If the execute bit were missing, the JS launcher's `spawn()` call would fail with `EACCES`. + +**Partial automation:** Static workflow test should assert the `publish-platform` job contains `chmod +x` for the three non-Windows binaries (linux-x64, linux-arm64, darwin-arm64). + +### AC3.1: cross-build.sh produces all 3 binaries via Docker + +**Justification:** This requires Docker to be running and the full cross-compilation toolchain to build. It takes several minutes on first run (Docker image build + three `cargo zigbuild` invocations). This is not suitable for the standard `cargo test` suite. + +**Verification approach:** Run `src/simlin-mcp/scripts/cross-build.sh` manually and verify `dist/` contains all three binaries. The script itself prints the `ls -lh` output and runs a smoke test. Phase 2 Task 2 documents the expected output. + +**Rationale for not automating:** Docker-in-CI adds significant complexity and runtime. The cross-build script is a developer convenience tool, not a release-critical path (CI builds natively via the workflow). A manual run during Phase 2 development validates it; regressions are unlikely since the script rarely changes. + +### AC3.2: Linux x64 binary runs on host (smoke test) + +**Justification:** Requires the binary to actually exist (produced by AC3.1) and the host to be Linux x64 or have a compatible execution environment. The cross-build script includes a built-in smoke test that feeds empty input to the binary with a timeout. + +**Verification approach:** Included in `cross-build.sh` execution (AC3.1 verification). The script runs `echo '' | timeout 2 ./dist/x86_64-unknown-linux-musl/simlin-mcp` and reports success if the binary loads. + +### AC3.3: Script works on both x64 and arm64 hosts + +**Justification:** Requires running the script on two different architectures. The Dockerfile uses `uname -m` to download the correct Zig tarball, and `cargo-zigbuild` handles cross-compilation regardless of host architecture. Testing both requires access to both an x64 and arm64 machine. + +**Verification approach:** Run `cross-build.sh` on an x64 host (standard Linux dev machine or CI runner) and an arm64 host (Apple Silicon Mac with Docker, or arm64 Linux). The script's architecture-detection logic (`uname -m` in the Dockerfile) is the only host-dependent code path. + +**Rationale for not automating:** Maintaining CI runners for both architectures solely for this test is disproportionate. The script's architecture handling is a single `uname -m` call with no conditional logic, making regression unlikely. + +--- + +## Summary Matrix + +| AC | Criterion | Automated | Human | Implementation Phase | +|----|-----------|-----------|-------|---------------------| +| AC1.1 | Tag triggers workflow | Static YAML parse | Runtime push observation | Phase 3, Phase 4 | +| AC1.2 | Linux x64 musl binary | Static YAML parse | -- | Phase 3 | +| AC1.3 | Linux arm64 musl binary | Static YAML parse | -- | Phase 3 | +| AC1.4 | Windows x64 PE binary | Static YAML parse | -- | Phase 3 | +| AC1.5 | macOS arm64 binary | Static YAML parse | -- | Phase 3 | +| AC1.6 | Non-matching tags ignored | Static YAML parse | Runtime non-trigger observation | Phase 3 | +| AC2.1 | Platform before wrapper | Static `needs` check | Runtime publish order | Phase 3, Phase 4 | +| AC2.2 | Correct optionalDeps versions | -- | `npm view` after publish | Phase 4 | +| AC2.3 | Provenance attestation | Static `--provenance` check | npm package page inspection | Phase 3, Phase 4 | +| AC2.4 | OIDC authentication | Static no-token check | Successful publish without secrets | Phase 3, Phase 4 | +| AC2.5 | Execute permission preserved | Static `chmod +x` check | Install and run binary | Phase 3, Phase 4 | +| AC3.1 | cross-build.sh produces 3 binaries | -- | Manual script run | Phase 2 | +| AC3.2 | Linux x64 binary runs | -- | Built-in smoke test in script | Phase 2 | +| AC3.3 | Works on x64 and arm64 hosts | -- | Manual run on both archs | Phase 2 | +| AC4.1 | darwin-x64 removed | Build script produces exactly 4 | -- | Phase 1 | +| AC4.2 | Windows triple is gnu | JS file string check | -- | Phase 1 | +| AC4.3 | Exactly 4 platform packages | Integration test iteration | -- | Phase 1 | +| AC4.4 | publishConfig and repository | Integration test assertions | -- | Phase 1 | +| AC5.1 | Build jobs contents:read only | Static YAML parse | -- | Phase 3 | +| AC5.2 | Only publish jobs get id-token | Static YAML parse | -- | Phase 3 | +| AC5.3 | No stored npm tokens | Static string search | -- | Phase 3 | + +--- + +## Test File Inventory + +| File | Type | ACs Covered | Phase Created | +|------|------|-------------|---------------| +| `src/simlin-mcp/tests/build_npm_packages.rs` | Integration | AC4.1, AC4.2, AC4.3, AC4.4 | Exists (extended in Phase 1) | +| `src/simlin-mcp/tests/mcp_release_workflow.rs` | Unit (static) | AC1.1-AC1.6, AC2.1, AC2.3, AC2.4, AC2.5, AC5.1, AC5.2, AC5.3 | Phase 3 | + +### Dev dependency additions + +- `serde_yaml` -- required by `mcp_release_workflow.rs` for parsing the workflow YAML. Add to `[dev-dependencies]` in `src/simlin-mcp/Cargo.toml`. From e85ac3c3d86908d86777052c84d95c662d0e227e Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 13 Mar 2026 15:08:45 -0700 Subject: [PATCH 14/22] mcp: address review feedback for npm release pipeline Replace deprecated serde_yaml with serde_yml, bump Dockerfile.cross Rust version to match rust-toolchain.toml (1.94.0), add npm view existence checks so publish steps are rerunnable after partial failure, skip the execution smoke test on non-Linux hosts in cross-build.sh, and add an msvc-triple dev fallback in the JS launcher for Windows developers. Tests strengthened: chmod assertions now verify platform appears on the same line as chmod +x, new tests for Dockerfile version parity, publish idempotency, and cross-build platform detection. --- .github/workflows/mcp-release.yml | 40 ++++++++-- src/simlin-mcp/Cargo.toml | 2 +- src/simlin-mcp/Dockerfile.cross | 2 +- src/simlin-mcp/bin/simlin-mcp.js | 13 ++++ src/simlin-mcp/scripts/cross-build.sh | 34 ++++---- src/simlin-mcp/tests/build_npm_packages.rs | 8 +- src/simlin-mcp/tests/mcp_release_workflow.rs | 81 +++++++++++++++++--- 7 files changed, 146 insertions(+), 34 deletions(-) diff --git a/.github/workflows/mcp-release.yml b/.github/workflows/mcp-release.yml index 39a347d3d..6d3157375 100644 --- a/.github/workflows/mcp-release.yml +++ b/.github/workflows/mcp-release.yml @@ -147,19 +147,43 @@ jobs: - name: Publish @simlin/mcp-linux-x64 working-directory: src/simlin-mcp/npm/@simlin/mcp-linux-x64 - run: npm publish --provenance --access public + run: | + VERSION=$(jq -r .version package.json) + if npm view "@simlin/mcp-linux-x64@$VERSION" version > /dev/null 2>&1; then + echo "Already published @simlin/mcp-linux-x64@$VERSION, skipping" + else + npm publish --provenance --access public + fi - name: Publish @simlin/mcp-linux-arm64 working-directory: src/simlin-mcp/npm/@simlin/mcp-linux-arm64 - run: npm publish --provenance --access public + run: | + VERSION=$(jq -r .version package.json) + if npm view "@simlin/mcp-linux-arm64@$VERSION" version > /dev/null 2>&1; then + echo "Already published @simlin/mcp-linux-arm64@$VERSION, skipping" + else + npm publish --provenance --access public + fi - name: Publish @simlin/mcp-win32-x64 working-directory: src/simlin-mcp/npm/@simlin/mcp-win32-x64 - run: npm publish --provenance --access public + run: | + VERSION=$(jq -r .version package.json) + if npm view "@simlin/mcp-win32-x64@$VERSION" version > /dev/null 2>&1; then + echo "Already published @simlin/mcp-win32-x64@$VERSION, skipping" + else + npm publish --provenance --access public + fi - name: Publish @simlin/mcp-darwin-arm64 working-directory: src/simlin-mcp/npm/@simlin/mcp-darwin-arm64 - run: npm publish --provenance --access public + run: | + VERSION=$(jq -r .version package.json) + if npm view "@simlin/mcp-darwin-arm64@$VERSION" version > /dev/null 2>&1; then + echo "Already published @simlin/mcp-darwin-arm64@$VERSION, skipping" + else + npm publish --provenance --access public + fi publish-wrapper: name: Publish @simlin/mcp @@ -194,4 +218,10 @@ jobs: - name: Publish @simlin/mcp working-directory: src/simlin-mcp - run: npm publish --provenance --access public + run: | + VERSION=$(jq -r .version package.json) + if npm view "@simlin/mcp@$VERSION" version > /dev/null 2>&1; then + echo "Already published @simlin/mcp@$VERSION, skipping" + else + npm publish --provenance --access public + fi diff --git a/src/simlin-mcp/Cargo.toml b/src/simlin-mcp/Cargo.toml index 6769500d7..00260ec17 100644 --- a/src/simlin-mcp/Cargo.toml +++ b/src/simlin-mcp/Cargo.toml @@ -20,4 +20,4 @@ tokio = { version = "1", features = ["macros", "io-util", "io-std", "sync", "rt- [dev-dependencies] tempfile = "3" -serde_yaml = "0.9" +serde_yml = "0.0.12" diff --git a/src/simlin-mcp/Dockerfile.cross b/src/simlin-mcp/Dockerfile.cross index 908e8a7e4..cabfe8e4f 100644 --- a/src/simlin-mcp/Dockerfile.cross +++ b/src/simlin-mcp/Dockerfile.cross @@ -5,7 +5,7 @@ # Update RUST_VERSION to match the current stable Rust when building. # Override at build time: docker build --build-arg RUST_VERSION=1.88.0 ... -ARG RUST_VERSION=1.87.0 +ARG RUST_VERSION=1.94.0 # Keep in sync with .github/workflows/mcp-release.yml ARG ZIG_VERSION=0.15.2 diff --git a/src/simlin-mcp/bin/simlin-mcp.js b/src/simlin-mcp/bin/simlin-mcp.js index 4cb2cc102..3ef7fe0fa 100755 --- a/src/simlin-mcp/bin/simlin-mcp.js +++ b/src/simlin-mcp/bin/simlin-mcp.js @@ -60,6 +60,17 @@ const vendorBinaryPath = path.join( binaryName, ); +// On Windows, `cargo build` defaults to the MSVC toolchain, but the npm +// package ships the GNU-targeted binary. Try the MSVC triple as a dev +// fallback so developers don't need to cross-compile to GNU locally. +const DEV_FALLBACK_TRIPLES = { + "x86_64-pc-windows-gnu": "x86_64-pc-windows-msvc", +}; +const devFallbackTriple = DEV_FALLBACK_TRIPLES[platformInfo.triple]; +const devFallbackPath = devFallbackTriple + ? path.join(__dirname, "..", "vendor", devFallbackTriple, binaryName) + : null; + let binaryPath = null; try { @@ -78,6 +89,8 @@ try { if (!binaryPath) { if (existsSync(vendorBinaryPath)) { binaryPath = vendorBinaryPath; + } else if (devFallbackPath && existsSync(devFallbackPath)) { + binaryPath = devFallbackPath; } else { console.error( `simlin-mcp: could not find native binary for ${platformKey}`, diff --git a/src/simlin-mcp/scripts/cross-build.sh b/src/simlin-mcp/scripts/cross-build.sh index 30aba9301..801f7a0f2 100755 --- a/src/simlin-mcp/scripts/cross-build.sh +++ b/src/simlin-mcp/scripts/cross-build.sh @@ -49,7 +49,7 @@ echo "" echo "Binaries:" ls -lh "$DIST_DIR"/*/simlin-mcp* -# Smoke test: verify the Linux x64 binary is a valid static executable and runs +# Smoke test: verify the Linux x64 binary is a valid static executable. echo "" echo "==> Smoke test: Linux x64 binary..." FILE_OUTPUT=$(file "$DIST_DIR/x86_64-unknown-linux-musl/simlin-mcp") @@ -59,20 +59,26 @@ if ! echo "$FILE_OUTPUT" | grep -q "ELF.*executable"; then exit 1 fi -echo "==> Verifying binary executes..." -# simlin-mcp is a stdio MCP server that waits for input, so feed it empty input -# with a timeout. timeout exits 124 when it kills a running process (expected), -# and the binary itself may exit with a small non-zero code on empty input. -# Fatal failures are: 126 (cannot execute), 127 (not found), >= 128 (signal). -set +e -echo '' | timeout 2 "$DIST_DIR/x86_64-unknown-linux-musl/simlin-mcp" 2>/dev/null -EXIT_CODE=$? -set -e -if [ "$EXIT_CODE" -ge 126 ] && [ "$EXIT_CODE" -ne 124 ]; then - echo "FAIL: binary did not execute properly (exit code $EXIT_CODE)" - exit 1 +# The execution test only works on Linux hosts since the binary targets +# x86_64-unknown-linux-musl. On macOS (or other non-Linux), skip it. +if [[ "$(uname -s)" == "Linux" ]]; then + echo "==> Verifying binary executes..." + # simlin-mcp is a stdio MCP server that waits for input, so feed it empty + # input with a timeout. timeout exits 124 when it kills a running process + # (expected), and the binary itself may exit non-zero on empty input. + # Fatal failures are: 126 (cannot execute), 127 (not found), >= 128 (signal). + set +e + echo '' | timeout 2 "$DIST_DIR/x86_64-unknown-linux-musl/simlin-mcp" 2>/dev/null + EXIT_CODE=$? + set -e + if [ "$EXIT_CODE" -ge 126 ] && [ "$EXIT_CODE" -ne 124 ]; then + echo "FAIL: binary did not execute properly (exit code $EXIT_CODE)" + exit 1 + fi + echo "Smoke test passed (binary executed, exit code $EXIT_CODE)" +else + echo "==> Skipping execution smoke test (Linux binary cannot run on $(uname -s))" fi -echo "Smoke test passed (binary executed, exit code $EXIT_CODE)" echo "" echo "Done. Binaries in $DIST_DIR/" diff --git a/src/simlin-mcp/tests/build_npm_packages.rs b/src/simlin-mcp/tests/build_npm_packages.rs index 3d89079c1..5c430edef 100644 --- a/src/simlin-mcp/tests/build_npm_packages.rs +++ b/src/simlin-mcp/tests/build_npm_packages.rs @@ -188,11 +188,13 @@ fn ac4_2_js_launcher_windows_triple() { let contents = std::fs::read_to_string(&js_path).expect("read simlin-mcp.js"); assert!( contents.contains("x86_64-pc-windows-gnu"), - "JS launcher should map Windows to x86_64-pc-windows-gnu" + "JS launcher should map Windows to x86_64-pc-windows-gnu for npm packages" ); + // The dev vendor fallback should also try the msvc triple, since + // `cargo build` on Windows produces msvc binaries by default. assert!( - !contents.contains("x86_64-pc-windows-msvc"), - "JS launcher should not reference the msvc triple" + contents.contains("x86_64-pc-windows-msvc"), + "JS launcher dev fallback should also try the msvc triple for Windows" ); } diff --git a/src/simlin-mcp/tests/mcp_release_workflow.rs b/src/simlin-mcp/tests/mcp_release_workflow.rs index 3e33214ad..eca223608 100644 --- a/src/simlin-mcp/tests/mcp_release_workflow.rs +++ b/src/simlin-mcp/tests/mcp_release_workflow.rs @@ -10,7 +10,7 @@ use std::path::Path; -fn load_workflow() -> serde_yaml::Value { +fn load_workflow() -> serde_yml::Value { // CARGO_MANIFEST_DIR is src/simlin-mcp; repo root is two levels up. let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")) .parent() @@ -19,7 +19,7 @@ fn load_workflow() -> serde_yaml::Value { .unwrap(); let wf_path = repo_root.join(".github/workflows/mcp-release.yml"); let contents = std::fs::read_to_string(&wf_path).expect("read workflow YAML"); - serde_yaml::from_str(&contents).expect("parse workflow YAML") + serde_yml::from_str(&contents).expect("parse workflow YAML") } fn load_workflow_text() -> String { @@ -197,20 +197,81 @@ fn ac2_3_all_npm_publish_commands_have_provenance() { } } -// AC2.5: chmod +x is present for the 3 non-Windows binaries +// AC2.5: chmod +x is present for the 3 non-Windows binaries. +// Each assertion verifies the chmod and the platform appear on the SAME line, +// not just somewhere independently in the file. #[test] fn ac2_5_chmod_for_non_windows_binaries() { let text = load_workflow_text(); - assert!( - text.contains("chmod +x") && text.contains("mcp-linux-x64"), - "workflow must chmod +x the linux-x64 binary (AC2.5)" + for platform in &["mcp-linux-x64", "mcp-linux-arm64", "mcp-darwin-arm64"] { + assert!( + text.lines() + .any(|line| line.contains("chmod +x") && line.contains(platform)), + "workflow must chmod +x the {platform} binary on the same line (AC2.5)" + ); + } +} + +// Dockerfile.cross must default to the same Rust version as rust-toolchain.toml +// so that local cross-builds use the same compiler as the rest of the project. +#[test] +fn dockerfile_rust_version_matches_toolchain() { + let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap(); + + let toolchain = std::fs::read_to_string(repo_root.join("rust-toolchain.toml")) + .expect("read rust-toolchain.toml"); + let toolchain_version = toolchain + .lines() + .find_map(|line| { + line.strip_prefix("channel = \"") + .and_then(|rest| rest.strip_suffix('"')) + }) + .expect("rust-toolchain.toml must have channel = \"...\""); + + let dockerfile = std::fs::read_to_string(repo_root.join("src/simlin-mcp/Dockerfile.cross")) + .expect("read Dockerfile.cross"); + let docker_version = dockerfile + .lines() + .find_map(|line| line.strip_prefix("ARG RUST_VERSION=")) + .expect("Dockerfile.cross must have ARG RUST_VERSION=..."); + + assert_eq!( + docker_version, toolchain_version, + "Dockerfile.cross RUST_VERSION ({docker_version}) must match rust-toolchain.toml ({toolchain_version})" ); +} + +// Each npm publish step must be guarded by an existence check so that +// reruns after partial failure skip already-published packages. +#[test] +fn publish_steps_are_rerunnable() { + let text = load_workflow_text(); + for line in text.lines() { + if line.contains("npm publish") { + // The publish command should be inside a conditional block that + // first checks whether the version is already on the registry. + // We verify the workflow contains npm view checks overall. + } + } + // There must be at least one `npm view` invocation for idempotent publish. assert!( - text.contains("chmod +x") && text.contains("mcp-linux-arm64"), - "workflow must chmod +x the linux-arm64 binary (AC2.5)" + text.contains("npm view"), + "workflow must use `npm view` to check for already-published versions" ); +} + +// cross-build.sh must skip the execution smoke test on non-Linux hosts, +// since the output binary targets Linux and cannot run on macOS/Windows. +#[test] +fn cross_build_script_skips_smoke_on_non_linux() { + let script_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("scripts/cross-build.sh"); + let text = std::fs::read_to_string(&script_path).expect("read cross-build.sh"); assert!( - text.contains("chmod +x") && text.contains("mcp-darwin-arm64"), - "workflow must chmod +x the darwin-arm64 binary (AC2.5)" + text.contains("uname"), + "cross-build.sh should detect the host OS to skip smoke tests on non-Linux" ); } From 5e4bdfef368c388e88cb7a1768a070dc67e8d704 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 13 Mar 2026 15:20:11 -0700 Subject: [PATCH 15/22] mcp: tighten publish guards, pin Rust toolchain in CI Restrict publish job if-guards to refs/tags/mcp-v (not just refs/tags/) so workflow_dispatch on arbitrary tags cannot accidentally publish. Replace dtolnay/rust-toolchain@stable with @master reading the version from rust-toolchain.toml, ensuring release binaries use the same compiler as the rest of the project. Pin cargo-zigbuild version in Dockerfile.cross to match the CI workflow. Strengthen publish_steps_are_rerunnable test to verify the count of npm view checks matches the count of npm publish commands. --- .github/workflows/mcp-release.yml | 11 +++- src/simlin-mcp/Dockerfile.cross | 3 +- src/simlin-mcp/tests/mcp_release_workflow.rs | 64 +++++++++++++++++--- 3 files changed, 65 insertions(+), 13 deletions(-) diff --git a/.github/workflows/mcp-release.yml b/.github/workflows/mcp-release.yml index 6d3157375..d2f69e520 100644 --- a/.github/workflows/mcp-release.yml +++ b/.github/workflows/mcp-release.yml @@ -66,9 +66,14 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Read Rust toolchain version + id: rust-version + run: echo "version=$(grep '^channel' rust-toolchain.toml | sed 's/channel = \"\(.*\)\"/\1/')" >> "$GITHUB_OUTPUT" + - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@master with: + toolchain: ${{ steps.rust-version.outputs.version }} targets: ${{ matrix.target }} - name: Cache Cargo artifacts @@ -114,7 +119,7 @@ jobs: name: Publish platform packages needs: [validate, build] runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/') + if: startsWith(github.ref, 'refs/tags/mcp-v') permissions: contents: read id-token: write @@ -189,7 +194,7 @@ jobs: name: Publish @simlin/mcp needs: [validate, publish-platform] runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/') + if: startsWith(github.ref, 'refs/tags/mcp-v') permissions: contents: read id-token: write diff --git a/src/simlin-mcp/Dockerfile.cross b/src/simlin-mcp/Dockerfile.cross index cabfe8e4f..d408bbd11 100644 --- a/src/simlin-mcp/Dockerfile.cross +++ b/src/simlin-mcp/Dockerfile.cross @@ -21,7 +21,8 @@ RUN ARCH=$(uname -m) && \ | tar -J -x -C /usr/local && \ ln -s "/usr/local/${DIR}/zig" /usr/local/bin/zig -RUN cargo install --locked cargo-zigbuild +# Keep version in sync with .github/workflows/mcp-release.yml +RUN cargo install --locked cargo-zigbuild@0.22 RUN rustup target add \ x86_64-unknown-linux-musl \ diff --git a/src/simlin-mcp/tests/mcp_release_workflow.rs b/src/simlin-mcp/tests/mcp_release_workflow.rs index eca223608..80b5f4f80 100644 --- a/src/simlin-mcp/tests/mcp_release_workflow.rs +++ b/src/simlin-mcp/tests/mcp_release_workflow.rs @@ -247,20 +247,66 @@ fn dockerfile_rust_version_matches_toolchain() { // Each npm publish step must be guarded by an existence check so that // reruns after partial failure skip already-published packages. +// We verify that `npm view` appears at least as many times as `npm publish`, +// ensuring every publish is individually guarded. #[test] fn publish_steps_are_rerunnable() { let text = load_workflow_text(); - for line in text.lines() { - if line.contains("npm publish") { - // The publish command should be inside a conditional block that - // first checks whether the version is already on the registry. - // We verify the workflow contains npm view checks overall. - } + let publish_count = text.lines().filter(|l| l.contains("npm publish")).count(); + let view_count = text.lines().filter(|l| l.contains("npm view")).count(); + assert!( + publish_count > 0, + "workflow should have at least one npm publish command" + ); + assert!( + view_count >= publish_count, + "each npm publish must be guarded by an npm view check ({view_count} views < {publish_count} publishes)" + ); +} + +// Publish jobs must guard on `refs/tags/mcp-v`, not just `refs/tags/`, +// so that workflow_dispatch on an arbitrary tag cannot trigger publishing. +#[test] +fn publish_jobs_guard_on_mcp_v_tag() { + let wf = load_workflow(); + for job_name in &["publish-platform", "publish-wrapper"] { + let condition = wf["jobs"][*job_name]["if"] + .as_str() + .unwrap_or_else(|| panic!("{job_name} must have an if condition")); + assert!( + condition.contains("refs/tags/mcp-v"), + "{job_name} if-guard must check for 'refs/tags/mcp-v', not just 'refs/tags/': {condition}" + ); } - // There must be at least one `npm view` invocation for idempotent publish. +} + +// The CI build job must use the repo's pinned Rust toolchain, not @stable, +// so release binaries are compiled with the same compiler as local/CI builds. +#[test] +fn build_job_uses_pinned_rust_toolchain() { + let text = load_workflow_text(); + assert!( + !text.contains("rust-toolchain@stable"), + "workflow must not use rust-toolchain@stable; use the repo's rust-toolchain.toml version" + ); +} + +// Dockerfile.cross must pin cargo-zigbuild to a specific version +// to stay in sync with the CI workflow. +#[test] +fn dockerfile_pins_cargo_zigbuild_version() { + let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap(); + let dockerfile = std::fs::read_to_string(repo_root.join("src/simlin-mcp/Dockerfile.cross")) + .expect("read Dockerfile.cross"); assert!( - text.contains("npm view"), - "workflow must use `npm view` to check for already-published versions" + dockerfile + .lines() + .any(|line| line.contains("cargo-zigbuild@")), + "Dockerfile.cross must pin cargo-zigbuild to a specific version" ); } From 7ab70b498867955a02f739cdef91d30c46d1a778 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 13 Mar 2026 15:33:36 -0700 Subject: [PATCH 16/22] mcp: fix smoke test on arm64 Linux, clean up Dockerfile comment The cross-build smoke test tried to execute the x86_64-linux-musl binary on any Linux host, which fails on arm64. Now checks both uname -s and uname -m before attempting execution. Simplified the Dockerfile.cross comment to reference the test that enforces version parity with rust-toolchain.toml, removing the stale example version. --- src/simlin-mcp/Dockerfile.cross | 3 +-- src/simlin-mcp/scripts/cross-build.sh | 8 ++++---- src/simlin-mcp/tests/mcp_release_workflow.rs | 15 ++++++++++----- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/simlin-mcp/Dockerfile.cross b/src/simlin-mcp/Dockerfile.cross index d408bbd11..c2c3134fe 100644 --- a/src/simlin-mcp/Dockerfile.cross +++ b/src/simlin-mcp/Dockerfile.cross @@ -3,8 +3,7 @@ # Provides Rust + Zig + cargo-zigbuild for building Linux (musl) and # Windows (mingw) binaries from a single image. Used by scripts/cross-build.sh. -# Update RUST_VERSION to match the current stable Rust when building. -# Override at build time: docker build --build-arg RUST_VERSION=1.88.0 ... +# Keep in sync with rust-toolchain.toml (tested by dockerfile_rust_version_matches_toolchain) ARG RUST_VERSION=1.94.0 # Keep in sync with .github/workflows/mcp-release.yml ARG ZIG_VERSION=0.15.2 diff --git a/src/simlin-mcp/scripts/cross-build.sh b/src/simlin-mcp/scripts/cross-build.sh index 801f7a0f2..03bc2b392 100755 --- a/src/simlin-mcp/scripts/cross-build.sh +++ b/src/simlin-mcp/scripts/cross-build.sh @@ -59,9 +59,9 @@ if ! echo "$FILE_OUTPUT" | grep -q "ELF.*executable"; then exit 1 fi -# The execution test only works on Linux hosts since the binary targets -# x86_64-unknown-linux-musl. On macOS (or other non-Linux), skip it. -if [[ "$(uname -s)" == "Linux" ]]; then +# The execution test only works on x86_64 Linux since the binary targets +# x86_64-unknown-linux-musl. Skip on macOS, Windows, and arm64 Linux. +if [[ "$(uname -s)" == "Linux" && "$(uname -m)" == "x86_64" ]]; then echo "==> Verifying binary executes..." # simlin-mcp is a stdio MCP server that waits for input, so feed it empty # input with a timeout. timeout exits 124 when it kills a running process @@ -77,7 +77,7 @@ if [[ "$(uname -s)" == "Linux" ]]; then fi echo "Smoke test passed (binary executed, exit code $EXIT_CODE)" else - echo "==> Skipping execution smoke test (Linux binary cannot run on $(uname -s))" + echo "==> Skipping execution smoke test (x86_64-linux-musl binary cannot run on $(uname -s)/$(uname -m))" fi echo "" diff --git a/src/simlin-mcp/tests/mcp_release_workflow.rs b/src/simlin-mcp/tests/mcp_release_workflow.rs index 80b5f4f80..f42f0ab8a 100644 --- a/src/simlin-mcp/tests/mcp_release_workflow.rs +++ b/src/simlin-mcp/tests/mcp_release_workflow.rs @@ -310,14 +310,19 @@ fn dockerfile_pins_cargo_zigbuild_version() { ); } -// cross-build.sh must skip the execution smoke test on non-Linux hosts, -// since the output binary targets Linux and cannot run on macOS/Windows. +// cross-build.sh must skip the execution smoke test on non-x86_64-Linux hosts, +// since the output binary targets x86_64-unknown-linux-musl and cannot run on +// macOS, Windows, or arm64 Linux. #[test] -fn cross_build_script_skips_smoke_on_non_linux() { +fn cross_build_script_skips_smoke_on_incompatible_hosts() { let script_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("scripts/cross-build.sh"); let text = std::fs::read_to_string(&script_path).expect("read cross-build.sh"); assert!( - text.contains("uname"), - "cross-build.sh should detect the host OS to skip smoke tests on non-Linux" + text.contains("uname -s"), + "cross-build.sh should check the host OS" + ); + assert!( + text.contains("uname -m"), + "cross-build.sh should check the host CPU architecture (arm64 Linux cannot run x86_64 binaries)" ); } From 72c729a8ec4a7b5f93b7ccef71027f2fd45a3148 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 13 Mar 2026 15:45:51 -0700 Subject: [PATCH 17/22] mcp: add zig version sync test, fix windows dev hint Add test enforcing Zig version parity between Dockerfile.cross and mcp-release.yml, matching the existing Rust version parity test. Fix the JS launcher's error message for Windows dev fallback to suggest the MSVC vendor path, since cargo on Windows defaults to the MSVC toolchain. --- src/simlin-mcp/bin/simlin-mcp.js | 6 ++-- src/simlin-mcp/tests/mcp_release_workflow.rs | 34 ++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/simlin-mcp/bin/simlin-mcp.js b/src/simlin-mcp/bin/simlin-mcp.js index 3ef7fe0fa..b7b31459b 100755 --- a/src/simlin-mcp/bin/simlin-mcp.js +++ b/src/simlin-mcp/bin/simlin-mcp.js @@ -98,10 +98,12 @@ if (!binaryPath) { console.error( `Install the platform package: npm install ${platformInfo.package}`, ); + // For Windows, suggest the MSVC vendor path since cargo defaults to MSVC + const hintTriple = devFallbackTriple ?? platformInfo.triple; console.error( `Or for development, build with: cargo build -p simlin-mcp && ` + - `mkdir -p vendor/${platformInfo.triple} && ` + - `cp target/debug/simlin-mcp vendor/${platformInfo.triple}/simlin-mcp`, + `mkdir -p vendor/${hintTriple} && ` + + `cp target/debug/${binaryName} vendor/${hintTriple}/${binaryName}`, ); process.exit(1); } diff --git a/src/simlin-mcp/tests/mcp_release_workflow.rs b/src/simlin-mcp/tests/mcp_release_workflow.rs index f42f0ab8a..6e795d7a4 100644 --- a/src/simlin-mcp/tests/mcp_release_workflow.rs +++ b/src/simlin-mcp/tests/mcp_release_workflow.rs @@ -310,6 +310,40 @@ fn dockerfile_pins_cargo_zigbuild_version() { ); } +// Zig version in Dockerfile.cross must match the version in mcp-release.yml. +#[test] +fn dockerfile_zig_version_matches_workflow() { + let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap(); + + let dockerfile = std::fs::read_to_string(repo_root.join("src/simlin-mcp/Dockerfile.cross")) + .expect("read Dockerfile.cross"); + let docker_zig = dockerfile + .lines() + .find_map(|line| line.strip_prefix("ARG ZIG_VERSION=")) + .expect("Dockerfile.cross must have ARG ZIG_VERSION=..."); + + let workflow = load_workflow_text(); + // The workflow sets zig version like: version: '0.15.2' + let wf_zig = workflow + .lines() + .find_map(|line| { + let trimmed = line.trim(); + trimmed + .strip_prefix("version: '") + .and_then(|rest| rest.strip_suffix('\'')) + }) + .expect("workflow must have a zig version: '...' entry"); + + assert_eq!( + docker_zig, wf_zig, + "Dockerfile.cross ZIG_VERSION ({docker_zig}) must match mcp-release.yml ({wf_zig})" + ); +} + // cross-build.sh must skip the execution smoke test on non-x86_64-Linux hosts, // since the output binary targets x86_64-unknown-linux-musl and cannot run on // macOS, Windows, or arm64 Linux. From c7880736a38a583e8036bb40e5c0fa1662bcf545 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 13 Mar 2026 15:54:07 -0700 Subject: [PATCH 18/22] mcp: include rust-toolchain.toml in CI cache key A Rust toolchain bump without Cargo.lock changes would leave the cache key unchanged, potentially serving stale artifacts built with the old compiler. Including rust-toolchain.toml in the hashFiles call ensures toolchain version changes invalidate the build cache. --- .github/workflows/mcp-release.yml | 2 +- src/simlin-mcp/tests/mcp_release_workflow.rs | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/mcp-release.yml b/.github/workflows/mcp-release.yml index d2f69e520..3b6f7fd49 100644 --- a/.github/workflows/mcp-release.yml +++ b/.github/workflows/mcp-release.yml @@ -84,7 +84,7 @@ jobs: ~/.cargo/registry ~/.cargo/git target - key: cargo-mcp-${{ matrix.os }}-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }} + key: cargo-mcp-${{ matrix.os }}-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock', 'rust-toolchain.toml') }} restore-keys: | cargo-mcp-${{ matrix.os }}-${{ matrix.target }}- diff --git a/src/simlin-mcp/tests/mcp_release_workflow.rs b/src/simlin-mcp/tests/mcp_release_workflow.rs index 6e795d7a4..b447f83ff 100644 --- a/src/simlin-mcp/tests/mcp_release_workflow.rs +++ b/src/simlin-mcp/tests/mcp_release_workflow.rs @@ -310,6 +310,18 @@ fn dockerfile_pins_cargo_zigbuild_version() { ); } +// The build cache key must include rust-toolchain.toml so toolchain version +// bumps invalidate cached artifacts. +#[test] +fn build_cache_key_includes_toolchain() { + let text = load_workflow_text(); + assert!( + text.lines() + .any(|line| line.contains("hashFiles") && line.contains("rust-toolchain.toml")), + "build cache key must hash rust-toolchain.toml to invalidate on toolchain changes" + ); +} + // Zig version in Dockerfile.cross must match the version in mcp-release.yml. #[test] fn dockerfile_zig_version_matches_workflow() { From 347655428617610694cbf4949ed00567873dcb70 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 13 Mar 2026 16:03:40 -0700 Subject: [PATCH 19/22] mcp: clean root-owned dist files via Docker before rebuild Docker runs as root by default, leaving root-owned files in the bind-mounted dist/ directory. On a subsequent run, rm -rf fails for non-root users. Clean up via a lightweight Alpine container before recreating the directory. --- src/simlin-mcp/scripts/cross-build.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/simlin-mcp/scripts/cross-build.sh b/src/simlin-mcp/scripts/cross-build.sh index 03bc2b392..758256420 100755 --- a/src/simlin-mcp/scripts/cross-build.sh +++ b/src/simlin-mcp/scripts/cross-build.sh @@ -20,6 +20,11 @@ DIST_DIR="$MCP_DIR/dist" echo "==> Building cross-compilation toolchain image..." docker build -t "$IMAGE_NAME" -f "$MCP_DIR/Dockerfile.cross" "$MCP_DIR/" +# Previous Docker runs leave root-owned files in dist/; remove via Docker +# to avoid "Permission denied" when cleaning up as a non-root user. +if [ -d "$DIST_DIR" ]; then + docker run --rm -v "$DIST_DIR:/dist" alpine rm -rf /dist/* 2>/dev/null || true +fi rm -rf "$DIST_DIR" mkdir -p "$DIST_DIR" From f555c0d2f0a980259b7efc7f3ad1be0aa6cc344e Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 13 Mar 2026 16:14:10 -0700 Subject: [PATCH 20/22] mcp: add --locked to release builds in CI Without --locked, cargo silently updates the lockfile if Cargo.toml changed but Cargo.lock was not refreshed, publishing binaries from a dependency graph not represented by the tagged commit. Adding --locked ensures the build fails in that scenario. --- .github/workflows/mcp-release.yml | 4 ++-- src/simlin-mcp/tests/mcp_release_workflow.rs | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/.github/workflows/mcp-release.yml b/.github/workflows/mcp-release.yml index 3b6f7fd49..a3327e3ea 100644 --- a/.github/workflows/mcp-release.yml +++ b/.github/workflows/mcp-release.yml @@ -101,11 +101,11 @@ jobs: - name: Build (zigbuild) if: matrix.use-zigbuild - run: cargo zigbuild -p simlin-mcp --release --target ${{ matrix.target }} + run: cargo zigbuild -p simlin-mcp --locked --release --target ${{ matrix.target }} - name: Build (native) if: ${{ !matrix.use-zigbuild }} - run: cargo build -p simlin-mcp --release --target ${{ matrix.target }} + run: cargo build -p simlin-mcp --locked --release --target ${{ matrix.target }} - name: Upload binary uses: actions/upload-artifact@v4 diff --git a/src/simlin-mcp/tests/mcp_release_workflow.rs b/src/simlin-mcp/tests/mcp_release_workflow.rs index b447f83ff..be958365b 100644 --- a/src/simlin-mcp/tests/mcp_release_workflow.rs +++ b/src/simlin-mcp/tests/mcp_release_workflow.rs @@ -291,6 +291,24 @@ fn build_job_uses_pinned_rust_toolchain() { ); } +// Release builds must use --locked so a stale Cargo.lock causes a build +// failure rather than silently resolving different dependencies. +#[test] +fn release_builds_use_locked() { + let text = load_workflow_text(); + for (lineno, line) in text.lines().enumerate() { + if (line.contains("cargo zigbuild") || line.contains("cargo build")) + && line.contains("--release") + { + assert!( + line.contains("--locked"), + "line {} has a release build without --locked: {line}", + lineno + 1 + ); + } + } +} + // Dockerfile.cross must pin cargo-zigbuild to a specific version // to stay in sync with the CI workflow. #[test] From b750aee260cf9781eb0abd264dfb507562eab6bf Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 13 Mar 2026 16:27:04 -0700 Subject: [PATCH 21/22] mcp: fix docker cleanup glob, use structured YAML in zig test The Docker cleanup command passed /dist/* as a literal argument to rm since Docker exec doesn't invoke a shell. Wrap in sh -c for proper glob expansion. The Zig version parity test now navigates the parsed YAML to find the setup-zig step's version field, instead of fragile text matching on the first version: '...' line in the file. --- src/simlin-mcp/scripts/cross-build.sh | 2 +- src/simlin-mcp/tests/mcp_release_workflow.rs | 24 +++++++++++--------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/simlin-mcp/scripts/cross-build.sh b/src/simlin-mcp/scripts/cross-build.sh index 758256420..35c23eb45 100755 --- a/src/simlin-mcp/scripts/cross-build.sh +++ b/src/simlin-mcp/scripts/cross-build.sh @@ -23,7 +23,7 @@ docker build -t "$IMAGE_NAME" -f "$MCP_DIR/Dockerfile.cross" "$MCP_DIR/" # Previous Docker runs leave root-owned files in dist/; remove via Docker # to avoid "Permission denied" when cleaning up as a non-root user. if [ -d "$DIST_DIR" ]; then - docker run --rm -v "$DIST_DIR:/dist" alpine rm -rf /dist/* 2>/dev/null || true + docker run --rm -v "$DIST_DIR:/dist" alpine sh -c 'rm -rf /dist/*' 2>/dev/null || true fi rm -rf "$DIST_DIR" mkdir -p "$DIST_DIR" diff --git a/src/simlin-mcp/tests/mcp_release_workflow.rs b/src/simlin-mcp/tests/mcp_release_workflow.rs index be958365b..9ddf6e0b3 100644 --- a/src/simlin-mcp/tests/mcp_release_workflow.rs +++ b/src/simlin-mcp/tests/mcp_release_workflow.rs @@ -356,17 +356,19 @@ fn dockerfile_zig_version_matches_workflow() { .find_map(|line| line.strip_prefix("ARG ZIG_VERSION=")) .expect("Dockerfile.cross must have ARG ZIG_VERSION=..."); - let workflow = load_workflow_text(); - // The workflow sets zig version like: version: '0.15.2' - let wf_zig = workflow - .lines() - .find_map(|line| { - let trimmed = line.trim(); - trimmed - .strip_prefix("version: '") - .and_then(|rest| rest.strip_suffix('\'')) - }) - .expect("workflow must have a zig version: '...' entry"); + // Navigate the parsed YAML to find the Zig version in the setup-zig step, + // rather than doing fragile text matching on `version: '...'` lines. + let wf = load_workflow(); + let steps = wf["jobs"]["build"]["steps"] + .as_sequence() + .expect("build.steps should be a sequence"); + let zig_step = steps + .iter() + .find(|s| s["uses"].as_str().is_some_and(|u| u.contains("setup-zig"))) + .expect("build steps should include a setup-zig action"); + let wf_zig = zig_step["with"]["version"] + .as_str() + .expect("setup-zig step should have with.version"); assert_eq!( docker_zig, wf_zig, From 9aa8bdb7f04769510900631e76a701e93af1db60 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 13 Mar 2026 16:37:53 -0700 Subject: [PATCH 22/22] mcp: cache cargo install metadata, add --locked to cross-build Include .crates.toml and .crates2.json in the CI cache so that cargo install can detect already-installed tools on cache hits. Add --locked to cross-build.sh for parity with the CI workflow, catching lockfile drift during local builds. --- .github/workflows/mcp-release.yml | 2 ++ src/simlin-mcp/scripts/cross-build.sh | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/mcp-release.yml b/.github/workflows/mcp-release.yml index a3327e3ea..0ddda7c25 100644 --- a/.github/workflows/mcp-release.yml +++ b/.github/workflows/mcp-release.yml @@ -81,6 +81,8 @@ jobs: with: path: | ~/.cargo/bin + ~/.cargo/.crates.toml + ~/.cargo/.crates2.json ~/.cargo/registry ~/.cargo/git target diff --git a/src/simlin-mcp/scripts/cross-build.sh b/src/simlin-mcp/scripts/cross-build.sh index 35c23eb45..721116393 100755 --- a/src/simlin-mcp/scripts/cross-build.sh +++ b/src/simlin-mcp/scripts/cross-build.sh @@ -39,7 +39,7 @@ docker run --rm \ cd /src for target in x86_64-unknown-linux-musl aarch64-unknown-linux-musl x86_64-pc-windows-gnu; do echo "--- Building $target ---" - cargo zigbuild -p simlin-mcp --release --target "$target" + cargo zigbuild -p simlin-mcp --locked --release --target "$target" mkdir -p "/dist/$target" if [[ "$target" == *windows* ]]; then cp "/tmp/target/$target/release/simlin-mcp.exe" "/dist/$target/"