From 0c4d7f4a83cfe6c1c82c765835296701e91fa0bd Mon Sep 17 00:00:00 2001 From: Renn F Date: Fri, 16 Oct 2026 05:09:18 +0200 Subject: [PATCH] ci(release): gate the tag on the pull request CI instead of re-running checks Closes #46 --- .github/workflows/release.yml | 143 ++++++++++++++++++++-------------- AGENTS.md | 4 +- CLAUDE.md | 4 +- 3 files changed, 87 insertions(+), 64 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 04c7eb9..a9dca3c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,90 +1,113 @@ name: Release Gate +# A pushed v* tag is the release for echo-guard (there is no publish step). +# This workflow only confirms that the tagged commit is good. Nothing is +# linted or tested here: the tagged commit must already have passed the push +# workflows listed in REQUIRED_WORKFLOWS below (ci.yml, live-smoke.yml). A release +# can therefore never fail on a check its pull request did not run. +# +# govulncheck is not repeated here: it is a job of ci.yml, which runs it on +# the same commit against the same go.mod, so a vulnerable dependency set +# already fails ci.yml above. The module-proxy check below stays because it +# can only run once the tag exists. +# +# Re-check: run this workflow by hand (Actions > Release Gate > Run workflow) +# with the tag, for example v1.5.0. It checks out that tag, so a fixed +# workflow on the default branch can re-check an existing tag. on: push: tags: - 'v*' + workflow_dispatch: + inputs: + tag: + description: 'Release tag to check, for example v1.5.0' + required: true permissions: {} +concurrency: + group: release-${{ inputs.tag || github.ref_name }} + cancel-in-progress: false + env: - GOTOOLCHAIN: auto + REQUIRED_WORKFLOWS: >- + .github/workflows/ci.yml + .github/workflows/live-smoke.yml jobs: - test: - name: Test at tag (go ${{ matrix.go }}) + verify: + name: Tagged commit passed CI runs-on: ubuntu-latest permissions: contents: read - strategy: - fail-fast: false - matrix: - go: ['1.26.x'] - services: - redis: - image: redis:7-alpine - ports: - - 6379:6379 - options: >- - --health-cmd "redis-cli ping" - --health-interval 5s - --health-timeout 3s - --health-retries 10 + actions: read + outputs: + tag: ${{ steps.tag.outputs.tag }} steps: - - name: Checkout tag - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Set up Go - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 - with: - go-version: ${{ matrix.go }} - - - name: gofmt check + - name: Resolve the tag + id: tag + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_TAG: ${{ inputs.tag }} + REF_NAME: ${{ github.ref_name }} run: | - unformatted=$(gofmt -l .) - if [ -n "$unformatted" ]; then - echo "gofmt needed on:" - echo "$unformatted" + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + tag="$INPUT_TAG" + else + tag="$REF_NAME" + fi + if ! printf '%s' "$tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::tag '$tag' is not of the form v1.2.3" exit 1 fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" - - name: go vet - run: go vet ./... - - - name: go test (unit) - run: go test ./... - - - name: go test (integration, redis) - env: - REDIS_HOST: 127.0.0.1 - run: go test -tags integration ./... - - govulncheck: - name: govulncheck at tag - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - name: Checkout tag + - name: Checkout the tag uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: refs/tags/${{ steps.tag.outputs.tag }} persist-credentials: false - - name: Set up Go - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 - with: - go-version: 'stable' - - - name: Run govulncheck + # The newest push run of each required workflow on the tagged commit + # must have succeeded. A tag pushed right after a merge waits for them + # (up to an hour) instead of failing. + - name: Require green CI on the tagged commit + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} run: | - go install golang.org/x/vuln/cmd/govulncheck@latest - govulncheck ./... + sha=$(git rev-parse HEAD) + for _ in $(seq 1 60); do + runs=$(gh api "repos/$REPO/actions/runs?head_sha=$sha&event=push&per_page=100" \ + --jq '[.workflow_runs | group_by(.path)[] | max_by(.run_number) | {path, status, conclusion}]') + waiting=0 + for wf in $REQUIRED_WORKFLOWS; do + run=$(jq -c --arg p "$wf" '.[] | select(.path == $p)' <<< "$runs") + if [ -z "$run" ]; then + echo "$wf: no push run on $sha yet"; waiting=1; continue + fi + status=$(jq -r .status <<< "$run"); conclusion=$(jq -r .conclusion <<< "$run") + if [ "$status" != "completed" ]; then + echo "$wf: $status"; waiting=1 + elif [ "$conclusion" != "success" ]; then + echo "::error::$wf concluded $conclusion on $sha; fix the default branch and release a new tag" + exit 1 + else + echo "ok: $wf success on $sha" + fi + done + [ "$waiting" = "0" ] && exit 0 + sleep 60 + done + echo "::error::required workflows did not finish on $sha within an hour" + exit 1 + # Can only run once the tag exists: a consumer must be able to resolve the + # module at this tag straight from the VCS (no proxy cache). module-consumable: name: Module tag consumable - needs: [test, govulncheck] + needs: verify runs-on: ubuntu-latest permissions: contents: read @@ -97,7 +120,7 @@ jobs: - name: Verify module tag resolves env: MODULE: github.com/rennf93/echo-guard - TAG: ${{ github.ref_name }} + TAG: ${{ needs.verify.outputs.tag }} run: | cd "$(mktemp -d)" go mod init consumable-check diff --git a/AGENTS.md b/AGENTS.md index 2116234..ff942b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,7 +105,7 @@ CI runs the test job on a Go matrix of `1.25.x` and `1.26.x` (fail-fast disabled │ └── advanced_app/ # production-style: cmd/server, internal/config, internal/routes, Dockerfile, docker-compose.yml └── .github/ ├── workflows/ci.yml # push/PR: gofmt, vet, unit, integration, govulncheck - ├── workflows/release.yml # tag push gate: same tests, plus module tag consumable check + ├── workflows/release.yml # tag push gate: requires green CI on the tagged commit, plus module tag consumable check ├── workflows/scheduled-lint.yml # weekly cron vet + govulncheck ├── workflows/code-ql.yml # CodeQL go analysis on push/PR/weekly ├── workflows/live-smoke.yml # dockerized smoke of both example apps (push/PR to master) @@ -154,7 +154,7 @@ CI runs the test job on a Go matrix of `1.25.x` and `1.26.x` (fail-fast disabled ## Best Practices -- Never push to `main` and never push tags from routine work. A `v*` tag push triggers the Release Gate workflow, which re-runs the full matrix and verifies the module tag resolves from a clean consumer module. +- Never push to `main` and never push tags from routine work. A `v*` tag push triggers the Release Gate workflow, which requires the push runs of `ci.yml` and `live-smoke.yml` on the tagged commit to have succeeded and verifies the module tag resolves from a clean consumer module. Run it by hand with a `tag` input to re-check an existing tag. - Work on a branch, open a PR against `main`, and keep the diff scoped. Do not merge your own PR without review. - Do not add a `replace` directive for guard-core-go in a commit; it is consumed as a normal module dependency. - Do not commit generated files, `.DS_Store`, or stray artifacts. Stage explicit paths only. diff --git a/CLAUDE.md b/CLAUDE.md index f6df9f7..7536189 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,7 +98,7 @@ CI runs the test job on a Go matrix of `1.25.x` and `1.26.x` (fail-fast disabled ├── LICENSE # MIT └── .github/ ├── workflows/ci.yml # push/PR: gofmt, vet, unit, integration, govulncheck - ├── workflows/release.yml # tag push gate: same tests, plus module tag consumable check + ├── workflows/release.yml # tag push gate: requires green CI on the tagged commit, plus module tag consumable check ├── workflows/scheduled-lint.yml # weekly cron vet + govulncheck ├── workflows/code-ql.yml # CodeQL go analysis on push/PR/weekly └── dependabot.yml # weekly gomod and github-actions updates @@ -135,7 +135,7 @@ CI runs the test job on a Go matrix of `1.25.x` and `1.26.x` (fail-fast disabled ## Best Practices -- Never push to `main` and never push tags from routine work. A `v*` tag push triggers the Release Gate workflow, which re-runs the full matrix and verifies the module tag resolves from a clean consumer module. +- Never push to `main` and never push tags from routine work. A `v*` tag push triggers the Release Gate workflow, which requires the push runs of `ci.yml` and `live-smoke.yml` on the tagged commit to have succeeded and verifies the module tag resolves from a clean consumer module. Run it by hand with a `tag` input to re-check an existing tag. - Work on a branch, open a PR against `main`, and keep the diff scoped. Do not merge your own PR without review. - Do not add a `replace` directive for guard-core-go in a commit; it is consumed as a normal module dependency. - Do not commit generated files, `.DS_Store`, or stray artifacts. Stage explicit paths only.