diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..753fd1dfc --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,244 @@ +name: Release cudly-mcp + +# Triggered by pushing a v* tag (see mcp/README.md and +# docs/plans/mcp/05-store.md Phase B4). 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) -> MCPB pack (full edition, all tools) 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 (they land as separate PRs). +# +# 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 +# per #1660's finding (which this repeats for tags, not environments): an +# `environment:` binding alone is NOT a reviewer gate until protection rules +# are configured out-of-band, and GitHub auto-creates a referenced +# environment bare (no reviewers) on first use. Nothing today also restricts +# who can push a `v*` tag -- the repo has a branch ruleset +# (protect-default-branch) but no tag ruleset. Tracked in #1896: 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*"] + +permissions: + contents: read + +env: + MCPB_CLI_VERSION: "2.1.2" # @anthropic-ai/mcpb on npm; pinned, never @latest + +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 ./mcp/... ./cmd/cudly-mcp/... + + goreleaser: + needs: test + runs-on: ubuntu-latest + environment: release # see the SECURITY note above and #1896 + permissions: + contents: write # create the GitHub Release and upload its binaries + 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 }} + + mcpb: + needs: goreleaser + runs-on: ubuntu-latest + environment: release # see the SECURITY note above and #1896 + permissions: + contents: write # upload the .mcpb bundle as an extra release asset + 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: 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 #1896 + 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 + + - name: Install mcp-publisher + run: | + curl -L "https://github.com/modelcontextprotocol/registry/releases/download/v1.8.1/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" \ + | tar xz mcp-publisher + + - name: Authenticate to the MCP Registry (GitHub OIDC) + run: ./mcp-publisher login github-oidc + + - name: Publish to the MCP Registry + run: ./mcp-publisher publish diff --git a/.gitignore b/.gitignore index 8465ec5af..5cd561cf3 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 000000000..0791445f5 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,50 @@ +# GoReleaser config for cmd/cudly-mcp (see mcp/README.md and +# docs/plans/mcp/05-store.md Phase B4). This repo hosts several independent +# tools under one module; this config -- and the v* tag trigger in +# .github/workflows/release.yml -- is scoped to cudly-mcp only. There is +# nothing else in this repo's release surface yet. +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 pack step +# reads them straight out of dist/ (no download/extract round trip needed). +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 across this repo's unrelated tools, most of which have +# nothing to do with cudly-mcp. Disabled rather than misleading. +changelog: + disable: true + +release: + github: + owner: LeanerCloud + name: CUDly + # 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 000000000..a325c1928 --- /dev/null +++ b/mcpb/manifest.json @@ -0,0 +1,54 @@ +{ + "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/CUDly" + }, + "license": "OSL-3.0", + "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 000000000..f190fe2a7 --- /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 000000000..b09c042cf --- /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" "$@"