Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions .github/workflows/auto-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
name: Auto Release

on:
push:
branches:
- main

permissions:
contents: write
pull-requests: read

# Serialize runs so two PRs merged back-to-back can't both read the same
# "latest" tag and race on `gh release create`.
concurrency:
group: auto-release-main
cancel-in-progress: false

jobs:
release:
name: Tag & Release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# fetch-depth: 0 is load-bearing, not just for `--generate-notes`:
# "Compute next version" reads the local tag list to find the highest
# released semver. At depth 0 checkout fetches with the refspec
# `+refs/tags/*:refs/tags/*` and omits `--no-tags`, so every tag is
# present locally. Lowering this silently truncates version history.
fetch-depth: 0

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod

- name: Resolve merged PR and bump type
id: pr
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
SHA: ${{ github.sha }}
run: |
set -euo pipefail

# Ask GitHub which PR this pushed commit belongs to, rather than
# parsing the commit subject. This is merge-strategy agnostic (works
# for squash, merge-commit and rebase) and returns empty for a direct
# push to main, which we treat as "nothing to release".
PR_NUMBER=$(gh api "repos/${REPO}/commits/${SHA}/pulls" --jq '.[0].number // ""')

if [ -z "$PR_NUMBER" ]; then
echo "No PR is associated with ${SHA} (direct push to main?). Nothing to release."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi

LABELS=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.labels[].name')

if echo "$LABELS" | grep -qx 'skip-release'; then
echo "PR #${PR_NUMBER} has 'skip-release' label — no release."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi

BUMP=$(echo "$LABELS" | grep -E '^(major|minor|patch)$' | head -1 || true)
if [ -z "$BUMP" ]; then
echo "::error::PR #${PR_NUMBER} has no major/minor/patch/skip-release label. (PR Release Label Check should have blocked this merge.)"
exit 1
fi

echo "Merged PR: #${PR_NUMBER} (bump: ${BUMP})"
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "bump=${BUMP}" >> "$GITHUB_OUTPUT"
echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT"

- name: Compute next version
if: steps.pr.outputs.skip == 'false'
id: version
env:
BUMP: ${{ steps.pr.outputs.bump }}
run: |
set -euo pipefail
# Highest existing semver tag, not the most recently created release.
# Tags are read locally (see the checkout step) so the whole history
# is considered — a paged `gh release list` window can miss a high
# release that was created before a burst of newer backports, which
# would regress LATEST and re-derive an already-published NEXT.
#
# `--sort=v:refname` orders numerically (v1.10.0 > v1.9.0) and sorts
# prereleases below their stable release, so `tail -1` can only be a
# stable version. grep keeps that to canonical vX.Y.Z: Go module tags
# must be v-prefixed for `go get ...@vX.Y.Z`, and rejecting leading
# zeros keeps the arithmetic below out of bash's octal parsing.
LATEST=$(git tag -l --sort=v:refname 'v[0-9]*.[0-9]*.[0-9]*' \
| grep -E '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' \
| tail -n 1 || true)
if [ -z "$LATEST" ]; then
LATEST="v0.0.0"
fi

CLEAN="${LATEST#v}"
IFS='.' read -r MAJOR MINOR PATCH <<< "$CLEAN"
case "$BUMP" in
major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;;
minor) MINOR=$((MINOR + 1)); PATCH=0 ;;
patch) PATCH=$((PATCH + 1)) ;;
esac
NEXT="v${MAJOR}.${MINOR}.${PATCH}"

echo "Highest existing tag: ${LATEST} -> Next: ${NEXT} (bump: ${BUMP})"
echo "next=${NEXT}" >> "$GITHUB_OUTPUT"
echo "previous=${LATEST}" >> "$GITHUB_OUTPUT"

- name: Guard the v2+ import path
if: steps.pr.outputs.skip == 'false'
env:
NEXT: ${{ steps.version.outputs.next }}
run: |
set -euo pipefail
# Go's module rules: at v2 and above the major version must be part
# of the module path (github.com/hoophq/mcpproxy/v2). Tagging v2.0.0
# against an unsuffixed go.mod produces a tag `go get` refuses to
# resolve, so fail here instead of publishing a broken release.
MAJOR="${NEXT#v}"; MAJOR="${MAJOR%%.*}"
WANT="github.com/${{ github.repository }}"
if [ "$MAJOR" -ge 2 ]; then
WANT="${WANT}/v${MAJOR}"
fi
GOT=$(go list -m)
if [ "$GOT" != "$WANT" ]; then
echo "::error::Releasing ${NEXT} needs module path '${WANT}', but go.mod declares '${GOT}'. Update go.mod and every internal import first."
exit 1
fi

