diff --git a/.github/workflows/mcp-release.yml b/.github/workflows/mcp-release.yml new file mode 100644 index 000000000..0ddda7c25 --- /dev/null +++ b/.github/workflows/mcp-release.yml @@ -0,0 +1,234 @@ +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: 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@master + with: + toolchain: ${{ steps.rust-version.outputs.version }} + targets: ${{ matrix.target }} + + - name: Cache Cargo artifacts + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin + ~/.cargo/.crates.toml + ~/.cargo/.crates2.json + ~/.cargo/registry + ~/.cargo/git + target + key: cargo-mcp-${{ matrix.os }}-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock', 'rust-toolchain.toml') }} + restore-keys: | + cargo-mcp-${{ matrix.os }}-${{ matrix.target }}- + + - name: Install Zig + 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 + 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 --locked --release --target ${{ matrix.target }} + + - name: Build (native) + if: ${{ !matrix.use-zigbuild }} + run: cargo build -p simlin-mcp --locked --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/mcp-v') + 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: | + 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: | + 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: | + 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: | + 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 + needs: [validate, publish-platform] + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/mcp-v') + 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: | + 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/.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 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/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/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`. 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` | -- | 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 diff --git a/src/simlin-mcp/Cargo.toml b/src/simlin-mcp/Cargo.toml index c181e9ff4..00260ec17 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_yml = "0.0.12" diff --git a/src/simlin-mcp/Dockerfile.cross b/src/simlin-mcp/Dockerfile.cross new file mode 100644 index 000000000..c2c3134fe --- /dev/null +++ b/src/simlin-mcp/Dockerfile.cross @@ -0,0 +1,31 @@ +# 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. + +# 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 + +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 + +# 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 \ + aarch64-unknown-linux-musl \ + x86_64-pc-windows-gnu + +WORKDIR /src diff --git a/src/simlin-mcp/bin/simlin-mcp.js b/src/simlin-mcp/bin/simlin-mcp.js index 952c45082..b7b31459b 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", }, }; @@ -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}`, @@ -85,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/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 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" + } } diff --git a/src/simlin-mcp/scripts/cross-build.sh b/src/simlin-mcp/scripts/cross-build.sh new file mode 100755 index 000000000..721116393 --- /dev/null +++ b/src/simlin-mcp/scripts/cross-build.sh @@ -0,0 +1,89 @@ +#!/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/" + +# 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 sh -c 'rm -rf /dist/*' 2>/dev/null || true +fi +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 --locked --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. +echo "" +echo "==> Smoke test: Linux x64 binary..." +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 + +# 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 + # (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 (x86_64-linux-musl binary cannot run on $(uname -s)/$(uname -m))" +fi + +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 0a93c1b49..5c430edef 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,72 @@ 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 + ); } + + // 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 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 dev fallback should also try the msvc triple for Windows" + ); +} + +#[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..9ddf6e0b3 --- /dev/null +++ b/src/simlin-mcp/tests/mcp_release_workflow.rs @@ -0,0 +1,394 @@ +// 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_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() + .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_yml::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. +// 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(); + 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. +// 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(); + 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}" + ); + } +} + +// 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" + ); +} + +// 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] +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!( + dockerfile + .lines() + .any(|line| line.contains("cargo-zigbuild@")), + "Dockerfile.cross must pin cargo-zigbuild to a specific 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() { + 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=..."); + + // 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, + "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. +#[test] +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 -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)" + ); +}