Skip to content

Commit ccb76c4

Browse files
authored
Publish a release in about 25 minutes instead of 68 (#1214)
* Build the Intel Mac app on the Apple silicon runner The Intel signing job ran on macos-15-intel and took 22 minutes against 12 for the same build on macos-15. Both now build on macos-15: signed-build.ts names the Rust target for every Mac build (x86_64-apple-darwin for Intel), the CLI's dependencies are installed for the installer's platform with npm --os/--cpu, and verify-mac-arch.sh checks every Mach-O in the app and in its disk image carries the job's architecture. * Publish a release in a third of the time, keeping every guarantee A stable release no longer builds an unsigned desktop app the signing runs then replace: the signed builds are the only installers it ever carries. A candidate or beta still gets its ad-hoc and unsigned installers, built beside the release and attached without ever replacing anything, so a signing run that gets there first keeps its signed build. The tag and the release are created right after the images, and both signing runs are dispatched from there, so they build while the publish run finishes. Latest still waits for every signed installer and latest.json. The full CI suite no longer runs again on a release commit that changes only the release files on a commit main's CI passed (scripts/release-ci-gate.ts). That run stands in for it, and the checks that read the release files run again. Anything else, including a renamed or deleted file, runs the full suite. The diff is read with renames off, which also closes the same hole in the final-against-candidate check. CI keeps a run per commit on main, and the release's own checks out of main's concurrency group: they were waiting for main's in-progress run (the five minutes before v0.3.1's checks) and cancelling the pending one. stapler validate retries a dropped CloudKit connection, as stapling does. * Run the full checks when main's CI cannot be read * Say where a final's desktop build was tested * Scan the app for its architecture with one file call, not one per file
1 parent 8df6fe1 commit ccb76c4

20 files changed

Lines changed: 2030 additions & 261 deletions

‎.github/workflows/ci.yml‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,18 @@ on:
1212
permissions:
1313
contents: read
1414

15-
# The newest push on a branch wins; main runs are retained for badge and release history.
15+
# The newest push on a pull request wins. Every commit on main keeps its own run, for the badge and
16+
# for the release: publishing a release relies on main's CI having passed on the commit the release
17+
# PR merged onto (scripts/release-ci-gate.ts). In one group, a push landing while another commit's
18+
# run was in progress replaced the pending run of the commit before it, and that commit never got a
19+
# result.
20+
#
21+
# `github.workflow` keeps the release's own call of these checks out of main's group. A called
22+
# workflow sees its caller's context, so without it Publish release's checks shared
23+
# `ci-refs/heads/main` with main's push runs: they queued behind whichever was in progress (the
24+
# five-minute wait before the checks of v0.3.1) and cancelled the one that was pending.
1625
concurrency:
17-
group: ci-${{ github.ref }}
26+
group: ci-${{ github.workflow }}-${{ github.ref == 'refs/heads/main' && github.sha || github.ref }}
1827
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
1928

2029
jobs:

‎.github/workflows/desktop-macos-signing.yml‎

