Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 41 additions & 22 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -707,14 +707,13 @@ jobs:
secrets: inherit

# Per-env desktop prereleases: a dev/staging push that touches shell code
# publishes an environment-tagged GitHub prerelease (vX.Y.Z-dev.N from dev,
# vX.Y.Z-staging.N from staging). Each environment's /api/desktop/update feed
# offers only its stream, so dev-pointed shells pick up dev builds,
# staging-pointed shells staging builds, and prod-pointed shells stable
# releases — independently. Unlike stable releases, prereleases build even
# publishes an environment-tagged GitHub prerelease to the public,
# release-only simstudioai/sim-desktop-releases repository. Keeping these
# builds out of this source repository prevents its followers from receiving
# every internal shell release. Each environment's /api/desktop/update feed
# still offers only its own stream. Unlike stable releases, prereleases build
# before the Apple signing secrets exist — unsigned, so the update pipeline
# is testable end to end; installed shells detect the missing Developer ID
# and offer a manual download instead of a Squirrel install.
# remains testable end to end with a manual download.
create-desktop-prerelease:
name: Create Desktop Prerelease
runs-on: blacksmith-4vcpu-ubuntu-2404
Expand All @@ -724,7 +723,7 @@ jobs:
# cancelled") so a probe failure can't produce a release with no build.
if: ${{ !cancelled() && needs.detect-desktop-changes.outputs.changed == 'true' && needs.check-desktop-signing.result == 'success' }}
permissions:
contents: write
contents: read
outputs:
version: ${{ steps.version.outputs.version }}
steps:
Expand All @@ -734,10 +733,16 @@ jobs:
- name: Compute prerelease version and create draft release
id: version
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
DESKTOP_RELEASE_TOKEN: ${{ secrets.DESKTOP_RELEASE_TOKEN }}
GH_TOKEN: ${{ github.token }}
PRERELEASE_REPOSITORY: simstudioai/sim-desktop-releases
SOURCE_REPOSITORY: ${{ github.repository }}
SIGNED: ${{ needs.check-desktop-signing.outputs.configured }}
run: |
if [ -z "$DESKTOP_RELEASE_TOKEN" ]; then
echo "::error::DESKTOP_RELEASE_TOKEN is required to publish desktop prereleases."
exit 1
fi
if [ "$GITHUB_REF" = "refs/heads/dev" ]; then CHANNEL=dev; APP_NAME="Sim Dev"; else CHANNEL=staging; APP_NAME="Sim Staging"; fi
# Prerelease core = next patch after the latest stable release, so
# channel builds always outrank the stable they are built on top of
Expand All @@ -747,7 +752,7 @@ jobs:
# Fail loudly if the query itself fails: silently falling back to
# v0.0.0 would publish a channel build that sorts below the shipped
# stable, and installed shells would never see it as an update.
if ! LATEST="$(gh release list --exclude-pre-releases --limit 1 --json tagName --jq '.[0].tagName')"; then
if ! LATEST="$(gh release list --repo "$SOURCE_REPOSITORY" --exclude-pre-releases --limit 1 --json tagName --jq '.[0].tagName')"; then
echo "::error::Could not query the latest stable release."
exit 1
fi
Expand All @@ -767,12 +772,14 @@ jobs:
fi
# Draft until the build uploads its artifacts: drafts are invisible
# to the update feed, so a failed or in-flight build can never take
# the channel down with an assetless release. Publishing later also
# defers tag creation, so failed builds strand no tags.
gh release create "$TAG" \
# the channel down with an assetless release. The release-only repo
# has no source commit for this SHA, so its tag intentionally targets
# that repository's main branch; the notes retain the source SHA.
GH_TOKEN="$DESKTOP_RELEASE_TOKEN" gh release create "$TAG" \
--repo "$PRERELEASE_REPOSITORY" \
--draft \
--prerelease \
--target "$GITHUB_SHA" \
--target main \
--title "$TAG" \
--notes "$NOTES"
echo "version=$TAG" >> "$GITHUB_OUTPUT"
Expand All @@ -781,6 +788,9 @@ jobs:
desktop-prerelease:
name: Desktop Prerelease Build
needs: [create-desktop-prerelease, check-desktop-signing]
# The reusable workflow declares contents: write for its stable-release
# path. GitHub cannot elevate a caller's token, even though this prerelease
# path uses the dedicated cross-repository token for its actual upload.
permissions:
contents: write
uses: ./.github/workflows/desktop-release.yml
Expand All @@ -799,14 +809,19 @@ jobs:
timeout-minutes: 5
needs: [create-desktop-prerelease, desktop-prerelease]
permissions:
contents: write
contents: read
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
GH_TOKEN: ${{ secrets.DESKTOP_RELEASE_TOKEN }}
GH_REPO: simstudioai/sim-desktop-releases
TAG: ${{ needs.create-desktop-prerelease.outputs.version }}
steps:
- name: Publish the draft release
run: gh release edit "$TAG" --draft=false
run: |
if [ -z "$GH_TOKEN" ]; then
echo "::error::DESKTOP_RELEASE_TOKEN is required to publish desktop prereleases."
exit 1
fi
gh release edit "$TAG" --draft=false

