-
Notifications
You must be signed in to change notification settings - Fork 6
687 lines (644 loc) · 30.8 KB
/
Copy pathrelease.yml
File metadata and controls
687 lines (644 loc) · 30.8 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
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
name: Release
on:
push:
branches:
- main
# Never cancel a release in flight: a publish that is interrupted between npm
# and the git tags cannot be undone. Runs therefore queue instead. GitHub keeps
# only one pending run per group, so a third push while a release is running
# silently drops the one waiting behind it — the commits still ship, on the
# next run, but nothing announces the skip. Keeping this job short is what
# keeps that window small.
concurrency: ${{ github.workflow }}-${{ github.ref }}
# This repository installs a local pre-push gate that re-runs the full test
# suite. It exists to save a CI round trip from a developer's machine; inside
# CI it is pure duplication, and `scripts/pre-push.sh` reads this to stand
# down. Set at the workflow level so it reaches every push, including the ones
# a JS action spawns.
env:
SKIP_HOOKS: "1"
# Fail closed: a job gets no token scope it does not ask for, so the repository
# or organisation default can never quietly hand write access to a job that
# only reads. Every job below grants itself exactly what it needs.
permissions: {}
jobs:
# Publishing to npm is irreversible — never let a commit that fails CI or
# Nix Build ship. Those workflows run in parallel on the same push, so this
# job blocks the release until both conclude successfully for this SHA.
wait-for-checks:
name: Wait for CI and Nix Build
runs-on: ubuntu-latest
if: github.repository_owner == 'PyModel'
timeout-minutes: 45
permissions:
actions: read
contents: read
steps:
- name: Wait for required workflows on this commit
env:
GH_TOKEN: ${{ github.token }}
SHA: ${{ github.sha }}
REPO: ${{ github.repository }}
run: |
for workflow in "CI" "Nix Build"; do
echo "Waiting for workflow: $workflow"
while true; do
# Transient API failures must not kill the gate (step shell is -e).
pair=$(gh api "repos/$REPO/actions/runs?head_sha=$SHA&per_page=50" \
--jq "[.workflow_runs[] | select(.name==\"$workflow\")][0] | \"\(.status)/\(.conclusion)\"" \
2>/dev/null) || pair="api-error/null"
case "$pair" in
completed/success)
echo "$workflow: success"; break ;;
completed/*)
echo "::error::$workflow concluded '${pair#completed/}' for $SHA — refusing to release."
exit 1 ;;
*)
echo "$workflow: $pair — waiting..."; sleep 30 ;;
esac
done
done
release:
name: Release
needs: wait-for-checks
runs-on: ubuntu-latest
timeout-minutes: 30
if: github.repository_owner == 'PyModel'
outputs:
packages_published: ${{ steps.changesets.outputs.published }}
cli_version_bumped: ${{ steps.lane-versions.outputs.cli_version_bumped }}
cli_version: ${{ steps.lane-versions.outputs.cli_version }}
desktop_version_bumped: ${{ steps.lane-versions.outputs.desktop_version_bumped }}
desktop_version: ${{ steps.lane-versions.outputs.desktop_version }}
extension_version_bumped: ${{ steps.lane-versions.outputs.extension_version_bumped }}
extension_version: ${{ steps.lane-versions.outputs.extension_version }}
# The Changesets output is true on the first publish. The push-boundary
# signal remains true on a rerun, where the idempotent wrapper correctly
# exits without republishing an npm version that is already live.
pythinker_native_release: ${{ steps.pythinker-release.outputs.should_publish == 'true' || steps.lane-versions.outputs.cli_version_bumped == 'true' }}
# `pythinker-release` only runs when changesets publishes, so on the
# rerun path above its tag output is empty. Fall back to the same string
# resolve-release.mjs builds, or publish-native-assets would run
# `gh release view ""`.
pythinker_release_tag: ${{ steps.pythinker-release.outputs.tag || format('@pymodel/pythinker-code@{0}', steps.lane-versions.outputs.cli_version) }}
permissions:
contents: write
pull-requests: write
id-token: write # Required for NPM Trusted Publishing (OIDC)
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # pinned from v6
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # pinned from v7.0.0
with:
node-version-file: .nvmrc
cache: "pnpm"
registry-url: "https://registry.npmjs.org"
- name: Upgrade npm for Trusted Publishing
run: npm install -g npm@11
# `github.event.before` is the exact start of this push. HEAD^ only sees
# the final commit and misses version bumps in a multi-commit push.
- name: Detect release lane version bumps
id: lane-versions
env:
BEFORE_SHA: ${{ github.event.before }}
AFTER_SHA: ${{ github.sha }}
run: node scripts/release/detect-lane-bumps.mjs "$BEFORE_SHA" "$AFTER_SHA"
- name: Install dependencies
run: pnpm install --frozen-lockfile
# The `prepare` script reinstalls the local hooks during the install
# above, so this has to run after it, not before. `SKIP_HOOKS` already
# tells the hook to stand down; deleting it as well removes the last way
# the suite could run inside this job — an environment that reaches the
# hook scrubbed, or a hook that stops honouring the variable.
#
# Worth the belt and braces: on the publish path, the push these hooks
# would gate carries the version tags, and it runs after npm has already
# accepted the packages. A hook failure there — one flaky test under
# runner load is enough — leaves the release published but untagged, and
# skips every job that depends on it: docs, native artifacts, the VS Code
# extension, the Homebrew tap, the CDN.
- name: Disarm the local git hooks
run: |
# `--git-path hooks` is git's own resolution of core.hooksPath, so
# this deletes from the directory git will actually read rather than
# assuming `.git/hooks`.
hooks_dir=$(git rev-parse --git-path hooks)
rm -f -- "${hooks_dir}/pre-push" "${hooks_dir}/pre-commit"
if [ -e "${hooks_dir}/pre-push" ] || [ -e "${hooks_dir}/pre-commit" ]; then
echo "::error::A git hook survived removal in ${hooks_dir}."
echo "::error::The release push would re-run the full test suite; fix this before releasing."
exit 1
fi
echo "Local git hooks disarmed (${hooks_dir})."
- name: Generate Pythinker Code built-in catalog
shell: bash
run: |
CATALOG_FILE="$RUNNER_TEMP/pythinker-code-built-in-catalog.json"
node apps/pythinker-code/scripts/update-catalog.mjs --out "$CATALOG_FILE"
echo "PYTHINKER_CODE_BUILT_IN_CATALOG_FILE=$CATALOG_FILE" >> "$GITHUB_ENV"
- name: Build packages
run: pnpm build
- name: Mint release-bot token
id: release-bot
uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # pinned from v2
with:
app-id: ${{ vars.RELEASE_BOT_APP_ID }}
private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
- name: Create Release Pull Request or Publish to npm
id: changesets
uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # pinned from v1
with:
publish: node scripts/release/changeset-publish-idempotent.mjs
version: pnpm run version:release
commit: "ci: release packages"
title: "ci: release packages"
env:
# App token (not GITHUB_TOKEN) so the version PR triggers pull_request
# workflows and required status checks / CodeRabbit run on it.
GITHUB_TOKEN: ${{ steps.release-bot.outputs.token }}
# No NPM_TOKEN on purpose: changesets prefers it over OIDC when set, so
# defining it would silently downgrade publishing to a long-lived token.
# Release cadence switch. With this on, the version PR merges itself once
# every required check passes, so one merge to main becomes one release
# and the version tracks each change instead of collapsing a backlog of
# changesets into a single jump. Turn it off to go back to releasing by
# hand: `gh variable set AUTO_MERGE_RELEASE_PR --body false`.
#
# This is deliberately not a blanket auto-merge. It only ever targets the
# changesets-authored branch, the repository requires its status checks
# before any merge, and a major bump is gated separately on the pull
# request that introduces the changeset — so an unattended release can
# still never rename the major version on its own.
#
# Never fail the release over this: a version PR that stays open costs a
# manual merge, while a failure here would block npm, the Marketplace and
# the CDN behind it.
# Resolve the version PR once, for every step that acts on it.
#
# The changesets action reports the pull request it just created or
# updated, which is the only unambiguous identifier. The fallback covers
# the run where changesets had nothing to change, and it is scoped to a
# head branch in THIS repository by full `owner/name`: `--head` matches a
# branch name only, so a head-name match on its own can select a fork's
# branch of the same name, and matching the owner alone would still accept
# a different repository belonging to that owner.
- name: Resolve the version PR
id: version-pr
if: steps.changesets.outputs.published != 'true'
env:
GH_TOKEN: ${{ steps.release-bot.outputs.token }}
CHANGESETS_PR: ${{ steps.changesets.outputs.pullRequestNumber }}
run: |
set -uo pipefail
if [ -n "${CHANGESETS_PR}" ] && [ "${CHANGESETS_PR}" != "null" ]; then
echo "number=${CHANGESETS_PR}" >> "${GITHUB_OUTPUT}"
echo "Version PR #${CHANGESETS_PR}, as reported by the changesets action."
exit 0
fi
if ! open_prs=$(gh pr list \
--repo "${GITHUB_REPOSITORY}" \
--head changeset-release/main \
--base main \
--state open \
--json number,headRepository); then
echo "::warning::Could not look up the version PR. Nothing downstream will run; check it by hand."
exit 0
fi
number=$(printf '%s' "${open_prs}" \
| jq -r --arg repo "${GITHUB_REPOSITORY}" \
'[.[] | select(.headRepository.nameWithOwner == $repo)][0].number // ""')
if [ -z "${number}" ]; then
echo "::notice::No open version PR in this repository."
exit 0
fi
echo "number=${number}" >> "${GITHUB_OUTPUT}"
echo "Version PR #${number}."
# Release cadence switch. With this on, the version PR merges itself once
# every required check passes, so one merge to main becomes one release
# and the version tracks each change instead of collapsing a backlog of
# changesets into a single jump. Turn it off to go back to releasing by
# hand: `gh variable set AUTO_MERGE_RELEASE_PR --body false`.
#
# This is deliberately not a blanket auto-merge. It only ever targets the
# changesets-authored branch in this repository, the repository requires
# its status checks before any merge, and a major bump is gated separately
# on the pull request that introduces the changeset — so an unattended
# release can still never rename the major version on its own.
#
# A changeset that lands while this pull request is waiting on its checks
# joins the same release rather than starting the next one. That window is
# how changesets works, not something this step can close; keeping one
# changeset per pull request keeps it small.
#
# Never fail the release over this: a version PR that stays open costs a
# manual merge, while a failure here would block npm, the Marketplace and
# the CDN behind it.
- name: Enable auto-merge on the version PR
if: steps.version-pr.outputs.number != '' && vars.AUTO_MERGE_RELEASE_PR == 'true'
continue-on-error: true
env:
GH_TOKEN: ${{ steps.release-bot.outputs.token }}
PR: ${{ steps.version-pr.outputs.number }}
run: |
set -uo pipefail
if gh pr merge "${PR}" --squash --auto; then
echo "Auto-merge armed on #${PR}; it lands when its required checks pass."
else
echo "::warning::Could not arm auto-merge on #${PR}. Merge it by hand to cut the release."
fi
- name: Request CodeRabbit review on version PR
if: steps.version-pr.outputs.number != ''
env:
GH_TOKEN: ${{ steps.release-bot.outputs.token }}
PR: ${{ steps.version-pr.outputs.number }}
run: gh pr comment "${PR}" --body '@coderabbitai review'
- name: Resolve Pythinker Code native release
if: steps.changesets.outputs.published == 'true'
id: pythinker-release
run: node apps/pythinker-code/scripts/native/resolve-release.mjs
env:
CHANGESETS_PUBLISHED_PACKAGES: ${{ steps.changesets.outputs.publishedPackages }}
# Desktop tagging is isolated from npm and VS Code. A tag failure turns this
# workflow red without preventing the other lanes from reporting their state.
cut-desktop-tag:
timeout-minutes: 10
name: Cut desktop release tag
needs: release
if: >-
needs.release.outputs.desktop_version_bumped == 'true'
&& vars.RELEASE_LANE_DESKTOP != 'disabled'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2
with:
fetch-depth: 0
persist-credentials: false
# GITHUB_TOKEN-created tags cannot trigger another workflow. The App
# token makes `desktop-v*` start desktop-release.yml.
- name: Mint release-bot token
id: release-bot
uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # pinned from v2
with:
app-id: ${{ vars.RELEASE_BOT_APP_ID }}
private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
permission-contents: write
- name: Cut required desktop tag
env:
GH_TOKEN: ${{ steps.release-bot.outputs.token }}
DESKTOP_VERSION: ${{ needs.release.outputs.desktop_version }}
run: |
set -euo pipefail
tag="desktop-v${DESKTOP_VERSION}"
existing="$(git ls-remote --tags origin "refs/tags/${tag}" "refs/tags/${tag}^{}" |
awk '$2 ~ /\^\{\}$/ { peeled=$1 } $2 !~ /\^\{\}$/ { direct=$1 } END { print (peeled ? peeled : direct) }')"
if [ -n "$existing" ]; then
if [ "$existing" != "$GITHUB_SHA" ]; then
echo "::error::${tag} exists at ${existing}, not ${GITHUB_SHA}."
exit 1
fi
echo "::notice::${tag} already points at ${GITHUB_SHA}; nothing to do."
exit 0
fi
if ! gh api "repos/${GITHUB_REPOSITORY}/git/refs" \
-f ref="refs/tags/${tag}" \
-f sha="${GITHUB_SHA}" > /dev/null; then
echo "::error::Could not cut ${tag}. Recovery: git tag ${tag} ${GITHUB_SHA} && git push origin ${tag}"
exit 1
fi
echo "Cut ${tag} at ${GITHUB_SHA}."
# The extension lane runs only when its own version changes. The reusable
# workflow also supports a version-checked manual recovery run.
publish-vscode-extension:
name: Publish VS Code extension
needs: release
if: >-
needs.release.outputs.extension_version_bumped == 'true'
&& vars.RELEASE_LANE_VSCODE != 'disabled'
uses: ./.github/workflows/vscode-release.yml
permissions:
contents: read
id-token: write
attestations: write
artifact-metadata: write
with:
expected-version: ${{ needs.release.outputs.extension_version }}
secrets:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
# code.pythinker.com redeploys via Dokploy autodeploy on push to main (app
# Pythinker/code builds apps/site/Dockerfile from the repo). That autodeploy
# fires on the `ci: release packages` push — which STARTS the release — while
# apps/site/scripts/build-cdn.mjs reads the version from npm's dist-tag, which
# only moves when the publish FINISHES. The first build therefore bakes in the
# previous version and nothing rebuilds it, so the release stays invisible to
# every installed client. This job fires a second deploy after the publish.
#
# It must run after publish-native-assets: latest.json only gets its
# per-platform `platforms` block once the native zips exist on the release.
# That job is itself conditional and SKIPS on an npm-only release, and a job
# whose `needs` includes a skipped job is skipped too — hence `always()`, and
# hence the explicit upstream result assertions it forces us to spell out.
redeploy-cdn:
timeout-minutes: 10
name: Redeploy CDN
# Posts to a webhook with a secret; it never touches the GitHub API.
permissions: {}
needs:
- release
- publish-native-assets
# A native release whose zips never reached the GitHub release must not
# reach the CDN either. `publish-native-assets` refuses to publish a
# partial set, and a job whose needs failed reports `skipped`, not
# `failure` — so checking only for failure let a release with zero assets
# through and pointed every native installer at a release that has none.
# Requiring success (only when this release has native artifacts at all)
# leaves the CDN on the last installable version instead.
if: >-
always()
&& needs.release.result == 'success'
&& needs.release.outputs.pythinker_native_release == 'true'
&& needs.publish-native-assets.result == 'success'
&& vars.RELEASE_LANE_CDN != 'disabled'
runs-on: ubuntu-latest
steps:
- name: Trigger Dokploy rebuild
env:
WEBHOOK: ${{ secrets.DOKPLOY_CDN_DEPLOY_WEBHOOK }}
run: |
if [ -z "$WEBHOOK" ]; then
echo "::error::DOKPLOY_CDN_DEPLOY_WEBHOOK is required. Configure it or set RELEASE_LANE_CDN=disabled."
exit 1
fi
# The URL is itself the deploy credential, so never send it over a
# scheme that puts it on the wire in cleartext.
case "$WEBHOOK" in
https://*) ;;
*)
echo "::error::DOKPLOY_CDN_DEPLOY_WEBHOOK must use https://."
exit 1
;;
esac
# The webhook reads the branch from the body, but only when the
# request also carries `X-GitHub-Event`: Dokploy's extractBranchName
# returns null without that header, so the request answers
# 301 {"message":"Branch Not Match"} and deploys nothing.
#
# 301 is not an error status, so `--fail` does not see it and curl
# exits 0. Capture the status code and treat anything but 2xx as a
# failed deploy.
#
# A transient failure must never fail the workflow. npm has already
# published by now and that is irreversible, so dying here buys
# nothing — an earlier version of this job was deleted because a
# curl exit-28 timeout failed the 0.5.0 release. verify-cdn-release
# polls the manifest and is the gate that fails loudly.
# On 0.18.0, one connect consumed the full 60-second budget. A short
# connect timeout turns the same wall-clock budget into more attempts
# during an outage instead of waiting on connections never made.
status=$(curl -sS -o /dev/stderr -w '%{http_code}' -X POST "$WEBHOOK" \
-H 'Content-Type: application/json' \
-H 'X-GitHub-Event: push' \
-d '{"ref":"refs/heads/main"}' \
--connect-timeout 15 --max-time 45 --retry 5 --retry-all-errors --retry-delay 15) || status=000
case "$status" in
2*) echo "CDN redeploy triggered (HTTP $status)." ;;
*) echo "::warning::CDN redeploy webhook returned HTTP $status — verify-cdn-release will catch a stale CDN." ;;
esac
# Verifies that the published release is internally consistent and that the
# CDN caught up with npm. It polls, so it must run after redeploy-cdn.
#
# It also runs on a `ci: release packages` merge that published nothing: that
# commit bumps the version on main, so gating the check on a successful
# publish hid the one case where the version and the published artifacts
# diverge — and every client polled the CDN for a release that never existed.
verify-cdn-release:
timeout-minutes: 20
name: Verify release consistency
# Checkout only; verify-release-consistency.mjs uses no GitHub token.
permissions:
contents: read
needs:
- release
- redeploy-cdn
# Without `always()` a skip anywhere upstream skips this job too, and the
# gate that exists to catch a half-shipped release goes quiet in exactly
# the runs that need it. It stays out of `redeploy-cdn`'s result on
# purpose: a CDN that never redeployed is the failure this asserts, so it
# has to run and report it rather than disappear with it.
if: >-
always()
&& needs.release.result == 'success'
&& vars.RELEASE_LANE_CDN != 'disabled'
&& (needs.release.outputs.pythinker_native_release == 'true'
|| needs.release.outputs.cli_version_bumped == 'true')
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # pinned from v7.0.0
with:
node-version-file: .nvmrc
- name: Verify release consistency
env:
DOKPLOY_CDN_DEPLOY_WEBHOOK: ${{ secrets.DOKPLOY_CDN_DEPLOY_WEBHOOK }}
run: node scripts/release/verify-release-consistency.mjs
update-brew-tap:
timeout-minutes: 15
# Checkout only; the tap push uses a minted app token, not this one.
permissions:
contents: read
name: Update Homebrew tap
needs: release
if: >-
needs.release.outputs.pythinker_native_release == 'true'
&& vars.RELEASE_LANE_BREW != 'disabled'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # pinned from v7.0.0
with:
node-version-file: .nvmrc
# Any permission-* input switches the token from inheriting every
# permission the App installation holds to exactly the ones listed here.
# Cloning and pushing the tap needs contents and nothing else.
- name: Mint tap token
id: tap-token
uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # pinned from v2
with:
app-id: ${{ vars.RELEASE_BOT_APP_ID }}
private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
owner: PyModel
repositories: homebrew-tap
permission-contents: write
- name: Bump formula
env:
TAP_GITHUB_TOKEN: ${{ steps.tap-token.outputs.token }}
run: node scripts/release/update-brew-formula.mjs
deploy-docs:
name: Deploy docs
needs: release
if: needs.release.outputs.packages_published == 'true'
uses: ./.github/workflows/docs-deploy.yml
permissions:
contents: read
pages: write
id-token: write
native-artifacts:
name: Native release artifact
needs: release
# A called workflow cannot elevate the permissions granted here.
permissions:
contents: read
id-token: write
attestations: write
artifact-metadata: write
if: needs.release.outputs.pythinker_native_release == 'true'
uses: ./.github/workflows/_native-build.yml
with:
upload-artifact-prefix: pythinker-code-native
retention-days: 7
sign-macos: true
# One certificate, one notary key, one set of secret names. These are the
# names desktop-release.yml already ships signed and notarized from; the
# APPLE_CERTIFICATE_*/APPLE_NOTARIZATION_* names this workflow used to read
# were never set on this repository, which is why every darwin CLI bundle
# so far shipped ad-hoc signed behind a green job.
secrets:
APPLE_CERTIFICATE_P12: ${{ secrets.MAC_CSC_LINK }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER }}
publish-native-assets:
timeout-minutes: 15
name: Publish native release assets
needs:
- release
- native-artifacts
if: needs.release.outputs.pythinker_native_release == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2
- name: Download native artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # pinned from v8
with:
pattern: pythinker-code-native-*
path: dist-native-release
merge-multiple: true
- name: Assert all native targets are present
run: |
missing=0
for target in darwin-arm64 darwin-x64 linux-arm64 linux-x64 win32-arm64 win32-x64; do
if ! ls dist-native-release/pythinker-code-"$target".zip >/dev/null 2>&1; then
echo "::error::Missing native bundle for $target — refusing to publish a partial release."
missing=1
fi
done
exit $missing
- name: Produce manifest.json
env:
RELEASE_TAG: ${{ needs.release.outputs.pythinker_release_tag }}
run: node apps/pythinker-code/scripts/native/produce-manifest.mjs dist-native-release "$RELEASE_TAG"
# Unlike the desktop release, which uploads into a draft and refuses to
# touch a published one, this release is already live: changesets creates
# it when it publishes to npm. `--clobber` therefore replaced the assets
# of a shipped version on any re-run of this job — and a re-run after a
# source change would put different bytes behind a version number users
# already have. A version has to identify one exact build forever.
#
# These assets are one set, not a bag of files: manifest.json pins a
# sha256 for every zip, and install.sh / install.ps1 verify against it.
# So the rule is all or nothing. A release that already carries the whole
# set is finished and is left alone; an empty one gets everything. A
# partial set stops the job, because filling in the gaps would pair zips
# from one build with checksums from another, and a rebuild is not
# guaranteed to be byte-identical even at the same commit. Sorting that
# out is a decision for a person: either what is published is the
# release, or it needs a new patch version.
- name: Upload assets to GitHub Release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ needs.release.outputs.pythinker_release_tag }}
run: |
set -euo pipefail
existing="$(gh release view "$RELEASE_TAG" --json assets --jq '.assets[].name')"
present=()
absent=()
for path in dist-native-release/*; do
name="$(basename "$path")"
if printf '%s\n' "$existing" | grep -qxF "$name"; then
present+=("$name")
else
absent+=("$path")
fi
done
if [ "${#absent[@]}" -eq 0 ]; then
echo "::notice::All ${#present[@]} assets are already on ${RELEASE_TAG}; nothing to upload."
exit 0
fi
if [ "${#present[@]}" -gt 0 ]; then
echo "::error::${RELEASE_TAG} already carries ${#present[@]} of these assets: ${present[*]}"
echo "::error::Adding the rest would mix two builds behind one version - manifest.json pins a sha256 per zip."
echo "::error::Resolve by hand: keep what is published, or cut the next patch and release that instead."
exit 1
fi
gh release upload "$RELEASE_TAG" "${absent[@]}"
release-summary:
name: Release lane summary
if: always() && github.repository_owner == 'PyModel'
needs:
- release
- cut-desktop-tag
- publish-vscode-extension
- native-artifacts
- publish-native-assets
- redeploy-cdn
- verify-cdn-release
- update-brew-tap
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # pinned from v7.0.0
with:
node-version-file: .nvmrc
- name: Render and enforce lane summary
env:
RELEASE_RESULT: ${{ needs.release.result }}
CLI_EXPECTED: ${{ needs.release.outputs.cli_version_bumped == 'true' || needs.release.outputs.pythinker_native_release == 'true' }}
CLI_PUBLISHED: ${{ needs.release.outputs.pythinker_native_release }}
NATIVE_RESULT: ${{ needs.publish-native-assets.result }}
CDN_ENABLED: ${{ vars.RELEASE_LANE_CDN != 'disabled' }}
CDN_DEPLOY_RESULT: ${{ needs.redeploy-cdn.result }}
CDN_VERIFY_RESULT: ${{ needs.verify-cdn-release.result }}
BREW_ENABLED: ${{ vars.RELEASE_LANE_BREW != 'disabled' }}
BREW_RESULT: ${{ needs.update-brew-tap.result }}
DESKTOP_EXPECTED: ${{ needs.release.outputs.desktop_version_bumped }}
DESKTOP_ENABLED: ${{ vars.RELEASE_LANE_DESKTOP != 'disabled' }}
DESKTOP_RESULT: ${{ needs.cut-desktop-tag.result }}
VSCODE_EXPECTED: ${{ needs.release.outputs.extension_version_bumped }}
VSCODE_ENABLED: ${{ vars.RELEASE_LANE_VSCODE != 'disabled' }}
VSCODE_RESULT: ${{ needs.publish-vscode-extension.result }}
run: node scripts/release/render-summary.mjs