Repository navigation
576 lines (554 loc) · 28.1 KB
/
Copy pathrelease.yml
File metadata and controls
576 lines (554 loc) · 28.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
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
# One-button release staging: builds macOS (arm64 + x64, signed, notarized,
# stapled) from ONE commit, then verifies one complete artifact set. The
# default run only uploads a private Actions artifact.
# Draft creation and publication are separate, explicit inputs.
#
# Publish target = the built-in GITHUB_TOKEN. The job's `contents: write`
# (set just below) is the only extra scope the release steps need, and
# nothing in this repo listens for `released` events, so a fine-grained PAT
# adds rotation without buying anything. Decision on 2026-09-18.
#
# Every gate below exists because its absence shipped (or nearly shipped) a
# broken build from a laptop:
# - clean output dirs -> 0.1.15 "app is damaged" (stale dist/ broke the seal)
# - codesign gate BEFORE -> notarization ACCEPTS invalid signatures
# - packaged-server smoke -> 0.1.24 died on launch (unbundled zod import)
# - proxy-path probe -> the 0.1.24 fix broke every helper while
# /api/health stayed green
# - staple, THEN hash -> stapling rewrites bytes; stale feeds make
# electron-updater reject every update
# - blockmap regeneration -> stale differential maps waste every updater's
# bandwidth silently
# - assemble-then-publish -> 0.1.24 sat as an invisible draft; a partial
# publish leaves the feed without its zips
#
# One-time signing setup is documented in docs/releasing.md.
name: Release
on:
workflow_dispatch:
inputs:
ref:
description: "Commit/tag/branch to release (defaults to the branch this runs from)"
required: false
type: string
publish:
description: "Publish immediately after assembly (also creates/updates the draft)"
required: false
type: boolean
default: false
draft:
description: "Create or update a GitHub draft release after artifact verification"
required: false
type: boolean
default: false
permissions:
contents: write
env:
# The one and only publish target. Must match electron-builder.yml
# `publish`, or installed apps check a feed no release ever lands on.
RELEASES_REPO: Simple-With-Us/BotFleet
jobs:
prepare:
name: Pin the release commit
runs-on: ubuntu-26.04
timeout-minutes: 5
outputs:
sha: ${{ steps.pin.outputs.sha }}
version: ${{ steps.pin.outputs.version }}
marketing_version: ${{ steps.pin.outputs.marketing_version }}
build: ${{ steps.pin.outputs.build }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref || github.ref }}
# fetch-depth: 0 is REQUIRED, not a preference. The build number is
# the committer date of the commit being released, and on the default
# depth-1 clone the merge stamp cannot be proven. The stamp script
# refuses a shallow clone; this is what lets it not.
fetch-depth: 0
persist-credentials: false
# The shared planner consults App Store Connect, so this job loads
# the ASC key with INFISICAL_IOS_SHIP_READ_TOKEN (BF-2588).
- name: Load the App Store Connect credentials
uses: ./.github/actions/infisical-secrets
with:
names: APPLE_API_KEY_ID APPLE_API_ISSUER_ID APPLE_API_KEY_P8_BASE64
required: APPLE_API_KEY_ID APPLE_API_ISSUER_ID APPLE_API_KEY_P8_BASE64
project-id: ${{ secrets.INFISICAL_PROJECT_ID }}
token: ${{ secrets.INFISICAL_IOS_SHIP_READ_TOKEN }}
- id: pin
run: |
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
# Plan BEFORE the overwrite guard below, or the guard would compare
# the hand-typed version against what was published and approve a
# release whose artifacts are numbered something else entirely.
#
# The shared planner answers three values (BF-2588): the fixed
# marketing version 0.1.0, the commit's Central merge stamp as the
# build, and the app version the Electron updater orders releases
# by, which carries that build in its patch. It exits 1 when it
# cannot plan, and `echo "$(...)"` would return echo's status and
# publish an EMPTY version, which then fails much later with an
# unrelated error.
plan="$(node scripts/electron-release-version.mjs --repo-root . --sha "$(git rev-parse HEAD)")" || exit 1
app_version="$(printf '%s\n' "$plan" | sed -n 's/^app_version=//p')"
marketing_version="$(printf '%s\n' "$plan" | sed -n 's/^version=//p')"
build="$(printf '%s\n' "$plan" | sed -n 's/^build=//p')"
if [ -z "$app_version" ] || [ -z "$marketing_version" ] || [ -z "$build" ]; then
echo "::error::the shared planner returned no app version, marketing version and build"
exit 1
fi
echo "version=$app_version" >> "$GITHUB_OUTPUT"
echo "marketing_version=$marketing_version" >> "$GITHUB_OUTPUT"
echo "build=$build" >> "$GITHUB_OUTPUT"
- name: Refuse to overwrite a published release
if: ${{ inputs.draft || inputs.publish }}
env:
GH_TOKEN: ${{ github.token }}
run: |
# steps.pin.outputs.version, NOT needs.prepare.outputs.version. This
# step runs INSIDE the prepare job, and the needs context there holds
# the jobs prepare depends on -- which is none of them, because
# prepare is the first job. The needs expression rendered as an empty
# string, so the guard compared the literal tag "v", which matches no
# release, so the gate never fired: a release already published under
# this version would have been overwritten instead of stopping the run.
v="v${{ steps.pin.outputs.version }}"
if gh release view "$v" --repo "$RELEASES_REPO" --json isDraft --jq .isDraft 2>/dev/null | grep -q false; then
echo "::error::$v is already published on $RELEASES_REPO — bump the version first"
exit 1
fi
mac:
name: macOS ${{ matrix.arch }} (sign, notarize, staple)
needs: prepare
runs-on: macos-26
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
include:
- arch: arm64
app: release/mac-arm64/BotFleet.app
package_script: package:mac:arm64:release
cloudflared_arch: arm64
- arch: x64
app: release/mac/BotFleet.app
package_script: package:mac:x64:release
cloudflared_arch: x86_64
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.prepare.outputs.sha }}
persist-credentials: false
# One version for the whole release, computed once in `prepare`. This job
# never edits package.json: the provenance gate refuses a dirty checkout,
# so the value is handed to electron-builder and the server bundler instead.
# GITHUB_ENV, not a step env: the packaging step that needs this runs
# later in the job, and a step-scoped value would not reach it.
#
# Two values are pinned, not one (BF-2588): the app version, which
# carries the build in its patch because the Electron updater orders
# releases by semver on it, and the marketing version, which is the
# version a person sees and the one the app's own Info.plist carries.
- name: Resolve the pinned version
run: |
echo "BOTFLEET_BUILD_VERSION=${{ needs.prepare.outputs.version }}" >> "$GITHUB_ENV"
echo "BOTFLEET_MARKETING_VERSION=${{ needs.prepare.outputs.marketing_version }}" >> "$GITHUB_ENV"
- uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 26
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Clean generated output
run: rm -rf dist dist-server dist-native release
# Prefer Infisical values from the dedicated release read token
# (INFISICAL_RELEASE_READ_TOKEN) and accept same-named GitHub secrets as a
# migration fallback; a release without both values cannot be signed, so
# fail here with the missing names.
# (Not optional: with them empty the import step below would decode an
# empty .p12 and die inside `security import`, and the signature gate
# after packaging would fail anyway -- a still-empty vault cannot make
# this job green, so stop early with a message that names the secrets.
# scripts/release-workflow.node-test.mjs asserts this `required:` line.)
- name: Load the Developer ID certificate
uses: ./.github/actions/infisical-secrets
with:
names: MAC_CERT_P12_BASE64 MAC_CERT_PASSWORD
required: MAC_CERT_P12_BASE64 MAC_CERT_PASSWORD
project-id: ${{ secrets.INFISICAL_PROJECT_ID }}
token: ${{ secrets.INFISICAL_RELEASE_READ_TOKEN }}
env:
GH_FALLBACK_MAC_CERT_P12_BASE64: ${{ secrets.MAC_CERT_P12_BASE64 }}
GH_FALLBACK_MAC_CERT_PASSWORD: ${{ secrets.MAC_CERT_PASSWORD }}
- name: Import the Developer ID certificate into a throwaway keychain
env:
MAC_CERT_P12_BASE64: ${{ env.MAC_CERT_P12_BASE64 }}
MAC_CERT_PASSWORD: ${{ env.MAC_CERT_PASSWORD }}
run: |
KEYCHAIN="$RUNNER_TEMP/release.keychain-db"
KEYCHAIN_PASSWORD="$(uuidgen)"
echo "$MAC_CERT_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security import "$RUNNER_TEMP/cert.p12" -P "$MAC_CERT_PASSWORD" -A \
-t cert -f pkcs12 -k "$KEYCHAIN"
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security list-keychains -d user -s "$KEYCHAIN" login.keychain
rm "$RUNNER_TEMP/cert.p12"
- name: Resolve the Sentry DSN
uses: ./.github/actions/infisical-secrets
with:
names: SENTRY_DSN VITE_SENTRY_DSN SENTRY_AUTH_TOKEN
project-id: ${{ secrets.INFISICAL_PROJECT_ID }}
token: ${{ secrets.INFISICAL_RELEASE_READ_TOKEN }}
env:
GH_FALLBACK_SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
GH_FALLBACK_SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
# Vite inlines VITE_SENTRY_DSN at build time, so it must be in the
# environment before the package step runs. Infisical/GitHub hold no
# separate VITE_SENTRY_DSN today, so the Cocoa/Electron DSN doubles as
# the web one -- the behavior this replaces, preserved here. Never
# fail the build over a missing DSN -- an inert Sentry beats a red
# release.
- name: Default VITE_SENTRY_DSN to SENTRY_DSN when unset
if: env.VITE_SENTRY_DSN == ''
shell: bash
env:
SENTRY_DSN: ${{ env.SENTRY_DSN }}
run: |
if [ -n "${SENTRY_DSN:-}" ]; then
echo "::add-mask::$SENTRY_DSN"
{
echo "VITE_SENTRY_DSN<<VITE_SENTRY_DSN_EOF"
printf '%s\n' "$SENTRY_DSN"
echo "VITE_SENTRY_DSN_EOF"
} >> "$GITHUB_ENV"
else
echo "::warning::SENTRY_DSN and VITE_SENTRY_DSN are both empty after the Infisical prod export and the GitHub secret fallback. This build packages with Sentry inert."
fi
# The build number reaches electron-builder as extraMetadata, never as an
# edit to package.json: the provenance gate refuses a dirty checkout.
#
# The bundle's own Info.plist carries the two halves the policy splits:
# CFBundleShortVersionString is the marketing version 0.1.0, and
# CFBundleVersion is the build number, the commit's merge stamp (BF-2588).
- name: Package ${{ matrix.arch }}
run: |
pnpm ${{ matrix.package_script }} \
-c.extraMetadata.version="$BOTFLEET_BUILD_VERSION" \
-c.mac.bundleShortVersion="$BOTFLEET_MARKETING_VERSION" \
-c.mac.bundleVersion="${{ needs.prepare.outputs.build }}"
- name: "Gate: the signature must verify BEFORE notarization"
run: |
app="${{ matrix.app }}"
codesign --verify --deep --strict "$app"
bin="$app/Contents/Resources/cloudflared/cloudflared"
test -x "$bin" || { echo "::error::missing executable $bin"; exit 1; }
test -f "$app/Contents/Resources/licenses/cloudflared-LICENSE.txt" \
|| { echo "::error::missing cloudflared license in $app"; exit 1; }
test -f "$app/Contents/Resources/licenses/cloudflared-README.md" \
|| { echo "::error::missing cloudflared provenance in $app"; exit 1; }
codesign --verify --strict "$bin"
app_team=$(codesign -dv --verbose=4 "$app" 2>&1 | sed -n 's/^TeamIdentifier=//p')
bin_team=$(codesign -dv --verbose=4 "$bin" 2>&1 | sed -n 's/^TeamIdentifier=//p')
test -n "$app_team" && test "$bin_team" = "$app_team" || {
echo "::error::cloudflared is not signed by the app's Developer ID team in $app"; exit 1; }
echo "ok: $app"
# BF-2531: the feed orders by the app version, the Control update by the
# bundled Control's Info.plist, and the handshake by the server's
# build-identity.json. The three are stamped in three places, so prove
# the packaged bytes agree with the pinned release before notarization:
# a stale Control (or server) riding inside a new release would outlive
# its server with every other gate green.
- name: "Gate: the bundled Control and server carry the pinned release"
run: |
node scripts/verify-packaged-versions.mjs "${{ matrix.app }}" \
--version "${{ needs.prepare.outputs.version }}" \
--build "${{ needs.prepare.outputs.build }}"
- name: "Gate: the packaged server starts and every helper path resolves (runtime pass or bundle check)"
env:
MAC_APP: ${{ matrix.app }}
CLOUDFLARED_ARCH: ${{ matrix.cloudflared_arch }}
run: |
app="$MAC_APP"
res="$app/Contents/Resources"
# BF-1991: the server tree is plain JS, so it starts on the runner's
# Node whatever the app binary's arch -- but that is a BUNDLE CHECK,
# not a runtime pass. When this host can exec the packaged binary
# (the arm64 entry on an arm64 runner) the gate boots it under
# ELECTRON_RUN_AS_NODE=1, which is the runtime the shipped app uses.
# The x64 entry cannot exec its own binary here, so it runs and is
# LABELLED a bundle check. Never read a bundle check as a runtime
# pass.
node scripts/smoke-runtime-mode.mjs gate \
--app "$app" \
--dist "$res/server" \
--summary "$GITHUB_STEP_SUMMARY" \
|| { echo "::error::packaged server in $app failed to start"; exit 1; }
node --input-type=module - "$res/server" <<'EOF'
import { existsSync } from "node:fs";
import { pathToFileURL } from "node:url";
const server = process.argv[2];
const { SPAWNED_PROXIES } = await import(pathToFileURL(`${server}/proxy-paths.js`));
const missing = Object.entries(SPAWNED_PROXIES).filter(([, p]) => !existsSync(p));
if (missing.length) {
console.error("unresolved proxies:", missing);
process.exit(1);
}
console.log(`ok: ${Object.keys(SPAWNED_PROXIES).length} proxies resolve in ${server}`);
EOF
for bin in "$res/cua-driver" "$res/BotFleet Speech.app/Contents/MacOS/speech-helper"; do
lipo -archs "$bin" | grep -q "x86_64" || { echo "::error::$bin lacks x86_64"; exit 1; }
lipo -archs "$bin" | grep -q "arm64" || { echo "::error::$bin lacks arm64"; exit 1; }
done
cloudflared="$res/cloudflared/cloudflared"
expected_arch="$CLOUDFLARED_ARCH"
test "$(lipo -archs "$cloudflared")" = "$expected_arch" || {
echo "::error::$cloudflared is not the expected $expected_arch executable"; exit 1; }
if [ "$(uname -m)" = "$expected_arch" ]; then
"$cloudflared" version | grep -Fq "cloudflared version 2026.8.2 " || {
echo "::error::$cloudflared has the wrong version"; exit 1; }
fi
# All three names are required -- with GH_FALLBACK_* covering the
# cutover while the vault copies are confirmed (orchestrator step 3).
- name: Load the notarization credentials
uses: ./.github/actions/infisical-secrets
with:
names: APPLE_API_KEY_P8_BASE64 APPLE_API_KEY_ID APPLE_API_ISSUER_ID
required: APPLE_API_KEY_P8_BASE64 APPLE_API_KEY_ID APPLE_API_ISSUER_ID
project-id: ${{ secrets.INFISICAL_PROJECT_ID }}
token: ${{ secrets.INFISICAL_RELEASE_READ_TOKEN }}
env:
GH_FALLBACK_APPLE_API_KEY_P8_BASE64: ${{ secrets.APPLE_API_KEY_P8_BASE64 }}
GH_FALLBACK_APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
GH_FALLBACK_APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }}
- name: Notarize this architecture's DMG and ZIP
env:
MAC_ARCH: ${{ matrix.arch }}
APPLE_API_KEY_P8_BASE64: ${{ env.APPLE_API_KEY_P8_BASE64 }}
APPLE_API_KEY_ID: ${{ env.APPLE_API_KEY_ID }}
APPLE_API_ISSUER_ID: ${{ env.APPLE_API_ISSUER_ID }}
run: |
echo "$APPLE_API_KEY_P8_BASE64" | base64 --decode > "$RUNNER_TEMP/notary.p8"
v="${{ needs.prepare.outputs.version }}"
for f in "release/BotFleet-$v-$MAC_ARCH.dmg" "release/BotFleet-$v-$MAC_ARCH.zip"; do
out=$(xcrun notarytool submit "$f" \
--key "$RUNNER_TEMP/notary.p8" \
--key-id "$APPLE_API_KEY_ID" \
--issuer "$APPLE_API_ISSUER_ID" \
--wait 2>&1)
echo "$out" | tail -2
echo "$out" | grep -q "status: Accepted" || { echo "::error::notarization not Accepted for $f"; exit 1; }
done
rm "$RUNNER_TEMP/notary.p8"
- name: Staple, re-zip, and regenerate blockmaps
env:
MAC_APP: ${{ matrix.app }}
MAC_ARCH: ${{ matrix.arch }}
run: |
v="${{ needs.prepare.outputs.version }}"
xcrun stapler staple "release/BotFleet-$v-$MAC_ARCH.dmg"
xcrun stapler staple "$MAC_APP"
rm -f "release/BotFleet-$v-$MAC_ARCH.zip"
ditto -c -k --sequesterRsrc --keepParent "$MAC_APP" "release/BotFleet-$v-$MAC_ARCH.zip"
node scripts/regenerate-blockmaps.mjs \
"release/BotFleet-$v-$MAC_ARCH.zip" "release/BotFleet-$v-$MAC_ARCH.dmg"
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: mac-release-${{ matrix.arch }}
path: |
release/BotFleet-*-${{ matrix.arch }}.dmg
release/BotFleet-*-${{ matrix.arch }}.zip
release/BotFleet-*-${{ matrix.arch }}.dmg.blockmap
release/BotFleet-*-${{ matrix.arch }}.zip.blockmap
if-no-files-found: error
retention-days: 7
mac-merge:
name: macOS updater feed and stable aliases
needs: [prepare, mac]
if: always() && !cancelled() && needs.prepare.result == 'success'
runs-on: ubuntu-26.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.prepare.outputs.sha }}
persist-credentials: false
# One version for the whole release, computed once in `prepare`. This job
# never edits package.json: the provenance gate refuses a dirty checkout,
# so the value is handed to electron-builder and the server bundler instead.
# GITHUB_ENV, not a step env: the packaging step that needs this runs
# later in the job, and a step-scoped value would not reach it.
- name: Resolve the pinned version
run: echo "BOTFLEET_BUILD_VERSION=${{ needs.prepare.outputs.version }}" >> "$GITHUB_ENV"
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
continue-on-error: true
with:
name: mac-release-arm64
path: release
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
continue-on-error: true
with:
name: mac-release-x64
path: release
- name: Require at least one signed mac artifact set
run: |
shopt -s nullglob
files=(release/BotFleet-*.dmg)
if [ ${#files[@]} -eq 0 ]; then
echo "::error::no mac release artifacts were produced"
exit 1
fi
echo "mac artifacts: ${files[*]}"
- name: Regenerate latest-mac.yml when both architectures built
run: |
v="${{ needs.prepare.outputs.version }}"
if [ -f "release/BotFleet-$v-arm64.dmg" ] && [ -f "release/BotFleet-$v-x64.dmg" ]; then
# Pass the pinned version explicitly. The script's fallback is
# package.json's version, which is the FLOOR of the stamp scheme
# (1.0.31) and the version already installed on the owner's Mac:
# the first unified release died looking for a 1.0.31 artifact that
# the 1.0.2193 build had never made.
node scripts/regenerate-mac-feed.mjs release/latest-mac.yml "$v"
else
echo "::warning::skipping latest-mac.yml — not every mac architecture built"
fi
- name: Stable-named copies for the README's /latest/download links
run: |
v="${{ needs.prepare.outputs.version }}"
if [ -f "release/BotFleet-$v-arm64.dmg" ]; then
cp "release/BotFleet-$v-arm64.dmg" release/BotFleet.dmg
fi
if [ -f "release/BotFleet-$v-x64.dmg" ]; then
cp "release/BotFleet-$v-x64.dmg" release/BotFleet-intel.dmg
fi
- name: Stage merged mac artifacts for upload
run: |
mkdir -p mac-upload
shopt -s nullglob
for f in release/BotFleet-*.dmg release/BotFleet-*.zip release/BotFleet-*.blockmap \
release/BotFleet.dmg release/BotFleet-intel.dmg release/latest-mac.yml; do
[ -e "$f" ] && cp "$f" mac-upload/
done
ls -la mac-upload/
test "$(find mac-upload -type f | wc -l)" -gt 0
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: mac-release
path: mac-upload/
if-no-files-found: error
retention-days: 7
assemble:
name: Assemble and verify every feed
needs: [prepare, mac-merge]
runs-on: ubuntu-26.04
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.prepare.outputs.sha }}
persist-credentials: false
# One version for the whole release, computed once in `prepare`. This job
# never edits package.json: the provenance gate refuses a dirty checkout,
# so the value is handed to electron-builder and the server bundler instead.
# GITHUB_ENV, not a step env: the packaging step that needs this runs
# later in the job, and a step-scoped value would not reach it.
- name: Resolve the pinned version
run: echo "BOTFLEET_BUILD_VERSION=${{ needs.prepare.outputs.version }}" >> "$GITHUB_ENV"
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
path: assets
merge-multiple: true
- name: Verify every feed hash against the actual bytes
env:
VERSION: ${{ needs.prepare.outputs.version }}
run: node scripts/verify-release-assets.mjs assets "$VERSION"
- name: Preserve the complete verified release set
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: botfleet-release-${{ needs.prepare.outputs.version }}-${{ needs.prepare.outputs.sha }}
path: assets
if-no-files-found: error
retention-days: 14
release:
name: Create the GitHub release
if: ${{ inputs.draft || inputs.publish }}
needs: [prepare, assemble]
runs-on: ubuntu-26.04
timeout-minutes: 30
permissions:
contents: write
concurrency:
group: release-${{ needs.prepare.outputs.version }}
cancel-in-progress: false
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.prepare.outputs.sha }}
persist-credentials: false
# One version for the whole release, computed once in `prepare`. This job
# never edits package.json: the provenance gate refuses a dirty checkout,
# so the value is handed to electron-builder and the server bundler instead.
# GITHUB_ENV, not a step env: the packaging step that needs this runs
# later in the job, and a step-scoped value would not reach it.
- name: Resolve the pinned version
run: echo "BOTFLEET_BUILD_VERSION=${{ needs.prepare.outputs.version }}" >> "$GITHUB_ENV"
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: botfleet-release-${{ needs.prepare.outputs.version }}-${{ needs.prepare.outputs.sha }}
path: assets
- name: Create or update the draft with the complete asset set
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.prepare.outputs.version }}
SHA: ${{ needs.prepare.outputs.sha }}
run: |
node scripts/verify-release-tag.mjs
cd assets
tag="v$VERSION"
notes="Draft assembled by the release workflow from $RELEASES_REPO@$SHA. Edit these notes, then publish."
if existing=$(gh api "repos/$RELEASES_REPO/releases/tags/$tag" 2>/dev/null); then
is_draft=$(jq -r .draft <<< "$existing")
existing_target=$(jq -r .target_commitish <<< "$existing")
if [ "$is_draft" != true ]; then
echo "::error::$tag is already published; refusing to replace its assets"
exit 1
fi
if [ "$existing_target" != "$SHA" ]; then
echo "::error::$tag existing draft targets $existing_target, not pinned $SHA"
exit 1
fi
else
gh release create "$tag" --repo "$RELEASES_REPO" \
--draft --target "$SHA" --title "BotFleet $VERSION" \
--notes "$notes"
fi
while IFS= read -r old_asset; do
if [ ! -f "$old_asset" ]; then
gh release delete-asset "$tag" "$old_asset" --repo "$RELEASES_REPO" --yes
fi
done < <(gh release view "$tag" --repo "$RELEASES_REPO" --json assets --jq '.assets[].name')
for f in *; do
gh release upload "$tag" "$f" --repo "$RELEASES_REPO" --clobber
done
echo "$RELEASES_REPO asset count: $(gh release view "$tag" --repo "$RELEASES_REPO" --json assets --jq '.assets | length')"
- name: Download and verify the uploaded draft bytes
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
mkdir uploaded
gh release download "v$VERSION" --repo "$RELEASES_REPO" --dir uploaded
node scripts/verify-release-assets.mjs uploaded "$VERSION"
- name: Publish (only when asked to)
if: ${{ inputs.publish }}
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
gh release edit "v$VERSION" --repo "$RELEASES_REPO" --draft=false --latest
# a draft that LOOKS published is the 0.1.24 failure mode — verify
gh release view "v$VERSION" --repo "$RELEASES_REPO" --json isDraft --jq .isDraft | grep -q false
echo "v$VERSION is live on $RELEASES_REPO"