Skip to content

Commit c07e736

Browse files
fix(desktop): move prereleases to release repo
1 parent 23318a1 commit c07e736

9 files changed

Lines changed: 202 additions & 94 deletions

File tree

.github/workflows/ci.yml

Lines changed: 39 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -707,14 +707,13 @@ jobs:
707707
secrets: inherit
708708

709709
# Per-env desktop prereleases: a dev/staging push that touches shell code
710-
# publishes an environment-tagged GitHub prerelease (vX.Y.Z-dev.N from dev,
711-
# vX.Y.Z-staging.N from staging). Each environment's /api/desktop/update feed
712-
# offers only its stream, so dev-pointed shells pick up dev builds,
713-
# staging-pointed shells staging builds, and prod-pointed shells stable
714-
# releases — independently. Unlike stable releases, prereleases build even
710+
# publishes an environment-tagged GitHub prerelease to the public,
711+
# release-only simstudioai/sim-desktop-releases repository. Keeping these
712+
# builds out of this source repository prevents its followers from receiving
713+
# every internal shell release. Each environment's /api/desktop/update feed
714+
# still offers only its own stream. Unlike stable releases, prereleases build
715715
# before the Apple signing secrets exist — unsigned, so the update pipeline
716-
# is testable end to end; installed shells detect the missing Developer ID
717-
# and offer a manual download instead of a Squirrel install.
716+
# remains testable end to end with a manual download.
718717
create-desktop-prerelease:
719718
name: Create Desktop Prerelease
720719
runs-on: blacksmith-4vcpu-ubuntu-2404
@@ -724,7 +723,7 @@ jobs:
724723
# cancelled") so a probe failure can't produce a release with no build.
725724
if: ${{ !cancelled() && needs.detect-desktop-changes.outputs.changed == 'true' && needs.check-desktop-signing.result == 'success' }}
726725
permissions:
727-
contents: write
726+
contents: read
728727
outputs:
729728
version: ${{ steps.version.outputs.version }}
730729
steps:
@@ -734,10 +733,16 @@ jobs:
734733
- name: Compute prerelease version and create draft release
735734
id: version
736735
env:
737-
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
738-
GH_REPO: ${{ github.repository }}
736+
DESKTOP_RELEASE_TOKEN: ${{ secrets.DESKTOP_RELEASE_TOKEN }}
737+
GH_TOKEN: ${{ github.token }}
738+
PRERELEASE_REPOSITORY: simstudioai/sim-desktop-releases
739+
SOURCE_REPOSITORY: ${{ github.repository }}
739740
SIGNED: ${{ needs.check-desktop-signing.outputs.configured }}
740741
run: |
742+
if [ -z "$DESKTOP_RELEASE_TOKEN" ]; then
743+
echo "::error::DESKTOP_RELEASE_TOKEN is required to publish desktop prereleases."
744+
exit 1
745+
fi
741746
if [ "$GITHUB_REF" = "refs/heads/dev" ]; then CHANNEL=dev; APP_NAME="Sim Dev"; else CHANNEL=staging; APP_NAME="Sim Staging"; fi
742747
# Prerelease core = next patch after the latest stable release, so
743748
# channel builds always outrank the stable they are built on top of
@@ -747,7 +752,7 @@ jobs:
747752
# Fail loudly if the query itself fails: silently falling back to
748753
# v0.0.0 would publish a channel build that sorts below the shipped
749754
# stable, and installed shells would never see it as an update.
750-
if ! LATEST="$(gh release list --exclude-pre-releases --limit 1 --json tagName --jq '.[0].tagName')"; then
755+
if ! LATEST="$(gh release list --repo "$SOURCE_REPOSITORY" --exclude-pre-releases --limit 1 --json tagName --jq '.[0].tagName')"; then
751756
echo "::error::Could not query the latest stable release."
752757
exit 1
753758
fi
@@ -767,12 +772,14 @@ jobs:
767772
fi
768773
# Draft until the build uploads its artifacts: drafts are invisible
769774
# to the update feed, so a failed or in-flight build can never take
770-
# the channel down with an assetless release. Publishing later also
771-
# defers tag creation, so failed builds strand no tags.
772-
gh release create "$TAG" \
775+
# the channel down with an assetless release. The release-only repo
776+
# has no source commit for this SHA, so its tag intentionally targets
777+
# that repository's main branch; the notes retain the source SHA.
778+
GH_TOKEN="$DESKTOP_RELEASE_TOKEN" gh release create "$TAG" \
779+
--repo "$PRERELEASE_REPOSITORY" \
773780
--draft \
774781
--prerelease \
775-
--target "$GITHUB_SHA" \
782+
--target main \
776783
--title "$TAG" \
777784
--notes "$NOTES"
778785
echo "version=$TAG" >> "$GITHUB_OUTPUT"
@@ -782,7 +789,7 @@ jobs:
782789
name: Desktop Prerelease Build
783790
needs: [create-desktop-prerelease, check-desktop-signing]
784791
permissions:
785-
contents: write
792+
contents: read
786793
uses: ./.github/workflows/desktop-release.yml
787794
with:
788795
version: ${{ needs.create-desktop-prerelease.outputs.version }}
@@ -799,14 +806,19 @@ jobs:
799806
timeout-minutes: 5
800807
needs: [create-desktop-prerelease, desktop-prerelease]
801808
permissions:
802-
contents: write
809+
contents: read
803810
env:
804-
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
805-
GH_REPO: ${{ github.repository }}
811+
GH_TOKEN: ${{ secrets.DESKTOP_RELEASE_TOKEN }}
812+
GH_REPO: simstudioai/sim-desktop-releases
806813
TAG: ${{ needs.create-desktop-prerelease.outputs.version }}
807814
steps:
808815
- name: Publish the draft release
809-
run: gh release edit "$TAG" --draft=false
816+
run: |
817+
if [ -z "$GH_TOKEN" ]; then
818+
echo "::error::DESKTOP_RELEASE_TOKEN is required to publish desktop prereleases."
819+
exit 1
820+
fi
821+
gh release edit "$TAG" --draft=false
810822
811823
# Keep the release list tidy: per channel, retain the newest 5 prereleases
812824
# and delete the rest (with their tags, so dev force-resets don't strand
@@ -818,13 +830,17 @@ jobs:
818830
timeout-minutes: 5
819831
needs: [publish-desktop-prerelease]
820832
permissions:
821-
contents: write
833+
contents: read
822834
env:
823-
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
824-
GH_REPO: ${{ github.repository }}
835+
GH_TOKEN: ${{ secrets.DESKTOP_RELEASE_TOKEN }}
836+
GH_REPO: simstudioai/sim-desktop-releases
825837
steps:
826838
- name: Delete stale prereleases
827839
run: |
840+
if [ -z "$GH_TOKEN" ]; then
841+
echo "::error::DESKTOP_RELEASE_TOKEN is required to prune desktop prereleases."
842+
exit 1
843+
fi
828844
if [ "$GITHUB_REF" = "refs/heads/dev" ]; then CHANNELS='(dev|alpha)'; else CHANNELS='(staging|beta)'; fi
829845
gh release list --limit 100 --json tagName,isPrerelease,isDraft,createdAt \
830846
--jq "[.[] | select(.isPrerelease and (.isDraft | not) and (.tagName | test(\"-${CHANNELS}\\\\.\")))] | sort_by(.createdAt) | reverse | .[5:] | .[].tagName" |