- name: Release module
if: steps.pr.outputs.skip == 'false'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NEXT: ${{ steps.version.outputs.next }}
PREVIOUS: ${{ steps.version.outputs.previous }}
SHA: ${{ github.sha }}
run: |
set -euo pipefail
if gh release view "$NEXT" >/dev/null 2>&1; then
echo "::error::Release $NEXT already exists. Another run may have created it; investigate before retrying."
exit 1
fi
# Creating the release also creates the vNEXT tag at $SHA. That tag is
# the published artifact: `go get github.com/hoophq/mcpproxy@NEXT`.
ARGS=(--title "$NEXT" --target "$SHA" --generate-notes)
if [ "$PREVIOUS" != "v0.0.0" ]; then
ARGS+=(--notes-start-tag "$PREVIOUS")
fi
gh release create "$NEXT" "${ARGS[@]}"
echo "Released github.com/${{ github.repository }}@${NEXT}"
175 changes: 175 additions & 0 deletions .github/workflows/binary-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
name: Binary Release

# Auto Release creates the vX.Y.Z tag/release with GITHUB_TOKEN, and a tag
# pushed by GITHUB_TOKEN cannot start a `push: tags` workflow. So this chains
# off Auto Release's completion: it cross-compiles the mcpproxy commands
# (mcpproxyd plus the configcheck / import-catalog / mcpsmoke operator tools)
# and uploads the archives and checksums to that release.
#
# No Homebrew tap here, unlike alcatraz: this repo is private, so a formula
# pointing at its release assets could not be downloaded by `brew install`.
# Add the tap step if and when the repo goes public.
#
# The manual trigger re-publishes binaries for an existing tag — the recovery
# path when a release was cut while this workflow was broken.
on:
workflow_run:
workflows: ["Auto Release"]
types: [completed]
workflow_dispatch:
inputs:
version:
description: "Existing release tag to (re)publish binaries for, without the leading v (e.g. 0.5.0)"
required: true
type: string

permissions:
contents: write

# Serialize runs so back-to-back releases can't race on uploading assets to
# the same release.
concurrency:
group: binary-release
cancel-in-progress: false

