Skip to content

Commit c155a57

Browse files
fix(desktop): move prereleases to release repo (#6674)
* fix(desktop): move prereleases to release repo * fix(desktop): validate prerelease authentication
1 parent e2b7335 commit c155a57

9 files changed

Lines changed: 248 additions & 115 deletions

File tree

.github/workflows/ci.yml

Lines changed: 41 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -715,14 +715,13 @@ jobs:
715715
secrets: inherit
716716

717717
# Per-env desktop prereleases: a dev/staging push that touches shell code
718-
# publishes an environment-tagged GitHub prerelease (vX.Y.Z-dev.N from dev,
719-
# vX.Y.Z-staging.N from staging). Each environment's /api/desktop/update feed
720-
# offers only its stream, so dev-pointed shells pick up dev builds,
721-
# staging-pointed shells staging builds, and prod-pointed shells stable
722-
# releases — independently. Unlike stable releases, prereleases build even
718+
# publishes an environment-tagged GitHub prerelease to the public,
719+
# release-only simstudioai/sim-desktop-releases repository. Keeping these
720+
# builds out of this source repository prevents its followers from receiving
721+
# every internal shell release. Each environment's /api/desktop/update feed
722+
# still offers only its own stream. Unlike stable releases, prereleases build
723723
# before the Apple signing secrets exist — unsigned, so the update pipeline
724-
# is testable end to end; installed shells detect the missing Developer ID
725-
# and offer a manual download instead of a Squirrel install.
724+
# remains testable end to end with a manual download.
726725
create-desktop-prerelease:
727726
name: Create Desktop Prerelease
728727
runs-on: blacksmith-4vcpu-ubuntu-2404
@@ -732,7 +731,7 @@ jobs:
732731
# cancelled") so a probe failure can't produce a release with no build.
733732
if: ${{ !cancelled() && needs.detect-desktop-changes.outputs.changed == 'true' && needs.check-desktop-signing.result == 'success' }}
734733
permissions:
735-
contents: write
734+
contents: read
736735
outputs:
737736
version: ${{ steps.version.outputs.version }}
738737
steps:
@@ -742,10 +741,16 @@ jobs:
742741
- name: Compute prerelease version and create draft release
743742
id: version
744743
env:
745-
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
746-
GH_REPO: ${{ github.repository }}
744+
DESKTOP_RELEASE_TOKEN: ${{ secrets.DESKTOP_RELEASE_TOKEN }}
745+
GH_TOKEN: ${{ github.token }}
746+
PRERELEASE_REPOSITORY: simstudioai/sim-desktop-releases
747+
SOURCE_REPOSITORY: ${{ github.repository }}
747748
SIGNED: ${{ needs.check-desktop-signing.outputs.configured }}
748749
run: |
750+
if [ -z "$DESKTOP_RELEASE_TOKEN" ]; then
751+
echo "::error::DESKTOP_RELEASE_TOKEN is required to publish desktop prereleases."
752+
exit 1
753+
fi
749754
if [ "$GITHUB_REF" = "refs/heads/dev" ]; then CHANNEL=dev; APP_NAME="Sim Dev"; else CHANNEL=staging; APP_NAME="Sim Staging"; fi
750755
# Prerelease core = next patch after the latest stable release, so
751756
# channel builds always outrank the stable they are built on top of
@@ -755,7 +760,7 @@ jobs:
755760
# Fail loudly if the query itself fails: silently falling back to
756761
# v0.0.0 would publish a channel build that sorts below the shipped
757762
# stable, and installed shells would never see it as an update.
758-
if ! LATEST="$(gh release list --exclude-pre-releases --limit 1 --json tagName --jq '.[0].tagName')"; then
763+
if ! LATEST="$(gh release list --repo "$SOURCE_REPOSITORY" --exclude-pre-releases --limit 1 --json tagName --jq '.[0].tagName')"; then
759764
echo "::error::Could not query the latest stable release."
760765
exit 1
761766
fi
@@ -775,12 +780,14 @@ jobs:
775780
fi
776781
# Draft until the build uploads its artifacts: drafts are invisible
777782
# to the update feed, so a failed or in-flight build can never take
778-
# the channel down with an assetless release. Publishing later also
779-
# defers tag creation, so failed builds strand no tags.
780-
gh release create "$TAG" \
783+
# the channel down with an assetless release. The release-only repo
784+
# has no source commit for this SHA, so its tag intentionally targets
785+
# that repository's main branch; the notes retain the source SHA.
786+
GH_TOKEN="$DESKTOP_RELEASE_TOKEN" gh release create "$TAG" \
787+
--repo "$PRERELEASE_REPOSITORY" \
781788
--draft \
782789
--prerelease \
783-
--target "$GITHUB_SHA" \
790+
--target main \
784791
--title "$TAG" \
785792
--notes "$NOTES"
786793
echo "version=$TAG" >> "$GITHUB_OUTPUT"
@@ -789,6 +796,9 @@ jobs:
789796
desktop-prerelease:
790797
name: Desktop Prerelease Build
791798
needs: [create-desktop-prerelease, check-desktop-signing]
799+
# The reusable workflow declares contents: write for its stable-release
800+
# path. GitHub cannot elevate a caller's token, even though this prerelease
801+
# path uses the dedicated cross-repository token for its actual upload.
792802
permissions:
793803
contents: write
794804
uses: ./.github/workflows/desktop-release.yml
@@ -807,14 +817,19 @@ jobs:
807817
timeout-minutes: 5
808818
needs: [create-desktop-prerelease, desktop-prerelease]
809819
permissions:
810-
contents: write
820+
contents: read
811821
env:
812-
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
813-
GH_REPO: ${{ github.repository }}
822+
GH_TOKEN: ${{ secrets.DESKTOP_RELEASE_TOKEN }}
823+
GH_REPO: simstudioai/sim-desktop-releases
814824
TAG: ${{ needs.create-desktop-prerelease.outputs.version }}
815825
steps:
816826
- name: Publish the draft release
817-
run: gh release edit "$TAG" --draft=false
827+
run: |
828+
if [ -z "$GH_TOKEN" ]; then
829+
echo "::error::DESKTOP_RELEASE_TOKEN is required to publish desktop prereleases."
830+
exit 1
831+
fi
832+
gh release edit "$TAG" --draft=false
818833
819834
# Keep the release list tidy: per channel, retain the newest 5 prereleases
820835
# and delete the rest (with their tags, so dev force-resets don't strand
@@ -826,13 +841,17 @@ jobs:
826841
timeout-minutes: 5
827842
needs: [publish-desktop-prerelease]
828843
permissions:
829-
contents: write
844+
contents: read
830845
env:
831-
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
832-
GH_REPO: ${{ github.repository }}
846+
GH_TOKEN: ${{ secrets.DESKTOP_RELEASE_TOKEN }}
847+
GH_REPO: simstudioai/sim-desktop-releases
833848
steps:
834849
- name: Delete stale prereleases
835850
run: |
851+
if [ -z "$GH_TOKEN" ]; then
852+
echo "::error::DESKTOP_RELEASE_TOKEN is required to prune desktop prereleases."
853+
exit 1
854+
fi
836855
if [ "$GITHUB_REF" = "refs/heads/dev" ]; then CHANNELS='(dev|alpha)'; else CHANNELS='(staging|beta)'; fi
837856
gh release list --limit 100 --json tagName,isPrerelease,isDraft,createdAt \
838857
--jq "[.[] | select(.isPrerelease and (.isDraft | not) and (.tagName | test(\"-${CHANNELS}\\\\.\")))] | sort_by(.createdAt) | reverse | .[5:] | .[].tagName" |

.github/workflows/desktop-release.yml

Lines changed: 61 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
name: Desktop Release (macOS)
22

33
# Builds, signs, notarizes, and uploads the desktop app to an existing GitHub
4-
# release. Ordering is load-bearing: scripts/create-single-release.ts skips
5-
# creation when the tag already exists, so this workflow must never create the
6-
# release itself — it only uploads assets after create-release ran (wired via
7-
# workflow_call from ci.yml with needs: [create-release]).
4+
# release. Stable releases live in this source repository; dev and staging
5+
# releases live in simstudioai/sim-desktop-releases. Ordering is load-bearing:
6+
# scripts/create-single-release.ts skips creation when the stable tag already
7+
# exists, so this workflow must never create a release itself.
88

99
on:
1010
workflow_call:
@@ -54,6 +54,46 @@ jobs:
5454
- name: Checkout code
5555
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
5656

57+
# Prerelease versions carry their environment in the tag: -dev.N is a
58+
# dev build, -staging.N a staging build. Legacy -alpha/-beta tags remain
59+
# accepted while already-published builds age out. The channel decides the app's
60+
# identity (name/bundle id — a separate app per environment, installable
61+
# side by side) and the default origin baked into the bundle, which in
62+
# turn selects the update feed the installed app polls.
63+
- name: Resolve channel identity
64+
id: channel
65+
env:
66+
VERSION: ${{ inputs.version }}
67+
run: |
68+
case "$VERSION" in
69+
*-dev.*|*-alpha.*)
70+
NAME='Sim Dev'; APP_ID=ai.sim.desktop.dev; ORIGIN=https://www.dev.sim.ai; RELEASE_REPOSITORY=simstudioai/sim-desktop-releases; TOKEN_KIND=prerelease ;;
71+
*-staging.*|*-beta.*)
72+
NAME='Sim Staging'; APP_ID=ai.sim.desktop.staging; ORIGIN=https://www.staging.sim.ai; RELEASE_REPOSITORY=simstudioai/sim-desktop-releases; TOKEN_KIND=prerelease ;;
73+
*)
74+
NAME='Sim'; APP_ID=ai.sim.desktop; ORIGIN=''; RELEASE_REPOSITORY="$GITHUB_REPOSITORY"; TOKEN_KIND=stable ;;
75+
esac
76+
{
77+
echo "name=$NAME"
78+
echo "app_id=$APP_ID"
79+
echo "origin=$ORIGIN"
80+
echo "release_repository=$RELEASE_REPOSITORY"
81+
echo "token_kind=$TOKEN_KIND"
82+
} >> "$GITHUB_OUTPUT"
83+
echo "Building $NAME ($APP_ID) for $RELEASE_REPOSITORY; default origin: ${ORIGIN:-production}"
84+
85+
- name: Validate release authentication
86+
if: ${{ inputs.publish }}
87+
env:
88+
DESKTOP_RELEASE_TOKEN: ${{ secrets.DESKTOP_RELEASE_TOKEN }}
89+
RELEASE_REPOSITORY: ${{ steps.channel.outputs.release_repository }}
90+
TOKEN_KIND: ${{ steps.channel.outputs.token_kind }}
91+
run: |
92+
if [ "$TOKEN_KIND" = prerelease ] && [ -z "$DESKTOP_RELEASE_TOKEN" ]; then
93+
echo "::error::DESKTOP_RELEASE_TOKEN is required to publish prereleases to $RELEASE_REPOSITORY."
94+
exit 1
95+
fi
96+
5797
- name: Setup Bun
5898
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
5999
with:
@@ -91,32 +131,6 @@ jobs:
91131
exit 1
92132
fi
93133
94-
# Prerelease versions carry their environment in the tag: -dev.N is a
95-
# dev build, -staging.N a staging build. Legacy -alpha/-beta tags remain
96-
# accepted while already-published builds age out. The channel decides the app's
97-
# identity (name/bundle id — a separate app per environment, installable
98-
# side by side) and the default origin baked into the bundle, which in
99-
# turn selects the update feed the installed app polls.
100-
- name: Resolve channel identity
101-
id: channel
102-
env:
103-
VERSION: ${{ inputs.version }}
104-
run: |
105-
case "$VERSION" in
106-
*-dev.*|*-alpha.*)
107-
NAME='Sim Dev'; APP_ID=ai.sim.desktop.dev; ORIGIN=https://www.dev.sim.ai ;;
108-
*-staging.*|*-beta.*)
109-
NAME='Sim Staging'; APP_ID=ai.sim.desktop.staging; ORIGIN=https://www.staging.sim.ai ;;
110-
*)
111-
NAME='Sim'; APP_ID=ai.sim.desktop; ORIGIN='' ;;
112-
esac
113-
{
114-
echo "name=$NAME"
115-
echo "app_id=$APP_ID"
116-
echo "origin=$ORIGIN"
117-
} >> "$GITHUB_OUTPUT"
118-
echo "Building $NAME ($APP_ID) default origin: ${ORIGIN:-production}"
119-
120134
- name: Bundle main and preload
121135
working-directory: apps/desktop
122136
env:
@@ -177,9 +191,24 @@ jobs:
177191
- name: Upload artifacts to the release
178192
if: ${{ inputs.publish }}
179193
env:
180-
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
194+
DESKTOP_RELEASE_TOKEN: ${{ secrets.DESKTOP_RELEASE_TOKEN }}
195+
RELEASE_REPOSITORY: ${{ steps.channel.outputs.release_repository }}
196+
SOURCE_RELEASE_TOKEN: ${{ github.token }}
197+
TOKEN_KIND: ${{ steps.channel.outputs.token_kind }}
181198
VERSION: ${{ inputs.version }}
182199
run: |
200+
case "$TOKEN_KIND" in
201+
prerelease) GH_TOKEN="$DESKTOP_RELEASE_TOKEN" ;;
202+
stable) GH_TOKEN="$SOURCE_RELEASE_TOKEN" ;;
203+
*)
204+
echo "::error::Unknown desktop release token kind: $TOKEN_KIND"
205+
exit 1 ;;
206+
esac
207+
if [ -z "$GH_TOKEN" ]; then
208+
echo "::error::No GitHub token is available to publish to $RELEASE_REPOSITORY."
209+
exit 1
210+
fi
211+
export GH_TOKEN
183212
# electron-builder's GitHub provider always names the manifest
184213
# latest-mac.yml (channels are a generic-provider concept), and the
185214
# update feed expects exactly that asset name on every release —
@@ -198,6 +227,7 @@ jobs:
198227
apps/desktop/release/*.zip \
199228
apps/desktop/release/*.blockmap \
200229
apps/desktop/release/latest-mac.yml \
230+
--repo "$RELEASE_REPOSITORY" \
201231
--clobber
202232
203233
- name: Upload artifacts to the workflow run

apps/desktop/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ Pre-release share (no Developer ID yet): `SIM_DESKTOP_DEFAULT_ORIGIN=https://www
104104
The build also derives the app icon from `SIM_DESKTOP_DEFAULT_ORIGIN`. Every channel uses the exact production icon with its white background and black `sim` mark. Non-production channels add a thin outline using existing platform colors: dev uses orange, staging uses Loop blue, and localhost uses Workflow violet. The macOS menu-bar icon also carries a compact `D`, `S`, or `L` subscript for those environments; production remains unmarked. Native Icon Composer assets live in `build/`; `scripts/build.ts` copies the selected variant to the ignored `build/generated-icon.icon` path consumed by electron-builder. Electron-builder compiles it to `Assets.car` and derives the legacy `.icns` fallback from the same source. Matching 512px PNGs in `static/` provide the Dock icon for unpackaged runs.
105105

106106
CI (`.github/workflows/desktop-release.yml`, wired into `ci.yml`):
107-
- Runs only after `create-release` on a `vX.Y.Z:` commit to main — **never before**: `scripts/create-single-release.ts` skips creation if the tag exists, so a desktop job publishing first would eat the changelog. The job builds `--publish never` and uploads assets with `gh release upload --clobber` (idempotent re-runs).
107+
- Stable builds run only after `create-release` on a `vX.Y.Z:` commit to main — **never before**: `scripts/create-single-release.ts` skips creation if the tag exists, so a desktop job publishing first would eat the changelog. Stable assets remain on `simstudioai/sim`; dev/staging assets publish to the public `simstudioai/sim-desktop-releases` repository so source-repository followers are not notified for internal shell builds. The job builds `--publish never` and uploads assets with `gh release upload --clobber` (idempotent re-runs).
108108
- **Secrets gate**: `check-desktop-signing` in `ci.yml` probes the six Apple secrets and skips the desktop job with a warning until they exist — releases never fail on a missing Apple account, and the first release after the secrets land ships desktop artifacts automatically. Manual/one-off builds: Actions → "Desktop Release (macOS)" → Run workflow with a `vX.Y.Z` version (`publish: false` uploads artifacts to the run instead of the release).
109109
- The product semver is **injected** from the release tag into `apps/desktop/package.json` at build time (repo package versions are placeholders). A mismatch guard fails the build.
110110
- Fuses are flipped at package time (`electronFuses` in `electron-builder.yml`): runAsNode off, NODE_OPTIONS off, inspect args off, ASAR-only + integrity validation, cookie encryption on, `strictlyRequireAllFuses` so new fuses fail loudly on Electron bumps.
@@ -120,6 +120,7 @@ Required repo secrets (owner: whoever holds the Apple Developer account; calenda
120120
| `APPLE_API_KEY_ID` | API key ID |
121121
| `APPLE_API_ISSUER` | API issuer ID |
122122
| `APPLE_TEAM_ID` | Developer team ID |
123+
| `DESKTOP_RELEASE_TOKEN` | Fine-grained GitHub token with `Contents: write` on only `simstudioai/sim-desktop-releases`; used to create, upload, publish, and prune dev/staging releases |
123124

124125
## Desktop-only features (how to add them cleanly)
125126

@@ -168,7 +169,7 @@ Raw local file bytes are never exposed through the preload bridge and cannot be
168169

169170
## Auto-update, channels, rollout, rollback
170171

171-
- `electron-updater` reads the GitHub Releases feed (`publish` is pinned to `simstudioai/sim`); deltas via `.zip.blockmap`. Install is prompt-based (Restart Now / Later; Later installs on quit) — never forced mid-session.
172+
- `electron-updater` reads the deployment's `/api/desktop/update` feed; production resolves stable releases from `simstudioai/sim`, while dev/staging resolve prereleases from `simstudioai/sim-desktop-releases`. Artifact downloads go directly to GitHub and deltas use `.zip.blockmap`. Install is prompt-based (Restart Now / Later; Later installs on quit) — never forced mid-session.
172173
- Streams: production follows stable `X.Y.Z` releases, dev follows `-dev.N`, and staging follows `-staging.N`. The feed still recognizes legacy `-alpha.N`/`-beta.N` releases during migration.
173174
- Staged rollout: after publishing, edit `stagingPercentage: 10` into the release's `latest-mac.yml`, then raise as crash metrics stay clean.
174175
- Rollback: a pulled release must be superseded by a **higher** version — users on the broken build will not reinstall an equal one. (A blocked-versions kill-switch was removed as unwired dead code; reintroduce it in `updater.ts` if a remote config source ever exists to feed it.)

apps/desktop/src/main/updater.test.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -391,15 +391,15 @@ describe('initUpdater state machine', () => {
391391
})
392392
})
393393

394-
function manifest(version: string): string {
394+
function manifest(version: string, repository = 'simstudioai/sim'): string {
395395
return [
396396
`version: ${version}`,
397397
'files:',
398-
` - url: https://github.com/simstudioai/sim/releases/download/v${version}/Sim-${version}-universal-mac.zip`,
398+
` - url: https://github.com/${repository}/releases/download/v${version}/Sim-${version}-universal-mac.zip`,
399399
' sha512: abc',
400-
` - url: https://github.com/simstudioai/sim/releases/download/v${version}/Sim-${version}-universal.dmg`,
400+
` - url: https://github.com/${repository}/releases/download/v${version}/Sim-${version}-universal.dmg`,
401401
' sha512: def',
402-
`path: https://github.com/simstudioai/sim/releases/download/v${version}/Sim-${version}-universal-mac.zip`,
402+
`path: https://github.com/${repository}/releases/download/v${version}/Sim-${version}-universal-mac.zip`,
403403
"releaseDate: '2026-07-23T00:00:00.000Z'",
404404
].join('\n')
405405
}
@@ -453,6 +453,26 @@ describe('initUpdater manual mode (no Developer ID signature)', () => {
453453
expect(shell.openExternal).toHaveBeenCalledTimes(2)
454454
})
455455

456+
it('offers prerelease-repository assets as manual downloads', async () => {
457+
const fetchManifest = vi.fn(async () =>
458+
manifest('9.9.9-dev.1', 'simstudioai/sim-desktop-releases')
459+
)
460+
const { handle } = await createManualUpdater(fetchManifest)
461+
462+
handle.check()
463+
await vi.advanceTimersByTimeAsync(0)
464+
expect(handle.getState()).toEqual({
465+
status: 'available',
466+
version: '9.9.9-dev.1',
467+
manual: true,
468+
})
469+
470+
handle.check()
471+
expect(shell.openExternal).toHaveBeenCalledWith(
472+
'https://github.com/simstudioai/sim-desktop-releases/releases/download/v9.9.9-dev.1/Sim-9.9.9-dev.1-universal.dmg'
473+
)
474+
})
475+
456476
it('refuses a manifest whose download urls are not http(s)', async () => {
457477
const hostile = [
458478
'version: 9.9.9',
@@ -499,6 +519,19 @@ describe('initUpdater manual mode (no Developer ID signature)', () => {
499519
expect(shell.openExternal).not.toHaveBeenCalled()
500520
})
501521

522+
it('refuses assets from other repositories on github.com', async () => {
523+
const offRepository = manifest('9.9.9', 'simstudioai/not-desktop-releases')
524+
const { handle } = await createManualUpdater(async () => offRepository)
525+
526+
handle.check()
527+
await vi.advanceTimersByTimeAsync(0)
528+
529+
expect(handle.getState()).toMatchObject({ status: 'error', manual: true })
530+
handle.check()
531+
handle.install()
532+
expect(shell.openExternal).not.toHaveBeenCalled()
533+
})
534+
502535
it('skips an unusable url but still offers a safe one from the same manifest', async () => {
503536
const mixed = [
504537
'version: 9.9.9',

0 commit comments

Comments
 (0)