-
Notifications
You must be signed in to change notification settings - Fork 33
297 lines (281 loc) · 14.1 KB
/
Copy pathrelease.yml
File metadata and controls
297 lines (281 loc) · 14.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
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
name: Create Release
# Manual-only release pathway. A push or PR to develop runs NORMAL CI only
# (Generate Artifacts: regen bindings + tests) and never releases. To cut a
# release, dispatch this workflow by hand (GitHub UI, or
# `gh workflow run "Create Release"`). With the version input left empty, it
# reads the version from the checked-out ref's pubspec.yaml and tags the develop
# tip.
#
# Flow (all from a manual dispatch):
# check - read the version (input or pubspec); skip if already
# released; FAIL if the tag exists but the release never
# completed (a stuck tag)
# validate - version regex, lockstep (both pubspecs), tag-not-exists
# tag - annotated v<version> at the develop tip, pushed with
# RELEASE_TOKEN
# watch-release - wait for "Publish to pub.dev" + "Deploy site" to fire
# -> the tag push fires Publish to pub.dev (verify gates -> tests -> publish
# behind the pub.dev environment approval) and Deploy site (docs + gallery
# to Cloudflare Pages).
#
# The tag push MUST use a fine-grained PAT with BOTH "Contents: read and write"
# AND "Actions: read and write" (secret RELEASE_TOKEN). GITHUB_TOKEN pushes fire
# no follow-up runs — and do NOT give the generator workflows a firing PAT
# either: web.version always differs, so that would loop forever.
#
# master is a legacy reference. Nothing releases from it.
on:
workflow_dispatch:
inputs:
version:
description: 'Version to release, e.g. 0.5.0 or 0.5.0-pre.1 (no leading "v"; leave empty to read it from pubspec)'
required: false
type: string
default: ''
ref:
description: 'Branch/ref to release from (default: develop tip)'
required: false
type: string
default: 'develop'
permissions:
# contents: read — checkout/ls-remote; actions: read — gh run polling
# (watch-release). The tag push itself uses RELEASE_TOKEN.
contents: read
actions: read
concurrency:
# One release at a time. A newer dispatch cancels an in-flight one.
group: release
cancel-in-progress: true
jobs:
check:
name: Check release needed
runs-on: ubuntu-24.04
outputs:
version: ${{ steps.release.outputs.version }}
should_release: ${{ steps.release.outputs.should_release }}
release_sha: ${{ steps.release.outputs.release_sha }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
- name: Decide what to release
id: release
shell: bash
run: |
set -euo pipefail
# Version comes from the input, or — if left empty — is read from the
# checked-out ref's pubspec.
v="${{ inputs.version }}"
v="${v#v}"
if [ -z "$v" ]; then
v="$(grep -m1 '^version:' thermion_dart/pubspec.yaml | sed 's/^version:[[:space:]]*//; s/[[:space:]]*$//')"
echo "No version given; read $v from thermion_dart/pubspec.yaml."
fi
if ! [[ "$v" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$ ]]; then
echo "::error::Invalid version '$v'. Expected e.g. 0.5.0 or 0.5.0-pre.1 (no leading 'v')."
exit 1
fi
echo "release_sha=${{ inputs.ref }}" >> "$GITHUB_OUTPUT"
echo "Release version: $v"
echo "version=$v" >> "$GITHUB_OUTPUT"
if git ls-remote --tags origin "refs/tags/v$v" | grep -q .; then
echo "::notice::Tag v$v already exists — verifying the release actually completed."
released=""
exists_on_pubdev() {
# Returns 0 if version $2 is listed in pub.dev's API for package $1.
local pkg="$1" want="$2"
curl -fsSL "https://pub.dev/api/packages/$pkg" \
| python3 -c "import sys,json; d=json.load(sys.stdin); sys.exit(0 if sys.argv[1] in [x['version'] for x in d.get('versions',[])] else 1)" "$want" \
|| return 1
}
if exists_on_pubdev thermion_dart "$v" && exists_on_pubdev thermion_flutter "$v"; then
released="pub.dev"
elif gh run list --workflow "Publish to pub.dev" --event push --branch "v$v" --limit 10 --json conclusion,status \
| python3 -c "import sys,json; sys.exit(0 if any(r.get('conclusion')=='success' and r.get('status')=='completed' for r in json.load(sys.stdin)) else 1)"; then
released="a previous run"
fi
if [ -n "$released" ]; then
echo "::notice::v$v was already released ($released) — nothing to do."
echo "should_release=false" >> "$GITHUB_OUTPUT"
else
echo "::error::Tag v$v exists but the release never completed. Possible causes: the RELEASE_TOKEN PAT lacks 'Actions: read and write' (the tag push fired no workflows), or a publish run failed. Fix the PAT, delete the tag (git push origin :refs/tags/v$v), or bump the version, then re-dispatch. See docs/RELEASING.md."
exit 1
fi
else
echo "should_release=true" >> "$GITHUB_OUTPUT"
fi
validate:
name: Validate version
needs: check
if: ${{ needs.check.outputs.should_release == 'true' }}
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.check.outputs.release_sha }}
- name: Check lockstep + tag-not-exists
shell: bash
run: |
set -euo pipefail
v="${{ needs.check.outputs.version }}"
dart_v="$(grep -m1 '^version:' thermion_dart/pubspec.yaml | sed 's/^version:[[:space:]]*//; s/[[:space:]]*$//')"
flt_v="$(grep -m1 '^version:' thermion_flutter/thermion_flutter/pubspec.yaml | sed 's/^version:[[:space:]]*//; s/[[:space:]]*$//')"
echo "thermion_dart version: $dart_v"
echo "thermion_flutter version: $flt_v"
if [ "$dart_v" != "$flt_v" ]; then
echo "::error::Lockstep broken: thermion_dart ($dart_v) != thermion_flutter ($flt_v). Bump both pubspecs to $v on develop first."
exit 1
fi
if [ "$dart_v" != "$v" ]; then
echo "::error::Pubspec version $dart_v does not match release version $v."
exit 1
fi
if git ls-remote --tags origin "refs/tags/v$v" | grep -q .; then
echo "::error::Tag v$v already exists on origin. Delete it (or pick a new version) before re-running."
exit 1
fi
echo "OK: tag v$v can be created"
tag:
name: Create and push tag
needs: [check, validate]
if: ${{ needs.check.outputs.should_release == 'true' }}
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.check.outputs.release_sha }}
fetch-depth: 1
- name: Resolve release tip
id: tip
shell: bash
run: |
set -euo pipefail
REF="${{ needs.check.outputs.release_sha }}"
# Fetch the true remote tip in case a push landed between the dispatch
# and this step. If REF is a branch, use origin/<ref>; otherwise (a SHA
# or tag) resolve it directly.
git fetch origin "$REF" --depth=1 || true
if git rev-parse --verify "origin/$REF" >/dev/null 2>&1; then
SHA="$(git rev-parse "origin/$REF")"
else
SHA="$(git rev-parse "$REF")"
fi
echo "Release tip: $SHA"
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
- name: Re-verify version + tag at tip
shell: bash
run: |
set -euo pipefail
v="${{ needs.check.outputs.version }}"
TIP="${{ steps.tip.outputs.sha }}"
dart_v="$(git show "$TIP:thermion_dart/pubspec.yaml" | grep -m1 '^version:' | sed 's/^version:[[:space:]]*//; s/[[:space:]]*$//')"
flt_v="$(git show "$TIP:thermion_flutter/thermion_flutter/pubspec.yaml" | grep -m1 '^version:' | sed 's/^version:[[:space:]]*//; s/[[:space:]]*$//')"
if [ "$dart_v" != "$flt_v" ] || [ "$dart_v" != "$v" ]; then
echo "::error::Version at tip $TIP ($dart_v/$flt_v) does not match release version $v."
exit 1
fi
if git ls-remote --tags origin "refs/tags/v$v" | grep -q .; then
echo "::error::Tag v$v already exists on origin (created between validate and this step). Delete it or bump the version, then re-dispatch."
exit 1
fi
echo "OK: tagging $TIP as v$v"
- name: Create and push annotated tag
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
shell: bash
run: |
set -euo pipefail
if [ -z "$RELEASE_TOKEN" ]; then
echo "::error::RELEASE_TOKEN secret is not configured. Create a fine-grained PAT with 'Contents: read and write' AND 'Actions: read and write' on this repo and add it as a repo secret 'RELEASE_TOKEN'. Actions write is required for the tag push to fire the publish/deploy workflows (GITHUB_TOKEN and Contents-only PAT pushes fire no runs)."
exit 1
fi
v="${{ needs.check.outputs.version }}"
TIP="${{ steps.tip.outputs.sha }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "v$v" -m "Release v$v" "$TIP"
# actions/checkout (persist-credentials: true) writes the GITHUB_TOKEN
# as http.https://github.com/.extraheader. That extraheader WINS over
# the PAT embedded in the push URL, so the server sees the read-only
# github-actions[bot] token (permissions are contents: read here) and
# the push 403s. Drop it so the URL PAT is the credential that ships.
git config --unset-all http.https://github.com/.extraheader || true
git push "https://x-access-token:${RELEASE_TOKEN}@github.com/${{ github.repository }}" "refs/tags/v$v"
{
echo "## Release v$v"
echo ""
echo "- Tag: \`v$v\` at \`$TIP\`"
echo "- The tag push should now fire **Publish to pub.dev** and **Deploy site**."
echo "- If no runs appear within ~5 minutes, the RELEASE_TOKEN PAT is missing **Actions: read and write** — see docs/RELEASING.md."
} >> "$GITHUB_STEP_SUMMARY"
watch-release:
name: Watch publish + deploy
needs: [check, tag]
if: ${{ needs.check.outputs.should_release == 'true' }}
runs-on: ubuntu-24.04
steps:
- name: Wait for Publish to pub.dev run
id: publish
shell: bash
run: |
set -euo pipefail
v="${{ needs.check.outputs.version }}"
ID=""
for i in $(seq 1 12); do
ID="$(gh run list --workflow "Publish to pub.dev" --event push --branch "v$v" --limit 1 --json id --jq '.[0].id // ""' 2>/dev/null || true)"
[ -n "$ID" ] && break
sleep 25
done
if [ -z "$ID" ]; then
echo "::error::No 'Publish to pub.dev' run fired for tag v$v within 5 minutes. The tag push likely fired no workflows: the RELEASE_TOKEN fine-grained PAT needs BOTH 'Contents: read and write' AND 'Actions: read and write' (a Contents-only PAT pushes silently but fires zero runs). Fix the PAT, delete the tag (git push origin :refs/tags/v$v), and re-dispatch. See docs/RELEASING.md."
exit 1
fi
echo "id=$ID" >> "$GITHUB_OUTPUT"
echo "Publish run: https://github.com/${{ github.repository }}/actions/runs/$ID"
- name: Wait for Deploy site run to complete
shell: bash
run: |
set -euo pipefail
v="${{ needs.check.outputs.version }}"
ID="$(gh run list --workflow "Deploy site" --event push --branch "v$v" --limit 1 --json id --jq '.[0].id // ""' 2>/dev/null || true)"
if [ -z "$ID" ]; then
echo "::error::No 'Deploy site' run fired for tag v$v. See https://github.com/${{ github.repository }}/actions/workflows/deploy.yml"
exit 1
fi
echo "Deploy run: https://github.com/${{ github.repository }}/actions/runs/$ID"
if ! timeout 35m gh run watch "$ID" --exit-status >/dev/null 2>&1; then
echo "::error::Site deploy failed or timed out. See https://github.com/${{ github.repository }}/actions/runs/$ID"
exit 1
fi
echo "Site deployed"
- name: Wait for Publish to complete (or approve)
shell: bash
run: |
set -euo pipefail
PUBLISH_ID="${{ steps.publish.outputs.id }}"
URL="https://github.com/${{ github.repository }}/actions/runs/$PUBLISH_ID"
for i in $(seq 1 240); do # up to 2h; approval can pend for hours
STATE="$(gh api "repos/${{ github.repository }}/actions/runs/$PUBLISH_ID" --jq '{status, conclusion}' 2>/dev/null || true)"
STATUS="$(echo "$STATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status',''))" 2>/dev/null || true)"
CONCL="$(echo "$STATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('conclusion',''))" 2>/dev/null || true)"
if [ "$STATUS" = "completed" ]; then
if [ "$CONCL" = "success" ]; then
echo "Publish succeeded: $URL"
exit 0
fi
echo "::error::Publish run ended with '$CONCL': $URL"
exit 1
fi
# Approval-pending at the pub.dev environment: report and stop here —
# the release is correctly waiting on a human.
PENDING="$(gh api "repos/${{ github.repository }}/actions/runs/$PUBLISH_ID/jobs" --jq '[.jobs[] | select(.status=="queued") | .steps[] | select(.name | ascii_downcase | contains("approval"))] | length' 2>/dev/null || echo 0)"
if [ "$PENDING" -gt 0 ]; then
echo "Publish is awaiting approval at the pub.dev environment: $URL"
exit 0
fi
sleep 30
done
# Still running (e.g. tag-path tests) — the publish run is the source
# of truth; don't fail a healthy in-flight release.
echo "Publish still in progress after 2h: $URL"
exit 0