-
Notifications
You must be signed in to change notification settings - Fork 4
310 lines (293 loc) · 15.3 KB
/
Copy pathpromote-release.yml
File metadata and controls
310 lines (293 loc) · 15.3 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
298
299
300
301
302
303
304
305
306
307
308
309
310
name: Promote release
# Platform build workflows (windows-installer, linux-installer,
# release-pythinker-cli) create the GitHub Release as a PRERELEASE so it stays
# out of the date-based /releases/latest endpoint (which ignores make_latest)
# until every install channel is ready. This workflow waits for exact release
# assets and PyPI, then clears `prerelease` and marks the release latest — the
# single point where a version becomes resolvable by the install scripts and
# in-app updater.
#
# Homebrew is checked best-effort AFTER promotion: a broken tap (lost token,
# org migration, etc.) must not hold the GitHub Latest badge and install scripts
# hostage. The Homebrew check emits a warning annotation and step summary note
# so the gap is visible without blocking.
#
# It runs on the tag push (not `release: published`, which a GITHUB_TOKEN-created
# release never fires) so promotion always happens. workflow_dispatch allows a
# manual re-promote, e.g. after re-running a single platform build that
# re-marked the release as prerelease.
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
workflow_dispatch:
inputs:
tag:
description: "Release tag to promote (e.g. v0.27.0)"
required: true
type: string
# Serialize promotion per tag so a tag push and a manual re-promote can't race.
concurrency:
group: promote-release-${{ inputs.tag || github.ref_name }}
cancel-in-progress: false
jobs:
promote:
runs-on: ubuntu-latest
# Only this job mutates the release; notify-failure stays read-only.
permissions:
contents: write
steps:
- name: Resolve and validate tag
id: tag
env:
REF_NAME: ${{ github.ref_name }}
INPUT_TAG: ${{ inputs.tag }}
EVENT_NAME: ${{ github.event_name }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
tag="$INPUT_TAG"
else
tag="$REF_NAME"
fi
# Defend against injection: only ever act on a strict vMAJOR.MINOR.PATCH tag.
if ! printf '%s' "$tag" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::Refusing to promote: invalid tag '$tag'"
exit 1
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: Wait for install-channel readiness
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.tag.outputs.tag }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
version="${TAG#v}"
required_assets=(
"PythinkerSetup-${version}.exe"
"PythinkerSetup-${version}.exe.sha256"
"pythinker-code_${version}_amd64.deb"
"pythinker-code_${version}_amd64.deb.sha256"
"pythinker-code_${version}_arm64.deb"
"pythinker-code_${version}_arm64.deb.sha256"
"pythinker-code-${version}.x86_64.rpm"
"pythinker-code-${version}.x86_64.rpm.sha256"
"pythinker-code-${version}.aarch64.rpm"
"pythinker-code-${version}.aarch64.rpm.sha256"
"pythinker-${version}-x86_64-unknown-linux-gnu.tar.gz"
"pythinker-${version}-x86_64-unknown-linux-gnu.tar.gz.sha256"
"pythinker-${version}-aarch64-unknown-linux-gnu.tar.gz"
"pythinker-${version}-aarch64-unknown-linux-gnu.tar.gz.sha256"
"pythinker-${version}-aarch64-apple-darwin.tar.gz"
"pythinker-${version}-aarch64-apple-darwin.tar.gz.sha256"
"pythinker-${version}-x86_64-apple-darwin.tar.gz"
"pythinker-${version}-x86_64-apple-darwin.tar.gz.sha256"
"pythinker-${version}-x86_64-unknown-linux-gnu-onedir.tar.gz"
"pythinker-${version}-x86_64-unknown-linux-gnu-onedir.tar.gz.sha256"
"pythinker-${version}-aarch64-unknown-linux-gnu-onedir.tar.gz"
"pythinker-${version}-aarch64-unknown-linux-gnu-onedir.tar.gz.sha256"
"pythinker-${version}-aarch64-apple-darwin-onedir.tar.gz"
"pythinker-${version}-aarch64-apple-darwin-onedir.tar.gz.sha256"
"pythinker-${version}-x86_64-apple-darwin-onedir.tar.gz"
"pythinker-${version}-x86_64-apple-darwin-onedir.tar.gz.sha256"
)
pypi_url="https://pypi.org/pypi/pythinker-code/${version}/json"
# The budget must comfortably exceed the slowest platform build, since
# this job runs on the tag push in parallel with them. The long pole is
# linux-installer's emulated arm64 .deb/.rpm step: on the 0.26.0 release
# it finished at ~21m, 45s after the old 40x30s=20m budget had already
# timed out — leaving the release stuck as a prerelease. 80x30s=40m
# gives ~2x margin; a genuinely stuck build still surfaces as a failed
# build workflow, and workflow_dispatch allows a manual re-promote.
#
# Homebrew is NOT checked here — it is best-effort and checked after
# promotion so a broken tap never blocks the GitHub Latest badge.
max_attempts=80
poll_interval=30
budget_min=$(( max_attempts * poll_interval / 60 ))
echo "Polling install-channel readiness for $TAG (up to ${budget_min}m)..."
all_ready=false
for i in $(seq 1 "$max_attempts"); do
assets_json=$(gh api "repos/$REPO/releases/tags/$TAG" --jq '[.assets[].name]' 2>/dev/null || printf '[]')
missing_assets=()
for asset in "${required_assets[@]}"; do
if ! jq -e --arg name "$asset" 'index($name)' <<<"$assets_json" >/dev/null; then
missing_assets+=("$asset")
fi
done
pypi_ready=false
if curl -fsSL --retry 2 --retry-delay 2 -o /dev/null "$pypi_url"; then
pypi_ready=true
fi
if [[ "${#missing_assets[@]}" -eq 0 && "$pypi_ready" == "true" ]]; then
all_ready=true
echo "All required install channels ready (attempt $i)"
break
fi
echo "Attempt $i/$max_attempts: install channels not ready."
if [[ "${#missing_assets[@]}" -gt 0 ]]; then
printf 'Missing release assets: %s\n' "${missing_assets[*]}"
fi
if [[ "$pypi_ready" != "true" ]]; then
echo "PyPI is not serving ${version} yet: $pypi_url"
fi
if [[ "$i" -lt "$max_attempts" ]]; then
echo "Retrying in ${poll_interval}s..."
sleep "$poll_interval"
fi
done
if [[ "$all_ready" != "true" ]]; then
echo "::error::Install channels were not fully ready after ${budget_min} minutes"
exit 1
fi
- name: Promote release (clear prerelease, mark latest)
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.tag.outputs.tag }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
release_id=$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.id')
gh api -X PATCH "repos/$REPO/releases/$release_id" -F prerelease=false -f make_latest=true
echo "Promoted $TAG: prerelease=false, make_latest=true (all platform assets present)."
# Best-effort Homebrew check. The release is ALREADY promoted above; a
# broken tap (lost token, org migration, etc.) must not re-block it.
# This step emits a warning annotation and step summary note so the gap
# is visible, then exits 0. The tap is repaired separately via
# HOMEBREW_TAP_TOKEN; this step just surfaces when it lags behind.
- name: Check Homebrew tap (best-effort)
env:
TAG: ${{ steps.tag.outputs.tag }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
SOURCE_REPO: ${{ github.repository }}
run: |
# No `set -e`: this step must never fail the job.
set -uo pipefail
version="${TAG#v}"
homebrew_formula_url="https://raw.githubusercontent.com/Pythoughts-labs/homebrew-pythinker/main/Formula/pythinker-code.rb"
formula_text=$(curl -fsSL --retry 2 --retry-delay 2 "$homebrew_formula_url" 2>/dev/null || true)
if grep -qF "version \"${version}\"" <<<"$formula_text"; then
echo "Homebrew tap is at ${version}. ✓"
exit 0
fi
# Tap is behind — warn but do not fail.
reason="Homebrew tap formula is not yet at ${version}: ${homebrew_formula_url}"
echo "::warning title=Homebrew tap lagging::${reason}"
{
echo "### :warning: Homebrew tap is not yet at ${version} (non-blocking)"
echo ""
echo "${reason}"
echo ""
echo "**Non-blocking:** the GitHub Release has already been promoted to Latest."
echo "The tap updates automatically once \`HOMEBREW_TAP_TOKEN\` is valid and the"
echo "\`homebrew-tap\` workflow runs. To fix: restore the token secret and"
echo "re-run the \`homebrew-tap\` workflow for this tag via \`workflow_dispatch\`."
} >> "$GITHUB_STEP_SUMMARY"
if [ -n "${SLACK_WEBHOOK_URL:-}" ]; then
alert=$(jq -n --arg run_url "$RUN_URL" --arg repo "$SOURCE_REPO" \
--arg version "$version" --arg url "$homebrew_formula_url" \
'{"text":":warning: *Homebrew tap lagging (non-blocking)*","attachments":[{"color":"warning","fields":[{"title":"Repo","value":$repo,"short":true},{"title":"Expected version","value":$version,"short":true},{"title":"Formula URL","value":$url,"short":false},{"title":"Run","value":"<\($run_url)|View logs>","short":false}]}]}')
curl -sS -X POST -H "Content-Type: application/json" -d "$alert" "$SLACK_WEBHOOK_URL" || true
fi
exit 0
# Best-effort website sync trigger. The release is ALREADY promoted above;
# this only accelerates the pythinker-home mirror, which also re-syncs on
# its own daily cron. So a missing/rotated App degrades gracefully here
# rather than failing an otherwise-successful promotion.
- name: Mint GitHub App token for pythinker-home
id: app-token
continue-on-error: true
uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # pinned from v2.2.2
with:
app-id: ${{ secrets.PYTHINKER_RELEASE_BOT_APP_ID }}
private-key: ${{ secrets.PYTHINKER_RELEASE_BOT_APP_PRIVATE_KEY }}
owner: Pythoughts-labs
repositories: pythinker-home
permission-contents: write
- name: Trigger pythinker-home sync (best-effort)
env:
DISPATCH_TOKEN: ${{ steps.app-token.outputs.token }}
TOKEN_OUTCOME: ${{ steps.app-token.outcome }}
SOURCE_REPO: ${{ github.repository }}
RELEASE_TAG: ${{ steps.tag.outputs.tag }}
DISPATCH_OWNER: Pythoughts-labs
DISPATCH_REPO: pythinker-home
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
# No `set -e`: a failed website-sync trigger must not fail an already
# successful release promotion. The daily cron in pythinker-home is
# the real sync guarantee; here we warn + alert and exit 0.
set -uo pipefail
degrade() {
reason="$1"
echo "::warning title=pythinker-home sync skipped::${reason}"
{
echo "### :warning: pythinker-home sync dispatch skipped (release was still promoted)"
echo ""
echo "${reason}"
echo ""
echo "**Non-blocking:** pythinker-home re-syncs on its daily cron (\`sync-upstream-products\` @ 04:17 UTC), so the website is not stale."
echo ""
echo "**Restore the fast path:** recreate/install the **pythinker-release-bot** App on \`${DISPATCH_OWNER}\` with *Contents: write* on \`${DISPATCH_REPO}\`, then update the \`PYTHINKER_RELEASE_BOT_APP_ID\` and \`PYTHINKER_RELEASE_BOT_APP_PRIVATE_KEY\` secrets."
} >> "$GITHUB_STEP_SUMMARY"
if [ -n "${SLACK_WEBHOOK_URL:-}" ]; then
alert=$(jq -n --arg run_url "$RUN_URL" --arg repo "$SOURCE_REPO" --arg reason "$reason" \
'{"text":":warning: *pythinker-home sync dispatch skipped (non-blocking)*","attachments":[{"color":"warning","fields":[{"title":"Repo","value":$repo,"short":true},{"title":"Reason","value":$reason,"short":false},{"title":"Run","value":"<\($run_url)|View logs>","short":false}]}]}')
curl -sS -X POST -H "Content-Type: application/json" -d "$alert" "$SLACK_WEBHOOK_URL" || true
fi
exit 0
}
if [ "${TOKEN_OUTCOME}" != "success" ] || [ -z "${DISPATCH_TOKEN:-}" ]; then
degrade "Could not mint a pythinker-release-bot App token (token step outcome: ${TOKEN_OUTCOME}). The App is likely missing/uninstalled on ${DISPATCH_OWNER}, or its credentials are stale."
fi
payload=$(jq -n --arg source_repo "$SOURCE_REPO" --arg tag "$RELEASE_TAG" \
'{"event_type":"sync-pythinker-products","client_payload":{"source_repo":$source_repo,"tag":$tag}}')
resp=$(mktemp)
code="000"
for attempt in 1 2 3; do
code=$(curl -sS -o "$resp" -w '%{http_code}' \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $DISPATCH_TOKEN" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/repos/${DISPATCH_OWNER}/${DISPATCH_REPO}/dispatches" \
-d "$payload") || code="000"
if [ "$code" = "204" ]; then
echo "Dispatched sync-pythinker-products to ${DISPATCH_OWNER}/${DISPATCH_REPO} (HTTP 204)."
exit 0
fi
echo "Dispatch attempt ${attempt} returned HTTP ${code}: $(head -c 200 "$resp")"
[ "$attempt" -lt 3 ] && sleep $((attempt * 3)) || true
done
degrade "repository_dispatch to ${DISPATCH_OWNER}/${DISPATCH_REPO} failed after 3 attempts (last HTTP ${code}): $(head -c 200 "$resp")"
notify-failure:
name: Notify on failure
runs-on: ubuntu-latest
needs: promote
if: failure()
permissions:
contents: read
steps:
- name: Post Slack alert
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
REPO: ${{ github.repository }}
TAG: ${{ inputs.tag || github.ref_name }}
run: |
set -euo pipefail
if [ -z "${SLACK_WEBHOOK_URL:-}" ]; then
exit 0
fi
payload=$(jq -n \
--arg run_url "$RUN_URL" \
--arg repo "$REPO" \
--arg tag "$TAG" \
'{"text":":red_circle: *Release promotion failed*","attachments":[{"color":"danger","fields":[{"title":"Repo","value":$repo,"short":true},{"title":"Tag","value":$tag,"short":true},{"title":"Run","value":"<\($run_url)|View logs>","short":false}]}]}')
curl --fail-with-body -X POST \
-H "Content-Type: application/json" \
-d "$payload" \
"$SLACK_WEBHOOK_URL"