-
Notifications
You must be signed in to change notification settings - Fork 0
242 lines (223 loc) · 10.1 KB
/
Copy pathrelease.yml
File metadata and controls
242 lines (223 loc) · 10.1 KB
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
name: Release
# Cut a release from the `release` branch, tagging it only once everything has passed.
#
# `release` is the integration branch: push the candidate there — its tip being the
# `task-rs: release X.Y.Z` commit that bumps Cargo.toml and dates the CHANGELOG section —
# and this workflow gates it, then does the writes a human used to do by hand:
# fast-forward `main` to it, push the `vX.Y.Z` tag, publish the GitHub release. A failed
# candidate is fixed and pushed to `release` again; no tag existed for it, so no tag is
# ever moved or force-pushed. Only the newest candidate runs (concurrency below), and
# `main` is checked against the sha it had when the pipeline started, so a push there
# mid-pipeline fails the publish instead of being silently rebased over.
#
# prepare derives the tag from Cargo.toml and refuses a candidate that is already
# released, has no CHANGELOG section, or does not contain main — in seconds, before
# anything compiles. quality (fmt, clippy, test, audit) and build (the full platform
# matrix, with the Linux binaries rebuilt from a clean copy and required to reproduce
# byte-for-byte) run in parallel. e2e then unpacks the very archives publish uploads and
# drives the shipped `task` binary. publish runs once all of them have passed.
on:
push:
branches:
- release
# One candidate at a time, the newest: a second push to `release` while a pipeline runs
# supersedes it, so two pipelines never race for the same tag or for `main`. The write
# phase is not exempt: a cancel landing in publish's few seconds can leave the tag pushed
# with no release. Every step there is idempotent, so the fix is to re-run the cancelled
# run's failed jobs.
concurrency:
group: release
cancel-in-progress: true
# Default to read-only; the publish job opts up to contents: write below.
permissions:
contents: read
jobs:
# Validate the candidate before the long jobs start. Derive the tag from the binary
# version, which `task --update` compares against the release tag.
prepare:
runs-on: ubuntu-24.04
timeout-minutes: 10
outputs:
tag: ${{ steps.candidate.outputs.tag }}
main-sha: ${{ steps.candidate.outputs.main-sha }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Check the candidate
id: candidate
run: |
set -euo pipefail
version=$(sed -nE '/^\[workspace\.package\]/,/^\[/ s/^version = "(.*)"$/\1/p' Cargo.toml)
[ -n "$version" ] || { echo "no [workspace.package] version in Cargo.toml" >&2; exit 1; }
# Validate before using the version in a tag or $GITHUB_OUTPUT: duplicate
# version lines could inject a newline and a second output key.
[[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] ||
{ echo "not a release version: $version" >&2; exit 1; }
tag="v$version"
echo "candidate: $tag at $GITHUB_SHA"
# --exit-code is 2 for "no such tag" but 128 for a transport error, and only the
# first means the tag is free.
rc=0
git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null || rc=$?
case $rc in
0) echo "$tag is already released; bump the version in Cargo.toml for a new release" >&2; exit 1 ;;
2) ;;
*) echo "cannot reach origin to check $tag (git ls-remote exit $rc)" >&2; exit 1 ;;
esac
# Require a dated heading; release-notes.sh checks for a body. Escape version
# dots so the pattern matches them literally.
grep -qE "^## $(printf '%s' "$tag" | sed 's/\./\\./g') - [0-9]{4}-[0-9]{2}-[0-9]{2}$" CHANGELOG.md || {
echo "CHANGELOG.md has no dated '## $tag - YYYY-MM-DD' section" >&2
exit 1
}
./release-notes.sh "$tag" > /dev/null
git fetch --quiet origin +refs/heads/main:refs/remotes/origin/main
main_sha=$(git rev-parse origin/main)
git merge-base --is-ancestor "$main_sha" HEAD || {
echo "the candidate does not contain main ($main_sha); rebase it onto main" >&2
exit 1
}
{
echo "tag=$tag"
echo "main-sha=$main_sha"
} >> "$GITHUB_OUTPUT"
# Run the same fmt, clippy, unit-test and audit checks as CI.
quality:
needs: [prepare]
uses: ./.github/workflows/quality.yml
build:
needs: [prepare]
uses: ./.github/workflows/build.yml
with:
artifact-prefix: release
retention-days: 3
verify-reproducible: true
# Test the built archives that publish uploads, without rebuilding. The script checks
# sha256 sidecars and the binary version against the tag before running the tests.
e2e:
needs: [prepare, quality, build]
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
# No token in .git/config: this job unpacks and executes a downloaded binary.
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Download release assets
uses: actions/download-artifact@v7
with:
pattern: release-*-${{ github.sha }}
path: dist
merge-multiple: true
- name: Run the end-to-end gate against the built archives
env:
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
run: tests/release-e2e.sh
# The writes, in the order that never produces a wrong tag: main first — a refused
# fast-forward means main moved and nothing has been published — then the tag, then the
# release. A failure after the first write leaves main advanced with no release yet;
# every step is idempotent, so re-running this job from the Actions UI finishes it.
publish:
needs: [prepare, quality, build, e2e]
runs-on: ubuntu-24.04
timeout-minutes: 20
# git push and gh release create need write access to the repo's refs and releases.
permissions:
contents: write
env:
TAG: ${{ needs.prepare.outputs.tag }}
MAIN_SHA: ${{ needs.prepare.outputs.main-sha }}
steps:
# The full history: a fast-forward push has to prove to the server that main's tip
# is an ancestor of the candidate.
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Download release assets
uses: actions/download-artifact@v7
with:
pattern: release-*-${{ github.sha }}
path: dist
merge-multiple: true
# Check every asset before advancing main or tagging: the sidecar loop cannot
# detect missing uploads, which would otherwise fail only at release creation.
# Save the list for that step to reuse.
- name: All release assets are present
run: |
set -euo pipefail
assets=(
dist/task-linux-x86_64.tar.gz
dist/task-linux-x86_64.sha256
dist/task-linux-aarch64.tar.gz
dist/task-linux-aarch64.sha256
dist/task-macos-x86_64.tar.gz
dist/task-macos-x86_64.sha256
dist/task-macos-aarch64.tar.gz
dist/task-macos-aarch64.sha256
dist/task-windows-x86_64.zip
dist/task-windows-x86_64.sha256
dist/task-windows-aarch64.zip
dist/task-windows-aarch64.sha256
dist/task-linux-x86_64.build-info.txt
dist/task-linux-aarch64.build-info.txt
)
for a in "${assets[@]}"; do
[ -f "$a" ] || { echo "missing release asset: $a" >&2; exit 1; }
done
printf '%s\n' "${assets[@]}" > "$RUNNER_TEMP/assets.txt"
echo "all ${#assets[@]} release assets present"
- name: Verify the archives against their sidecars
run: |
set -euo pipefail
cd dist
for sidecar in task-*.sha256; do sha256sum -c "$sidecar"; done
- name: Release notes from CHANGELOG
run: ./release-notes.sh "$TAG" > "$RUNNER_TEMP/release-notes.md"
# Refused unless main is still where prepare saw it — a push there mid-pipeline
# means the candidate no longer contains main, and the fast-forward below would fail
# anyway; this names the cause. A re-run after a successful publish sees main already
# at HEAD.
- name: Fast-forward main to the candidate
run: |
set -euo pipefail
git fetch --quiet origin +refs/heads/main:refs/remotes/origin/main
now=$(git rev-parse origin/main)
if [ "$now" != "$MAIN_SHA" ] && [ "$now" != "$GITHUB_SHA" ]; then
echo "main moved from $MAIN_SHA to $now since the pipeline started; rebase and push release again" >&2
exit 1
fi
git push origin "HEAD:refs/heads/main"
# ls-remote returns the commit for a lightweight tag, so a re-run can compare it
# directly against the candidate.
- name: Push the tag
run: |
set -euo pipefail
# As in prepare, distinguish transport errors from a missing tag.
rc=0
existing=$(git ls-remote --exit-code --tags origin "refs/tags/$TAG" | cut -f1) || rc=$?
[ "$rc" -eq 0 ] || [ "$rc" -eq 2 ] || { echo "cannot reach origin to check $TAG (git ls-remote exit $rc)" >&2; exit 1; }
if [ -n "$existing" ]; then
[ "$existing" = "$GITHUB_SHA" ] || { echo "$TAG already exists at $existing" >&2; exit 1; }
echo "$TAG already at $GITHUB_SHA"
exit 0
fi
git tag "$TAG" "$GITHUB_SHA"
git push origin "refs/tags/$TAG"
# Upload to a draft first so a partial release stays unpublished.
- name: Create the release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
run: |
set -euo pipefail
# Reuse the asset list checked before advancing main or tagging.
mapfile -t assets < "$RUNNER_TEMP/assets.txt"
if gh release view "$TAG" >/dev/null 2>&1; then
gh release upload "$TAG" "${assets[@]}" --clobber
else
gh release create "$TAG" "${assets[@]}" \
--draft --title "$TAG" --verify-tag \
--notes-file "$RUNNER_TEMP/release-notes.md"
fi
gh release edit "$TAG" --draft=false