1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
name: CI
on:
push:
branches:
pull_request:
branches:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
# ── Detect whether this push/PR touches anything that affects the built binary ──
#
# Deliberately narrow: only source/dependency files count as "code". Pipeline
# files (this workflow included), scripts, and docs must never trigger a full
# lint+test+release cycle, even when they change build/release *logic* - a
# workflow YAML bug (like the one fixed for release.yml's idempotency) isn't
# something `cargo test`/`clippy` would catch anyway, so gating those files
# behind the Rust test suite never bought real protection.
#
# Jobs below are still triggered normally and report a `skipped` conclusion
# when `code` is false - unlike relying on paths-ignore alone, a `skipped` job
# still posts a check run, so required status checks in branch protection are
# satisfied instead of hanging as "expected but never reported".
changes:
name: Detect code changes
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
code: ${{ steps.filter.outputs.code }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
- uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v3
id: filter
with:
filters: |
code:
- "src/**"
- "Cargo.toml"
- "Cargo.lock"
- "build.rs"
- "rust-toolchain.toml"
# ── Code format & lint ────────────────────────────────────────────────────────
lint:
name: Lint & Format
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
- name: Install Rust stable (rustfmt + clippy)
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Cache Cargo registry and build artifacts
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2
- name: Check formatting
run: cargo fmt --all -- --check
- name: Clippy (deny warnings)
run: cargo clippy --all-targets --all-features -- -D warnings
# ── Unit tests - Windows · Linux · macOS ─────────────────────────────────────
#
# Intentionally no job-level `if` here (unlike `lint`): a job-level `if` on a
# matrix job skips the whole job *before* the matrix expands, which posts a
# single check run literally named "Test (${{ matrix.os }})" instead of the
# three required contexts ("Test (ubuntu-latest)" etc.) - those then stay
# "Expected" forever and block merging. Guarding every step instead lets the
# matrix expand normally; each OS still gets its own named check, it just
# completes almost instantly with nothing to do when code changes are absent.
test:
name: Test (${{ matrix.os }})
needs: changes
runs-on: ${{ matrix.os }}
permissions:
contents: read
strategy:
fail-fast: false
matrix:
os:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
if: needs.changes.outputs.code == 'true'
- name: Install Rust stable
if: needs.changes.outputs.code == 'true'
uses: dtolnay/rust-toolchain@stable
- name: Cache Cargo registry and build artifacts
if: needs.changes.outputs.code == 'true'
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2
with:
key: ${{ matrix.os }}
- name: Run unit tests
if: needs.changes.outputs.code == 'true'
run: cargo test --all-targets -- --nocapture
- name: Verify release build compiles
if: needs.changes.outputs.code == 'true'
run: cargo build --release
# ── Auto-tag + release (push to main only) ────────────────────────────────────
#
# Reads conventional commits since the last tag and computes the next semver.
# Creates an annotated tag on the current HEAD and pushes it - no commit to
# main is needed, so branch protection is never triggered.
# Pushing the tag fires release.yml which cross-compiles and publishes binaries.
#
# Bump rules (both the commit prefix AND an actual code/dependency file
# change are required - see the file-path check in "Determine version bump"):
# feat!: / BREAKING CHANGE -> major
# feat: -> minor
# fix: / perf: / refactor: / security: -> patch
# docs: / chore: / ci:, or no src/Cargo.*/build.rs/rust-toolchain.toml change -> no release
auto-release:
name: Auto Tag & Release
needs:
# lint/test are skipped (not run) for pushes that only touch non-code paths
# (see the `changes` job) - `always()` plus explicit result checks lets this
# job proceed on a skip, since the default implicit `success()` condition
# would otherwise treat "skipped" as blocking, same as a failure.
if: |
always() &&
(needs.lint.result == 'success' || needs.lint.result == 'skipped') &&
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
github.ref == 'refs/heads/main' &&
github.event_name == 'push' &&
github.actor != 'github-actions[bot]'
runs-on: ubuntu-latest
permissions:
contents: write
actions: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
with:
fetch-depth: 0
# ── 1. Determine bump type from Conventional Commits ─────────────────────
- name: Determine version bump
id: bump
run: |
set -euo pipefail
LATEST_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | sort -V | tail -1)
[ -z "$LATEST_TAG" ] && LATEST_TAG="v0.0.0"
echo "Latest tag: $LATEST_TAG"
if git rev-parse "${LATEST_TAG}" >/dev/null 2>&1; then
COMMITS=$(git log "${LATEST_TAG}..HEAD" --format="%s %b")
else
COMMITS=$(git log --format="%s %b")
fi
echo "Commits since ${LATEST_TAG}:"
echo "$COMMITS"
BUMP="none"
if echo "$COMMITS" | grep -qE '^[a-z]+(\([^)]*\))?!:|BREAKING.CHANGE'; then
BUMP="major"
elif echo "$COMMITS" | grep -qE '^feat(\([^)]*\))?:'; then
BUMP="minor"
elif echo "$COMMITS" | grep -qE '^(fix|perf|refactor|security)(\([^)]*\))?:'; then
BUMP="patch"
fi
# A Conventional Commit prefix alone is not enough: require that at
# least one file actually relevant to the built binary changed too.
# Otherwise e.g. "fix(ci): ..." on a workflow-only change would tag
# and publish a release with no code difference from the last one.
if [ "$BUMP" != "none" ]; then
if git rev-parse "${LATEST_TAG}" >/dev/null 2>&1; then
CHANGED_FILES=$(git diff --name-only "${LATEST_TAG}..HEAD")
else
CHANGED_FILES=$(git log --name-only --format="")
fi
echo "Changed files since ${LATEST_TAG}:"
echo "$CHANGED_FILES"
if ! echo "$CHANGED_FILES" | grep -qE '^(src/|Cargo\.toml$|Cargo\.lock$|build\.rs$|rust-toolchain\.toml$)'; then
echo "Commit prefix implies a release, but no code/dependency files changed - skipping."
BUMP="none"
fi
fi
echo "Bump type: $BUMP"
echo "bump=${BUMP}" >> "$GITHUB_OUTPUT"
echo "latest_tag=${LATEST_TAG}" >> "$GITHUB_OUTPUT"
# ── 2. Compute the new version number ────────────────────────────────────
- name: Compute new version
id: version
if: steps.bump.outputs.bump != 'none'
run: |
LATEST="${{ steps.bump.outputs.latest_tag }}"
VER="${LATEST#v}"
MAJOR=$(echo "$VER" | cut -d. -f1)
MINOR=$(echo "$VER" | cut -d. -f2)
PATCH=$(echo "$VER" | cut -d. -f3)
case "${{ steps.bump.outputs.bump }}" in
major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;;
minor) MINOR=$((MINOR + 1)); PATCH=0 ;;
patch) PATCH=$((PATCH + 1)) ;;
esac
NEW_TAG="v${MAJOR}.${MINOR}.${PATCH}"
echo "New tag: $NEW_TAG"
echo "new_tag=${NEW_TAG}" >> "$GITHUB_OUTPUT"
# ── 3. Create annotated tag and push it ──────────────────────────────────
#
# Pushing a tag does not touch any branch, so branch protection rules
# are not involved.
#
# NOTE: GITHUB_TOKEN tag pushes do not trigger downstream workflows
# (GitHub anti-loop policy). release.yml is dispatched explicitly in
# the next step instead of relying on the push event.
- name: Create and push version tag
if: steps.bump.outputs.bump != 'none'
run: |
NEW_TAG="${{ steps.version.outputs.new_tag }}"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag -a "$NEW_TAG" -m "Release ${NEW_TAG}"
git push origin "$NEW_TAG"
echo "Tag ${NEW_TAG} pushed"
# ── 4. Dispatch release.yml for the new tag ──────────────────────────────
#
# GITHUB_TOKEN tag pushes do not fire push.tags triggers in other
# workflows. Dispatch explicitly so the cross-platform build runs.
- name: Dispatch release workflow
if: steps.bump.outputs.bump != 'none'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
NEW_TAG="${{ steps.version.outputs.new_tag }}"
gh workflow run release.yml \
--repo "${{ github.repository }}" \
--ref "$NEW_TAG"
echo "Dispatched release.yml for ${NEW_TAG}"
- name: Skip message
if: steps.bump.outputs.bump == 'none'
run: |
echo "No releasable commits since ${{ steps.bump.outputs.latest_tag }}."
echo "Only docs/chore/ci commits found - skipping version bump."