jobs:
resolve:
name: Resolve release tag
runs-on: ubuntu-latest
# Auto Release runs on every push to main; act only on successful runs.
# Manual dispatches name their tag explicitly and always proceed.
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
outputs:
publish: ${{ steps.dispatch.outputs.publish || steps.tag.outputs.publish }}
version: ${{ steps.dispatch.outputs.version || steps.tag.outputs.version }}
ref: ${{ steps.dispatch.outputs.ref || steps.tag.outputs.ref }}
steps:
- name: Use the dispatched tag
id: dispatch
if: ${{ github.event_name == 'workflow_dispatch' }}
env:
RAW: ${{ inputs.version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
# This job has write permissions: refuse to build/upload for a
# malformed version or a tag that has no release.
VERSION="${RAW#v}"
if ! echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::'${RAW}' is not a semver version (want X.Y.Z)"
exit 1
fi
if ! gh release view "v${VERSION}" --repo "${{ github.repository }}" >/dev/null 2>&1; then
echo "::error::release v${VERSION} does not exist in ${{ github.repository }} — create the release first, this workflow only attaches binaries"
exit 1
fi
{
echo "publish=true"
echo "version=${VERSION}"
echo "ref=v${VERSION}"
} >> "$GITHUB_OUTPUT"
echo "Publishing v${VERSION} (manual dispatch)"

- uses: actions/checkout@v4
if: ${{ github.event_name == 'workflow_run' }}
with:
# The exact commit Auto Release ran on. fetch-depth: 0 brings the
# tags so we can read the version tag it created at that commit.
ref: ${{ github.event.workflow_run.head_sha }}
fetch-depth: 0

- name: Find the release tag at this commit
id: tag
if: ${{ github.event_name == 'workflow_run' }}
run: |
set -euo pipefail
# Auto Release tags the released commit vX.Y.Z, or tags nothing for
# a skip-release PR or a direct push. Bind the published version to
# that tag so source and version always match; skip when absent.
TAG=$(git tag --points-at HEAD | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1 || true)
if [ -z "$TAG" ]; then
echo "No release tag at ${{ github.event.workflow_run.head_sha }}; nothing to publish."
echo "publish=false" >> "$GITHUB_OUTPUT"
else
echo "publish=true" >> "$GITHUB_OUTPUT"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
echo "ref=${{ github.event.workflow_run.head_sha }}" >> "$GITHUB_OUTPUT"
echo "Publishing ${TAG}"
fi

binaries:
name: Build archives
needs: resolve
if: ${{ needs.resolve.outputs.publish == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.resolve.outputs.ref }}

- uses: actions/setup-go@v5
with:
go-version-file: go.mod

- name: Cross-compile and package archives
env:
VERSION: ${{ needs.resolve.outputs.version }}
run: |
set -euo pipefail
mkdir -p dist
# CGO_ENABLED=0: the daemon uses no cgo, and a static binary runs on
# any glibc/musl base image. -X main.version is read by
# `mcpproxyd -version`; the other three commands have no version var,
# so the flag is scoped to mcpproxyd only.
for target in darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64; do
os="${target%%/*}"; arch="${target##*/}"
ext=""; [ "$os" = windows ] && ext=".exe"
stage="dist/stage_${os}_${arch}"
mkdir -p "$stage"
for cmd in mcpproxyd configcheck import-catalog mcpsmoke; do
ldflags="-s -w"
[ "$cmd" = mcpproxyd ] && ldflags="$ldflags -X main.version=v${VERSION}"
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" \
go build -trimpath -ldflags "$ldflags" \
-o "${stage}/${cmd}${ext}" "./cmd/${cmd}"
done
cp README.md config.example.yaml "$stage/"
cp -r examples "$stage/examples"
if [ "$os" = windows ]; then
(cd "$stage" && zip -qr "../mcpproxy_${VERSION}_${os}_${arch}.zip" .)
else
tar -czf "dist/mcpproxy_${VERSION}_${os}_${arch}.tar.gz" -C "$stage" .
fi
done
rm -rf dist/stage_*
# Bare globs (no ./ prefix): installers grep checksums.txt for the
# exact archive name.
(cd dist && sha256sum -- *.tar.gz *.zip > checksums.txt && cat checksums.txt)

- name: Smoke test the linux/amd64 build
env:
VERSION: ${{ needs.resolve.outputs.version }}
run: |
set -euo pipefail
# Prove the uploaded archive contains a runnable daemon carrying the
# version we claim, rather than trusting that `go build` succeeded.
mkdir -p smoke
tar -xzf "dist/mcpproxy_${VERSION}_linux_amd64.tar.gz" -C smoke
got=$(./smoke/mcpproxyd -version)
echo "$got"
if [ "$got" != "mcpproxyd v${VERSION}" ]; then
echo "::error::built binary reports '${got}', want 'mcpproxyd v${VERSION}'"
exit 1
fi
./smoke/configcheck smoke/examples/01-minimal-stdio.yaml

- name: Attach archives to the release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: v${{ needs.resolve.outputs.version }}
run: |
set -euo pipefail
# --clobber lets a re-run replace assets instead of failing on
# "asset already exists". checksums.txt is consumed by installers.
gh release upload "$TAG" dist/*.tar.gz dist/*.zip dist/checksums.txt \
--clobber --repo "${{ github.repository }}"
38 changes: 38 additions & 0 deletions .github/workflows/pr-label-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: PR Release Label Check

on:
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled, edited, ready_for_review]

permissions:
contents: read
pull-requests: read

jobs:
check-label:
name: Check Release Label
runs-on: ubuntu-latest
steps:
- name: Verify exactly one release label
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
LABELS=$(gh pr view "$PR_NUMBER" --repo "${{ github.repository }}" --json labels --jq '.labels[].name')
COUNT=$(echo "$LABELS" | grep -cE '^(major|minor|patch|skip-release)$' || true)

if [ "$COUNT" -eq 0 ]; then
echo "::error::PR must have exactly one of these labels: 'major', 'minor', 'patch', or 'skip-release'."
echo "::error::Use 'major'/'minor'/'patch' to publish a release (git tag) on merge, or 'skip-release' for changes that don't need a release (docs, CI, etc)."
exit 1
fi

if [ "$COUNT" -gt 1 ]; then
FOUND=$(echo "$LABELS" | grep -E '^(major|minor|patch|skip-release)$' | tr '\n' ' ')
echo "::error::PR must have exactly ONE release label, but found multiple: $FOUND"
exit 1
fi

MATCHED=$(echo "$LABELS" | grep -E '^(major|minor|patch|skip-release)$')
echo "Release label found: $MATCHED"
Loading
Loading