release: auto-tag and publish on every green ci run
- release.yml now triggers on workflow_run of ci (completed+success), not on bare push, so we never publish binaries of broken code - checks out github.event.workflow_run.head_sha, computes the next vX.Y.Z by bumping the patch of the newest existing tag (starts at v0.0.1), pushes the tag, runs goreleaser - supports [skip release] in the commit message for docs/ci-only commits that shouldn't cut a version
- commit
- 63547d43ba5a11b3a79adc305e90927febfa4ed8
- author
- patriceckhart · · 2026-04-18 09:13 UTC
- parent
- c0adbc4
- signature
- unsigned
1 file · +69 -10
M .github/workflows/release.yml +69 -10
| @@ -1,24 +1,84 @@ | |||
| 1 | 1 | name: release | |
| 2 | 2 | ||
| 3 | + | # Every push to main that passes CI cuts a new release. The workflow: | |
| 4 | + | # 1. waits for the ci workflow on the same commit to succeed | |
| 5 | + | # 2. looks up the latest existing vX.Y.Z tag | |
| 6 | + | # 3. bumps the patch number (vX.Y.Z+1) — or starts at v0.0.1 if none | |
| 7 | + | # 4. creates and pushes that tag | |
| 8 | + | # 5. runs goreleaser, which builds binaries for linux/darwin/windows | |
| 9 | + | # (amd64 + arm64) and attaches them plus checksums.txt to a fresh | |
| 10 | + | # GitHub Release page | |
| 11 | + | # | |
| 12 | + | # Skip a release by putting [skip release] anywhere in the commit | |
| 13 | + | # message — useful for docs-only or ci-only commits. | |
| 3 | 14 | on: | |
| 4 | - | push: | |
| 5 | - | tags: | |
| 6 | - | - "v*" | |
| 15 | + | workflow_run: | |
| 16 | + | workflows: [ci] | |
| 17 | + | types: [completed] | |
| 18 | + | branches: [main] | |
| 7 | 19 | ||
| 8 | 20 | permissions: | |
| 9 | - | # Needed for GoReleaser to create the GitHub Release and upload assets. | |
| 21 | + | # Needed to create tags and to upload release archives. | |
| 10 | 22 | contents: write | |
| 11 | 23 | ||
| 24 | + | # Only one release job at a time. If two pushes land in quick | |
| 25 | + | # succession, queue them so they don't race on the tag. | |
| 26 | + | concurrency: | |
| 27 | + | group: release | |
| 28 | + | cancel-in-progress: false | |
| 29 | + | ||
| 12 | 30 | jobs: | |
| 13 | - | goreleaser: | |
| 31 | + | release: | |
| 32 | + | # Only run when ci succeeded and we're on main, and when the head | |
| 33 | + | # commit doesn't opt out via [skip release]. | |
| 34 | + | if: >- | |
| 35 | + | github.event.workflow_run.conclusion == 'success' && | |
| 36 | + | github.event.workflow_run.head_branch == 'main' && | |
| 37 | + | !contains(github.event.workflow_run.head_commit.message, '[skip release]') | |
| 14 | 38 | runs-on: ubuntu-latest | |
| 15 | 39 | steps: | |
| 16 | 40 | - name: checkout | |
| 17 | 41 | uses: actions/checkout@v4 | |
| 18 | 42 | with: | |
| 19 | - | # GoReleaser needs the full history to build a useful | |
| 20 | - | # changelog from commit messages. | |
| 43 | + | # Full history so we can read existing tags. | |
| 21 | 44 | fetch-depth: 0 | |
| 45 | + | # Check out the exact sha that ci verified, not whatever the | |
| 46 | + | # branch tip happens to be at this moment (which may have | |
| 47 | + | # advanced since ci finished). | |
| 48 | + | ref: ${{ github.event.workflow_run.head_sha }} | |
| 49 | + | ||
| 50 | + | - name: compute next version | |
| 51 | + | id: bump | |
| 52 | + | shell: bash | |
| 53 | + | run: | | |
| 54 | + | set -euo pipefail | |
| 55 | + | # Find the newest vX.Y.Z tag. If the repo has none yet, start | |
| 56 | + | # at v0.0.0 so the first release becomes v0.0.1. | |
| 57 | + | latest=$(git tag --list 'v*.*.*' --sort=-v:refname | head -n1) | |
| 58 | + | if [ -z "$latest" ]; then | |
| 59 | + | latest="v0.0.0" | |
| 60 | + | fi | |
| 61 | + | echo "latest tag: $latest" | |
| 62 | + | ||
| 63 | + | # Strip the v prefix, split on dots, bump the patch. | |
| 64 | + | ver="${latest#v}" | |
| 65 | + | IFS=. read -r major minor patch <<<"$ver" | |
| 66 | + | patch=$((patch + 1)) | |
| 67 | + | next="v${major}.${minor}.${patch}" | |
| 68 | + | echo "next tag: $next" | |
| 69 | + | ||
| 70 | + | echo "next=$next" >> "$GITHUB_OUTPUT" | |
| 71 | + | echo "version=${next#v}" >> "$GITHUB_OUTPUT" | |
| 72 | + | ||
| 73 | + | - name: create and push tag | |
| 74 | + | env: | |
| 75 | + | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| 76 | + | run: | | |
| 77 | + | git config user.name "github-actions[bot]" | |
| 78 | + | git config user.email "github-actions[bot]@users.noreply.github.com" | |
| 79 | + | git tag -a "${{ steps.bump.outputs.next }}" \ | |
| 80 | + | -m "release ${{ steps.bump.outputs.next }}" | |
| 81 | + | git push origin "${{ steps.bump.outputs.next }}" | |
| 22 | 82 | ||
| 23 | 83 | - name: set up go | |
| 24 | 84 | uses: actions/setup-go@v5 | |
| @@ -33,11 +93,10 @@ jobs: | |||
| 33 | 93 | version: latest | |
| 34 | 94 | args: release --clean | |
| 35 | 95 | env: | |
| 36 | - | # Default token for the release itself (uploads artifacts, | |
| 37 | - | # creates the release page). | |
| 96 | + | # goreleaser reads the tag we just pushed and builds binaries | |
| 97 | + | # for every (goos, goarch) combination in .goreleaser.yaml. | |
| 38 | 98 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| 39 | 99 | # Optional: PAT with `repo` scope for patriceckhart/homebrew-tap | |
| 40 | 100 | # so we can push a Formula/zot.rb update on every release. | |
| 41 | - | # Create the secret in repo settings → actions → secrets. | |
| 42 | 101 | # If unset, goreleaser skips the brew step (skip_upload: auto). | |
| 43 | 102 | HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} | |
| @@ -1,24 +1,84 @@ | |||||
| 1 | name: release | 1 | name: release | ||
| 2 | 2 | ||||
| 3 | + | # Every push to main that passes CI cuts a new release. The workflow: | |||
| 4 | + | # 1. waits for the ci workflow on the same commit to succeed | |||
| 5 | + | # 2. looks up the latest existing vX.Y.Z tag | |||
| 6 | + | # 3. bumps the patch number (vX.Y.Z+1) — or starts at v0.0.1 if none | |||
| 7 | + | # 4. creates and pushes that tag | |||
| 8 | + | # 5. runs goreleaser, which builds binaries for linux/darwin/windows | |||
| 9 | + | # (amd64 + arm64) and attaches them plus checksums.txt to a fresh | |||
| 10 | + | # GitHub Release page | |||
| 11 | + | # | |||
| 12 | + | # Skip a release by putting [skip release] anywhere in the commit | |||
| 13 | + | # message — useful for docs-only or ci-only commits. | |||
| 3 | on: | 14 | on: | ||
| 4 | - | push: | 15 | + | workflow_run: |
| 5 | - | tags: | 16 | + | workflows: [ci] |
| 6 | - | - "v*" | 17 | + | types: [completed] |
| 18 | + | branches: [main] | |||
| 7 | 19 | ||||
| 8 | permissions: | 20 | permissions: | ||
| 9 | - | # Needed for GoReleaser to create the GitHub Release and upload assets. | 21 | + | # Needed to create tags and to upload release archives. |
| 10 | contents: write | 22 | contents: write | ||
| 11 | 23 | ||||
| 24 | + | # Only one release job at a time. If two pushes land in quick | |||
| 25 | + | # succession, queue them so they don't race on the tag. | |||
| 26 | + | concurrency: | |||
| 27 | + | group: release | |||
| 28 | + | cancel-in-progress: false | |||
| 29 | + | ||||
| 12 | jobs: | 30 | jobs: | ||
| 13 | - | goreleaser: | 31 | + | release: |
| 32 | + | # Only run when ci succeeded and we're on main, and when the head | |||
| 33 | + | # commit doesn't opt out via [skip release]. | |||
| 34 | + | if: >- | |||
| 35 | + | github.event.workflow_run.conclusion == 'success' && | |||
| 36 | + | github.event.workflow_run.head_branch == 'main' && | |||
| 37 | + | !contains(github.event.workflow_run.head_commit.message, '[skip release]') | |||
| 14 | runs-on: ubuntu-latest | 38 | runs-on: ubuntu-latest | ||
| 15 | steps: | 39 | steps: | ||
| 16 | - name: checkout | 40 | - name: checkout | ||
| 17 | uses: actions/checkout@v4 | 41 | uses: actions/checkout@v4 | ||
| 18 | with: | 42 | with: | ||
| 19 | - | # GoReleaser needs the full history to build a useful | 43 | + | # Full history so we can read existing tags. |
| 20 | - | # changelog from commit messages. | |||
| 21 | fetch-depth: 0 | 44 | fetch-depth: 0 | ||
| 45 | + | # Check out the exact sha that ci verified, not whatever the | |||
| 46 | + | # branch tip happens to be at this moment (which may have | |||
| 47 | + | # advanced since ci finished). | |||
| 48 | + | ref: ${{ github.event.workflow_run.head_sha }} | |||
| 49 | + | ||||
| 50 | + | - name: compute next version | |||
| 51 | + | id: bump | |||
| 52 | + | shell: bash | |||
| 53 | + | run: | | |||
| 54 | + | set -euo pipefail | |||
| 55 | + | # Find the newest vX.Y.Z tag. If the repo has none yet, start | |||
| 56 | + | # at v0.0.0 so the first release becomes v0.0.1. | |||
| 57 | + | latest=$(git tag --list 'v*.*.*' --sort=-v:refname | head -n1) | |||
| 58 | + | if [ -z "$latest" ]; then | |||
| 59 | + | latest="v0.0.0" | |||
| 60 | + | fi | |||
| 61 | + | echo "latest tag: $latest" | |||
| 62 | + | ||||
| 63 | + | # Strip the v prefix, split on dots, bump the patch. | |||
| 64 | + | ver="${latest#v}" | |||
| 65 | + | IFS=. read -r major minor patch <<<"$ver" | |||
| 66 | + | patch=$((patch + 1)) | |||
| 67 | + | next="v${major}.${minor}.${patch}" | |||
| 68 | + | echo "next tag: $next" | |||
| 69 | + | ||||
| 70 | + | echo "next=$next" >> "$GITHUB_OUTPUT" | |||
| 71 | + | echo "version=${next#v}" >> "$GITHUB_OUTPUT" | |||
| 72 | + | ||||
| 73 | + | - name: create and push tag | |||
| 74 | + | env: | |||
| 75 | + | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |||
| 76 | + | run: | | |||
| 77 | + | git config user.name "github-actions[bot]" | |||
| 78 | + | git config user.email "github-actions[bot]@users.noreply.github.com" | |||
| 79 | + | git tag -a "${{ steps.bump.outputs.next }}" \ | |||
| 80 | + | -m "release ${{ steps.bump.outputs.next }}" | |||
| 81 | + | git push origin "${{ steps.bump.outputs.next }}" | |||
| 22 | 82 | ||||
| 23 | - name: set up go | 83 | - name: set up go | ||
| 24 | uses: actions/setup-go@v5 | 84 | uses: actions/setup-go@v5 | ||
| @@ -33,11 +93,10 @@ jobs: | |||||
| 33 | version: latest | 93 | version: latest | ||
| 34 | args: release --clean | 94 | args: release --clean | ||
| 35 | env: | 95 | env: | ||
| 36 | - | # Default token for the release itself (uploads artifacts, | 96 | + | # goreleaser reads the tag we just pushed and builds binaries |
| 37 | - | # creates the release page). | 97 | + | # for every (goos, goarch) combination in .goreleaser.yaml. |
| 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | 98 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| 39 | # Optional: PAT with `repo` scope for patriceckhart/homebrew-tap | 99 | # Optional: PAT with `repo` scope for patriceckhart/homebrew-tap | ||
| 40 | # so we can push a Formula/zot.rb update on every release. | 100 | # so we can push a Formula/zot.rb update on every release. | ||
| 41 | - | # Create the secret in repo settings → actions → secrets. | |||
| 42 | # If unset, goreleaser skips the brew step (skip_upload: auto). | 101 | # If unset, goreleaser skips the brew step (skip_upload: auto). | ||
| 43 | HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} | 102 | HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} | ||