Skip to content

Commit 09fcfc9

Browse files
add: CI pipe
1 parent 1216596 commit 09fcfc9

6 files changed

Lines changed: 496 additions & 0 deletions

File tree

‎.github/workflows/auto-release.yml‎

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
name: Auto Release
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
8+
permissions:
9+
contents: write
10+
pull-requests: read
11+
12+
# Serialize runs so two PRs merged back-to-back can't both read the same
13+
# "latest" tag and race on `gh release create`.
14+
concurrency:
15+
group: auto-release-main
16+
cancel-in-progress: false
17+
18+
jobs:
19+
release:
20+
name: Tag & Release
21+
runs-on: ubuntu-latest
22+
steps:
23+
- name: Checkout
24+
uses: actions/checkout@v4
25+
with:
26+
fetch-depth: 0
27+
28+
- name: Set up Go
29+
uses: actions/setup-go@v5
30+
with:
31+
go-version: "1.26"
32+
33+
- name: Resolve merged PR and bump type
34+
id: pr
35+
env:
36+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
37+
REPO: ${{ github.repository }}
38+
SHA: ${{ github.sha }}
39+
run: |
40+
set -euo pipefail
41+
42+
# Ask GitHub which PR this pushed commit belongs to, rather than
43+
# parsing the commit subject. This is merge-strategy agnostic (works
44+
# for squash, merge-commit and rebase) and returns empty for a direct
45+
# push to main, which we treat as "nothing to release".
46+
PR_NUMBER=$(gh api "repos/${REPO}/commits/${SHA}/pulls" --jq '.[0].number // ""')
47+
48+
if [ -z "$PR_NUMBER" ]; then
49+
echo "No PR is associated with ${SHA} (direct push to main?). Nothing to release."
50+
echo "skip=true" >> "$GITHUB_OUTPUT"
51+
exit 0
52+
fi
53+
54+
LABELS=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.labels[].name')
55+
56+
if echo "$LABELS" | grep -qx 'skip-release'; then
57+
echo "PR #${PR_NUMBER} has 'skip-release' label — no release."
58+
echo "skip=true" >> "$GITHUB_OUTPUT"
59+
exit 0
60+
fi
61+
62+
BUMP=$(echo "$LABELS" | grep -E '^(major|minor|patch)$' | head -1 || true)
63+
if [ -z "$BUMP" ]; then
64+
echo "::error::PR #${PR_NUMBER} has no major/minor/patch/skip-release label. (PR Release Label Check should have blocked this merge.)"
65+
exit 1
66+
fi
67+
68+
echo "Merged PR: #${PR_NUMBER} (bump: ${BUMP})"
69+
echo "skip=false" >> "$GITHUB_OUTPUT"
70+
echo "bump=${BUMP}" >> "$GITHUB_OUTPUT"
71+
echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT"
72+
73+
- name: Compute next version
74+
if: steps.pr.outputs.skip == 'false'
75+
id: version
76+
env:
77+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
78+
BUMP: ${{ steps.pr.outputs.bump }}
79+
run: |
80+
set -euo pipefail
81+
# Highest existing semver release, not the most recently created, so an
82+
# out-of-order hotfix can't trick the bump. Go module tags must be
83+
# v-prefixed (vX.Y.Z) — that is what makes `go get ...@vX.Y.Z` work.
84+
LATEST=$(gh release list -L 30 --json tagName --jq '
85+
[.[].tagName | select(test("^v[0-9]+\\.[0-9]+\\.[0-9]+$"))]
86+
| sort_by(sub("^v"; "") | split(".") | map(tonumber))
87+
| .[-1] // ""
88+
')
89+
if [ -z "$LATEST" ]; then
90+
LATEST="v0.0.0"
91+
fi
92+
93+
CLEAN="${LATEST#v}"
94+
IFS='.' read -r MAJOR MINOR PATCH <<< "$CLEAN"
95+
case "$BUMP" in
96+
major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;;
97+
minor) MINOR=$((MINOR + 1)); PATCH=0 ;;
98+
patch) PATCH=$((PATCH + 1)) ;;
99+
esac
100+
NEXT="v${MAJOR}.${MINOR}.${PATCH}"
101+
102+
echo "Highest existing release: ${LATEST} -> Next: ${NEXT} (bump: ${BUMP})"
103+
echo "next=${NEXT}" >> "$GITHUB_OUTPUT"
104+
echo "previous=${LATEST}" >> "$GITHUB_OUTPUT"
105+
106+
- name: Guard the v2+ import path
107+
if: steps.pr.outputs.skip == 'false'
108+
env:
109+
NEXT: ${{ steps.version.outputs.next }}
110+
run: |
111+
set -euo pipefail
112+
# Go's module rules: at v2 and above the major version must be part
113+
# of the module path (github.com/hoophq/mcpproxy/v2). Tagging v2.0.0
114+
# against an unsuffixed go.mod produces a tag `go get` refuses to
115+
# resolve, so fail here instead of publishing a broken release.
116+
MAJOR="${NEXT#v}"; MAJOR="${MAJOR%%.*}"
117+
WANT="github.com/${{ github.repository }}"
118+
if [ "$MAJOR" -ge 2 ]; then
119+
WANT="${WANT}/v${MAJOR}"
120+
fi
121+
GOT=$(go list -m)
122+
if [ "$GOT" != "$WANT" ]; then
123+
echo "::error::Releasing ${NEXT} needs module path '${WANT}', but go.mod declares '${GOT}'. Update go.mod and every internal import first."
124+
exit 1
125+
fi
126+
127+
- name: Release module
128+
if: steps.pr.outputs.skip == 'false'
129+
env:
130+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
131+
NEXT: ${{ steps.version.outputs.next }}
132+
PREVIOUS: ${{ steps.version.outputs.previous }}
133+
SHA: ${{ github.sha }}
134+
run: |
135+
set -euo pipefail
136+
if gh release view "$NEXT" >/dev/null 2>&1; then
137+
echo "::error::Release $NEXT already exists. Another run may have created it; investigate before retrying."
138+
exit 1
139+
fi
140+
# Creating the release also creates the vNEXT tag at $SHA. That tag is
141+
# the published artifact: `go get github.com/hoophq/mcpproxy@NEXT`.
142+
ARGS=(--title "$NEXT" --target "$SHA" --generate-notes)
143+
if [ "$PREVIOUS" != "v0.0.0" ]; then
144+
ARGS+=(--notes-start-tag "$PREVIOUS")
145+
fi
146+
gh release create "$NEXT" "${ARGS[@]}"
147+
echo "Released github.com/${{ github.repository }}@${NEXT}"
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
name: Binary Release
2+
3+
# Auto Release creates the vX.Y.Z tag/release with GITHUB_TOKEN, and a tag
4+
# pushed by GITHUB_TOKEN cannot start a `push: tags` workflow. So this chains
5+
# off Auto Release's completion: it cross-compiles the mcpproxy commands
6+
# (mcpproxyd plus the configcheck / import-catalog / mcpsmoke operator tools)
7+
# and uploads the archives and checksums to that release.
8+
#
9+
# No Homebrew tap here, unlike alcatraz: this repo is private, so a formula
10+
# pointing at its release assets could not be downloaded by `brew install`.
11+
# Add the tap step if and when the repo goes public.
12+
#
13+
# The manual trigger re-publishes binaries for an existing tag — the recovery
14+
# path when a release was cut while this workflow was broken.
15+
on:
16+
workflow_run:
17+
workflows: ["Auto Release"]
18+
types: [completed]
19+
workflow_dispatch:
20+
inputs:
21+
version:
22+
description: "Existing release tag to (re)publish binaries for, without the leading v (e.g. 0.5.0)"
23+
required: true
24+
type: string
25+
26+
permissions:
27+
contents: write
28+
29+
# Serialize runs so back-to-back releases can't race on uploading assets to
30+
# the same release.
31+
concurrency:
32+
group: binary-release
33+
cancel-in-progress: false
34+
35+
jobs:
36+
resolve:
37+
name: Resolve release tag
38+
runs-on: ubuntu-latest
39+
# Auto Release runs on every push to main; act only on successful runs.
40+
# Manual dispatches name their tag explicitly and always proceed.
41+
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
42+
outputs:
43+
publish: ${{ steps.dispatch.outputs.publish || steps.tag.outputs.publish }}
44+
version: ${{ steps.dispatch.outputs.version || steps.tag.outputs.version }}
45+
ref: ${{ steps.dispatch.outputs.ref || steps.tag.outputs.ref }}
46+
steps:
47+
- name: Use the dispatched tag
48+
id: dispatch
49+
if: ${{ github.event_name == 'workflow_dispatch' }}
50+
env:
51+
RAW: ${{ inputs.version }}
52+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
53+
run: |
54+
set -euo pipefail
55+
# This job has write permissions: refuse to build/upload for a
56+
# malformed version or a tag that has no release.
57+
VERSION="${RAW#v}"
58+
if ! echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
59+
echo "::error::'${RAW}' is not a semver version (want X.Y.Z)"
60+
exit 1
61+
fi
62+
if ! gh release view "v${VERSION}" --repo "${{ github.repository }}" >/dev/null 2>&1; then
63+
echo "::error::release v${VERSION} does not exist in ${{ github.repository }} — create the release first, this workflow only attaches binaries"
64+
exit 1
65+
fi
66+
{
67+
echo "publish=true"
68+
echo "version=${VERSION}"
69+
echo "ref=v${VERSION}"
70+
} >> "$GITHUB_OUTPUT"
71+
echo "Publishing v${VERSION} (manual dispatch)"
72+
73+
- uses: actions/checkout@v4
74+
if: ${{ github.event_name == 'workflow_run' }}
75+
with:
76+
# The exact commit Auto Release ran on. fetch-depth: 0 brings the
77+
# tags so we can read the version tag it created at that commit.
78+
ref: ${{ github.event.workflow_run.head_sha }}
79+
fetch-depth: 0
80+
81+
- name: Find the release tag at this commit
82+
id: tag
83+
if: ${{ github.event_name == 'workflow_run' }}
84+
run: |
85+
set -euo pipefail
86+
# Auto Release tags the released commit vX.Y.Z, or tags nothing for
87+
# a skip-release PR or a direct push. Bind the published version to
88+
# that tag so source and version always match; skip when absent.
89+
TAG=$(git tag --points-at HEAD | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1 || true)
90+
if [ -z "$TAG" ]; then
91+
echo "No release tag at ${{ github.event.workflow_run.head_sha }}; nothing to publish."
92+
echo "publish=false" >> "$GITHUB_OUTPUT"
93+
else
94+
echo "publish=true" >> "$GITHUB_OUTPUT"
95+
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
96+
echo "ref=${{ github.event.workflow_run.head_sha }}" >> "$GITHUB_OUTPUT"
97+
echo "Publishing ${TAG}"
98+
fi
99+
100+
binaries:
101+
name: Build archives
102+
needs: resolve
103+
if: ${{ needs.resolve.outputs.publish == 'true' }}
104+
runs-on: ubuntu-latest
105+
steps:
106+
- uses: actions/checkout@v4
107+
with:
108+
ref: ${{ needs.resolve.outputs.ref }}
109+
110+
- uses: actions/setup-go@v5
111+
with:
112+
go-version: "1.26"
113+
114+
- name: Cross-compile and package archives
115+
env:
116+
VERSION: ${{ needs.resolve.outputs.version }}
117+
run: |
118+
set -euo pipefail
119+
mkdir -p dist
120+
# CGO_ENABLED=0: the daemon uses no cgo, and a static binary runs on
121+
# any glibc/musl base image. -X main.version is read by
122+
# `mcpproxyd -version`; the other three commands have no version var,
123+
# so the flag is scoped to mcpproxyd only.
124+
for target in darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64; do
125+
os="${target%%/*}"; arch="${target##*/}"
126+
ext=""; [ "$os" = windows ] && ext=".exe"
127+
stage="dist/stage_${os}_${arch}"
128+
mkdir -p "$stage"
129+
for cmd in mcpproxyd configcheck import-catalog mcpsmoke; do
130+
ldflags="-s -w"
131+
[ "$cmd" = mcpproxyd ] && ldflags="$ldflags -X main.version=v${VERSION}"
132+
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" \
133+
go build -trimpath -ldflags "$ldflags" \
134+
-o "${stage}/${cmd}${ext}" "./cmd/${cmd}"
135+
done
136+
cp README.md config.example.yaml "$stage/"
137+
cp -r examples "$stage/examples"
138+
if [ "$os" = windows ]; then
139+
(cd "$stage" && zip -qr "../mcpproxy_${VERSION}_${os}_${arch}.zip" .)
140+
else
141+
tar -czf "dist/mcpproxy_${VERSION}_${os}_${arch}.tar.gz" -C "$stage" .
142+
fi
143+
done
144+
rm -rf dist/stage_*
145+
# Bare globs (no ./ prefix): installers grep checksums.txt for the
146+
# exact archive name.
147+
(cd dist && sha256sum -- *.tar.gz *.zip > checksums.txt && cat checksums.txt)
148+
149+
- name: Smoke test the linux/amd64 build
150+
env:
151+
VERSION: ${{ needs.resolve.outputs.version }}
152+
run: |
153+
set -euo pipefail
154+
# Prove the uploaded archive contains a runnable daemon carrying the
155+
# version we claim, rather than trusting that `go build` succeeded.
156+
mkdir -p smoke
157+
tar -xzf "dist/mcpproxy_${VERSION}_linux_amd64.tar.gz" -C smoke
158+
got=$(./smoke/mcpproxyd -version)
159+
echo "$got"
160+
if [ "$got" != "mcpproxyd v${VERSION}" ]; then
161+
echo "::error::built binary reports '${got}', want 'mcpproxyd v${VERSION}'"
162+
exit 1
163+
fi
164+
./smoke/configcheck smoke/examples/01-minimal-stdio.yaml
165+
166+
- name: Attach archives to the release
167+
env:
168+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
169+
TAG: v${{ needs.resolve.outputs.version }}
170+
run: |
171+
set -euo pipefail
172+
# --clobber lets a re-run replace assets instead of failing on
173+
# "asset already exists". checksums.txt is consumed by installers.
174+
gh release upload "$TAG" dist/*.tar.gz dist/*.zip dist/checksums.txt \
175+
--clobber --repo "${{ github.repository }}"
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
name: PR Release Label Check
2+
3+
on:
4+
pull_request:
5+
types: [opened, synchronize, reopened, labeled, unlabeled, edited, ready_for_review]
6+
7+
permissions:
8+
contents: read
9+
pull-requests: read
10+
11+
jobs:
12+
check-label:
13+
name: Check Release Label
14+
runs-on: ubuntu-latest
15+
steps:
16+
- name: Verify exactly one release label
17+
env:
18+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
19+
PR_NUMBER: ${{ github.event.pull_request.number }}
20+
run: |
21+
set -euo pipefail
22+
LABELS=$(gh pr view "$PR_NUMBER" --repo "${{ github.repository }}" --json labels --jq '.labels[].name')
23+
COUNT=$(echo "$LABELS" | grep -cE '^(major|minor|patch|skip-release)$' || true)
24+
25+
if [ "$COUNT" -eq 0 ]; then
26+
echo "::error::PR must have exactly one of these labels: 'major', 'minor', 'patch', or 'skip-release'."
27+
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)."
28+
exit 1
29+
fi
30+
31+
if [ "$COUNT" -gt 1 ]; then
32+
FOUND=$(echo "$LABELS" | grep -E '^(major|minor|patch|skip-release)$' | tr '\n' ' ')
33+
echo "::error::PR must have exactly ONE release label, but found multiple: $FOUND"
34+
exit 1
35+
fi
36+
37+
MATCHED=$(echo "$LABELS" | grep -E '^(major|minor|patch|skip-release)$')
38+
echo "Release label found: $MATCHED"

0 commit comments

Comments
 (0)