From c5df5ad0459dd4cec135b433d073557abadea0e9 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 25 Aug 2026 05:57:32 +0200 Subject: [PATCH 1/2] feat(mcp): add GoReleaser + release workflow + MCPB bundle for cudly-mcp Adds the release machinery for cmd/cudly-mcp (Phase B4) and the MCPB desktop-extension bundle (Phase C), both machinery only -- no tag is created and nothing is published by this commit. .goreleaser.yml builds static (CGO_ENABLED=0) darwin/linux, amd64/arm64 binaries with the version ldflag, uploaded raw (no compression) so release.yml's MCPB step can consume them directly. .github/workflows/release.yml, on a v* tag push: a consistency gate (server.json and mcpb/manifest.json versions both match the tag, fails loud, never rewrites either file) -> go test -> GoReleaser -> MCPB pack (full edition, all 11 tools) uploaded as an extra release asset -> mcp-publisher publish to the MCP Registry via GitHub OIDC. The consistency check deliberately duplicates .github/workflows/mcp-server-json.yml's own tag-check rather than a workflow_call, so the two workflows (added in separate PRs) stay independently mergeable. actionlint and zizmor are both clean. mcpb/manifest.json is the MCPB manifest: server.type "binary", a user_config enable_real_purchases boolean (default false) mapped to CUDLY_MCP_ENABLE_REAL_PURCHASES, and a placeholder https://cudly.io/privacy privacy_policies entry (needs a real page before any directory submission -- see PR description). MCPB's platform_overrides differentiate by OS only, not CPU architecture, so mcpb/server/{darwin,linux}-launch.sh each pick the right amd64/arm64 binary via `uname -m` at launch -- the binaries themselves are staged by release.yml, never committed (see .gitignore). Verified end to end locally: `goreleaser release --snapshot --skip=publish` produces working binaries; `mcpb pack` on the real (non-snapshot) output produces a valid, schema-passing bundle; unpacking it and launching darwin-launch.sh through an MCP client reports the injected version and lists all 11 tools correctly. This branch was cut from the same origin/main commit as the two sibling store-readiness PRs, so `server.json` (PR "server.json for the MCP Registry") and the exported `Version` symbol GoReleaser's ldflag targets (PR "inject build version into cudly-mcp") both need to land on main before release.yml is actually runnable -- verified locally by temporarily cherry-picking that commit, confirmed working, then reverted before committing here (see PR description for the reproduction). --- .github/workflows/release.yml | 219 ++++++++++++++++++++++++++++++++++ .gitignore | 11 ++ .goreleaser.yml | 50 ++++++++ mcpb/manifest.json | 54 +++++++++ mcpb/server/darwin-launch.sh | 21 ++++ mcpb/server/linux-launch.sh | 19 +++ 6 files changed, 374 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 .goreleaser.yml create mode 100644 mcpb/manifest.json create mode 100755 mcpb/server/darwin-launch.sh create mode 100755 mcpb/server/linux-launch.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..245552660 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,219 @@ +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). + +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 + 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 + 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 + + - name: Pack the MCPB bundle (full edition -- all tools) + run: npx --yes "@anthropic-ai/mcpb@${MCPB_CLI_VERSION}" pack mcpb 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 + sha=$(sha256sum cudly-mcp-full.mcpb | cut -d' ' -f1) + # --repo omitted: gh infers it from this checkout's git remote. + gh release upload "$RELEASE_TAG" cudly-mcp-full.mcpb --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 + 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. + - 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 + jq --arg sha "$MCPB_SHA256" \ + --arg url "$MCPB_ASSET_URL" \ + '.packages[0].fileSha256 = $sha | .packages[0].identifier = $url' \ + server.json > server.json.tmp + mv server.json.tmp 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" "$@" From 9afa221b5396fa4ee1e9d8d74d0e4ae6c997e900 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 27 Aug 2026 14:45:18 +0200 Subject: [PATCH 2/2] fix(mcp): address CR findings on release.yml -- environment gate + tmp files CodeRabbit's 23:44Z review on #1893 flagged two issues: Security (major, heavy lift): the goreleaser/mcpb/publish-registry jobs hold contents:write/id-token:write on this repo's first tag-triggered workflow, and nothing restricts who can push a v* tag. Bind the three jobs to a `release` environment now (the code-side half, matching this repo's existing documented pattern in cleanup-staging.yml) and file #1896 to track the two out-of-band admin actions that make it a real gate: required reviewers on that environment, and a v* tag-protection ruleset (neither is expressible in the workflow file itself -- see #1660 for the established precedent on the environment-reviewer half). Maintainability (minor, quick win): move the MCPB pack output and the server.json patch scratch file from the checkout root to $RUNNER_TEMP. The final server.json still lands at the checkout root deliberately -- mcp-publisher publish reads ./server.json from the working directory -- only the intermediate files moved. actionlint, zizmor (default + pedantic), and goreleaser check all stay clean after the change. --- .github/workflows/release.yml | 37 +++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 245552660..753fd1dfc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,6 +20,18 @@ name: Release cudly-mcp # .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: @@ -89,6 +101,7 @@ jobs: 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: @@ -125,6 +138,7 @@ jobs: 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: @@ -162,8 +176,12 @@ jobs: 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 cudly-mcp-full.mcpb + 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 @@ -173,15 +191,17 @@ jobs: REPO_SLUG: ${{ github.repository }} run: | set -euo pipefail - sha=$(sha256sum cudly-mcp-full.mcpb | cut -d' ' -f1) + 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" cudly-mcp-full.mcpb --clobber + 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 @@ -194,18 +214,23 @@ jobs: # 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. + # 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 > server.json.tmp - mv server.json.tmp server.json + server.json > "$patched" + mv "$patched" server.json - name: Install mcp-publisher run: |