From d38a895003785ef020944c4fa8745b0c778a8f4c Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sun, 27 Sep 2026 22:39:31 +0200 Subject: [PATCH 1/4] feat(release): add GoReleaser + release workflow + MCPB bundle for cudly-mcp Ports LeanerCloud/reserved-instances-cli#1893 into this split repo, where the MCP server now lives at the repo root and cmd/cudly-mcp is unchanged. - .goreleaser.yml: builds static (CGO_ENABLED=0) darwin/linux, amd64/arm64 binaries of ./cmd/cudly-mcp with the version ldflag, uploaded raw so the MCPB pack step can consume them directly; release.github.name now points at cloud-commitments-mcp. - .github/workflows/release.yml, on a v* tag push: consistency gate (server.json and mcpb/manifest.json versions both match the tag) -> 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. Carries forward the original PR's CR-driven hardening: the three publishing jobs (contents:write/id-token:write) are bound to an `environment: release`, and scratch files (the packed .mcpb, the patched server.json) live under $RUNNER_TEMP instead of the checkout root. - mcpb/manifest.json + mcpb/server/{darwin,linux}-launch.sh: the MCPB desktop-extension bundle. repository.url now points at cloud-commitments-mcp; the per-OS launch scripts are unchanged (MCPB's platform_overrides differentiate by OS only, not architecture). - .gitignore: ignore the staged per-arch binaries under mcpb/server/, packed *.mcpb files, and GoReleaser's /dist/ output. Path/reference adjustments from the monorepo version: - release.yml's test job runs `go test ./...` instead of `./mcp/... ./cmd/cudly-mcp/...` -- this repo's module *is* the MCP server now, so `./...` already covers both cmd/cudly-mcp and the rest. - .goreleaser.yml and mcpb/manifest.json point at github.com/LeanerCloud/cloud-commitments-mcp instead of github.com/LeanerCloud/CUDly. - Dropped comment references to mcp/README.md and docs/plans/mcp/05-store.md, which weren't carried over by the split. - The environment/tag-ruleset gap CodeRabbit raised on the original PR (environment:release alone isn't a reviewer gate; no v* tag ruleset exists) is still open here -- verified via `gh api repos/LeanerCloud/cloud-commitments-mcp/rulesets` (empty) and `.../environments` (empty). Re-filed as #3 (the monorepo tracked it as reserved-instances-cli#1896, which doesn't carry over). Verified locally: - `goreleaser check` -- config valid - `goreleaser release --snapshot --clean --skip=publish` -- builds all 4 binaries - `actionlint .github/workflows/release.yml` -- clean - `zizmor .github/workflows/release.yml` -- 0 findings (default persona); only informational/low findings under --persona pedantic (job naming, missing concurrency group), same as upstream - `GOWORK=off go build ./... && go vet ./...` -- clean - `GOWORK=off go test -race -short ./...` -- 338 passed, 3 packages - `npx @anthropic-ai/mcpb@2.1.2 validate mcpb/manifest.json` -- passes - `npx @anthropic-ai/mcpb@2.1.2 pack mcpb` against the real (non-snapshot) GoReleaser output -- produces a valid 95.2MB bundle - Unpacked the bundle and drove server/darwin-launch.sh through a real MCP stdio handshake: reports the injected snapshot version and lists all 11 tools correctly The release workflow cannot publish from a PR: it triggers only on `v*` tag pushes (no pull_request trigger), and no tag has been pushed by this change. Co-Authored-By: claude-flow --- .github/workflows/release.yml | 242 ++++++++++++++++++++++++++++++++++ .gitignore | 11 ++ .goreleaser.yml | 49 +++++++ mcpb/manifest.json | 54 ++++++++ mcpb/server/darwin-launch.sh | 21 +++ mcpb/server/linux-launch.sh | 19 +++ 6 files changed, 396 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 0000000..32675e6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,242 @@ +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) -> 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. +# +# 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*"] + +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 ./... + + 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 + 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 #3 + 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 #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 + + - 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 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..215b258 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,49 @@ +# 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 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, 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..48df320 --- /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/cloud-commitments-mcp" + }, + "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 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" "$@" From 2c6f7879df671d679391ac7133d210814a8e7801 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sun, 27 Sep 2026 22:55:58 +0200 Subject: [PATCH 2/4] fix(mcpb): declare darwin/linux-only platform compatibility CodeRabbit's review on #4 flagged that the manifest has no Windows binary or win32 platform_override, but MCPB treats an omitted compatibility.platforms list as "supports every platform" -- so a Windows user installing this bundle would have Claude Desktop select the Linux launcher and fail to start the server. Add `compatibility.platforms: ["darwin", "linux"]` to mcpb/manifest.json, matching the two launch scripts and the GoReleaser build matrix (both darwin/linux only). Re-validated with `npx @anthropic-ai/mcpb@2.1.2 validate mcpb/manifest.json`. Co-Authored-By: claude-flow --- mcpb/manifest.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mcpb/manifest.json b/mcpb/manifest.json index 48df320..0fa0582 100644 --- a/mcpb/manifest.json +++ b/mcpb/manifest.json @@ -12,6 +12,9 @@ "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", From 77c9f210f16554973f870da0648ebb145c876268 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 28 Sep 2026 00:09:50 +0200 Subject: [PATCH 3/4] fix(release): pin+verify mcp-publisher, attest artifacts, add concurrency Addresses an independent review of PR #4: 1. mcp-publisher install (release.yml): `curl -L | tar xz` had no integrity check on a binary executed by a job holding id-token: write. Switched to `curl -fsSL` into $RUNNER_TEMP, verify against a sha256 pinned from upstream's own registry_1.8.1_checksums.txt (cross-checked by re-downloading and hashing the linux_amd64 asset directly, not just reading the checksums file), then additionally verify the release's Sigstore bundle with `cosign verify-blob` against the exact GitHub Actions OIDC identity that signed it (extracted from the bundle's certificate SAN: repo, workflow, and tag all pinned to v1.8.1, so a future version bump without updating this step fails loud instead of silently trusting a different signer). Runner is always ubuntu-latest, so only the linux_amd64 asset is needed. 2. Build provenance: added actions/attest-build-provenance (SHA-pinned @4d101475d8b20a2381f78447822ac1eab6504dd8, v4.2.2) over the GoReleaser binaries (dist/cudly-mcp_*/cudly-mcp) in the `goreleaser` job and over the packed .mcpb bundle in the `mcpb` job. attestations: write + id-token: write added only to those two jobs. 3. Added workflow-level `concurrency: {group: release-${{ github.ref }}, cancel-in-progress: false}` -- one release pipeline per tag at a time; never cancels a partially-published run mid-flight. 4. .goreleaser.yml's comment claimed the MCPB step reads GoReleaser's binaries straight out of dist/; corrected -- the mcpb job runs on a separate runner and actually re-fetches them via `gh release download`, as release.yml already did. 5. release.yml's test job now runs `go test -race -short ./...`, matching ci.yml instead of a plain `go test ./...`. Verified: `goreleaser check` clean; native `actionlint 1.7.12`-compatible binary clean; `zizmor --persona=pedantic --min-severity=low` clean (5 job-naming findings remain at informational severity, filtered by min-severity=low, unchanged from before); manually exercised the new sha256sum -c and cosign-identity-derivation logic and the tar extraction path against the real v1.8.1 release assets. Co-Authored-By: claude-flow --- .github/workflows/release.yml | 63 ++++++++++++++++++++++++++++++----- .goreleaser.yml | 5 +-- 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 32675e6..f4e34e2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,9 +11,10 @@ name: Release cudly-mcp # 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). +# 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 @@ -35,11 +36,19 @@ 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: @@ -94,7 +103,7 @@ jobs: cache: false - name: Test the MCP server - run: go test ./... + run: go test -race -short ./... goreleaser: needs: test @@ -102,6 +111,8 @@ jobs: 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: @@ -133,12 +144,19 @@ jobs: 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 }} @@ -181,6 +199,11 @@ jobs: - 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: @@ -230,13 +253,37 @@ jobs: server.json > "$patched" mv "$patched" server.json + # 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: | - 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 + 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: ./mcp-publisher login github-oidc + run: '"$RUNNER_TEMP/mcp-publisher" login github-oidc' - name: Publish to the MCP Registry - run: ./mcp-publisher publish + run: '"$RUNNER_TEMP/mcp-publisher" publish' diff --git a/.goreleaser.yml b/.goreleaser.yml index 215b258..f9dd241 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -21,8 +21,9 @@ builds: - -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). +# 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] From 2cdc02b94e65ea6ce3d6df650cb97ba01ffcae35 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 28 Sep 2026 00:36:09 +0200 Subject: [PATCH 4/4] fix(release): explicitly install cosign before verify-blob CodeRabbit caught that the publish-registry job's cosign verify-blob step (added in 77c9f21) assumed cosign was preinstalled on ubuntu-latest. It isn't part of the documented runner-images toolset, and this job never runs on a PR, so CI couldn't have caught the gap. Add sigstore/cosign-installer (SHA-pinned @6f9f17788090df1f26f669e9d70 d6ae9567deba6, v4.1.2) pinned to cosign-release: v3.0.6 before the verify-blob call. Verified: actionlint clean, zizmor --persona=pedantic --min-severity=low clean, goreleaser check clean. Co-Authored-By: claude-flow --- .github/workflows/release.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f4e34e2..a720644 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -253,6 +253,13 @@ jobs: 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