# Keep the release list tidy: per channel, retain the newest 5 prereleases
# and delete the rest (with their tags, so dev force-resets don't strand
Expand All @@ -818,13 +833,17 @@ jobs:
timeout-minutes: 5
needs: [publish-desktop-prerelease]
permissions:
contents: write
contents: read
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
GH_TOKEN: ${{ secrets.DESKTOP_RELEASE_TOKEN }}
GH_REPO: simstudioai/sim-desktop-releases
steps:
- name: Delete stale prereleases
run: |
if [ -z "$GH_TOKEN" ]; then
echo "::error::DESKTOP_RELEASE_TOKEN is required to prune desktop prereleases."
exit 1
fi
if [ "$GITHUB_REF" = "refs/heads/dev" ]; then CHANNELS='(dev|alpha)'; else CHANNELS='(staging|beta)'; fi
gh release list --limit 100 --json tagName,isPrerelease,isDraft,createdAt \
--jq "[.[] | select(.isPrerelease and (.isDraft | not) and (.tagName | test(\"-${CHANNELS}\\\\.\")))] | sort_by(.createdAt) | reverse | .[5:] | .[].tagName" |
Expand Down
92 changes: 61 additions & 31 deletions .github/workflows/desktop-release.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
name: Desktop Release (macOS)

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

on:
workflow_call:
Expand Down Expand Up @@ -54,6 +54,46 @@ jobs:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6

# Prerelease versions carry their environment in the tag: -dev.N is a
# dev build, -staging.N a staging build. Legacy -alpha/-beta tags remain
# accepted while already-published builds age out. The channel decides the app's
# identity (name/bundle id — a separate app per environment, installable
# side by side) and the default origin baked into the bundle, which in
# turn selects the update feed the installed app polls.
- name: Resolve channel identity
id: channel
env:
VERSION: ${{ inputs.version }}
run: |
case "$VERSION" in
*-dev.*|*-alpha.*)
NAME='Sim Dev'; APP_ID=ai.sim.desktop.dev; ORIGIN=https://www.dev.sim.ai; RELEASE_REPOSITORY=simstudioai/sim-desktop-releases; TOKEN_KIND=prerelease ;;
*-staging.*|*-beta.*)
NAME='Sim Staging'; APP_ID=ai.sim.desktop.staging; ORIGIN=https://www.staging.sim.ai; RELEASE_REPOSITORY=simstudioai/sim-desktop-releases; TOKEN_KIND=prerelease ;;
*)
NAME='Sim'; APP_ID=ai.sim.desktop; ORIGIN=''; RELEASE_REPOSITORY="$GITHUB_REPOSITORY"; TOKEN_KIND=stable ;;
esac
{
echo "name=$NAME"
echo "app_id=$APP_ID"
echo "origin=$ORIGIN"
echo "release_repository=$RELEASE_REPOSITORY"
echo "token_kind=$TOKEN_KIND"
} >> "$GITHUB_OUTPUT"
echo "Building $NAME ($APP_ID) for $RELEASE_REPOSITORY; default origin: ${ORIGIN:-production}"

