From c07e736ca80ce3275109d0aa02c7ed48032dc24d Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 13 Aug 2026 12:55:49 -0700 Subject: [PATCH 1/2] fix(desktop): move prereleases to release repo --- .github/workflows/ci.yml | 62 +++++++++----- .github/workflows/desktop-release.yml | 26 ++++-- apps/desktop/README.md | 5 +- apps/desktop/src/main/updater.test.ts | 41 ++++++++- apps/desktop/src/main/updater.ts | 10 ++- .../update/latest-mac.yml/route.test.ts | 84 ++++++++++--------- .../desktop/update/latest-mac.yml/route.ts | 11 +-- apps/sim/lib/desktop/update-feed.test.ts | 26 ++++-- apps/sim/lib/desktop/update-feed.ts | 31 +++++-- 9 files changed, 202 insertions(+), 94 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf2f27d7836..03ff204bcf6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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: @@ -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 @@ -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 @@ -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" @@ -782,7 +789,7 @@ jobs: name: Desktop Prerelease Build needs: [create-desktop-prerelease, check-desktop-signing] permissions: - contents: write + contents: read uses: ./.github/workflows/desktop-release.yml with: version: ${{ needs.create-desktop-prerelease.outputs.version }} @@ -799,14 +806,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 @@ -818,13 +830,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" | diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index a160d22c37f..c81299279f3 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -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: @@ -104,18 +104,20 @@ jobs: run: | case "$VERSION" in *-dev.*|*-alpha.*) - NAME='Sim Dev'; APP_ID=ai.sim.desktop.dev; ORIGIN=https://www.dev.sim.ai ;; + 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 ;; + 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='' ;; + 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) default origin: ${ORIGIN:-production}" + echo "Building $NAME ($APP_ID) for $RELEASE_REPOSITORY; default origin: ${ORIGIN:-production}" - name: Bundle main and preload working-directory: apps/desktop @@ -177,9 +179,14 @@ jobs: - name: Upload artifacts to the release if: ${{ inputs.publish }} env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ steps.channel.outputs.token_kind == 'prerelease' && secrets.DESKTOP_RELEASE_TOKEN || github.token }} + RELEASE_REPOSITORY: ${{ steps.channel.outputs.release_repository }} VERSION: ${{ inputs.version }} run: | + if [ -z "$GH_TOKEN" ]; then + echo "::error::DESKTOP_RELEASE_TOKEN is required to publish prereleases to $RELEASE_REPOSITORY." + exit 1 + fi # 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 — @@ -198,6 +205,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 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index eab15594e1f..766873fab65 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -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. @@ -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) @@ -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.) diff --git a/apps/desktop/src/main/updater.test.ts b/apps/desktop/src/main/updater.test.ts index 6feece68361..5f00197d3c2 100644 --- a/apps/desktop/src/main/updater.test.ts +++ b/apps/desktop/src/main/updater.test.ts @@ -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') } @@ -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', @@ -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', diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts index 31333eb1403..a6702d0090b 100644 --- a/apps/desktop/src/main/updater.ts +++ b/apps/desktop/src/main/updater.ts @@ -43,7 +43,10 @@ export function feedUrlForOrigin(origin: string): string | null { * host cannot get a bundle in front of the user's Download button. */ const RELEASE_ASSET_ORIGIN = 'https://github.com' -const RELEASE_ASSET_PATH = '/simstudioai/sim/releases/download/' +const RELEASE_ASSET_PATHS = [ + '/simstudioai/sim/releases/download/', + '/simstudioai/sim-desktop-releases/releases/download/', +] as const /** Whether a manifest url is one of our own release assets. */ function isReleaseAssetUrl(rawUrl: string): boolean { @@ -53,7 +56,10 @@ function isReleaseAssetUrl(rawUrl: string): boolean { // Compared on the parsed origin and the parsed pathname, never by prefix on // the raw string: `https://github.com.evil.example/…` must not pass, and // `URL` has already normalized away any `..` segments by this point. - return url.origin === RELEASE_ASSET_ORIGIN && url.pathname.startsWith(RELEASE_ASSET_PATH) + return ( + url.origin === RELEASE_ASSET_ORIGIN && + RELEASE_ASSET_PATHS.some((path) => url.pathname.startsWith(path)) + ) } catch { return false } diff --git a/apps/sim/app/api/desktop/update/latest-mac.yml/route.test.ts b/apps/sim/app/api/desktop/update/latest-mac.yml/route.test.ts index 7750053970f..754b5fff8b4 100644 --- a/apps/sim/app/api/desktop/update/latest-mac.yml/route.test.ts +++ b/apps/sim/app/api/desktop/update/latest-mac.yml/route.test.ts @@ -4,10 +4,15 @@ import { setEnv } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { MANIFEST_ASSET_NAME } from '@/lib/desktop/update-feed' +import { + DESKTOP_PRERELEASE_REPOSITORY, + DESKTOP_STABLE_RELEASE_REPOSITORY, + MANIFEST_ASSET_NAME, +} from '@/lib/desktop/update-feed' import { GET } from '@/app/api/desktop/update/latest-mac.yml/route' -const RELEASES_URL = 'https://api.github.com/repos/simstudioai/sim/releases?per_page=30' +const STABLE_RELEASES_URL = `https://api.github.com/repos/${DESKTOP_STABLE_RELEASE_REPOSITORY}/releases?per_page=30` +const PRERELEASE_RELEASES_URL = `https://api.github.com/repos/${DESKTOP_PRERELEASE_REPOSITORY}/releases?per_page=30` const FEED_STATUS_HEADER = 'x-sim-desktop-update-feed' function release(tag: string) { @@ -49,36 +54,38 @@ describe('desktop update manifest route', () => { }) it.each([ - ['dev', 'v1.2.0-dev.4', '1.2.0-dev.4'], - ['staging', 'v1.2.0-staging.5', '1.2.0-staging.5'], - ['production', 'v1.1.0', '1.1.0'], - ])('serves the newest release for the %s deployment', async (environment, tag, version) => { - setEnv({ APPCONFIG_ENVIRONMENT: environment }) - fetchMock.mockImplementation(async (input: string | URL | Request) => { - const url = String(input) - if (url === RELEASES_URL) { - return Response.json([ - release('v1.2.0-dev.4'), - release('v1.2.0-staging.5'), - release('v1.1.0'), - ]) - } - if (url === `https://downloads.example/${tag}/${MANIFEST_ASSET_NAME}`) { - return new Response(manifest(version)) - } - return new Response(null, { status: 404 }) - }) + ['dev', 'v1.2.0-dev.4', '1.2.0-dev.4', DESKTOP_PRERELEASE_REPOSITORY], + ['staging', 'v1.2.0-staging.5', '1.2.0-staging.5', DESKTOP_PRERELEASE_REPOSITORY], + ['production', 'v1.1.0', '1.1.0', DESKTOP_STABLE_RELEASE_REPOSITORY], + ])( + 'serves the newest release for the %s deployment', + async (environment, tag, version, repository) => { + setEnv({ APPCONFIG_ENVIRONMENT: environment }) + fetchMock.mockImplementation(async (input: string | URL | Request) => { + const url = String(input) + if (url === PRERELEASE_RELEASES_URL) { + return Response.json([release('v1.2.0-dev.4'), release('v1.2.0-staging.5')]) + } + if (url === STABLE_RELEASES_URL) { + return Response.json([release('v1.1.0')]) + } + if (url === `https://downloads.example/${tag}/${MANIFEST_ASSET_NAME}`) { + return new Response(manifest(version)) + } + return new Response(null, { status: 404 }) + }) - const response = await getFeed('internal.service.local') - const body = await response.text() + const response = await getFeed('internal.service.local') + const body = await response.text() - expect(response.status).toBe(200) - expect(response.headers.get(FEED_STATUS_HEADER)).toBe('release') - expect(body).toContain(`version: ${version}`) - expect(body).toContain( - `https://github.com/simstudioai/sim/releases/download/${tag}/Sim-${version}-universal-mac.zip` - ) - }) + expect(response.status).toBe(200) + expect(response.headers.get(FEED_STATUS_HEADER)).toBe('release') + expect(body).toContain(`version: ${version}`) + expect(body).toContain( + `https://github.com/${repository}/releases/download/${tag}/Sim-${version}-universal-mac.zip` + ) + } + ) it.each([ ['dev', 'www.staging.sim.ai:443', 'v1.2.0-dev.4', '1.2.0-dev.4'], @@ -90,12 +97,11 @@ describe('desktop update manifest route', () => { setEnv({ APPCONFIG_ENVIRONMENT: environment }) fetchMock.mockImplementation(async (input: string | URL | Request) => { const url = String(input) - if (url === RELEASES_URL) { - return Response.json([ - release('v1.2.0-dev.4'), - release('v1.2.0-staging.5'), - release('v1.1.0'), - ]) + if (url === PRERELEASE_RELEASES_URL) { + return Response.json([release('v1.2.0-dev.4'), release('v1.2.0-staging.5')]) + } + if (url === STABLE_RELEASES_URL) { + return Response.json([release('v1.1.0')]) } if (url === `https://downloads.example/${tag}/${MANIFEST_ASSET_NAME}`) { return new Response(manifest(version)) @@ -118,8 +124,8 @@ describe('desktop update manifest route', () => { it('defaults self-hosted deployments to the stable channel', async () => { fetchMock.mockImplementation(async (input: string | URL | Request) => { const url = String(input) - if (url === RELEASES_URL) { - return Response.json([release('v1.2.0-dev.4'), release('v1.1.0')]) + if (url === STABLE_RELEASES_URL) { + return Response.json([release('v1.1.0')]) } if (url === `https://downloads.example/v1.1.0/${MANIFEST_ASSET_NAME}`) { return new Response(manifest('1.1.0')) @@ -134,6 +140,7 @@ describe('desktop update manifest route', () => { expect(response.status).toBe(200) expect(await response.text()).toContain('version: 1.1.0') + expect(fetchMock).toHaveBeenCalledWith(STABLE_RELEASES_URL, expect.any(Object)) }) it('reports an authoritative no-release result for production with only prereleases', async () => { @@ -159,5 +166,6 @@ describe('desktop update manifest route', () => { expect(response.status).toBe(502) expect(await response.json()).toMatchObject({ error: 'Release manifest unavailable' }) + expect(fetchMock).toHaveBeenNthCalledWith(1, PRERELEASE_RELEASES_URL, expect.any(Object)) }) }) diff --git a/apps/sim/app/api/desktop/update/latest-mac.yml/route.ts b/apps/sim/app/api/desktop/update/latest-mac.yml/route.ts index e3b483a210d..a731ba3be6f 100644 --- a/apps/sim/app/api/desktop/update/latest-mac.yml/route.ts +++ b/apps/sim/app/api/desktop/update/latest-mac.yml/route.ts @@ -4,9 +4,9 @@ import { env } from '@/lib/core/config/env' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { channelForDeploymentEnvironment, - DESKTOP_RELEASE_REPO, type DesktopReleaseCandidate, MANIFEST_ASSET_NAME, + releaseRepositoryForChannel, rewriteManifestUrls, selectReleaseForChannel, } from '@/lib/desktop/update-feed' @@ -20,8 +20,6 @@ const logger = createLogger('DesktopUpdateFeedAPI') const REVALIDATE_SECONDS = 300 const FEED_STATUS_HEADER = 'x-sim-desktop-update-feed' -const RELEASES_API_URL = `https://api.github.com/repos/${DESKTOP_RELEASE_REPO}/releases?per_page=30` - /** * The per-environment desktop update feed (see `lib/desktop/update-feed.ts`). * @@ -38,11 +36,13 @@ export const GET = withRouteHandler(async (_request: NextRequest): Promise { }) }) +describe('releaseRepositoryForChannel', () => { + it('keeps stable releases in sim and prereleases in the release-only repository', () => { + expect(releaseRepositoryForChannel('latest')).toBe(DESKTOP_STABLE_RELEASE_REPOSITORY) + expect(releaseRepositoryForChannel('dev')).toBe(DESKTOP_PRERELEASE_REPOSITORY) + expect(releaseRepositoryForChannel('staging')).toBe(DESKTOP_PRERELEASE_REPOSITORY) + }) +}) + describe('selectReleaseForChannel', () => { const releases = [ release('v0.5.25-dev.412'), @@ -122,7 +133,10 @@ describe('selectReleaseForChannel', () => { }) describe('rewriteManifestUrls', () => { - it('rewrites relative url and path entries to absolute asset URLs', () => { + it.each([ + ['stable', DESKTOP_STABLE_RELEASE_REPOSITORY], + ['prerelease', DESKTOP_PRERELEASE_REPOSITORY], + ])('rewrites relative url and path entries to absolute %s asset URLs', (_kind, repository) => { const manifest = [ 'version: 0.5.24', 'files:', @@ -133,18 +147,20 @@ describe('rewriteManifestUrls', () => { 'sha512: abc', "releaseDate: '2026-07-23T00:00:00.000Z'", ].join('\n') - const rewritten = rewriteManifestUrls(manifest, 'v0.5.24') + const rewritten = rewriteManifestUrls(manifest, 'v0.5.24', repository) expect(rewritten).toContain( - ' - url: https://github.com/simstudioai/sim/releases/download/v0.5.24/Sim-0.5.24-universal-mac.zip' + ` - url: https://github.com/${repository}/releases/download/v0.5.24/Sim-0.5.24-universal-mac.zip` ) expect(rewritten).toContain( - 'path: https://github.com/simstudioai/sim/releases/download/v0.5.24/Sim-0.5.24-universal-mac.zip' + `path: https://github.com/${repository}/releases/download/v0.5.24/Sim-0.5.24-universal-mac.zip` ) expect(rewritten).toContain('sha512: abc') }) it('leaves already-absolute URLs alone', () => { const manifest = ' - url: https://cdn.example.com/Sim.zip' - expect(rewriteManifestUrls(manifest, 'v0.5.24')).toBe(manifest) + expect(rewriteManifestUrls(manifest, 'v0.5.24', DESKTOP_STABLE_RELEASE_REPOSITORY)).toBe( + manifest + ) }) }) diff --git a/apps/sim/lib/desktop/update-feed.ts b/apps/sim/lib/desktop/update-feed.ts index 69efd67400c..b3b59f8be17 100644 --- a/apps/sim/lib/desktop/update-feed.ts +++ b/apps/sim/lib/desktop/update-feed.ts @@ -10,9 +10,12 @@ * - hosted `staging` deployment → `staging` (per-push prerelease builds from `staging`) * - production + self-hosted → `latest` (stable vX.Y.Z releases only) * - * Artifacts stay on GitHub Releases (dumb storage); the feed route picks the - * right release for its channel and serves that release's electron-updater - * manifest with download URLs rewritten to absolute GitHub asset URLs. + * Artifacts stay on GitHub Releases (dumb storage). Stable releases live in + * the source repository; dev and staging releases live in a release-only + * repository so source-repository followers are not notified for every shell + * build. The feed route picks both the repository and release for its channel, + * then serves that release's electron-updater manifest with download URLs + * rewritten to absolute GitHub asset URLs. * * Streams are strictly isolated: dev serves `-dev.` prereleases, staging * `-staging.`, and `latest` only stable releases. The legacy `-alpha.` and @@ -24,7 +27,12 @@ */ import { compareVersions } from '@/lib/desktop/min-version' -export const DESKTOP_RELEASE_REPO = 'simstudioai/sim' +export const DESKTOP_STABLE_RELEASE_REPOSITORY = 'simstudioai/sim' +export const DESKTOP_PRERELEASE_REPOSITORY = 'simstudioai/sim-desktop-releases' + +export type DesktopReleaseRepository = + | typeof DESKTOP_STABLE_RELEASE_REPOSITORY + | typeof DESKTOP_PRERELEASE_REPOSITORY export type DesktopUpdateChannel = 'dev' | 'staging' | 'latest' @@ -37,6 +45,13 @@ export function channelForDeploymentEnvironment( return 'latest' } +/** Keeps stable and prerelease release storage isolated by channel. */ +export function releaseRepositoryForChannel( + channel: DesktopUpdateChannel +): DesktopReleaseRepository { + return channel === 'latest' ? DESKTOP_STABLE_RELEASE_REPOSITORY : DESKTOP_PRERELEASE_REPOSITORY +} + /** The channel a specific version belongs to, from its prerelease tag. */ export function channelOfVersion(version: string): DesktopUpdateChannel { if (version.includes('-dev.') || version.includes('-alpha.')) return 'dev' @@ -106,8 +121,12 @@ export function selectReleaseForChannel( * to the file URL) straight from GitHub while the feed itself stays served * by this deployment. */ -export function rewriteManifestUrls(manifest: string, tag: string): string { - const base = `https://github.com/${DESKTOP_RELEASE_REPO}/releases/download/${tag}/` +export function rewriteManifestUrls( + manifest: string, + tag: string, + repository: DesktopReleaseRepository +): string { + const base = `https://github.com/${repository}/releases/download/${tag}/` return manifest.replace(/^(\s*(?:-\s*)?(?:url|path):\s*)(\S+)\s*$/gm, (line, prefix, value) => { if (value.startsWith('http://') || value.startsWith('https://')) { return line From bc8875c99aa367ee4f25f8ef03ea708af581a4ec Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 13 Aug 2026 13:16:46 -0700 Subject: [PATCH 2/2] fix(desktop): validate prerelease authentication --- .github/workflows/ci.yml | 5 +- .github/workflows/desktop-release.yml | 82 +++++++++++++++++---------- 2 files changed, 56 insertions(+), 31 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03ff204bcf6..97ea3af79be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -788,8 +788,11 @@ 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: read + contents: write uses: ./.github/workflows/desktop-release.yml with: version: ${{ needs.create-desktop-prerelease.outputs.version }} diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index c81299279f3..ed014c85039 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -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: @@ -91,34 +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; 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: Bundle main and preload working-directory: apps/desktop env: @@ -179,14 +191,24 @@ jobs: - name: Upload artifacts to the release if: ${{ inputs.publish }} env: - GH_TOKEN: ${{ steps.channel.outputs.token_kind == 'prerelease' && secrets.DESKTOP_RELEASE_TOKEN || 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::DESKTOP_RELEASE_TOKEN is required to publish prereleases to $RELEASE_REPOSITORY." + 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 —