diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a720644 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,296 @@ +name: Release cudly-mcp + +# Triggered by pushing a v* tag. This workflow is machinery only as added: +# no tag has been created and nothing has been published by the PR that +# introduces this file. The pipeline was instead verified locally -- +# `goreleaser release --snapshot --clean --skip=publish` for the build, and +# `npx @anthropic-ai/mcpb pack mcpb` against locally staged placeholder +# binaries for the bundle step -- see that PR's description for the exact +# commands and output. +# +# Pipeline: consistency gate (server.json and mcpb/manifest.json versions +# both match the tag -- fails loud, never silently rewrites either file) -> +# test -> GoReleaser (raw darwin/linux, amd64/arm64 binaries, GitHub +# Release, each attested with actions/attest-build-provenance) -> MCPB pack +# (full edition, all tools, also attested) uploaded as an extra release +# asset -> mcp-publisher publish to the MCP Registry via GitHub OIDC (no +# long-lived registry secret). +# +# The tag-version check here deliberately duplicates +# .github/workflows/mcp-server-json.yml's own check rather than calling it +# via workflow_call, so the two workflows stay independently triggerable, +# reviewable, and mergeable. +# +# SECURITY: this is the first tag-triggered workflow in this repo, and its +# publishing jobs (goreleaser, mcpb, publish-registry) hold `contents: write` +# / `id-token: write`. They are bound to the `release` environment below, +# but that binding alone is NOT a reviewer gate until protection rules are +# configured out-of-band -- GitHub auto-creates a referenced environment +# bare (no reviewers) on first use. This repo currently has zero rulesets +# of any kind, so nothing today restricts who can push a `v*` tag either. +# Tracked in #3: configuring required reviewers on `release` and adding a +# `v*` tag-protection ruleset are both repo-admin actions outside what this +# workflow file can enforce. + +on: + push: + tags: ["v*"] + +# One release pipeline per ref at a time. cancel-in-progress stays false: a +# half-finished GoReleaser run (partial GitHub Release, partial registry +# publish) is worse than a queued duplicate for the same tag. +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +env: + MCPB_CLI_VERSION: "2.1.2" # @anthropic-ai/mcpb on npm; pinned, never @latest + MCP_PUBLISHER_VERSION: "1.8.1" # modelcontextprotocol/registry release tag (no leading "v") + +jobs: + consistency-gate: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false + + - name: Assert server.json and mcpb/manifest.json versions match the pushed tag + run: | + set -euo pipefail + tag="${GITHUB_REF#refs/tags/v}" + + server_version=$(jq -r '.version' server.json) + if [[ "$tag" != "$server_version" ]]; then + echo "::error::server.json version ($server_version) does not match tag v$tag." \ + "Bump server.json's version (and packages[].identifier/fileSha256 for the MCPB entry) in the release PR before tagging." + exit 1 + fi + + manifest_version=$(jq -r '.version' mcpb/manifest.json) + if [[ "$tag" != "$manifest_version" ]]; then + echo "::error::mcpb/manifest.json version ($manifest_version) does not match tag v$tag." + exit 1 + fi + + echo "server.json and mcpb/manifest.json both match tag v$tag" + + test: + needs: consistency-gate + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false + + - name: Setup Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + # This workflow only runs off a v* tag push, so its runtime cache + # would be a persistent, cross-run artifact reachable by anything + # that can push a tag -- disable it rather than risk poisoning a + # release build's module cache. + cache: false + + - name: Test the MCP server + run: go test -race -short ./... + + goreleaser: + needs: test + runs-on: ubuntu-latest + environment: release # see the SECURITY note above and #3 + permissions: + contents: write # create the GitHub Release and upload its binaries + attestations: write # actions/attest-build-provenance below + id-token: write # attest-build-provenance's Sigstore/OIDC signing + outputs: + tag: ${{ steps.tag.outputs.tag }} + steps: + - name: Checkout + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + fetch-depth: 0 # GoReleaser needs full history/tags for its changelog and git-state checks + persist-credentials: false + + - id: tag + run: echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" + + - name: Setup Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + # This workflow only runs off a v* tag push, so its runtime cache + # would be a persistent, cross-run artifact reachable by anything + # that can push a tag -- disable it rather than risk poisoning a + # release build's module cache. + cache: false + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0 + with: + distribution: goreleaser + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Attest build provenance for the released binaries + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: "dist/cudly-mcp_*/cudly-mcp" + + mcpb: + needs: goreleaser + runs-on: ubuntu-latest + environment: release # see the SECURITY note above and #3 + permissions: + contents: write # upload the .mcpb bundle as an extra release asset + attestations: write # actions/attest-build-provenance below + id-token: write # attest-build-provenance's Sigstore/OIDC signing + outputs: + sha256: ${{ steps.pack.outputs.sha256 }} + asset_url: ${{ steps.pack.outputs.asset_url }} + steps: + - name: Checkout + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false + + - name: Download this release's GoReleaser binaries + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ needs.goreleaser.outputs.tag }} + run: | + set -euo pipefail + mkdir -p /tmp/cudly-mcp-bin + # --repo omitted: gh infers it from this checkout's git remote. + gh release download "$RELEASE_TAG" \ + --pattern 'cudly-mcp_*' --dir /tmp/cudly-mcp-bin + + - name: Verify downloaded binaries against GoReleaser's own checksums + run: | + set -euo pipefail + cd /tmp/cudly-mcp-bin + sha256sum -c cudly-mcp_checksums.txt + + - name: Stage per-platform binaries into the MCPB bundle layout + run: | + set -euo pipefail + for combo in darwin_amd64 darwin_arm64 linux_amd64 linux_arm64; do + dir="mcpb/server/${combo/_/-}" + mkdir -p "$dir" + install -m 0755 "/tmp/cudly-mcp-bin/cudly-mcp_${combo}" "$dir/cudly-mcp" + done + + # Packed under $RUNNER_TEMP, not the checkout root: this is a scratch + # release artifact, not source, and every step below in this same job + # can reach it by the same literal path (no cross-job propagation -- + # $GITHUB_ENV isn't needed within a single job). + - name: Pack the MCPB bundle (full edition -- all tools) + run: npx --yes "@anthropic-ai/mcpb@${MCPB_CLI_VERSION}" pack mcpb "$RUNNER_TEMP/cudly-mcp-full.mcpb" + + - name: Attest build provenance for the MCPB bundle + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: "${{ runner.temp }}/cudly-mcp-full.mcpb" + + - name: Upload the MCPB bundle to the GitHub Release + id: pack + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ needs.goreleaser.outputs.tag }} + REPO_SLUG: ${{ github.repository }} + run: | + set -euo pipefail + bundle="$RUNNER_TEMP/cudly-mcp-full.mcpb" + sha=$(sha256sum "$bundle" | cut -d' ' -f1) + # --repo omitted: gh infers it from this checkout's git remote. + gh release upload "$RELEASE_TAG" "$bundle" --clobber + echo "sha256=$sha" >> "$GITHUB_OUTPUT" + echo "asset_url=https://github.com/${REPO_SLUG}/releases/download/${RELEASE_TAG}/cudly-mcp-full.mcpb" >> "$GITHUB_OUTPUT" + + publish-registry: + needs: mcpb + runs-on: ubuntu-latest + environment: release # see the SECURITY note above and #3 + permissions: + id-token: write # GitHub OIDC auth to the MCP Registry -- no long-lived secret + contents: read + steps: + - name: Checkout + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false + + # server.json in git carries a placeholder fileSha256/identifier (see + # its own comment history) until a real release exists to hash; patch + # in the values this run just produced before publishing. This never + # writes back to the repository -- only the ephemeral runner's checkout + # -- and the final `server.json` intentionally stays at the checkout + # root: mcp-publisher publish reads ./server.json from the working + # directory by default. Only the scratch intermediate file lives under + # $RUNNER_TEMP. + - name: Patch server.json with this release's MCPB asset + env: + MCPB_SHA256: ${{ needs.mcpb.outputs.sha256 }} + MCPB_ASSET_URL: ${{ needs.mcpb.outputs.asset_url }} + run: | + set -euo pipefail + patched="$RUNNER_TEMP/server.json.tmp" + jq --arg sha "$MCPB_SHA256" \ + --arg url "$MCPB_ASSET_URL" \ + '.packages[0].fileSha256 = $sha | .packages[0].identifier = $url' \ + server.json > "$patched" + mv "$patched" server.json + + # cosign is not part of the documented ubuntu-latest toolset, so it + # must be installed explicitly rather than assumed present. + - name: Install cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + with: + cosign-release: "v3.0.6" + + # This job holds id-token: write (OIDC to the MCP Registry), so the + # binary it execs must be verified, not just downloaded. This runner + # is always ubuntu-latest (see runs-on above), so only the + # linux_amd64 asset is ever needed -- pinned by sha256 from upstream's + # own registry_${MCP_PUBLISHER_VERSION}_checksums.txt (verified by + # re-downloading and hashing the asset directly, not just eyeballing + # the checksums file), plus a Sigstore/cosign verify-blob check + # against the GitHub Actions OIDC identity that signed that release. + - name: Install mcp-publisher + env: + MCP_PUBLISHER_SHA256: a06c9096dcb9727c13555b6be26c7effa707b01f06a4c561ba7a3635443cf2cc # mcp-publisher_linux_amd64.tar.gz, registry v1.8.1 + run: | + set -euo pipefail + asset="mcp-publisher_linux_amd64.tar.gz" + base_url="https://github.com/modelcontextprotocol/registry/releases/download/v${MCP_PUBLISHER_VERSION}" + archive="$RUNNER_TEMP/$asset" + bundle="$RUNNER_TEMP/$asset.sigstore.json" + + curl -fsSL -o "$archive" "$base_url/$asset" + echo "${MCP_PUBLISHER_SHA256} $archive" | sha256sum -c - + + curl -fsSL -o "$bundle" "$base_url/$asset.sigstore.json" + cosign verify-blob "$archive" \ + --bundle "$bundle" \ + --certificate-identity "https://github.com/modelcontextprotocol/registry/.github/workflows/release.yml@refs/tags/v${MCP_PUBLISHER_VERSION}" \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" + + tar xz -C "$RUNNER_TEMP" -f "$archive" mcp-publisher + + - name: Authenticate to the MCP Registry (GitHub OIDC) + run: '"$RUNNER_TEMP/mcp-publisher" login github-oidc' + + - name: Publish to the MCP Registry + run: '"$RUNNER_TEMP/mcp-publisher" publish' diff --git a/.gitignore b/.gitignore index 8465ec5..5cd561c 100644 --- a/.gitignore +++ b/.gitignore @@ -150,3 +150,14 @@ docs/generated/ # Graphify knowledge graph output — regenerated locally, not committed graphify-out/ + +# MCPB bundle staging: release.yml stages GoReleaser's per-platform +# cudly-mcp binaries into mcpb/server/-/ before packing (see +# .github/workflows/release.yml). Only the two launch scripts are checked +# in; the binaries are release artifacts, never source. +mcpb/server/*/cudly-mcp +mcpb/server/*/cudly-mcp.exe +*.mcpb + +# GoReleaser's local output directory (see .goreleaser.yml) +/dist/ diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..f9dd241 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,50 @@ +# GoReleaser config for cmd/cudly-mcp (see README.md). This repo hosts a +# single Go module dedicated to cudly-mcp -- there is nothing else in this +# repo's release surface. +version: 2 + +project_name: cudly-mcp + +builds: + - id: cudly-mcp + main: ./cmd/cudly-mcp + binary: cudly-mcp + env: + - CGO_ENABLED=0 + goos: + - darwin + - linux + goarch: + - amd64 + - arm64 + ldflags: + - -s -w -X main.Version={{ .Version }} + +# formats: ["binary"] skips compression and uploads the raw per-platform +# executables directly to the GitHub Release -- release.yml's mcpb job runs +# in a separate job (and runner) from this one, so it fetches them back via +# `gh release download` rather than reading dist/ directly. +archives: + - id: cudly-mcp + ids: [cudly-mcp] + formats: [binary] + name_template: "cudly-mcp_{{ .Os }}_{{ .Arch }}" + +checksum: + name_template: "cudly-mcp_checksums.txt" + algorithm: sha256 + +# Per-tag release notes: the default changelog would pull in every commit +# since the last tag, which is fine now that this repo is scoped to +# cudly-mcp alone -- kept disabled anyway since there is no changelog +# convention established yet for this repo's tags. +changelog: + disable: true + +release: + github: + owner: LeanerCloud + name: cloud-commitments-mcp + # release.yml also uploads the MCPB bundle(s) as extra release assets + # after this step runs; GoReleaser only produces the raw binaries here. + mode: append diff --git a/mcpb/manifest.json b/mcpb/manifest.json new file mode 100644 index 0000000..0fa0582 --- /dev/null +++ b/mcpb/manifest.json @@ -0,0 +1,57 @@ +{ + "manifest_version": "0.3", + "name": "cudly-mcp", + "version": "0.1.0", + "description": "Search and buy AWS/Azure/GCP reserved capacity (RIs, Savings Plans, CUDs) from Claude Desktop.", + "author": { + "name": "LeanerCloud", + "url": "https://cudly.io" + }, + "repository": { + "type": "git", + "url": "https://github.com/LeanerCloud/cloud-commitments-mcp" + }, + "license": "OSL-3.0", + "compatibility": { + "platforms": ["darwin", "linux"] + }, + "server": { + "type": "binary", + "entry_point": "server/linux-launch.sh", + "mcp_config": { + "command": "${__dirname}/server/linux-launch.sh", + "args": [], + "env": { + "CUDLY_MCP_ENABLE_REAL_PURCHASES": "${user_config.enable_real_purchases}" + }, + "platform_overrides": { + "darwin": { + "command": "${__dirname}/server/darwin-launch.sh", + "args": [], + "env": { + "CUDLY_MCP_ENABLE_REAL_PURCHASES": "${user_config.enable_real_purchases}" + } + }, + "linux": { + "command": "${__dirname}/server/linux-launch.sh", + "args": [], + "env": { + "CUDLY_MCP_ENABLE_REAL_PURCHASES": "${user_config.enable_real_purchases}" + } + } + } + } + }, + "user_config": { + "enable_real_purchases": { + "type": "boolean", + "title": "Enable real purchases", + "description": "DANGER: turns on cudly-mcp's ability to execute REAL, money-spending cloud purchases (AWS Reserved Instances, Savings Plans, Azure Reservations, GCP CUDs) when a client explicitly requests dry_run=false and confirm=true. Leave this off until you have exercised the dry-run path and are ready to let it spend real money.", + "default": false, + "required": false + } + }, + "privacy_policies": [ + "https://cudly.io/privacy" + ] +} diff --git a/mcpb/server/darwin-launch.sh b/mcpb/server/darwin-launch.sh new file mode 100755 index 0000000..f190fe2 --- /dev/null +++ b/mcpb/server/darwin-launch.sh @@ -0,0 +1,21 @@ +#!/bin/sh +# Selects the right arch-specific cudly-mcp binary at launch. The MCPB +# manifest format's platform_overrides differentiate by OS only (darwin, +# linux, win32) -- there is no architecture-level template variable -- so +# each OS gets one of these tiny wrapper scripts to bridge the gap between +# a single bundled command and the amd64/arm64 binaries GoReleaser produces. +set -eu + +dir=$(cd "$(dirname "$0")" && pwd) +arch=$(uname -m) + +case "$arch" in + arm64) bin="$dir/darwin-arm64/cudly-mcp" ;; + x86_64) bin="$dir/darwin-amd64/cudly-mcp" ;; + *) + echo "cudly-mcp: unsupported macOS architecture: $arch" >&2 + exit 1 + ;; +esac + +exec "$bin" "$@" diff --git a/mcpb/server/linux-launch.sh b/mcpb/server/linux-launch.sh new file mode 100755 index 0000000..b09c042 --- /dev/null +++ b/mcpb/server/linux-launch.sh @@ -0,0 +1,19 @@ +#!/bin/sh +# Selects the right arch-specific cudly-mcp binary at launch. See +# darwin-launch.sh for why this exists: MCPB's platform_overrides +# differentiate by OS only, not architecture. +set -eu + +dir=$(cd "$(dirname "$0")" && pwd) +arch=$(uname -m) + +case "$arch" in + aarch64|arm64) bin="$dir/linux-arm64/cudly-mcp" ;; + x86_64) bin="$dir/linux-amd64/cudly-mcp" ;; + *) + echo "cudly-mcp: unsupported Linux architecture: $arch" >&2 + exit 1 + ;; +esac + +exec "$bin" "$@"