From 546fc6420f16fcb6bfd458afb2c17e9b314cdd7d Mon Sep 17 00:00:00 2001 From: Jack Sullivan Date: Thu, 30 Jul 2026 09:59:21 -0700 Subject: [PATCH] feat: release automatically on merge to main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Releasing required remembering to push a tag by hand, so a merged fix could sit unreleased indefinitely. Derive the version instead: the repo already enforces Conventional Commits, so the merged subject says what the bump should be. Add .github/next-version.sh, which maps the latest tag plus a commit subject to the next version — `!`/BREAKING to major, feat to minor, fix/perf to patch, everything else to no release, so docs- and CI-only merges do not cut one. It is a script rather than inline YAML so it can be tested directly; verified against 14 cases including scoped types, version rollover (v1.9.9 -> v1.10.0), prerelease tags, and malformed input. Split release.yml into a version job that decides and tags, and a release job gated on its output. The release job cannot be chained off the tag push: a tag pushed with GITHUB_TOKEN does not trigger another workflow run, so that arrangement would silently never build. It also checks out the tag rather than the branch head, so a release stays reproducible if main moves while the job runs. Manual `git tag && git push` still works for re-cuts. A duplicate tag is a no-op rather than a failed run. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VSrrciEDBTuFNMtKocScML --- .github/next-version.sh | 70 +++++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 61 +++++++++++++++++++++++++++--- README.md | 17 +++++++-- 3 files changed, 140 insertions(+), 8 deletions(-) create mode 100755 .github/next-version.sh diff --git a/.github/next-version.sh b/.github/next-version.sh new file mode 100755 index 0000000..e7f3ca4 --- /dev/null +++ b/.github/next-version.sh @@ -0,0 +1,70 @@ +#!/bin/sh +# Derive the next semver tag from the latest tag and a conventional commit +# subject. Prints the new tag (e.g. v1.2.0) on stdout, or nothing at all when +# the commit does not warrant a release. +# +# next-version.sh "" [] +# +# Bump rules, per Conventional Commits: +# `!` before the colon, or a BREAKING CHANGE trailer -> major +# feat -> minor +# fix, perf -> patch +# anything else (docs, ci, chore, refactor, ...) -> no release +# +# Kept as a script rather than inline YAML so it can be tested directly. +set -eu + +subject="${1:?usage: next-version.sh [latest-tag]}" +latest="${2:-}" + +if [ -z "$latest" ]; then + latest="$(git tag --list 'v*' --sort=-v:refname | head -n1)" +fi +[ -n "$latest" ] || latest="v0.0.0" + +# Strip the leading v and any trailing pre-release/build metadata. +core="${latest#v}" +core="${core%%-*}" +major="${core%%.*}" +rest="${core#*.}" +minor="${rest%%.*}" +patch="${rest#*.}" + +case "$major$minor$patch" in +*[!0-9]*) + echo "cannot parse tag '$latest' as vMAJOR.MINOR.PATCH" >&2 + exit 1 + ;; +esac + +# type(scope)?!?: description -- capture the type and whether ! is present. +type="$(printf '%s' "$subject" | sed -n 's/^\([a-z][a-z0-9-]*\)\((.*)\)\{0,1\}!\{0,1\}:.*/\1/p')" +breaking=no +case "$subject" in +*'!:'* | *'!):'*) breaking=yes ;; +esac +case "$subject" in +*'BREAKING CHANGE'*) breaking=yes ;; +esac + +if [ "$breaking" = yes ]; then + major=$((major + 1)) + minor=0 + patch=0 +else + case "$type" in + feat) + minor=$((minor + 1)) + patch=0 + ;; + fix | perf) + patch=$((patch + 1)) + ;; + *) + # Not a releasable change. + exit 0 + ;; + esac +fi + +printf 'v%s.%s.%s\n' "$major" "$minor" "$patch" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f20a9c5..6eb5f2c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,19 +1,70 @@ name: release -# Cutting a release is: git tag vX.Y.Z && git push origin vX.Y.Z +# Releases are automatic: merging a feat/fix/perf (or a breaking change) to main +# derives the next version, pushes that tag, and builds the release. Pushing a +# `v*` tag by hand still works for re-cuts and out-of-band releases. on: push: + branches: [main] tags: ["v*"] permissions: - contents: write # required to create the release and upload assets + contents: write # required to push the tag, create the release, and upload assets jobs: + # Decides the version. On a main-branch push it derives the next tag from the + # merged commit and pushes it; on a manual tag push it simply reports that tag. + # + # The release job below is gated on this job's output rather than on the tag + # push, because a tag pushed with GITHUB_TOKEN does not trigger another + # workflow run — chaining through the tag trigger would silently never build. + version: + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.pick.outputs.tag }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # need all tags to find the latest version + + - name: pick version + id: pick + env: + REF_TYPE: ${{ github.ref_type }} + REF_NAME: ${{ github.ref_name }} + SUBJECT: ${{ github.event.head_commit.message }} + run: | + if [ "$REF_TYPE" = tag ]; then + echo "manual tag push: $REF_NAME" + echo "tag=$REF_NAME" >> "$GITHUB_OUTPUT" + exit 0 + fi + + next="$(.github/next-version.sh "$SUBJECT")" + if [ -z "$next" ]; then + echo "commit is not a releasable change; no release" + exit 0 + fi + if git rev-parse -q --verify "refs/tags/$next" >/dev/null; then + echo "tag $next already exists; no release" + exit 0 + fi + + echo "tagging $next" + git tag "$next" + git push origin "$next" + echo "tag=$next" >> "$GITHUB_OUTPUT" + release: + needs: version + if: needs.version.outputs.tag != '' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: + # Build from the tag itself, not the branch head, so the release is + # reproducible even if main moves on while this job runs. + ref: ${{ needs.version.outputs.tag }} fetch-depth: 0 # full history so release notes can diff against the previous tag - uses: actions/setup-go@v5 @@ -27,7 +78,7 @@ jobs: # release, so refuse to build one that disagrees with the tag. - name: build release binaries env: - VERSION: ${{ github.ref_name }} + VERSION: ${{ needs.version.outputs.tag }} run: | mkdir -p dist for target in \ @@ -54,7 +105,7 @@ jobs: - name: verify version stamp matches tag env: - VERSION: ${{ github.ref_name }} + VERSION: ${{ needs.version.outputs.tag }} run: | go build -ldflags "-X main.version=${VERSION}" -o /tmp/commitlint . got="$(/tmp/commitlint version)" @@ -66,7 +117,7 @@ jobs: - name: publish release env: GH_TOKEN: ${{ github.token }} - VERSION: ${{ github.ref_name }} + VERSION: ${{ needs.version.outputs.tag }} run: | gh release create "$VERSION" \ --title "$VERSION" \ diff --git a/README.md b/README.md index 3288925..c93125e 100644 --- a/README.md +++ b/README.md @@ -153,9 +153,20 @@ tar -xzf commitlint_v1.0.1_linux_amd64.tar.gz ./commitlint_v1.0.1_linux_amd64/commitlint version ``` -Cutting a release is pushing a tag — `.github/workflows/release.yml` runs the -tests, cross-compiles, verifies the binary reports the tagged version, and -publishes: +Releases are **automatic on merge to `main`**. `.github/workflows/release.yml` +derives the next version from the merged commit's conventional type, pushes the +tag, runs the tests, cross-compiles, verifies the binary reports the tagged +version, and publishes: + +| Merged commit | Bump | Example | +|---|---|---| +| `feat!:` / `BREAKING CHANGE` | major | `v1.2.3` → `v2.0.0` | +| `feat:` | minor | `v1.2.3` → `v1.3.0` | +| `fix:` / `perf:` | patch | `v1.2.3` → `v1.2.4` | +| `docs:` / `ci:` / `chore:` / `refactor:` … | none | no release | + +Docs- and CI-only merges therefore do not cut releases. Pushing a tag by hand +still works, for re-cuts or out-of-band releases: ```sh git tag v1.0.1 && git push origin v1.0.1