|
| 1 | +/** |
| 2 | + * Share build: packages distributable .dmg/.zip artifacts from the current |
| 3 | + * checkout, signed with whatever identity is on the machine (no notarization, |
| 4 | + * no trusted timestamps) — the "send someone a build to try" loop. |
| 5 | + * |
| 6 | + * bun run package:share # production only |
| 7 | + * bun run package:share --all # all four channels |
| 8 | + * bun run package:share --staging --dev # just these two |
| 9 | + * bun run package:share --dir # skip dmg/zip, package the .app only (fast) |
| 10 | + * SIM_DESKTOP_DEFAULT_ORIGIN=https://sim.acme.example bun run package:share |
| 11 | + * |
| 12 | + * Each channel lands in release/<slug>/ with artifacts named for it — sim, |
| 13 | + * sim-staging, sim-dev, sim-local. electron-builder.yml's artifactName is a |
| 14 | + * single literal ("Sim-${version}-${arch}"), so without the override every |
| 15 | + * channel writes the same filename and the last build silently wins. Only this |
| 16 | + * path overrides it: the release workflow publishes one channel per GitHub |
| 17 | + * release, where the flat name is what electron-updater expects. |
| 18 | + * |
| 19 | + * The baked origin decides the bundle identity, exactly as it decides the |
| 20 | + * runtime one. This used to shell straight into electron-builder, which took |
| 21 | + * productName/appId from electron-builder.yml — always the production pair — so |
| 22 | + * a dev-pointed share packaged as "Sim.app" with the production bundle id while |
| 23 | + * naming itself "Sim Dev" at runtime. |
| 24 | + * |
| 25 | + * Channels build ONE AT A TIME on purpose. scripts/build.ts writes the bundle |
| 26 | + * to dist/ and the app icon to build/generated-icon.icns, both fixed paths, so |
| 27 | + * concurrent channels would overwrite each other's bundle mid-package and ship |
| 28 | + * a dmg whose baked origin belongs to a different environment — invisible until |
| 29 | + * someone signs in. Giving each channel its own bundle directory is what would |
| 30 | + * make concurrency safe, and the flag that redirects the app entry point |
| 31 | + * (-c.extraMetadata.main) rewrites this package.json IN THE SOURCE TREE, |
| 32 | + * stripping scripts and devDependencies. If parallelism is worth it later, the |
| 33 | + * way to get it is a per-channel project directory (electron-builder --project) |
| 34 | + * — not extraMetadata. |
| 35 | + */ |
| 36 | +import { spawnSync } from 'node:child_process' |
| 37 | +import { rmSync } from 'node:fs' |
| 38 | +import { ALL_CHANNELS, type ChannelIdentity, identityForOrigin } from './channels' |
| 39 | + |
| 40 | +const FLAG_TO_CHANNEL: Record<string, ChannelIdentity> = Object.fromEntries( |
| 41 | + ALL_CHANNELS.map((channel) => [ |
| 42 | + `--${channel.slug.replace(/^sim-/, '').replace(/^sim$/, 'prod')}`, |
| 43 | + channel, |
| 44 | + ]) |
| 45 | +) |
| 46 | + |
| 47 | +const args = process.argv.slice(2) |
| 48 | +const dirOnly = args.includes('--dir') |
| 49 | +const bakedOriginOverride = process.env.SIM_DESKTOP_DEFAULT_ORIGIN ?? '' |
| 50 | + |
| 51 | +function selectedChannels(): ChannelIdentity[] { |
| 52 | + if (args.includes('--all')) return [...ALL_CHANNELS] |
| 53 | + const picked = args.filter((arg) => arg in FLAG_TO_CHANNEL).map((arg) => FLAG_TO_CHANNEL[arg]) |
| 54 | + if (picked.length > 0) return picked |
| 55 | + // No channel flags: honour an explicit origin (self-hosted shares resolve to |
| 56 | + // the production identity), otherwise plain production. |
| 57 | + return [identityForOrigin(bakedOriginOverride)] |
| 58 | +} |
| 59 | + |
| 60 | +const channels = selectedChannels() |
| 61 | +// An explicit origin only makes sense for a single-channel run; with several |
| 62 | +// channels each one supplies its own. |
| 63 | +const originFor = (channel: ChannelIdentity): string => |
| 64 | + channels.length === 1 && bakedOriginOverride ? bakedOriginOverride : channel.origin |
| 65 | + |
| 66 | +function run(command: string, commandArgs: string[], env?: Record<string, string>): void { |
| 67 | + const result = spawnSync(command, commandArgs, { |
| 68 | + stdio: 'inherit', |
| 69 | + env: env ? { ...process.env, ...env } : process.env, |
| 70 | + }) |
| 71 | + if (result.status !== 0) { |
| 72 | + console.error(`\n✖ ${command} ${commandArgs.join(' ')} failed`) |
| 73 | + process.exit(result.status ?? 1) |
| 74 | + } |
| 75 | +} |
| 76 | + |
| 77 | +function buildChannel(channel: ChannelIdentity): void { |
| 78 | + const origin = originFor(channel) |
| 79 | + console.log(`\n• ${channel.slug}: ${channel.name} (${channel.appId}) → ${origin}`) |
| 80 | + |
| 81 | + // electron-builder only writes the output dir for the CURRENT target/arch, so |
| 82 | + // an app left by an earlier run with different settings would survive |
| 83 | + // alongside the new one. |
| 84 | + for (const dir of ['mac-universal', 'mac-arm64', 'mac']) { |
| 85 | + rmSync(`release/${channel.slug}/${dir}`, { recursive: true, force: true }) |
| 86 | + } |
| 87 | + // electron-builder's `files: dist/**` packages whatever is in dist/, not just |
| 88 | + // what this build wrote. Anything left there by an earlier run — another |
| 89 | + // channel's bundle, scratch from an experiment — rides along inside the dmg, |
| 90 | + // so a production artifact can end up carrying a dev-pointed bundle. Dead |
| 91 | + // weight rather than a live risk (the app boots package.json's `main`), but |
| 92 | + // not something to hand to anyone. build.ts rewrites the directory on the |
| 93 | + // next line. |
| 94 | + rmSync('dist', { recursive: true, force: true }) |
| 95 | + |
| 96 | + run('bun', ['run', 'scripts/build.ts'], { SIM_DESKTOP_DEFAULT_ORIGIN: origin }) |
| 97 | + run('bunx', [ |
| 98 | + 'electron-builder', |
| 99 | + '--mac', |
| 100 | + ...(dirOnly ? ['dir'] : []), |
| 101 | + '--publish', |
| 102 | + 'never', |
| 103 | + // Trusted timestamps make codesign do a network round trip to Apple per |
| 104 | + // file (hundreds inside the Electron framework). Distribution builds need |
| 105 | + // them; a share build does not, and it turns signing into a long stall. |
| 106 | + '-c.mac.timestamp=none', |
| 107 | + `-c.productName=${channel.name}`, |
| 108 | + `-c.appId=${channel.appId}`, |
| 109 | + `-c.directories.output=release/${channel.slug}`, |
| 110 | + // The ${...} placeholders are electron-builder's own templating, expanded |
| 111 | + // by it at packaging time — escaped here so JS leaves them alone. |
| 112 | + `-c.artifactName=${channel.slug}-\${version}-\${arch}.\${ext}`, |
| 113 | + ]) |
| 114 | + console.log(`✔ ${channel.slug}: release/${channel.slug}/`) |
| 115 | +} |
| 116 | + |
| 117 | +// Shared node_modules state, and the one step every channel has in common. |
| 118 | +run('bun', ['run', 'scripts/ensure-pty-prebuilds.ts']) |
| 119 | +console.log( |
| 120 | + `• Building ${channels.length} channel(s)${dirOnly ? ' (dir only)' : ''}: ${channels.map((c) => c.slug).join(', ')}` |
| 121 | +) |
| 122 | +for (const channel of channels) { |
| 123 | + buildChannel(channel) |
| 124 | +} |
0 commit comments