- name: Validate release authentication
if: ${{ inputs.publish }}
env:
DESKTOP_RELEASE_TOKEN: ${{ secrets.DESKTOP_RELEASE_TOKEN }}
RELEASE_REPOSITORY: ${{ steps.channel.outputs.release_repository }}
TOKEN_KIND: ${{ steps.channel.outputs.token_kind }}
run: |
if [ "$TOKEN_KIND" = prerelease ] && [ -z "$DESKTOP_RELEASE_TOKEN" ]; then
echo "::error::DESKTOP_RELEASE_TOKEN is required to publish prereleases to $RELEASE_REPOSITORY."
exit 1
fi

- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
Expand Down Expand Up @@ -91,32 +131,6 @@ jobs:
exit 1
fi

# Prerelease versions carry their environment in the tag: -dev.N is a
# dev build, -staging.N a staging build. Legacy -alpha/-beta tags remain
# accepted while already-published builds age out. The channel decides the app's
# identity (name/bundle id — a separate app per environment, installable
# side by side) and the default origin baked into the bundle, which in
# turn selects the update feed the installed app polls.
- name: Resolve channel identity
id: channel
env:
VERSION: ${{ inputs.version }}
run: |
case "$VERSION" in
*-dev.*|*-alpha.*)
NAME='Sim Dev'; APP_ID=ai.sim.desktop.dev; ORIGIN=https://www.dev.sim.ai ;;
*-staging.*|*-beta.*)
NAME='Sim Staging'; APP_ID=ai.sim.desktop.staging; ORIGIN=https://www.staging.sim.ai ;;
*)
NAME='Sim'; APP_ID=ai.sim.desktop; ORIGIN='' ;;
esac
{
echo "name=$NAME"
echo "app_id=$APP_ID"
echo "origin=$ORIGIN"
} >> "$GITHUB_OUTPUT"
echo "Building $NAME ($APP_ID) default origin: ${ORIGIN:-production}"

- name: Bundle main and preload
working-directory: apps/desktop
env:
Expand Down Expand Up @@ -177,9 +191,24 @@ jobs:
- name: Upload artifacts to the release
if: ${{ inputs.publish }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DESKTOP_RELEASE_TOKEN: ${{ secrets.DESKTOP_RELEASE_TOKEN }}
RELEASE_REPOSITORY: ${{ steps.channel.outputs.release_repository }}
SOURCE_RELEASE_TOKEN: ${{ github.token }}
TOKEN_KIND: ${{ steps.channel.outputs.token_kind }}
VERSION: ${{ inputs.version }}
run: |
case "$TOKEN_KIND" in
prerelease) GH_TOKEN="$DESKTOP_RELEASE_TOKEN" ;;
stable) GH_TOKEN="$SOURCE_RELEASE_TOKEN" ;;
*)
echo "::error::Unknown desktop release token kind: $TOKEN_KIND"
exit 1 ;;
esac
if [ -z "$GH_TOKEN" ]; then
echo "::error::No GitHub token is available to publish to $RELEASE_REPOSITORY."
exit 1
fi
export GH_TOKEN
# electron-builder's GitHub provider always names the manifest
# latest-mac.yml (channels are a generic-provider concept), and the
# update feed expects exactly that asset name on every release —
Expand All @@ -198,6 +227,7 @@ jobs:
apps/desktop/release/*.zip \
apps/desktop/release/*.blockmap \
apps/desktop/release/latest-mac.yml \
--repo "$RELEASE_REPOSITORY" \
--clobber

- name: Upload artifacts to the workflow run
Expand Down
5 changes: 3 additions & 2 deletions apps/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ Pre-release share (no Developer ID yet): `SIM_DESKTOP_DEFAULT_ORIGIN=https://www
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.

CI (`.github/workflows/desktop-release.yml`, wired into `ci.yml`):
- 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).
- 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).
- **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).
- 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.
- 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.
Expand All @@ -120,6 +120,7 @@ Required repo secrets (owner: whoever holds the Apple Developer account; calenda
| `APPLE_API_KEY_ID` | API key ID |
| `APPLE_API_ISSUER` | API issuer ID |
| `APPLE_TEAM_ID` | Developer team ID |
| `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 |

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

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

## Auto-update, channels, rollout, rollback

- `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.
- `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.
- 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.
- Staged rollout: after publishing, edit `stagingPercentage: 10` into the release's `latest-mac.yml`, then raise as crash metrics stay clean.
- 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.)
Expand Down
41 changes: 37 additions & 4 deletions apps/desktop/src/main/updater.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,15 +391,15 @@ describe('initUpdater state machine', () => {
})
})

function manifest(version: string): string {
function manifest(version: string, repository = 'simstudioai/sim'): string {
return [
`version: ${version}`,
'files:',
` - url: https://github.com/simstudioai/sim/releases/download/v${version}/Sim-${version}-universal-mac.zip`,
` - url: https://github.com/${repository}/releases/download/v${version}/Sim-${version}-universal-mac.zip`,
' sha512: abc',
` - url: https://github.com/simstudioai/sim/releases/download/v${version}/Sim-${version}-universal.dmg`,
` - url: https://github.com/${repository}/releases/download/v${version}/Sim-${version}-universal.dmg`,
' sha512: def',
`path: https://github.com/simstudioai/sim/releases/download/v${version}/Sim-${version}-universal-mac.zip`,
`path: https://github.com/${repository}/releases/download/v${version}/Sim-${version}-universal-mac.zip`,
"releaseDate: '2026-07-23T00:00:00.000Z'",
].join('\n')
}
Expand Down Expand Up @@ -453,6 +453,26 @@ describe('initUpdater manual mode (no Developer ID signature)', () => {
expect(shell.openExternal).toHaveBeenCalledTimes(2)
})

it('offers prerelease-repository assets as manual downloads', async () => {
const fetchManifest = vi.fn(async () =>
manifest('9.9.9-dev.1', 'simstudioai/sim-desktop-releases')
)
const { handle } = await createManualUpdater(fetchManifest)

handle.check()
await vi.advanceTimersByTimeAsync(0)
expect(handle.getState()).toEqual({
status: 'available',
version: '9.9.9-dev.1',
manual: true,
})

handle.check()
expect(shell.openExternal).toHaveBeenCalledWith(
'https://github.com/simstudioai/sim-desktop-releases/releases/download/v9.9.9-dev.1/Sim-9.9.9-dev.1-universal.dmg'
)
})

it('refuses a manifest whose download urls are not http(s)', async () => {
const hostile = [
'version: 9.9.9',
Expand Down Expand Up @@ -499,6 +519,19 @@ describe('initUpdater manual mode (no Developer ID signature)', () => {
expect(shell.openExternal).not.toHaveBeenCalled()
})

it('refuses assets from other repositories on github.com', async () => {
const offRepository = manifest('9.9.9', 'simstudioai/not-desktop-releases')
const { handle } = await createManualUpdater(async () => offRepository)

handle.check()
await vi.advanceTimersByTimeAsync(0)

expect(handle.getState()).toMatchObject({ status: 'error', manual: true })
handle.check()
handle.install()
expect(shell.openExternal).not.toHaveBeenCalled()
})

it('skips an unusable url but still offers a safe one from the same manifest', async () => {
const mixed = [
'version: 9.9.9',
Expand Down
Loading
Loading