Repository navigation
435 lines (428 loc) · 21.6 KB
/
Copy pathnative-mac-release.yml
File metadata and controls
435 lines (428 loc) · 21.6 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
# The native BotFleetMac release path (BF-2486, Phase 6): archive, Developer
# ID sign, notarize, staple, DMG, and the Sparkle appcast, from ONE pinned
# commit. The default run only uploads a private Actions artifact. A draft
# GitHub release is a separate explicit input, and this workflow never
# publishes: publishing stays the owner's click, exactly as in release.yml.
#
# Why a second workflow instead of a release.yml job: the two apps share the
# bundle identifier app.botfleet.macos, and their feeds are different formats
# (latest-mac.yml for electron-updater, appcast.xml for Sparkle) over
# disjoint asset names (BotFleet-* vs BotFleetMac-*). One workflow per feed
# keeps either pipeline runnable while the other shell still ships, and the
# Phase 6 milestone retires release.yml only when the Electron shell does.
#
# Every gate below mirrors scripts/mac-package.sh, which see for the local
# path: codesign BEFORE notarization (notarization accepts invalid
# signatures), hashes and lengths AFTER stapling (stapling rewrites bytes),
# and the version stamped once in `prepare` from a full-history checkout.
#
# When this workflow gains push triggers, their paths must cover
# ios/MacApp/**, ios/Sources/**, ios/project.yml and this file, or a
# native-only change becomes unshippable (the C-86 lesson BF-2486 records).
name: Native Mac Release
on:
workflow_dispatch:
inputs:
ref:
description: "Commit/tag/branch to release (defaults to the branch this runs from)"
required: false
type: string
draft:
description: "Attach the verified native assets to the v<version> draft release"
required: false
type: boolean
default: false
unsigned-draft:
description: "Build an explicitly unverified artifact only; never attach it to a release"
required: false
type: boolean
default: false
permissions:
contents: read
env:
RELEASES_REPO: Simple-With-Us/BotFleet
# The native assets live on their OWN release tag, never on the Electron
# pipeline's v<version> release. release.yml verifies that release's asset
# set is exactly the Electron set and deletes every asset it does not
# recognise, so a shared tag either fails the Electron release ("release
# asset set differs: unexpected [...]") or deletes the native ZIP, DMG and
# appcast. The suffix keeps the two feeds on separate releases; the
# end state where both asset sets share one release is BF-2559's work and
# needs release.yml and scripts/verify-release-assets.mjs.
NATIVE_TAG_SUFFIX: "-native"
jobs:
prepare:
name: Pin the release commit
runs-on: ubuntu-26.04
# The shared planner reads the App Store Connect inventory, so this job
# waits on a network read, not on the clock it used to need.
timeout-minutes: 10
outputs:
sha: ${{ steps.pin.outputs.sha }}
version: ${{ steps.pin.outputs.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: the merge stamp is the committer
# date of the commit being released, and a depth-1 clone cannot
# prove which commit that is.
fetch-depth: 0
persist-credentials: false
# The shared planner consults App Store Connect, so this job loads
# the ASC key with INFISICAL_NATIVE_RELEASE_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_NATIVE_RELEASE_READ_TOKEN }}
- id: pin
run: |
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
# The shared planner answers the fixed marketing version 0.1.0
# and the commit's Central merge stamp as the build, which is the
# same number the iOS ship plans for this commit. 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/mac-release-version.mjs --repo-root . --sha "$(git rev-parse HEAD)")" || exit 1
version="$(printf '%s\n' "$plan" | sed -n 's/^version=//p')"
build="$(printf '%s\n' "$plan" | sed -n 's/^build=//p')"
if [ -z "$version" ] || [ -z "$build" ]; then
echo "::error::the shared planner returned no version and build"
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "build=$build" >> "$GITHUB_OUTPUT"
- name: Refuse to overwrite a published release
if: ${{ inputs.draft }}
env:
GH_TOKEN: ${{ github.token }}
run: |
# Read this job's own pin output here, never the needs context:
# inside the job that produces an output, needs is still empty,
# which is how the release.yml guard silently never fired before
# PR #1315 fixed it there.
v="v${{ steps.pin.outputs.version }}-${{ steps.pin.outputs.build }}${NATIVE_TAG_SUFFIX}"
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; refusing to add native assets to it"
exit 1
fi
mac-native:
name: BotFleetMac (sign, notarize, staple)
needs: prepare
runs-on: macos-26
timeout-minutes: 90
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.prepare.outputs.sha }}
fetch-depth: 0
persist-credentials: false
- name: Refuse a beta Xcode
run: |
xcodebuild -version
if xcodebuild -version | grep -qi beta; then
echo "::error::xcodebuild reports a beta Xcode toolchain"
exit 1
fi
- 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_NATIVE_RELEASE_READ_TOKEN }}
env:
GH_FALLBACK_MAC_CERT_P12_BASE64: ${{ secrets.MAC_CERT_P12_BASE64 }}
GH_FALLBACK_MAC_CERT_PASSWORD: ${{ secrets.MAC_CERT_PASSWORD }}
# Best-effort: an inert Sentry beats a red release. The Mac app reads
# SENTRY_DSN from its Info.plist at launch (BF-2541, PR #1394), and the
# Archive step below passes whatever resolves here as a build setting.
- name: Load the Sentry DSN (optional)
uses: ./.github/actions/infisical-secrets
with:
names: SENTRY_DSN
project-id: ${{ secrets.INFISICAL_PROJECT_ID }}
token: ${{ secrets.INFISICAL_NATIVE_RELEASE_READ_TOKEN }}
env:
GH_FALLBACK_SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
- 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: Generate the Xcode project
run: |
bash scripts/ios-install-xcodegen.sh
cd ios
xcodegen generate
- name: Verify canonical native update configuration
env:
INFISICAL_NATIVE_RELEASE_READ_TOKEN: ${{ secrets.INFISICAL_NATIVE_RELEASE_READ_TOKEN }}
MIRROR_BOTFLEET_NATIVE_UPDATE_FEED_URL: ${{ vars.BOTFLEET_NATIVE_UPDATE_FEED_URL }}
MIRROR_BOTFLEET_NATIVE_UPDATE_PUBLIC_KEY: ${{ vars.BOTFLEET_NATIVE_UPDATE_PUBLIC_KEY }}
run: |
node scripts/native-update-release-config.mjs --output "$RUNNER_TEMP/native-update.env"
cat "$RUNNER_TEMP/native-update.env" >> "$GITHUB_ENV"
- name: Archive BotFleetMac
env:
VERSION: ${{ needs.prepare.outputs.version }}
BUILD: ${{ needs.prepare.outputs.build }}
SENTRY_DSN: ${{ env.SENTRY_DSN }}
SOURCE_COMMIT: ${{ needs.prepare.outputs.sha }}
BOTFLEET_NATIVE_UPDATE_FEED_URL: ${{ env.BOTFLEET_NATIVE_UPDATE_FEED_URL }}
BOTFLEET_NATIVE_UPDATE_PUBLIC_KEY: ${{ env.BOTFLEET_NATIVE_UPDATE_PUBLIC_KEY }}
run: |
build="$BUILD"
if [ -n "${SENTRY_DSN:-}" ]; then
echo "::add-mask::$SENTRY_DSN"
else
echo "::warning::SENTRY_DSN is empty after the Infisical prod export and the GitHub secret fallback. This build ships with Sentry inert (BF-2541)."
fi
xcodebuild archive \
-project ios/BotFleet.xcodeproj \
-scheme BotFleetMac \
-destination 'generic/platform=macOS' \
-archivePath "$RUNNER_TEMP/BotFleetMac.xcarchive" \
MARKETING_VERSION="$VERSION" \
CURRENT_PROJECT_VERSION="$build" \
SENTRY_DSN="$SENTRY_DSN" \
SOURCE_COMMIT="$SOURCE_COMMIT" \
BOTFLEET_NATIVE_UPDATE_FEED_URL="$BOTFLEET_NATIVE_UPDATE_FEED_URL" \
BOTFLEET_NATIVE_UPDATE_PUBLIC_KEY="$BOTFLEET_NATIVE_UPDATE_PUBLIC_KEY" \
CODE_SIGN_STYLE=Manual \
CODE_SIGN_IDENTITY="Developer ID Application" \
DEVELOPMENT_TEAM=CC8UTF7ATG
xcodebuild -exportArchive \
-archivePath "$RUNNER_TEMP/BotFleetMac.xcarchive" \
-exportOptionsPlist ios/ExportOptions-DeveloperID.plist \
-exportPath "$RUNNER_TEMP/export"
- name: "Gate: the signature must verify BEFORE notarization"
run: |
app="$RUNNER_TEMP/export/BotFleet.app"
[ -d "$app" ] || { echo "::error::the export produced no BotFleet.app"; exit 1; }
codesign --verify --deep --strict "$app"
team=$(codesign -dv --verbose=4 "$app" 2>&1 | sed -n 's/^TeamIdentifier=//p')
test "$team" = "CC8UTF7ATG" || {
echo "::error::BotFleet.app is signed by team '${team:-none}', not CC8UTF7ATG"; exit 1; }
echo "ok: $app"
- 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_NATIVE_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 and staple the app
env:
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: |
# The key never stays at a path: it is decoded into a private
# temporary file, that file is opened on a file descriptor and
# unlinked at once, and notarytool reads it through the
# descriptor.
p8="$RUNNER_TEMP/notary.p8"
echo "$APPLE_API_KEY_P8_BASE64" | base64 --decode > "$p8"
exec 3< "$p8"
rm -f "$p8"
app="$RUNNER_TEMP/export/BotFleet.app"
ditto -c -k --sequesterRsrc --keepParent "$app" "$RUNNER_TEMP/submit.zip"
# The notary's own output is the only place the reason is written
# down, so it is printed before the failure instead of being
# swallowed by `set -e`.
out=$(xcrun notarytool submit "$RUNNER_TEMP/submit.zip" \
--key /dev/fd/3 \
--key-id "$APPLE_API_KEY_ID" \
--issuer "$APPLE_API_ISSUER_ID" \
--wait 2>&1) || {
echo "$out"
echo "::error::notarization failed for the app ZIP"; exit 1; }
echo "$out" | tail -2
echo "$out" | grep -q "status: Accepted" || { echo "::error::notarization not Accepted for the app ZIP"; exit 1; }
xcrun stapler staple "$app"
xcrun stapler validate "$app"
spctl --assess --type execute "$app"
echo "ok: notarized, stapled, Gatekeeper-clean"
- name: Package the final ZIP and DMG from the stapled app
env:
VERSION: ${{ needs.prepare.outputs.version }}
BUILD: ${{ needs.prepare.outputs.build }}
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: |
mkdir -p native-upload
app="$RUNNER_TEMP/export/BotFleet.app"
ditto -c -k --sequesterRsrc --keepParent "$app" "native-upload/BotFleetMac-$VERSION.zip"
hdiutil create -volname "BotFleet" -srcfolder "$app" -ov -format UDZO \
"native-upload/BotFleetMac-$VERSION.dmg" > /dev/null
p8="$RUNNER_TEMP/notary.p8"
echo "$APPLE_API_KEY_P8_BASE64" | base64 --decode > "$p8"
exec 3< "$p8"
rm -f "$p8"
out=$(xcrun notarytool submit "native-upload/BotFleetMac-$VERSION.dmg" \
--key /dev/fd/3 \
--key-id "$APPLE_API_KEY_ID" \
--issuer "$APPLE_API_ISSUER_ID" \
--wait 2>&1) || {
echo "$out"
echo "::error::notarization failed for the DMG"; exit 1; }
echo "$out" | tail -2
echo "$out" | grep -q "status: Accepted" || { echo "::error::notarization not Accepted for the DMG"; exit 1; }
xcrun stapler staple "native-upload/BotFleetMac-$VERSION.dmg"
xcrun stapler validate "native-upload/BotFleetMac-$VERSION.dmg"
# Trusted artifacts require an actual signature; only an explicitly
# unverified draft can proceed without one and cannot join a release.
- name: Load the Sparkle EdDSA key
uses: ./.github/actions/infisical-secrets
with:
names: SPARKLE_EDDSA_PRIVATE_KEY_B64
required: ${{ !inputs.unsigned-draft && 'SPARKLE_EDDSA_PRIVATE_KEY_B64' || '' }}
project-id: ${{ secrets.INFISICAL_PROJECT_ID }}
token: ${{ secrets.INFISICAL_NATIVE_RELEASE_READ_TOKEN }}
- name: Generate and verify the appcast over the final bytes
env:
VERSION: ${{ needs.prepare.outputs.version }}
BUILD: ${{ needs.prepare.outputs.build }}
SPARKLE_EDDSA_PRIVATE_KEY_B64: ${{ env.SPARKLE_EDDSA_PRIVATE_KEY_B64 }}
BOTFLEET_NATIVE_UPDATE_PUBLIC_KEY: ${{ env.BOTFLEET_NATIVE_UPDATE_PUBLIC_KEY }}
UNSIGNED_DRAFT: ${{ inputs.unsigned-draft }}
run: |
zip="native-upload/BotFleetMac-$VERSION.zip"
if [ -n "${SPARKLE_EDDSA_PRIVATE_KEY_B64:-}" ]; then
# sign_update is NOT part of the macOS runner image, so without
# this install the signing branch can never run and a
# provisioned key would still produce an unsigned feed. The
# install is best effort, but a key with no tool is a hard
# error: an unsigned appcast that looks signed is the failure
# this pipeline exists to prevent.
brew install sparkle || echo "::warning::brew install sparkle failed"
sparkle_bin="$(brew --prefix 2>/dev/null)/bin"
if [ -x "$sparkle_bin/sign_update" ]; then
echo "$sparkle_bin" >> "$GITHUB_PATH"
export PATH="$sparkle_bin:$PATH"
fi
command -v sign_update > /dev/null || {
echo "::error::sign_update is not installed; cannot sign the appcast"; exit 1; }
key="$RUNNER_TEMP/sparkle-ed-key"
echo "$SPARKLE_EDDSA_PRIVATE_KEY_B64" | base64 --decode > "$key"
signature=$(sign_update --ed-key-file "$key" "$zip" | sed -n 's/.*sparkle:edSignature="\([^"]*\)".*/\1/p')
test -n "$signature" || { echo "::error::sign_update produced no signature"; exit 1; }
printf '%s' "$signature" > "$zip.edsig"
rm "$key"
echo "Sparkle update signed"
else
test "$UNSIGNED_DRAFT" = true || { echo "::error::trusted artifacts require Sparkle signing"; exit 1; }
echo "::warning::UNVERIFIED DRAFT: not activatable and cannot be attached to a release"
fi
node scripts/native-appcast.mjs native-upload "$VERSION" "v${VERSION}-${BUILD}${NATIVE_TAG_SUFFIX}" --build "$BUILD"
if [ -f "$zip.edsig" ]; then
node scripts/verify-native-release.mjs native-upload "$VERSION" --build "$BUILD" --require-signature --public-key "$BOTFLEET_NATIVE_UPDATE_PUBLIC_KEY"
rm "$zip.edsig"
else
node scripts/verify-native-release.mjs native-upload "$VERSION" --build "$BUILD"
fi
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: native-mac-release
path: native-upload/
if-no-files-found: error
retention-days: 14
assemble:
name: Verify the native asset set
needs: [prepare, mac-native]
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
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: native-mac-release
path: assets
- name: Verify the appcast against the actual bytes
env:
INFISICAL_NATIVE_RELEASE_READ_TOKEN: ${{ secrets.INFISICAL_NATIVE_RELEASE_READ_TOKEN }}
VERSION: ${{ needs.prepare.outputs.version }}
BUILD: ${{ needs.prepare.outputs.build }}
UNSIGNED_DRAFT: ${{ inputs.unsigned-draft }}
run: |
if [ "$UNSIGNED_DRAFT" = true ]; then
node scripts/verify-native-release.mjs assets "$VERSION" --build "$BUILD"
else
node scripts/native-update-release-config.mjs --output "$RUNNER_TEMP/assembled-native-update.env"
while IFS='=' read -r name value; do export "$name=$value"; done < "$RUNNER_TEMP/assembled-native-update.env"
node scripts/verify-native-release.mjs assets "$VERSION" --build "$BUILD" --require-signature --public-key "$BOTFLEET_NATIVE_UPDATE_PUBLIC_KEY"
fi
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: botfleet-native-${{ needs.prepare.outputs.version }}-${{ needs.prepare.outputs.sha }}
path: assets
if-no-files-found: error
retention-days: 14
draft:
name: Attach the native assets to the draft release
if: ${{ inputs.draft && !inputs.unsigned-draft }}
needs: [prepare, assemble]
runs-on: ubuntu-26.04
timeout-minutes: 15
permissions:
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.prepare.outputs.sha }}
persist-credentials: false
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: botfleet-native-${{ needs.prepare.outputs.version }}-${{ needs.prepare.outputs.sha }}
path: assets
- name: Create or update the draft with the native asset set
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.prepare.outputs.version }}
BUILD: ${{ needs.prepare.outputs.build }}
SHA: ${{ needs.prepare.outputs.sha }}
run: |
cd assets
tag="v${VERSION}-${BUILD}${NATIVE_TAG_SUFFIX}"
if existing=$(gh api "repos/$RELEASES_REPO/releases/tags/$tag" 2>/dev/null); then
is_draft=$(jq -r .draft <<< "$existing")
if [ "$is_draft" != true ]; then
echo "::error::$tag is already published; refusing to replace its assets"
exit 1
fi
else
gh release create "$tag" --repo "$RELEASES_REPO" \
--draft --target "$SHA" --title "BotFleet $VERSION ($BUILD, native)" \
--notes "Draft assembled by the native release workflow. Edit these notes, then publish."
fi
# Native names never collide with the Electron set, so --clobber
# here can only replace this workflow's own earlier uploads.
for f in BotFleetMac-*.zip BotFleetMac-*.dmg appcast.xml; do
gh release upload "$tag" "$f" --repo "$RELEASES_REPO" --clobber
done
echo "native assets on $tag: $(gh release view "$tag" --repo "$RELEASES_REPO" --json assets --jq '[.assets[].name | select(startswith("BotFleetMac") or . == "appcast.xml")] | join(", ")')"