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
name: Release
on:
push:
branches:
permissions:
contents: read
# One release at a time; do not cancel one mid-flight.
concurrency:
group: release
cancel-in-progress: false
env:
# Derive the image name from the repository so it tracks a rename automatically
# instead of drifting (the hardcoded name once lagged a rename and published under
# the old name). `github.repository` is `owner/repo`, lowercase as GHCR requires.
IMAGE: ghcr.io/${{ github.repository }}
jobs:
release:
name: Release
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
released: ${{ steps.release.outputs.released }}
version: ${{ steps.release.outputs.version }}
steps:
# The version-bump commit is pushed directly to `main`, which is protected by a
# branch ruleset that requires status checks. The built-in GITHUB_TOKEN cannot be
# granted a ruleset bypass, so mint a token for a GitHub App that is on the
# ruleset's bypass list and push with that instead.
- name: Mint a token for the release app
id: app-token
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- uses: actions/checkout@v7
with:
# Full history and tags so knope can read the commits since the last release.
fetch-depth: 0
# Push the release commit as the app so it bypasses the branch ruleset.
token: ${{ steps.app-token.outputs.token }}
- uses: knope-dev/action@v2.1.2
with:
version: 0.23.0
- name: Configure the release identity
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
# knope bumps the version, updates the changelog, tags, and creates the GitHub
# release from the conventional commits since the last tag. The push uses the app
# token, so - unlike the built-in token - it retriggers this workflow; that rerun
# finds no releasable commits and no-ops via `no_release`, so the publish job runs
# only in the run that actually released. `no_release` is a normal no-op, not a
# failure.
- name: Release
id: release
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
set +e
out="$(knope release 2>&1)"
code=$?
echo "$out"
if [ "$code" -eq 0 ]; then
# Read the version knope just bumped in the working tree. Do NOT derive it
# from `git tag`: with [github] configured, knope creates the tag via the
# GitHub API, so it never appears as a local tag in this checkout, and
# `git tag | head` would return the *previous* release instead.
version="$(grep -m1 '^version = ' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')"
echo "released=true" >> "$GITHUB_OUTPUT"
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "::notice::Released v${version}."
elif echo "$out" | grep -q 'no_release'; then
echo "::notice::No releasable commits since the last release; nothing to do."
echo "released=false" >> "$GITHUB_OUTPUT"
else
exit "$code"
fi
# Build and push the versioned image to GHCR, in the same run that cut the release, at
# the tag knope just created. The published GHCR package starts private; make it public
# once to allow anonymous `docker pull`.
publish:
name: Publish image
needs: release
if: needs.release.outputs.released == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v7
with:
ref: v${{ needs.release.outputs.version }}
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push the versioned image
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile
push: true
tags: |
${{ env.IMAGE }}:${{ needs.release.outputs.version }}
${{ env.IMAGE }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
# Publish the crate to crates.io (which feeds docs.rs) in the same run that cut the
# release, at the tag knope just created. Uses crates.io trusted publishing: GitHub's
# OIDC token is exchanged for a short-lived crates.io token, so no API token secret is
# stored. One-time bootstrap before this can work: publish once manually to claim the
# name (`cargo publish`), then add a GitHub Actions trusted publisher on the crate's
# crates.io settings for this repo and the Release workflow.
publish-crate:
name: Publish crate
needs: release
if: needs.release.outputs.released == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v7
with:
ref: v${{ needs.release.outputs.version }}
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Authenticate to crates.io
id: auth
uses: rust-lang/crates-io-auth-action@v1
- name: Publish to crates.io
run: cargo publish --locked
env:
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}