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
# The whole release pipeline: there is no registry, so publishing is tagging
# and the tarball attached to the release page is the distribution.
#
# Both jobs run on every push to the trunk, and to a release/* line when a
# project keeps older lines. The release-request job drives git-cliff to
# maintain the one release pull request — VERSION plus the changelog — against
# the pushed branch, and merging that request is the release: the bump push is
# what makes tag-and-attach tag the commit, build the tarball with make dist,
# and attach it. On a release/* line the same pair backports a patch and tags
# it there.
name: release
permissions:
on:
push:
branches:
jobs:
# The release request. git-cliff maintains no pull request on its own, so
# this job computes the bump, rewrites VERSION and the changelog on a
# request branch, and opens or refreshes the pull request. Force-pushing the
# branch is the refresh: the request always reflects the pushed branch's tip.
release-request:
if: github.repository_owner == 'OWNER'
runs-on: ubuntu-latest
permissions:
contents: read
concurrency:
group: release-request-${{ github.ref }}
cancel-in-progress: false
steps:
# The mint is scoped, not blanket: without a permission-* input the token
# carries every permission the installation holds. This half pushes the
# bump branch and opens, refreshes, and arms the request, so it takes
# contents and pull-requests.
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
id: app-token
with:
app-id: ${{ secrets.RELEASE_BOT_APP_ID }}
private-key: ${{ secrets.RELEASE_BOT_APP_PRIVATE_KEY }}
permission-contents: write
permission-pull-requests: write
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0
# zizmor: ignore[artipacked] the persisted credential is what pushes
# the request branch below; persist-credentials: false breaks it.
token: ${{ steps.app-token.outputs.token }}
- name: install git-cliff
run: pipx install git-cliff==2.13.1
- name: maintain the release request
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
REF: ${{ github.ref_name }}
RELEASE_STYLE: RK_STYLE
run: |
set -eu
current="$(cat VERSION)"
next="$(git cliff --bumped-version | sed 's/^v//')"
if [ "$next" = "$current" ]; then
echo "no release-worthy commits since v$current; no request to maintain."
exit 0
fi
if [ -n "$(git ls-remote --tags origin "refs/tags/v$next")" ]; then
echo "v$next is already tagged; no request to maintain."
exit 0
fi
branch="chore/release-v$next"
git switch -c "$branch"
printf '%s\n' "$next" > VERSION
git cliff --bump -o CHANGELOG.md
uid="$(gh api "/users/${APP_SLUG}[bot]" -q .id)"
git config user.name "${APP_SLUG}[bot]"
git config user.email "${uid}+${APP_SLUG}[bot]@users.noreply.github.com"
git add VERSION CHANGELOG.md
git commit -m "chore(release): v$next"
git push -f origin "$branch"
open="$(gh pr list --base "$REF" --head "$branch" --state open --json number -q 'length')"
if [ "$open" -eq 0 ]; then
gh pr create --base "$REF" --head "$branch" \
--title "chore(release): v$next" \
--body "Bumps VERSION to $next and rewrites the changelog. Merging this request is the release: the bump push tags v$next and attaches the tarball."
fi
# The trunk style's standing arm, re-applied on every refresh; the
# app token is what keeps the eventual merge able to start this
# workflow's release half, and the lines style leaves the merge a
# human's.
if [ "$RELEASE_STYLE" != "trunk" ]; then
echo "the lines style merges each release by hand; not arming."
exit 0
fi
if [ "$REF" != "master" ]; then
echo "a line's request is never armed; its candidate is what a human validated."
exit 0
fi
gh pr merge "$branch" --auto --squash --delete-branch
echo "armed the release request for v$next."
# The release itself. Only the push that lands the bot's own bump releases —
# the VERSION comparison against the parent commit is the guard — so an
# ordinary work merge tags nothing. Tag the bump commit, build the tarball
# as a pure function of the tree, attest it, and only then attach it with
# its checksum: nothing is publicly reachable before its attestation exists.
tag-and-attach:
if: github.repository_owner == 'OWNER'
runs-on: ubuntu-latest
permissions:
contents: read
# Minting the build-provenance attestation: the OIDC token identifies
# this run to Sigstore, and the other two write the attestation back.
id-token: write
attestations: write
artifact-metadata: write
steps:
# This half tags the bump commit and creates the release, and opens no
# pull request, so contents alone carries it. A target whose tag ruleset
# blocks creation needs administration here too; the convention's ruleset
# restricts deletion and update only.
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
id: app-token
with:
app-id: ${{ secrets.RELEASE_BOT_APP_ID }}
private-key: ${{ secrets.RELEASE_BOT_APP_PRIVATE_KEY }}
permission-contents: write
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0
persist-credentials: false
- name: install git-cliff
run: pipx install git-cliff==2.13.1
- name: tag and build
id: build
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
GH_REPO: ${{ github.repository }}
SHA: ${{ github.sha }}
run: |
set -eu
version="$(cat VERSION)"
previous="$(git show "$SHA^:VERSION" 2>/dev/null || echo "")"
if [ "$previous" = "$version" ]; then
echo "this commit does not bump the version; nothing to release."
exit 0
fi
if [ -z "$(git ls-remote --tags origin "refs/tags/v$version")" ]; then
gh api -X POST "repos/$GH_REPO/git/refs" \
-f "ref=refs/tags/v$version" -f "sha=$SHA" >/dev/null
echo "tagged v$version at $SHA."
fi
git fetch origin "refs/tags/v$version:refs/tags/v$version"
make dist
git cliff --latest -o notes.md
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "built=true" >> "$GITHUB_OUTPUT"
# Provenance, minted before anything is publishable. The run that built
# the tarball signs it, so a consumer can check which workflow and commit
# produced it instead of trusting the release page the checksum also sits
# on. Attesting by hand is not possible: only the building run holds the
# identity that signs. The order is the rule, not a detail: the
# attestation exists before the release page does, so no public release
# ever points at an unattested tarball, not even between the steps of a
# failed run. A rerun rebuilds the identical tarball from the tag and
# re-attests it before touching the page.
- uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4
if: steps.build.outputs.built == 'true'
with:
subject-path: dist/*.tar.gz
- name: attach
if: steps.build.outputs.built == 'true'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
VERSION: ${{ steps.build.outputs.version }}
run: |
set -eu
if gh release view "v$VERSION" >/dev/null 2>&1; then
echo "the release page already exists; leaving it as it is."
exit 0
fi
gh release create "v$VERSION" dist/*.tar.gz dist/*.sha256 \
--title "v$VERSION" --notes-file notes.md