Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions .github/next-version.sh
Original file line number Diff line number Diff line change
@@ -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 "<subject>" [<latest-tag>]
#
# 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 <subject> [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"
61 changes: 56 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 \
Expand All @@ -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)"
Expand All @@ -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" \
Expand Down
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading