From 09fcfc9af504432a4d9abc7a1a71d053f380cff3 Mon Sep 17 00:00:00 2001 From: matheusfrancisco Date: Fri, 31 Jul 2026 13:59:38 -0300 Subject: [PATCH 1/3] add: CI pipe --- .github/workflows/auto-release.yml | 147 ++++++++++++++++++++++ .github/workflows/binary-release.yml | 175 +++++++++++++++++++++++++++ .github/workflows/pr-label-check.yml | 38 ++++++ .github/workflows/test.yml | 96 +++++++++++++++ README.md | 30 +++++ cmd/mcpproxyd/main.go | 10 ++ 6 files changed, 496 insertions(+) create mode 100644 .github/workflows/auto-release.yml create mode 100644 .github/workflows/binary-release.yml create mode 100644 .github/workflows/pr-label-check.yml create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml new file mode 100644 index 0000000..eee6ac3 --- /dev/null +++ b/.github/workflows/auto-release.yml @@ -0,0 +1,147 @@ +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 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.26" + + - 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: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUMP: ${{ steps.pr.outputs.bump }} + run: | + set -euo pipefail + # Highest existing semver release, not the most recently created, so an + # out-of-order hotfix can't trick the bump. Go module tags must be + # v-prefixed (vX.Y.Z) — that is what makes `go get ...@vX.Y.Z` work. + LATEST=$(gh release list -L 30 --json tagName --jq ' + [.[].tagName | select(test("^v[0-9]+\\.[0-9]+\\.[0-9]+$"))] + | sort_by(sub("^v"; "") | split(".") | map(tonumber)) + | .[-1] // "" + ') + 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 release: ${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}" diff --git a/.github/workflows/binary-release.yml b/.github/workflows/binary-release.yml new file mode 100644 index 0000000..36e76c4 --- /dev/null +++ b/.github/workflows/binary-release.yml @@ -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: "1.26" + + - 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 }}" diff --git a/.github/workflows/pr-label-check.yml b/.github/workflows/pr-label-check.yml new file mode 100644 index 0000000..eaef504 --- /dev/null +++ b/.github/workflows/pr-label-check.yml @@ -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" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..0ce4e22 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,96 @@ +name: Test + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + lint: + name: gofmt + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.26" + - name: Check formatting + run: | + out=$(gofmt -l .) + if [ -n "$out" ]; then + echo "::error::These files are not gofmt-clean:" + echo "$out" + exit 1 + fi + - name: Check go.mod/go.sum are tidy + run: | + go mod tidy + if ! git diff --quiet go.mod go.sum; then + echo "::error::go.mod/go.sum are not tidy — run 'go mod tidy' and commit:" + git --no-pager diff go.mod go.sum + exit 1 + fi + + test: + # One module, so a single `./...` covers everything. Tests spawn real + # child processes (backend/stdio_exec_unix_test.go runs the testmcp + # server through `go run`, the same grandchild shape as `npx`), which is + # why this needs a full toolchain and not just a build cache. + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.26" + - run: go vet ./... + - run: go test -race ./... + + build: + # `go vet` on the test runner only type-checks the linux files, so + # backend/procgroup_windows.go is never compiled there. Vetting per + # GOOS/GOARCH is what keeps the build-tagged files and the release + # targets honest. + name: Build (${{ matrix.goos }}/${{ matrix.goarch }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - { goos: linux, goarch: amd64 } + - { goos: linux, goarch: arm64 } + - { goos: darwin, goarch: amd64 } + - { goos: darwin, goarch: arm64 } + - { goos: windows, goarch: amd64 } + env: + CGO_ENABLED: "0" + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.26" + - run: go vet ./... + - run: go build ./... + + examples: + # The shipped YAML is documentation users copy: parse it and run + # config.Validate over it so a renamed field cannot rot silently. + # config.Load expands ${VAR}, and mode:static rejects an empty token, so + # the placeholders referenced by the examples get dummy values here. + name: Validate example configs + runs-on: ubuntu-latest + env: + GATEWAY_TOKEN: ci-placeholder + MCPPROXY_TOKEN: ci-placeholder + MCPPROXY_APPROVAL_TOKEN: ci-placeholder + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.26" + - run: go run ./cmd/configcheck config.example.yaml examples/*.yaml examples/remote/*.yaml diff --git a/README.md b/README.md index ba423b6..5ebc7bc 100644 --- a/README.md +++ b/README.md @@ -142,3 +142,33 @@ embed the gateway and supply their own implementations of: - `gateway.IdentityResolver`: inbound identity (skip `auth/inbound` when the host already authenticated the caller) - `gateway.Observer`: telemetry + +Import it the way any Go dependency is imported: + +```sh +go get github.com/hoophq/mcpproxy@v0.1.0 +``` + +## Releases + +Releases are cut by CI from merged PRs; nobody tags by hand. Every PR needs +exactly one label, which `PR Release Label Check` enforces before merge: + +| Label | Effect on merge to `main` | +|---|---| +| `major` | `vX.Y.Z` → `v(X+1).0.0` | +| `minor` | `vX.Y.Z` → `vX.(Y+1).0` | +| `patch` | `vX.Y.Z` → `vX.Y.(Z+1)` | +| `skip-release` | no tag, no release (docs, CI, refactors) | + +`Auto Release` then creates the `vX.Y.Z` tag and GitHub release — that tag +is what `go get github.com/hoophq/mcpproxy@vX.Y.Z` resolves. `Binary Release` +chains off it and attaches `mcpproxy_X.Y.Z__.tar.gz` (`.zip` on +Windows) for darwin/linux/windows on amd64 and arm64, plus `checksums.txt`. +Each archive holds all four commands, `README.md`, `config.example.yaml` and +`examples/`. Re-publish binaries for an existing tag with the `Binary Release` +workflow's manual trigger. + +Going to `v2` requires the major suffix in the module path +(`github.com/hoophq/mcpproxy/v2`); `Auto Release` refuses to tag until +`go.mod` matches. diff --git a/cmd/mcpproxyd/main.go b/cmd/mcpproxyd/main.go index 12afea3..75f897e 100644 --- a/cmd/mcpproxyd/main.go +++ b/cmd/mcpproxyd/main.go @@ -34,11 +34,21 @@ import ( "github.com/hoophq/mcpproxy/wal" ) +// version is the released build's tag, set by the release build with +// -ldflags "-X main.version=vX.Y.Z". A build from source reports "dev". +var version = "dev" + func main() { cfgPath := flag.String("config", "config.yaml", "path to YAML config") debug := flag.Bool("debug", false, "debug logging") + showVersion := flag.Bool("version", false, "print version and exit") flag.Parse() + if *showVersion { + fmt.Println("mcpproxyd", version) + return + } + lvl := slog.LevelInfo if *debug { lvl = slog.LevelDebug From a2d7ee28459719cf27d045ff976610ba156fb3bf Mon Sep 17 00:00:00 2001 From: matheusfrancisco Date: Fri, 31 Jul 2026 14:16:56 -0300 Subject: [PATCH 2/3] add: license --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..760468c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 hoop.dev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 2f368533c29312d232fdd06e67671876d1b4d882 Mon Sep 17 00:00:00 2001 From: matheusfrancisco Date: Fri, 31 Jul 2026 14:41:51 -0300 Subject: [PATCH 3/3] add: fix code review --- .github/workflows/auto-release.yml | 32 ++++++++++++++++++---------- .github/workflows/binary-release.yml | 2 +- .github/workflows/test.yml | 12 +++++++---- 3 files changed, 30 insertions(+), 16 deletions(-) diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index eee6ac3..a5c6316 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -23,12 +23,17 @@ jobs: - 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: "1.26" + go-version-file: go.mod - name: Resolve merged PR and bump type id: pr @@ -74,18 +79,23 @@ jobs: if: steps.pr.outputs.skip == 'false' id: version env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} BUMP: ${{ steps.pr.outputs.bump }} run: | set -euo pipefail - # Highest existing semver release, not the most recently created, so an - # out-of-order hotfix can't trick the bump. Go module tags must be - # v-prefixed (vX.Y.Z) — that is what makes `go get ...@vX.Y.Z` work. - LATEST=$(gh release list -L 30 --json tagName --jq ' - [.[].tagName | select(test("^v[0-9]+\\.[0-9]+\\.[0-9]+$"))] - | sort_by(sub("^v"; "") | split(".") | map(tonumber)) - | .[-1] // "" - ') + # 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 @@ -99,7 +109,7 @@ jobs: esac NEXT="v${MAJOR}.${MINOR}.${PATCH}" - echo "Highest existing release: ${LATEST} -> Next: ${NEXT} (bump: ${BUMP})" + echo "Highest existing tag: ${LATEST} -> Next: ${NEXT} (bump: ${BUMP})" echo "next=${NEXT}" >> "$GITHUB_OUTPUT" echo "previous=${LATEST}" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/binary-release.yml b/.github/workflows/binary-release.yml index 36e76c4..0802171 100644 --- a/.github/workflows/binary-release.yml +++ b/.github/workflows/binary-release.yml @@ -109,7 +109,7 @@ jobs: - uses: actions/setup-go@v5 with: - go-version: "1.26" + go-version-file: go.mod - name: Cross-compile and package archives env: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0ce4e22..45369e2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,7 +16,11 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: "1.26" + # go.mod is the single source of truth for the toolchain. Its `go` + # directive is a full version (1.26.2), and setup-go treats a + # three-component spec as an exact pin rather than a 1.26.x range — + # so bumping the toolchain means editing go.mod, nothing here. + go-version-file: go.mod - name: Check formatting run: | out=$(gofmt -l .) @@ -45,7 +49,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: "1.26" + go-version-file: go.mod - run: go vet ./... - run: go test -race ./... @@ -73,7 +77,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: "1.26" + go-version-file: go.mod - run: go vet ./... - run: go build ./... @@ -92,5 +96,5 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: "1.26" + go-version-file: go.mod - run: go run ./cmd/configcheck config.example.yaml examples/*.yaml examples/remote/*.yaml