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
name: GitHub Release
# Cut a GitHub Release for a version. Triggered two ways:
# * `workflow_call` from auto-release.yml, right after it pushes the tag on a
# version bump (the automated path — see auto-release.yml). That commit
# already passed lint/test before landing on main (via cut-release.yml's
# `needs: [version, lint, test]` gate and/or PR branch protection), so
# re-running here would be redundant — skipped below.
# * a manual `v*` tag push (escape hatch for hotfixes cut by hand). Unlike
# the automated path, this can tag *any* commit, verified or not, so this
# is where the gap was (see #260): a release could be cut without ever
# checking cargo test/fmt/clippy. Fixed by calling lint.yml/test.yml
# (already workflow_call-callable) and gating `release` on them via
# `needs:`.
# Release notes are extracted from the matching version section in CHANGELOG.md
# so the published notes stay in sync with the changelog. Runs independently of
# publish.yml — neither blocks the other.
#
# The `lint`/`test` skip below keys off `inputs.tag`, not `github.event_name`:
# a *nested* `workflow_call` (this workflow, called from auto-release.yml,
# itself triggered by a `push` to main) inherits `github.event_name` from the
# chain's originating event — it reads `push` here too, not `workflow_call` —
# so `github.event_name == 'push'` was always true and never actually skipped
# anything. That silently ran this workflow's own `test.yml` call alongside
# publish.yml's identical call *and* the plain push-triggered `test.yml`, all
# three racing for the same `test-${{ github.ref }}` concurrency group with
# `cancel-in-progress: true` — auto-release.yml's `publish`/`release` jobs
# routinely lost that race. `inputs` is only populated on the `workflow_call`
# path (`required: true` there), so `inputs.tag == ''` reliably means "not
# called with a tag" — i.e. the direct tag-push escape hatch — regardless of
# what `github.event_name` reports.
on:
push:
tags:
- 'v*'
workflow_call:
inputs:
tag:
description: Tag to release (e.g. v0.14.0)
required: true
type: string
permissions:
contents: write
jobs:
# Gate for the manual `push: tags` escape hatch only — see #260. Skipped
# entirely on the `workflow_call` path since that commit is already verified
# (see header comment above).
lint:
name: Lint tagged commit
if: inputs.tag == ''
uses: ./.github/workflows/lint.yml
test:
name: Test tagged commit
if: inputs.tag == ''
uses: ./.github/workflows/test.yml
release:
name: Create GitHub Release
needs:
# Run if lint/test passed (push path) or were skipped (workflow_call path).
# Do NOT use the default `success()` here: a skipped need would otherwise
# make this job skip too.
if: always() && (needs.lint.result == 'success' || needs.lint.result == 'skipped') && (needs.test.result == 'success' || needs.test.result == 'skipped')
runs-on: ubuntu-latest
env:
TAG: ${{ inputs.tag || github.ref_name }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Extract changelog section for this tag
id: notes
run: |
VERSION=${TAG#v}
# Pull lines between '## [VERSION]' and the next '## [' heading.
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { capture=1; next }
capture && /^## \[/ { exit }
capture { print }
' CHANGELOG.md > release-notes.md
if [ ! -s release-notes.md ]; then
echo "No changelog section found for [$VERSION]" >&2
exit 1
fi
echo "Release notes for $VERSION:"
cat release-notes.md
- name: Create release
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
tag_name: ${{ env.TAG }}
name: ${{ env.TAG }}
body_path: release-notes.md