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
|
name: Release
run-name: Release v${{ github.event.inputs.version }} from ${{ github.ref_name }}
# THE ONLY workflow in this repository that declares workflow_dispatch.
#
# The rickub dispatch API (POST /api/v1/repos/{owner}/{repo}/actions/dispatch,
# and `rickub run dispatch`) triggers EVERY workflow_dispatch workflow on the
# ref, so keeping this trigger unique means a dispatch can never accidentally
# fan out into other pipelines. Cut releases from the web UI ("Run workflow"),
# which can target a single workflow. See RELEASING.md.
on:
workflow_dispatch:
inputs:
version:
description: "Version to release, without the leading v (e.g. 1.2.3)"
required: true
type: string
dry_run:
description: "Build and verify everything, but do not publish the release"
required: false
default: false
type: boolean
# Creating a release requires write access; the default token is read-only.
permissions:
contents: write
concurrency:
group: release-${{ github.event.inputs.version }}
cancel-in-progress: false
jobs:
release:
name: build and publish release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Full history + tags so the "tag already exists" check is meaningful.
fetch-depth: 0
- name: Validate inputs
id: check
env:
VERSION_INPUT: ${{ github.event.inputs.version }}
DRY_RUN_INPUT: ${{ github.event.inputs.dry_run }}
run: |
set -euo pipefail
version="$(printf '%s' "${VERSION_INPUT}" | tr -d ' \t\r\n')"
if [ -z "${version}" ]; then
echo "::error::version input is empty"
exit 1
fi
if [ "${version#v}" != "${version}" ]; then
echo "::error::Do not include the leading 'v'. Pass '${version#v}', not '${version}'."
exit 1
fi
if ! printf '%s' "${version}" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::version must be MAJOR.MINOR.PATCH (e.g. 1.2.3), got '${version}'"
exit 1
fi
tag="v${version}"
# Normalise the boolean input to an unambiguous string output;
# step outputs are always strings, so `!= 'true'` is safe downstream.
dry_run=false
case "$(printf '%s' "${DRY_RUN_INPUT}" | tr '[:upper:]' '[:lower:]')" in
true|1|yes|on) dry_run=true ;;
esac
# Cross-check the VERSION file. A mismatch is usually a forgotten bump
# PR; it is a warning, not a hard failure, so hotfixes stay possible.
if [ -f VERSION ]; then
base="$(tr -d ' \t\r\n' < VERSION)"
if [ "${base}" != "${version}" ]; then
echo "::warning::VERSION file says '${base}' but you are releasing '${version}'." \
"Open a PR bumping VERSION to '${version}' so future RCs are numbered from it."
fi
else
echo "::warning::No VERSION file at the repository root."
fi
{
echo "version=${version}"
echo "tag=${tag}"
echo "dry_run=${dry_run}"
} >> "$GITHUB_OUTPUT"
echo "Releasing ${tag} at ${GITHUB_SHA} (dry_run=${dry_run})"
- name: Refuse to overwrite an existing release
env:
TAG: ${{ steps.check.outputs.tag }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# 1. Local/remote git tag — authoritative and always available.
git fetch --tags --force --quiet || true
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null 2>&1; then
echo "::error::Tag ${TAG} already exists. Bump the version instead of re-releasing."
exit 1
fi
if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then
echo "::error::Tag ${TAG} already exists on the remote."
exit 1
fi
# 2. Release API — best effort. $GITHUB_API_URL already carries the
# /gh prefix of the rickub REST shim. Anything other than a clear
# 200 is treated as "cannot tell" so a shim/proxy hiccup does not
# block a legitimate release (the git tag check above is the gate).
code="$(curl -sS -o /tmp/release-probe.json -w '%{http_code}' \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" || echo 000)"
case "${code}" in
200)
echo "::error::A release for ${TAG} already exists (API returned 200)."
exit 1
;;
404)
echo "No existing release for ${TAG}."
;;
*)
echo "::warning::Could not check for an existing ${TAG} release (HTTP ${code});" \
"relying on the git tag check. A 403 here means the CI proxy still blocks" \
"/gh paths — see RELEASING.md."
;;
esac
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: go vet
run: go vet ./...
- name: go test
run: go test ./... -count=1
- name: Build distribution
run: bash scripts/build-dist.sh "${{ steps.check.outputs.version }}"
- name: Release notes
id: notes
env:
TAG: ${{ steps.check.outputs.tag }}
run: |
set -euo pipefail
{
echo "## rickub ${TAG}"
echo
echo "- Commit: \`${GITHUB_SHA}\` (\`${GITHUB_REF_NAME}\`)"
echo "- Built by \`${GITHUB_WORKFLOW}\` run #${GITHUB_RUN_NUMBER}"
echo
echo '### Install'
echo
echo 'Download the archive for your platform, verify it, and drop the binary on your PATH:'
echo
echo '```sh'
echo "tar -xzf rickub_${TAG#v}_\$(uname -s | tr '[:upper:]' '[:lower:]')_amd64.tar.gz"
echo 'sha256sum -c SHA256SUMS --ignore-missing'
echo 'install -m 0755 rickub /usr/local/bin/rickub'
echo '```'
echo
echo '### Checksums'
echo
echo '```'
cat dist/SHA256SUMS
echo '```'
} > "${RUNNER_TEMP}/release-notes.md"
echo "path=${RUNNER_TEMP}/release-notes.md" >> "$GITHUB_OUTPUT"
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: rickub-${{ steps.check.outputs.version }}
path: dist/
if-no-files-found: error
retention-days: 90
- name: Publish release
if: steps.check.outputs.dry_run != 'true'
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.check.outputs.tag }}
name: ${{ steps.check.outputs.tag }}
body_path: ${{ steps.notes.outputs.path }}
# The tag does not exist yet: the rickub release shim creates it at
# target_commitish, so the dispatched ref's commit is what gets tagged.
target_commitish: ${{ github.sha }}
draft: false
prerelease: false
files: |
dist/*.tar.gz
dist/SHA256SUMS
fail_on_unmatched_files: true
- name: Dry run summary
if: steps.check.outputs.dry_run == 'true'
run: |
set -euo pipefail
echo "::notice::Dry run — ${{ steps.check.outputs.tag }} was NOT published."
echo "Artifacts that would have been released:"
ls -l dist/
|