.github/workflows/desktop-release.yml

Lines changed: 17 additions & 9 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:
@@ -104,18 +104,20 @@ jobs:
104104
run: |
105105
case "$VERSION" in
106106
*-dev.*|*-alpha.*)
107-
NAME='Sim Dev'; APP_ID=ai.sim.desktop.dev; ORIGIN=https://www.dev.sim.ai ;;
107+
NAME='Sim Dev'; APP_ID=ai.sim.desktop.dev; ORIGIN=https://www.dev.sim.ai; RELEASE_REPOSITORY=simstudioai/sim-desktop-releases; TOKEN_KIND=prerelease ;;
108108
*-staging.*|*-beta.*)
109-
NAME='Sim Staging'; APP_ID=ai.sim.desktop.staging; ORIGIN=https://www.staging.sim.ai ;;
109+
NAME='Sim Staging'; APP_ID=ai.sim.desktop.staging; ORIGIN=https://www.staging.sim.ai; RELEASE_REPOSITORY=simstudioai/sim-desktop-releases; TOKEN_KIND=prerelease ;;
110110
*)
111-
NAME='Sim'; APP_ID=ai.sim.desktop; ORIGIN='' ;;
111+
NAME='Sim'; APP_ID=ai.sim.desktop; ORIGIN=''; RELEASE_REPOSITORY="$GITHUB_REPOSITORY"; TOKEN_KIND=stable ;;
112112
esac
113113
{
114114
echo "name=$NAME"
115115
echo "app_id=$APP_ID"
116116
echo "origin=$ORIGIN"
117+
echo "release_repository=$RELEASE_REPOSITORY"
118+
echo "token_kind=$TOKEN_KIND"
117119
} >> "$GITHUB_OUTPUT"
118-
echo "Building $NAME ($APP_ID) default origin: ${ORIGIN:-production}"
120+
echo "Building $NAME ($APP_ID) for $RELEASE_REPOSITORY; default origin: ${ORIGIN:-production}"
119121
120122
- name: Bundle main and preload
121123
working-directory: apps/desktop
@@ -177,9 +179,14 @@ jobs:
177179
- name: Upload artifacts to the release
178180
if: ${{ inputs.publish }}
179181
env:
180-
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
182+
GH_TOKEN: ${{ steps.channel.outputs.token_kind == 'prerelease' && secrets.DESKTOP_RELEASE_TOKEN || github.token }}
183+
RELEASE_REPOSITORY: ${{ steps.channel.outputs.release_repository }}
181184
VERSION: ${{ inputs.version }}
182185
run: |
186+
if [ -z "$GH_TOKEN" ]; then
187+
echo "::error::DESKTOP_RELEASE_TOKEN is required to publish prereleases to $RELEASE_REPOSITORY."
188+
exit 1
189+
fi
183190
# electron-builder's GitHub provider always names the manifest
184191
# latest-mac.yml (channels are a generic-provider concept), and the
185192
# update feed expects exactly that asset name on every release —
@@ -198,6 +205,7 @@ jobs:
198205
apps/desktop/release/*.zip \
199206
apps/desktop/release/*.blockmap \
200207
apps/desktop/release/latest-mac.yml \
208+
--repo "$RELEASE_REPOSITORY" \
201209
--clobber
202210
203211
- 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',

apps/desktop/src/main/updater.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,10 @@ export function feedUrlForOrigin(origin: string): string | null {
4343
* host cannot get a bundle in front of the user's Download button.
4444
*/
4545
const RELEASE_ASSET_ORIGIN = 'https://github.com'
46-
const RELEASE_ASSET_PATH = '/simstudioai/sim/releases/download/'
46+
const RELEASE_ASSET_PATHS = [
47+
'/simstudioai/sim/releases/download/',
48+
'/simstudioai/sim-desktop-releases/releases/download/',
49+
] as const
4750

4851
/** Whether a manifest url is one of our own release assets. */
4952
function isReleaseAssetUrl(rawUrl: string): boolean {
@@ -53,7 +56,10 @@ function isReleaseAssetUrl(rawUrl: string): boolean {
5356
// Compared on the parsed origin and the parsed pathname, never by prefix on
5457
// the raw string: `https://github.com.evil.example/…` must not pass, and
5558
// `URL` has already normalized away any `..` segments by this point.
56-
return url.origin === RELEASE_ASSET_ORIGIN && url.pathname.startsWith(RELEASE_ASSET_PATH)
59+
return (
60+
url.origin === RELEASE_ASSET_ORIGIN &&
61+
RELEASE_ASSET_PATHS.some((path) => url.pathname.startsWith(path))
62+
)
5763
} catch {
5864
return false
5965
}

0 commit comments

Comments
 (0)