Lines changed: 68 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,8 @@ jobs:
109109
# downloads.copilotkit.ai
110110
# serving only the Latest stable release's signed installers. And every signed build carries
111111
# the Intelligence CLI, its Mac binaries signed for notarization: an app without it cannot set
112-
# up Intelligence, and the updater would put it on every Mac.
112+
# up Intelligence, and the updater would put it on every Mac. And each Mac app, the Intel one
113+
# built on this Apple silicon runner included, is its own architecture all the way down.
113114
- name: Signed release regressions
114115
env:
115116
OPENBOT_REQUIRE_MINISIGN: "1"
@@ -119,6 +120,7 @@ jobs:
119120
scripts/sign-windows-update.test.ts
120121
../scripts/release-latest.test.ts ../scripts/release-download-names.test.ts
121122
../scripts/downloads-sync.test.ts scripts/release-cli-bundle.test.ts
123+
scripts/verify-mac-arch.test.ts
122124
working-directory: desktop
123125
- name: Importing the Developer ID intermediate twice succeeds
124126
# The first real signing run stopped here: the .p12 already carried the intermediate, and a
@@ -131,7 +133,7 @@ jobs:
131133
desktop/scripts/import-developer-id-intermediate.sh "$keychain"
132134
desktop/scripts/import-developer-id-intermediate.sh "$keychain"
133135
security find-certificate -c "Developer ID Certification Authority" "$keychain" > /dev/null
134-
- name: Stapling retries a dropped connection and still fails a missing ticket
136+
- name: Stapling and validating retry a dropped connection and still fail a missing ticket
135137
# The first real signing run had Apple accept the Intel image, then lost the connection
136138
# while stapling and failed the job. A stand-in for xcrun fails twice, then succeeds.
137139
run: |
@@ -148,11 +150,26 @@ jobs:
148150
chmod +x "$fake"
149151
XCRUN="$fake" STAPLE_RETRY_DELAY=0 desktop/scripts/staple-with-retry.sh image.dmg 5
150152
test "$(cat "$STAPLE_CALLS")" = 3
153+
# Validating retries the same way: a dry run lost CloudKit's connection mid-validate.
154+
printf '0' > "$STAPLE_CALLS"
155+
cat > "$fake" <<'FAKE'
156+
#!/usr/bin/env bash
157+
[ "$1 $2" = "stapler validate" ] || exit 9
158+
n=$(( $(cat "$STAPLE_CALLS") + 1 ))
159+
printf '%s' "$n" > "$STAPLE_CALLS"
160+
[ "$n" -ge 2 ]
161+
FAKE
162+
STAPLER_ACTION=validate XCRUN="$fake" STAPLE_RETRY_DELAY=0 \
163+
desktop/scripts/staple-with-retry.sh OpenBot.app 5
164+
test "$(cat "$STAPLE_CALLS")" = 2
151165
printf '#!/usr/bin/env bash\nexit 1\n' > "$fake"
152-
if XCRUN="$fake" STAPLE_RETRY_DELAY=0 desktop/scripts/staple-with-retry.sh image.dmg 3; then
153-
echo "A ticket that never arrives must fail the job." >&2
154-
exit 1
155-
fi
166+
for action in staple validate; do
167+
if STAPLER_ACTION="$action" XCRUN="$fake" STAPLE_RETRY_DELAY=0 \
168+
desktop/scripts/staple-with-retry.sh image.dmg 3; then
169+
echo "A ticket that never arrives must fail the job." >&2
170+
exit 1
171+
fi
172+
done
156173
- name: Check the updater is wired together
157174
run: |
158175
set -euo pipefail
@@ -198,10 +215,12 @@ jobs:
198215
fi
199216
echo 'Updater wiring is consistent across Cargo.toml, main.rs, the capability and both signing overlays.'
200217
201-
# One native runner per architecture, for the same reason `publish-release.yml` builds its images
202-
# that way: the machines installing this are laptops of both kinds, and a cross build is a thing
203-
# nobody has run. Each produces its own signed, notarized, stapled .app and .dmg, and, for a build
204-
# that is not pinned, the updater archive.
218+
# Both architectures on the Apple silicon runner, the Intel one built for `x86_64-apple-darwin` as
219+
# Tauri documents (signed-build.ts names the target, and assembles the CLI and its Node runtime for
220+
# it). The Intel runner took twice as long for the same build. Each produces its own signed,
221+
# notarized, stapled .app and .dmg, and, for a build that is not pinned, the updater archive, and
222+
# each is checked to be its own architecture all the way down (verify-mac-arch.sh), so an Intel
223+
# app with an Apple silicon binary in it never ships.
205224
sign:
206225
name: Sign and notarize (${{ matrix.platform.target }})
207226
needs: checks
@@ -218,11 +237,18 @@ jobs:
218237
- runner: macos-15
219238
target: darwin-aarch64
220239
arch: arm64
221-
- runner: macos-15-intel
240+
rust-target: aarch64-apple-darwin
241+
lipo-arch: arm64
242+
- runner: macos-15
222243
target: darwin-x86_64
223244
arch: x64
245+
rust-target: x86_64-apple-darwin
246+
lipo-arch: x86_64
224247
env:
225248
SIGNING_SOURCE_SHA: ${{ github.sha }}
249+
# Where Tauri leaves a build that names its Rust target, as signed-build.ts makes every Mac
250+
# build do.
251+
BUNDLE: desktop/src-tauri/target/${{ matrix.platform.rust-target }}/release/bundle
226252
steps:
227253
# The whole history: an internal build is numbered by its commit count (desktop-version.ts).
228254
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -307,9 +333,12 @@ jobs:
307333
no-cache: true
308334
# No caches in this job, Rust or Bun: it runs on release tags, and a signed, notarized build
309335
# must come from the tree and the lockfiles, not from anything an earlier run left behind.
336+
# The standard library for the architecture this job builds, which on the Apple silicon
337+
# runner is a second target for the Intel build.
310338
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
311339
with:
312340
toolchain: stable
341+
targets: ${{ matrix.platform.rust-target }}
313342
- run: bun install --frozen-lockfile
314343
working-directory: desktop
315344
- run: bun run typecheck
@@ -356,7 +385,7 @@ jobs:
356385
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
357386
run: |
358387
set -euo pipefail
359-
images=(desktop/src-tauri/target/release/bundle/dmg/*.dmg)
388+
images=("$BUNDLE"/dmg/*.dmg)
360389
test "${#images[@]}" -eq 1
361390
xcrun notarytool submit "${images[0]}" \
362391
--key "$APPLE_API_KEY_PATH" --key-id "$APPLE_API_KEY" --issuer "$APPLE_API_ISSUER" \
@@ -367,17 +396,18 @@ jobs:
367396
- name: Verify the signature, Gatekeeper and the notarization ticket
368397
run: |
369398
set -euo pipefail
370-
app=desktop/src-tauri/target/release/bundle/macos/OpenBot.app
371-
images=(desktop/src-tauri/target/release/bundle/dmg/*.dmg)
399+
app="$BUNDLE/macos/OpenBot.app"
400+
images=("$BUNDLE"/dmg/*.dmg)
372401
373-
# What the RED case fails. An ad-hoc build passes none of these three.
402+
# What the RED case fails. An ad-hoc build passes none of these three. Validating the
403+
# ticket asks Apple's CloudKit, and retries a dropped connection (see the script).
374404
codesign --verify --deep --strict --verbose=2 "$app"
375405
spctl -a -t exec -vv "$app"
376-
xcrun stapler validate "$app"
406+
STAPLER_ACTION=validate desktop/scripts/staple-with-retry.sh "$app"
377407
378408
codesign --verify --strict --verbose=2 "${images[0]}"
379409
spctl -a -t open --context context:primary-signature -vv "${images[0]}"
380-
xcrun stapler validate "${images[0]}"
410+
STAPLER_ACTION=validate desktop/scripts/staple-with-retry.sh "${images[0]}"
381411
382412
# Hardened runtime is what notarization requires and what the Entitlements.plist assumes;
383413
# a build that lost the flag is notarized today and rejected by the next macOS.
@@ -390,7 +420,23 @@ jobs:
390420
391421
# The CLI is in the app, and every Mach-O in it is ours, hardened and timestamped.
392422
desktop/scripts/verify-cli-bundle.sh "$app" developer-id
393-
# Named by architecture because both runners produce `OpenBot.app.tar.gz`, and latest.json
423+
# Every Mach-O in the app, the bundled Node and the CLI's native modules included, is built for
424+
# this job's architecture, and the image holds that same app. The Intel build is made on an
425+
# Apple silicon runner, so this is what proves it is an Intel app.
426+
- name: Verify the app is built for its architecture
427+
env:
428+
LIPO_ARCH: ${{ matrix.platform.lipo-arch }}
429+
run: |
430+
set -euo pipefail
431+
ROSETTA_CHECK=1 desktop/scripts/verify-mac-arch.sh "$BUNDLE/macos/OpenBot.app" "$LIPO_ARCH"
432+
images=("$BUNDLE"/dmg/*.dmg)
433+
mount="$RUNNER_TEMP/openbot-arch-check"
434+
mkdir -p "$mount"
435+
hdiutil attach -readonly -nobrowse -mountpoint "$mount" "${images[0]}"
436+
trap 'hdiutil detach "$mount"' EXIT
437+
desktop/scripts/verify-mac-arch.sh "$mount/OpenBot.app" "$LIPO_ARCH"
438+
codesign --verify --deep --strict --verbose=2 "$mount/OpenBot.app"
439+
# Named by architecture because both builds produce `OpenBot.app.tar.gz`, and latest.json
394440
# points at one URL per platform. A pinned build made no updater archive.
395441
- name: Collect the artifacts
396442
env:
@@ -399,13 +445,11 @@ jobs:
399445
run: |
400446
set -euo pipefail
401447
mkdir -p signed
402-
cp desktop/src-tauri/target/release/bundle/dmg/*.dmg signed/
448+
cp "$BUNDLE"/dmg/*.dmg signed/
403449
if [ "$UPDATER" = true ]; then
404-
cp "desktop/src-tauri/target/release/bundle/macos/OpenBot.app.tar.gz" \
405-
"signed/OpenBot-$TARGET.app.tar.gz"
406-
cp "desktop/src-tauri/target/release/bundle/macos/OpenBot.app.tar.gz.sig" \
407-
"signed/OpenBot-$TARGET.app.tar.gz.sig"
408-
elif [ -e desktop/src-tauri/target/release/bundle/macos/OpenBot.app.tar.gz ]; then
450+
cp "$BUNDLE/macos/OpenBot.app.tar.gz" "signed/OpenBot-$TARGET.app.tar.gz"
451+
cp "$BUNDLE/macos/OpenBot.app.tar.gz.sig" "signed/OpenBot-$TARGET.app.tar.gz.sig"
452+
elif [ -e "$BUNDLE/macos/OpenBot.app.tar.gz" ]; then
409453
echo '::error::A build with no updater made an updater archive.'
410454
exit 1
411455
fi

0 commit comments

Comments
 (0)