diff --git a/.github/workflows/canary-release.yml b/.github/workflows/canary-release.yml index cfad8bf89d..c98d1d3dea 100644 --- a/.github/workflows/canary-release.yml +++ b/.github/workflows/canary-release.yml @@ -76,6 +76,8 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: python3 scripts/release.py --prune-canaries --publish --remote origin + - name: Install locked feed tooling + run: npm ci --workspaces=false --ignore-scripts --no-audit --no-fund - name: Prune R2 canary objects env: CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }} diff --git a/.github/workflows/desktop-bundled-release.yml b/.github/workflows/desktop-bundled-release.yml index 020f3c3cda..0f49aa14e3 100644 --- a/.github/workflows/desktop-bundled-release.yml +++ b/.github/workflows/desktop-bundled-release.yml @@ -492,7 +492,7 @@ jobs: needs: validate runs-on: ${{ matrix.target.runner }} environment: release-signing - timeout-minutes: 900 + timeout-minutes: 180 strategy: fail-fast: false matrix: @@ -513,14 +513,12 @@ jobs: CLOUDFLARE_R2_BUCKET: ${{ vars.CLOUDFLARE_R2_BUCKET }} CLOUDFLARE_R2_PUBLIC_URL: ${{ vars.CLOUDFLARE_R2_PUBLIC_URL }} steps: - - name: Disable Spotlight indexing and XProtect + - name: Disable Spotlight indexing for DMG staging # Spotlight indexes the freshly-mounted dmg staging image past # hdiutil's detach retries (per-VM, not a cross-job race). shell: bash run: | sudo mdutil -a -i off || true - sudo pkill -9 XProtect >/dev/null || true - while pgrep XProtect; do sleep 3; done # Check out the SHA the validate job admitted — never the tag ref, # which a force-push can move between jobs. This is the privileged @@ -529,6 +527,7 @@ jobs: with: ref: ${{ needs.validate.outputs.sha }} fetch-tags: true + fetch-depth: 0 - name: Resolve toolchain pins from pm/lock.json id: pins @@ -557,7 +556,7 @@ jobs: console.log(`builder=${eb}`) ' >> "$GITHUB_OUTPUT" - - uses: actions/setup-node@v7.0.0 # immutable release. safe to pin. + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ${{ steps.pins.outputs.node }} cache: npm @@ -693,21 +692,12 @@ jobs: uv tool install cmake==3.31.6 echo "$(uv tool dir --bin)" >> "$GITHUB_PATH" - - name: Derive the feed channel from the tag - id: channel - shell: bash - env: - TAG: ${{ inputs.tag }} - run: | - case "$TAG" in - *-canary.*) echo "channel=canary" >> "$GITHUB_OUTPUT" ;; - *) echo "channel=stable" >> "$GITHUB_OUTPUT" ;; - esac - - name: Build and package shell: bash - timeout-minutes: 900 + timeout-minutes: 160 env: + GITHUB_SHA: ${{ needs.validate.outputs.sha }} + GITHUB_REF_NAME: ${{ inputs.tag }} PYTHONUTF8: '1' # electron-osx-sign*/electron-notarize* keep the sign+notarize # phase visible: without them NOTHING logs between "signing @@ -747,11 +737,13 @@ jobs: fi for app in "${apps[@]}"; do codesign --verify --strict --verbose=2 "$app" + xcrun stapler validate "$app" spctl -a -vv -t exec "$app" echo "signed + notarized: $app" done - name: Rename the feed yml per arch + if: inputs.upload_release == true # electron-builder writes the channel feed yml (stable-mac.yml / # canary-mac.yml) with the SAME name on both legs; prefix the arch # so the publish job's merge-multiple download keeps both and @@ -763,24 +755,33 @@ jobs: shopt -s nullglob MATRIX_LABEL="${{ matrix.target.label }}" arch="${MATRIX_LABEL##*-}" - channel="${{ steps.channel.outputs.channel }}" - for f in apps/desktop/release/*-mac.yml; do - mv "$f" "apps/desktop/release/${arch}-${channel}-mac.yml" - echo "renamed: $(basename "$f") -> ${arch}-${channel}-mac.yml" - done - test -n "$(shopt -s nullglob; echo apps/desktop/release/*-mac.yml)" + channel="${{ needs.validate.outputs.channel }}" + f="apps/desktop/release/${channel}-mac.yml" + test -s "$f" + mv "$f" "apps/desktop/release/${arch}-${channel}-mac.yml" - - name: Upload artifacts + - name: Upload feed metadata + if: inputs.upload_release == true uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - # The per-arch feed ymls for publish-darwin-updater to merge. The - # dmg/zip/blockmap binaries go straight to R2 from this leg. name: hermes-bundled-${{ matrix.target.label }}-${{ inputs.tag }} path: | apps/desktop/release/*-mac.yml retention-days: 30 if-no-files-found: error + - name: Retain non-publishing build artifacts + if: inputs.upload_release != true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: hermes-bundled-${{ matrix.target.label }}-${{ inputs.tag }} + path: | + apps/desktop/release/*.dmg + apps/desktop/release/*.zip + apps/desktop/release/*.blockmap + retention-days: 30 + if-no-files-found: error + - name: Stage to Cloudflare R2 if: inputs.upload_release == true shell: bash @@ -1131,6 +1132,9 @@ jobs: # Privileged job: pin to the SHA validate admitted, not the tag. ref: ${{ needs.validate.outputs.sha }} + - name: Install locked feed tooling + run: npm ci --workspaces=false --ignore-scripts --no-audit --no-fund + - name: Download both darwin legs' feed ymls uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: @@ -1146,7 +1150,7 @@ jobs: run: | shopt -s nullglob ymls=(staged/*-mac.yml) - if [ ${#ymls[@]} -lt 2 ]; then + if [ ${#ymls[@]} -ne 2 ]; then echo "::error::expected the arm64 AND x64 feed ymls in the staged artifacts, found ${#ymls[@]}: ${ymls[*]}" exit 1 fi diff --git a/apps/desktop/electron-builder.config.cjs b/apps/desktop/electron-builder.config.cjs index 67f55c3143..d433ea2c9f 100644 --- a/apps/desktop/electron-builder.config.cjs +++ b/apps/desktop/electron-builder.config.cjs @@ -12,6 +12,7 @@ const fs = require('node:fs') const path = require('node:path') +const feedContract = require('./update-feed.cjs') const { light, @@ -58,6 +59,9 @@ if (!/^\d+\.\d+\.\d+$/.test(electronVersion)) { throw new Error(`invalid electron version ${electronVersion} in package.json`) } +const macFeed = feedContract.darwinFeed(channel === 'canary' || channel === 'light-canary' ? 'canary' : 'stable', light) +const publicUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL?.replace(/\/+$/, '') + /** @type {Configuration} */ module.exports = { electronVersion, @@ -119,6 +123,12 @@ module.exports = { unpack: ['**/*.node', '**/prebuilds/**', 'dist/**'] }, mac: { + // The afterSign hook owns notarization, including keychain-profile builds. + notarize: false, + // The packaged client reads this generated app-update.yml by default. + publish: publicUrl && !store + ? [{ provider: 'generic', url: `${publicUrl}/${macFeed.directory}/`, channel: macFeed.channel }] + : null, category: 'public.app-category.developer-tools', extendInfo: { CFBundleDisplayName: displayName, diff --git a/apps/desktop/electron/app-updater.ts b/apps/desktop/electron/app-updater.ts index d050b64c54..49f39e7e38 100644 --- a/apps/desktop/electron/app-updater.ts +++ b/apps/desktop/electron/app-updater.ts @@ -14,14 +14,7 @@ // Source installs never reach this module. The callers gate on the install // stamp first and fall through to the git-based update path. // -// NOTE: the darwin electron-updater arm (and its gate/channel/feed helpers — -// shouldUseAppUpdater, selectUpdaterArm, resolveUpdaterChannel, -// resolveFeedBaseUrl, feedSelection, describeFeedCheck) was ripped out in -// wt/darwin-updater: main.ts reimplemented the gate/channel/feed inline, so -// the module surface was test-only. To add macOS in-app updates back, restore -// the arm from git history (this file @ the parent of that commit) together -// with the `electron-updater` dependency and its test block — see the -// add-back plan in the wt/darwin-updater commit message. +// macOS packaged updates live in updater/mac.ts and updater/mac-client.ts. // // The win32 helpers are pure so vitest covers them; the impure pieces // (electron shell, payload python) are injected. diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 4fc154db2d..a2cb52373b 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -412,6 +412,7 @@ import { createCheckoutStrategy } from './updater/checkout' import { ExternalStrategy } from './updater/external' +import { createMacStrategy } from './updater/mac-client' import { consumePendingRelaunch, registerUpdateRelaunch } from './updater/relaunch' import { startRelaunchWaiter } from './updater/relaunch-waiter' import { isHermesOwnedVenvDaemon } from './venv-holder-select' @@ -3099,18 +3100,20 @@ async function checkUpdates() { // mechanism once and delegate. Out-of-store MSIX asks the OS whether a // newer package is on the registered .appinstaller source; Store installs // report unsupported (the steward owns their update loop). - const bundledPayload = resolvePayload(process.resourcesPath, { fileExists, directoryExists, isWindows: IS_WINDOWS }) + let strategy: UpdaterStrategy | null = null - if (bundledPayload) { - const strategy = resolveBundledUpdateStrategy(bundledPayload) + try { + strategy = resolvePackagedUpdateStrategy() - return strategy.check().catch(error => ({ + if (strategy) { return await strategy.check() } + } catch (error) { + return { supported: true, - mechanism: strategy.mechanism, + mechanism: strategy?.mechanism, error: 'check-failed', - message: error?.message || String(error), + message: error instanceof Error ? error.message : String(error), fetchedAt: Date.now() - })) + } } // Checkout install: dispatch through the strategy layer — one mechanism, @@ -3199,21 +3202,51 @@ let updateInFlight = false // ── bundled / App Installer helpers ───────────────────────────────────────── /** - * Resolve the updater strategy for a BUNDLED install (payload present). - * Dispatch mirrors the pre-strategy ladder exactly: win32 out-of-store → - * App Installer arm; Store / other → external (unsupported). The checkout - * ladder below appliesUpdates' bundled branch stays in main.ts (it delegates - * through the same mechanism resolution via the wire's `mechanism` field). + * Keep the native updater instance alive across check, download and install. + * Its identity comes from the packaged app, not its optional Python payload. */ -function resolveBundledUpdateStrategy(bundledPayload) { +let packagedUpdateStrategy: UpdaterStrategy | undefined + +function resolvePackagedUpdateStrategy(): UpdaterStrategy | null { const mechanism = resolveUpdaterMechanism({ - isBundled: true, - isWindows: IS_WINDOWS, + isPackaged: IS_PACKAGED, + platform: process.platform, + payload: INSTALL_STAMP?.payload, + updateMechanism: BAKED_INSTALL_STAMP?.updateMechanism, isWindowsStore: isWindowsStore() }) + if (mechanism === 'windows-handoff' || mechanism === 'posix-handoff') { return null } + + if (packagedUpdateStrategy) { return packagedUpdateStrategy } + + if (mechanism === 'electron-updater') { + packagedUpdateStrategy = createMacStrategy({ + channel: resolveUpdaterChannelFromStamp(), + light: isLightVariant(), + feedBaseUrl: resolveDesktopFeedBaseUrl(), + appVersion: app.getVersion(), + log: rememberLog, + emitProgress: emitUpdateProgress, + beforeInstall: async () => { + isQuittingForHandoff = true + await Promise.all([teardownPrimaryBackendAndWait(), stopAllPoolBackends()]) + }, + onInstallFailure: async () => { + isQuittingForHandoff = false + updateInFlight = false + await startHermes() + } + }) + + return packagedUpdateStrategy + } + if (mechanism === 'app-installer') { - return new AppInstallerStrategy({ + const bundledPayload = resolvePayload(process.resourcesPath, { fileExists, directoryExists, isWindows: IS_WINDOWS }) + + if (!bundledPayload) { return new ExternalStrategy() } + packagedUpdateStrategy = new AppInstallerStrategy({ python: bundledPayload.storePython, // The checker ships inside the payload's repo snapshot (git archive of // the committed tree): //apps/desktop/scripts/. @@ -3255,6 +3288,8 @@ function resolveBundledUpdateStrategy(bundledPayload) { }) }) }) + + return packagedUpdateStrategy } return new ExternalStrategy() @@ -3986,29 +4021,17 @@ async function applyUpdates(opts: { stopSafeBlockers?: boolean } = {}) { throw new Error('An update is already in progress.') } - // A bundled install ships its whole runtime as a sealed payload — there - // is no checkout to pull or venv to sync. New app release IS the update. - if (resolvePayload(process.resourcesPath, { fileExists, directoryExists, isWindows: IS_WINDOWS })) { - // Delegate to the bundled strategy: out-of-store MSIX runs the graceful - // teardown, hands the swap to the OS App Installer, registers the - // one-shot relaunch marker, and quits; anything else gets the manual - // card (reinstall the app release). - const strategy = resolveBundledUpdateStrategy( - resolvePayload(process.resourcesPath, { fileExists, directoryExists, isWindows: IS_WINDOWS }) - ) - - return strategy.apply(opts) - } - - // Checkout install: dispatch through the strategy layer. The in-flight - // guard stays with the public entrypoint so no body path can start a - // second update. The flow lives in updater/checkout.ts. + const strategy = resolvePackagedUpdateStrategy() ?? resolveCheckoutUpdateStrategy() updateInFlight = true + let handedOff = false try { - return await resolveCheckoutUpdateStrategy().apply(opts) + const result = await strategy.apply(opts) + handedOff = result.handedOff === true + + return result } finally { - updateInFlight = false + if (!handedOff) { updateInFlight = false } } } diff --git a/apps/desktop/electron/updater/index.ts b/apps/desktop/electron/updater/index.ts index 90fe021fc7..acd5efcc04 100644 --- a/apps/desktop/electron/updater/index.ts +++ b/apps/desktop/electron/updater/index.ts @@ -12,13 +12,14 @@ // manual checkout with no staged updater — the user runs // `hermes update` themselves. // -// The mechanism is resolved ONCE from runtime facts (payload presence, -// platform, store flag) by resolveUpdaterMechanism — a pure function, unit +// The mechanism is resolved from the packaged identity, platform and store +// flag by resolveUpdaterMechanism — a pure function, unit // tested — and every strategy reports it on the wire so the renderer can // tailor copy per mechanism without probing the install shape itself. export type UpdaterMechanism = | 'app-installer' + | 'electron-updater' | 'external' | 'windows-handoff' | 'posix-handoff' @@ -26,24 +27,29 @@ export type UpdaterMechanism = /** The facts the mechanism dispatch keys on. Pure data — injectable for tests. */ export interface MechanismFacts { - /** A bundled payload ships inside this artifact (sealed runtime). */ - isBundled: boolean - isWindows: boolean + isPackaged: boolean + platform: NodeJS.Platform + payload: 'bundled' | 'light' | 'bootstrap' | undefined + updateMechanism: 'self' | 'external' | 'electron-updater' | undefined /** This process is a Microsoft Store deployment. */ isWindowsStore: boolean } /** - * Resolve which mechanism owns updates for this install. Precedence mirrors - * the historical checkUpdates/applyUpdates ladder in main.ts exactly: - * payload-probe first, store-flag second, platform third. Behavior-preserving. + * The stamp names the artifact owner. A missing payload must never turn a + * packaged app into a checkout, and Light needs no payload to update itself. */ export function resolveUpdaterMechanism(facts: MechanismFacts): UpdaterMechanism { - if (facts.isBundled) { - return facts.isWindows && !facts.isWindowsStore ? 'app-installer' : 'external' + if (facts.isPackaged && (facts.payload === 'bundled' || facts.payload === 'light')) { + if (facts.isWindowsStore) { return 'external' } + + if (facts.platform === 'win32') { return 'app-installer' } + + return facts.platform === 'darwin' && facts.updateMechanism === 'electron-updater' + ? 'electron-updater' : 'external' } - return facts.isWindows ? 'windows-handoff' : 'posix-handoff' + return facts.platform === 'win32' ? 'windows-handoff' : 'posix-handoff' } /** The status shape main.ts already sends over `hermes:updates:check`. */ diff --git a/apps/desktop/electron/updater/mac-client.test.ts b/apps/desktop/electron/updater/mac-client.test.ts new file mode 100644 index 0000000000..a15ee3644a --- /dev/null +++ b/apps/desktop/electron/updater/mac-client.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { client } = vi.hoisted(() => ({ + client: { + autoDownload: true, autoInstallOnAppQuit: true, autoRunAppAfterInstall: false, + channel: '', allowPrerelease: true, allowDowngrade: true, + on: vi.fn(), setFeedURL: vi.fn(), checkForUpdates: vi.fn(async () => null) + } +})) + +vi.mock('electron', () => ({ autoUpdater: {} })) +vi.mock('electron-updater', () => ({ default: { MacUpdater: class { constructor() { return client } } } })) + +import { createMacStrategy } from './mac-client' + +afterEach(() => vi.clearAllMocks()) + +function deps(feedBaseUrl = '', light = false, channel: 'stable' | 'canary' = 'stable') { + return { channel, light, feedBaseUrl, appVersion: '0.28.0', log: vi.fn(), emitProgress: vi.fn(), beforeInstall: vi.fn(), onInstallFailure: vi.fn() } +} + +describe('macOS client wiring', () => { + it('uses the generated provider by default and forbids implicit installs or downgrades', async () => { + const strategy = createMacStrategy(deps()) + expect(client.setFeedURL).not.toHaveBeenCalled() + await expect(strategy.check()).rejects.toThrow('not active') + expect(client.autoDownload).toBe(false) + expect(client.autoInstallOnAppQuit).toBe(false) + expect(client.autoRunAppAfterInstall).toBe(true) + expect(client.allowDowngrade).toBe(false) + expect(client.channel).toBe('stable') + expect(client.allowPrerelease).toBe(false) + }) + + it('overrides the provider with the same variant/channel path as the publisher', () => { + createMacStrategy(deps('https://updates.example/', true, 'canary')) + expect(client.setFeedURL).toHaveBeenCalledWith({ provider: 'generic', url: 'https://updates.example/releases/darwin/light/canary/', channel: 'canary' }) + expect(client.allowPrerelease).toBe(true) + expect(() => createMacStrategy(deps('http://untrusted.example'))).toThrow('HTTPS') + }) +}) diff --git a/apps/desktop/electron/updater/mac-client.ts b/apps/desktop/electron/updater/mac-client.ts new file mode 100644 index 0000000000..15da592ee4 --- /dev/null +++ b/apps/desktop/electron/updater/mac-client.ts @@ -0,0 +1,37 @@ +import { autoUpdater as nativeUpdater } from 'electron' +import electronUpdater from 'electron-updater' + +import feedContract from '../../update-feed.cjs' + +import { MacStrategy, type MacStrategyDeps, prepareMacInstall } from './mac' + +export interface MacClientDeps extends Omit { + light: boolean + feedBaseUrl: string + log: (message: string) => void +} + +export function createMacStrategy(deps: MacClientDeps): MacStrategy { + const feed = feedContract.darwinFeed(deps.channel, deps.light) + const updater = new electronUpdater.MacUpdater() + updater.autoDownload = false + updater.autoInstallOnAppQuit = false + updater.autoRunAppAfterInstall = true + updater.channel = feed.channel + updater.allowPrerelease = feed.allowPrerelease + // Setting channel enables downgrades in electron-updater. This app never does. + updater.allowDowngrade = false + updater.on('error', error => deps.log(`macOS updater: ${error.message}`)) + + if (deps.feedBaseUrl) { + const base = new URL(deps.feedBaseUrl) + + if (base.protocol !== 'https:' && !(base.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(base.hostname))) { + throw new Error('The update feed must use HTTPS or a loopback HTTP address.') + } + + updater.setFeedURL({ provider: 'generic', url: `${base.href.replace(/\/+$/, '')}/${feed.directory}/`, channel: feed.channel }) + } + + return new MacStrategy({ ...deps, updater, prepareInstall: () => prepareMacInstall(nativeUpdater) }) +} diff --git a/apps/desktop/electron/updater/mac.test.ts b/apps/desktop/electron/updater/mac.test.ts new file mode 100644 index 0000000000..19ec867cb1 --- /dev/null +++ b/apps/desktop/electron/updater/mac.test.ts @@ -0,0 +1,115 @@ +import { EventEmitter } from 'node:events' + +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { MacStrategy, type MacStrategyDeps, prepareMacInstall } from './mac' + +function fixture() { + const events: string[] = [] + const emitter = new EventEmitter() + const info = { version: '0.29.0', files: [], releaseDate: '', path: '', sha512: '' } + + const deps: MacStrategyDeps = { + updater: { + checkForUpdates: vi.fn(async () => { + events.push('check') + + return { isUpdateAvailable: true, updateInfo: info, versionInfo: info } + }), + downloadUpdate: vi.fn(async () => { events.push('download'); + + return [] }), + quitAndInstall: vi.fn(() => { events.push('install') }), + on: emitter.on.bind(emitter) as MacStrategyDeps['updater']['on'], + removeListener: emitter.removeListener.bind(emitter) as MacStrategyDeps['updater']['removeListener'] + }, + channel: 'canary', + appVersion: '0.28.0', + prepareInstall: vi.fn(async () => { events.push('verify') }), + beforeInstall: vi.fn(async () => { events.push('stop') }), + onInstallFailure: vi.fn(async () => { events.push('restore') }), + emitProgress: vi.fn() + } + + return { deps, events, emitter, strategy: new MacStrategy(deps) } +} + +afterEach(() => vi.useRealTimers()) + +describe('macOS strategy', () => { + it('checks the release, verifies before teardown, and installs once', async () => { + const { strategy, events, emitter } = fixture() + expect(await strategy.check()).toMatchObject({ channel: 'canary', latestTag: 'v0.29.0', updateAvailable: true }) + events.length = 0 + expect(await strategy.apply()).toMatchObject({ ok: true, handedOff: true }) + expect(events).toEqual(['check', 'download', 'verify', 'stop', 'install']) + expect(emitter.listenerCount('download-progress')).toBe(0) + }) + + it.each(['downloadUpdate', 'prepareInstall'] as const)('keeps backends alive on %s failure', async failure => { + const { deps, strategy, events, emitter } = fixture() + vi.mocked(failure === 'downloadUpdate' ? deps.updater.downloadUpdate : deps.prepareInstall) + .mockRejectedValueOnce(new Error('invalid update')) + await expect(strategy.apply()).rejects.toThrow('invalid update') + expect(events).not.toContain('stop') + expect(events).not.toContain('install') + expect(emitter.listenerCount('download-progress')).toBe(0) + }) + + it('does not install when the provider reports no newer release', async () => { + const { deps, strategy, events } = fixture() + const info = { version: '0.27.0', files: [], releaseDate: '', path: '', sha512: '' } + vi.mocked(deps.updater.checkForUpdates).mockResolvedValue({ + isUpdateAvailable: false, updateInfo: info, versionInfo: info + }) + await strategy.apply() + expect(events).toEqual([]) + expect(deps.updater.downloadUpdate).not.toHaveBeenCalled() + }) + + it('restores the backend if install handoff throws', async () => { + const { deps, strategy, events } = fixture() + vi.mocked(deps.updater.quitAndInstall).mockImplementation(() => { throw new Error('handoff failed') }) + await expect(strategy.apply()).rejects.toThrow('handoff failed') + expect(events.slice(-2)).toEqual(['stop', 'restore']) + }) + + it('rejects simultaneous apply calls', async () => { + const { deps, strategy } = fixture() + let release!: () => void + vi.mocked(deps.prepareInstall).mockImplementation(() => new Promise(resolve => { release = resolve })) + const applying = strategy.apply() + await vi.waitFor(() => expect(deps.prepareInstall).toHaveBeenCalledOnce()) + await expect(strategy.apply()).rejects.toThrow('already in progress') + await expect(strategy.check()).rejects.toThrow('already in progress') + release() + await applying + }) +}) + +describe('native signature verification', () => { + it('waits for native readiness and removes both listeners', async () => { + const native = Object.assign(new EventEmitter(), { checkForUpdates: vi.fn() }) + let ready = false + const pending = prepareMacInstall(native).then(() => { ready = true }) + await Promise.resolve() + expect(ready).toBe(false) + native.emit('update-downloaded') + await pending + expect(native.listenerCount('error')).toBe(0) + expect(native.listenerCount('update-downloaded')).toBe(0) + }) + + it('surfaces native rejection and bounds a missing readiness event', async () => { + vi.useFakeTimers() + const native = Object.assign(new EventEmitter(), { checkForUpdates: vi.fn() }) + const rejected = expect(prepareMacInstall(native)).rejects.toThrow('bad signature') + native.emit('error', new Error('bad signature')) + await rejected + const timeout = expect(prepareMacInstall(native, 2000)).rejects.toThrow('timed out') + await vi.advanceTimersByTimeAsync(2000) + await timeout + expect(native.listenerCount('error')).toBe(0) + expect(native.listenerCount('update-downloaded')).toBe(0) + }) +}) diff --git a/apps/desktop/electron/updater/mac.ts b/apps/desktop/electron/updater/mac.ts new file mode 100644 index 0000000000..12ddfc17f7 --- /dev/null +++ b/apps/desktop/electron/updater/mac.ts @@ -0,0 +1,104 @@ +import type { AppUpdater } from 'electron-updater' + +import type { UpdaterApplyResultWire, UpdaterStatusWire, UpdaterStrategy } from './index' + +export interface MacStrategyDeps { + updater: Pick + channel: 'stable' | 'canary' + appVersion: string + /** Squirrel verifies the signed app before any backend is stopped. */ + prepareInstall: () => Promise + beforeInstall: () => Promise + onInstallFailure: () => Promise + emitProgress: (payload: { stage: string; message: string; percent: number | null }) => void +} + +export class MacStrategy implements UpdaterStrategy { + readonly mechanism = 'electron-updater' as const + private applying = false + + constructor(private readonly deps: MacStrategyDeps) {} + + async check(): Promise { + if (this.applying) { throw new Error('An update is already in progress.') } + + return this.checkRelease() + } + + private async checkRelease(): Promise { + const result = await this.deps.updater.checkForUpdates() + + if (!result) { throw new Error('The macOS updater is not active for this app.') } + + return { + supported: true, + mechanism: this.mechanism, + currentVersion: this.deps.appVersion, + channel: this.deps.channel, + latestTag: `v${result.updateInfo.version}`, + updateAvailable: result.isUpdateAvailable, + fetchedAt: Date.now() + } + } + + async apply(): Promise { + if (this.applying) { throw new Error('An update is already in progress.') } + this.applying = true + let stopped = false + + const progress = ({ percent }: { percent: number }): void => { + this.deps.emitProgress({ stage: 'fetch', message: 'Downloading the Hermes update.', percent }) + } + + this.deps.updater.on('download-progress', progress) + + try { + const status = await this.checkRelease() + + if (!status.updateAvailable) { return { ok: true, mechanism: this.mechanism } } + await this.deps.updater.downloadUpdate() + this.deps.emitProgress({ stage: 'prepare', message: 'Verifying the signed macOS update.', percent: null }) + await this.deps.prepareInstall() + stopped = true + await this.deps.beforeInstall() + this.deps.emitProgress({ stage: 'restart', message: 'Restarting Hermes to install the update.', percent: 100 }) + this.deps.updater.quitAndInstall() + + return { ok: true, bundled: true, handedOff: true, mechanism: this.mechanism } + } catch (error) { + if (stopped) { await this.deps.onInstallFailure() } + throw error + } finally { + this.deps.updater.removeListener('download-progress', progress) + this.applying = false + } + } +} + +export interface NativeMacUpdater { + once(event: 'update-downloaded', listener: () => void): unknown + once(event: 'error', listener: (error: Error) => void): unknown + removeListener(event: 'update-downloaded', listener: () => void): unknown + removeListener(event: 'error', listener: (error: Error) => void): unknown + checkForUpdates(): void +} + +/** Download completion alone does not mean Squirrel accepted the signature. */ +export function prepareMacInstall(native: NativeMacUpdater, timeoutMs = 120_000): Promise { + return new Promise((resolve, reject) => { + const cleanup = (): void => { + clearTimeout(timer) + native.removeListener('error', failed) + native.removeListener('update-downloaded', ready) + } + + const failed = (error: Error): void => { cleanup(); reject(error) } + + const ready = (): void => { cleanup(); resolve() } + const timer = setTimeout(() => failed(new Error('macOS update verification timed out.')), timeoutMs) + native.once('error', failed) + native.once('update-downloaded', ready) + + try { native.checkForUpdates() } catch (error) { failed(error as Error) } + }) +} diff --git a/apps/desktop/electron/updater/updater.test.ts b/apps/desktop/electron/updater/updater.test.ts index 20a7491132..18aa9b5f87 100644 --- a/apps/desktop/electron/updater/updater.test.ts +++ b/apps/desktop/electron/updater/updater.test.ts @@ -9,31 +9,25 @@ import { consumePendingRelaunch, PENDING_RELAUNCH_FILENAME, registerUpdateRelaun import { resolveUpdaterMechanism } from './index' -describe('resolveUpdaterMechanism — precedence', () => { - it('bundled win32 out-of-store → app-installer', () => { - expect(resolveUpdaterMechanism({ isBundled: true, isWindows: true, isWindowsStore: false })).toBe('app-installer') +describe('resolveUpdaterMechanism — install ownership', () => { + it.each(['bundled', 'light'] as const)('macOS %s updates without probing for Python', payload => { + expect(resolveUpdaterMechanism({ isPackaged: true, payload, platform: 'darwin', updateMechanism: 'electron-updater', isWindowsStore: false })).toBe('electron-updater') }) - it('bundled win32 Store → external (steward owns updates)', () => { - expect(resolveUpdaterMechanism({ isBundled: true, isWindows: true, isWindowsStore: true })).toBe('external') + it.each([ + ['win32', false, 'app-installer'], + ['win32', true, 'external'], + ['linux', false, 'external'], + ['darwin', false, 'external'] + ] as const)('preserves %s steward ownership (store=%s)', (platform, isWindowsStore, expected) => { + expect(resolveUpdaterMechanism({ isPackaged: true, payload: 'bundled', platform, isWindowsStore, updateMechanism: 'external' })).toBe(expected) }) - it('bundled posix → external', () => { - expect(resolveUpdaterMechanism({ isBundled: true, isWindows: false, isWindowsStore: false })).toBe('external') - }) - - it('checkout win32 → windows-handoff', () => { - expect(resolveUpdaterMechanism({ isBundled: false, isWindows: true, isWindowsStore: false })).toBe('windows-handoff') - }) - - it('checkout posix → posix-handoff', () => { - expect(resolveUpdaterMechanism({ isBundled: false, isWindows: false, isWindowsStore: false })).toBe('posix-handoff') - }) - - it('store flag never downgrades a checkout (probe only fires for bundled)', () => { - // isWindowsStore on a checkout is meaningless; the resolver must not - // route a win32 checkout to external on a stray true. - expect(resolveUpdaterMechanism({ isBundled: false, isWindows: true, isWindowsStore: true })).toBe('windows-handoff') + it.each(['win32', 'darwin', 'linux'] as const)('dev and bootstrap %s retain checkout updates', platform => { + const facts = { isPackaged: true, platform, payload: 'bootstrap' as const, updateMechanism: 'self' as const, isWindowsStore: false } + const expected = platform === 'win32' ? 'windows-handoff' : 'posix-handoff' + expect(resolveUpdaterMechanism(facts)).toBe(expected) + expect(resolveUpdaterMechanism({ ...facts, isPackaged: false, payload: 'bundled' })).toBe(expected) }) }) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 7546a1e41f..4f21683318 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -120,6 +120,7 @@ "dnd-core": "14.0.1", "dompurify": "3.4.13", "driver.js": "1.8.0", + "electron-updater": "6.8.9", "emojibase-data": "16.0.3", "fflate": "0.8.3", "frimousse": "0.3.0", diff --git a/apps/desktop/scripts/write-build-stamp.mjs b/apps/desktop/scripts/write-build-stamp.mjs index ecd7db35bf..3981a7e929 100644 --- a/apps/desktop/scripts/write-build-stamp.mjs +++ b/apps/desktop/scripts/write-build-stamp.mjs @@ -186,7 +186,7 @@ function main() { * Dev/local builds (no variant) keep the old shape; installShape() then treats * them as checkout, which is correct for a dev run. */ -export function buildStampPayload(stamp, env = process.env) { +export function buildStampPayload(stamp, env = process.env, platform = process.platform) { const variant = (env.HERMES_DESKTOP_VARIANT || "").trim() const base = { schemaVersion: STAMP_SCHEMA_VERSION, @@ -203,7 +203,7 @@ export function buildStampPayload(stamp, env = process.env) { payload: variant === "store" ? "bundled" : variant || "bootstrap", store: variant === "store", distribution: "desktop-app", - updateMechanism: "external", // sealed artifact — the OS/steward owns updates + updateMechanism: platform === 'darwin' && ['bundled', 'light'].includes(variant) ? 'electron-updater' : 'external', tag: env.HERMES_PAYLOAD_TAG || null } } diff --git a/apps/desktop/scripts/write-build-stamp.test.mjs b/apps/desktop/scripts/write-build-stamp.test.mjs index 76ebfe866b..af6d5ebc9c 100644 --- a/apps/desktop/scripts/write-build-stamp.test.mjs +++ b/apps/desktop/scripts/write-build-stamp.test.mjs @@ -117,7 +117,7 @@ test('buildStampPayload with bundled variant stamps payload bundled, store false const payload = buildStampPayload(baseStamp, { HERMES_DESKTOP_VARIANT: 'bundled', HERMES_PAYLOAD_TAG: 'v0.27.1-canary.20260901072553' - }) + }, 'win32') assert.equal(payload.payload, 'bundled') assert.equal(payload.store, false) assert.equal(payload.distribution, 'desktop-app') @@ -125,6 +125,13 @@ test('buildStampPayload with bundled variant stamps payload bundled, store false assert.equal(payload.tag, 'v0.27.1-canary.20260901072553') }) +test('macOS bundles and Light declare app-owned updates, never Store builds', () => { + for (const variant of ['bundled', 'light', 'store']) { + const stamp = buildStampPayload(baseStamp, { HERMES_DESKTOP_VARIANT: variant }, 'darwin') + assert.equal(stamp.updateMechanism, variant === 'store' ? 'external' : 'electron-updater') + } +}) + test('buildStampPayload with store variant stamps payload bundled, store true', () => { const payload = buildStampPayload(baseStamp, { HERMES_DESKTOP_VARIANT: 'store', diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index a0148241b2..3d0f3c69dc 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -711,6 +711,7 @@ export interface DesktopUpdateCommit { export type UpdaterMechanismClient = | 'app-installer' + | 'electron-updater' | 'external' | 'windows-handoff' | 'posix-handoff' diff --git a/apps/desktop/src/lib/update-copy.test.ts b/apps/desktop/src/lib/update-copy.test.ts index 0bac332aa0..cd066756ff 100644 --- a/apps/desktop/src/lib/update-copy.test.ts +++ b/apps/desktop/src/lib/update-copy.test.ts @@ -77,4 +77,9 @@ describe('resolveUpdateCopy', () => { const r = resolveUpdateCopy({ target: 'client', shownItems: 5, mechanism: 'windows-handoff', copy }) expect(r.body).toBe(copy.availableBody) }) + + it('macOS feed updates name the release without Windows or commit vocabulary', () => { + expect(resolveUpdateCopy({ target: 'client', shownItems: 0, mechanism: 'electron-updater', latestTag: 'v0.29.0-canary.20260906000000', copy }).body) + .toBe(copy.availableBodyRelease('v0.29.0-canary.20260906000000')) + }) }) diff --git a/apps/desktop/src/lib/update-copy.ts b/apps/desktop/src/lib/update-copy.ts index fc31831899..01536d4bfb 100644 --- a/apps/desktop/src/lib/update-copy.ts +++ b/apps/desktop/src/lib/update-copy.ts @@ -40,7 +40,7 @@ export interface ResolveUpdateCopyInput { * 'app-installer': the OS App Installer owns the apply (out-of-store MSIX) * — the body names Windows as the finisher, never commit vocabulary. */ - mechanism?: 'app-installer' | 'external' | 'windows-handoff' | 'posix-handoff' | 'manual' + mechanism?: 'app-installer' | 'electron-updater' | 'external' | 'windows-handoff' | 'posix-handoff' | 'manual' copy: UpdateCopyStrings } @@ -66,7 +66,7 @@ export function resolveUpdateCopy({ return { title, body: latestTag ? copy.availableBodyRelease(latestTag) : copy.availableBodyAppInstaller } } - if (channel === 'stable') { + if (channel === 'stable' || mechanism === 'electron-updater') { // No-changelog copy would be wrong here: the absence of commit rows is // structural on a release feed, not a degraded install type. return { title, body: latestTag ? copy.availableBodyRelease(latestTag) : copy.availableBody } diff --git a/apps/desktop/tsconfig.electron.json b/apps/desktop/tsconfig.electron.json index 54d6b3f084..a9741faebd 100644 --- a/apps/desktop/tsconfig.electron.json +++ b/apps/desktop/tsconfig.electron.json @@ -16,6 +16,6 @@ "rootDir": "..", "outDir": "build/electron-types" }, - "include": ["electron", "product-identity.cjs", "product-identity.d.cts", "../shared/src/data-url-read-max.ts", "../shared/src/translucency.ts"], + "include": ["electron", "product-identity.cjs", "product-identity.d.cts", "update-feed.cjs", "update-feed.d.cts", "../shared/src/data-url-read-max.ts", "../shared/src/translucency.ts"], "exclude": ["src", "electron/**/*.e2e.mts"] } diff --git a/apps/desktop/update-feed.cjs b/apps/desktop/update-feed.cjs new file mode 100644 index 0000000000..ae1b6db014 --- /dev/null +++ b/apps/desktop/update-feed.cjs @@ -0,0 +1,36 @@ +'use strict' + +// apps/desktop/update-feed.cjs — the ONE source of truth for the Darwin +// (macOS) electron-updater feed layout, shared by the desktop runtime +// (generic provider base URL) and the release pipeline +// (scripts/r2-release.mjs finalize). Pure CJS: requireable from both the +// app bundle and ESM scripts via createRequire. No dependencies. +// +// darwinFeed('stable') → { directory: 'releases/darwin/stable', +// channel: 'stable', +// fileName: 'stable-mac.yml', +// allowPrerelease: false } +// darwinFeed('canary', true) → { directory: 'releases/darwin/light/canary', +// channel: 'canary', +// fileName: 'canary-mac.yml', +// allowPrerelease: true } +// +// A client composes its feed URL as PUBLIC_URL + '/' + feed.directory + +// '/' + feed.fileName; the producer publishes the manifest at exactly that +// key. There is no placeholder default URL — the caller supplies the base. + +const CHANNELS = ['stable', 'canary'] + +function darwinFeed(channel, light = false) { + if (!CHANNELS.includes(channel)) { + throw new TypeError(`darwinFeed: unknown channel ${JSON.stringify(channel)} (expected stable|canary)`) + } + return { + directory: light ? `releases/darwin/light/${channel}` : `releases/darwin/${channel}`, + channel, + fileName: `${channel}-mac.yml`, + allowPrerelease: channel === 'canary', + } +} + +module.exports = { darwinFeed } diff --git a/apps/desktop/update-feed.d.cts b/apps/desktop/update-feed.d.cts new file mode 100644 index 0000000000..a995cfd929 --- /dev/null +++ b/apps/desktop/update-feed.d.cts @@ -0,0 +1,20 @@ +interface DarwinFeed { + /** Feed directory key under the public bucket, no trailing slash. + * e.g. "releases/darwin/stable" | "releases/darwin/light/canary" */ + directory: string + /** Normalized channel. "stable" | "canary" */ + channel: 'stable' | 'canary' + /** electron-updater manifest filename. e.g. "stable-mac.yml" */ + fileName: string + /** True only for the canary channel. */ + allowPrerelease: boolean +} + +/** + * Feed layout contract shared by the desktop runtime and the release + * pipeline. `light` selects the Light-variant feed directory. + * The generic-provider feed URL is PUBLIC_URL + '/' + directory + '/' + fileName. + */ +declare function darwinFeed(channel: 'stable' | 'canary', light?: boolean): DarwinFeed + +export = { darwinFeed } diff --git a/docs/macos-bundle-updates.md b/docs/macos-bundle-updates.md new file mode 100644 index 0000000000..0e2511ecd9 --- /dev/null +++ b/docs/macos-bundle-updates.md @@ -0,0 +1,61 @@ +# macOS bundle updates + +The packaged macOS app uses `electron-updater`. The bundled and Light stamps +name that owner. Development and bootstrap installs keep checkout updates. +Windows App Installer and Store ownership are unchanged. + +## Feed contract + +`apps/desktop/update-feed.cjs` defines each channel directory and filename. +The builder writes that URL into `app-update.yml`. The client uses this file +unless `updates.desktop_feed_base_url` supplies an explicit bucket-base URL. + +- Stable: `releases/darwin/stable/stable-mac.yml` +- Canary: `releases/darwin/canary/canary-mac.yml` +- Light: the same paths with `light/` between `darwin/` and the channel. +- Artifacts: `releases/tag/TAG/FILENAME`, shared by download links and feeds. + +The current workflow builds the bundled variant, on ARM64 and Intel runners. +Light has separate client/feed routing but no release matrix leg in this change. + +`r2-release.mjs finalize` requires one metadata file for each architecture, +named `arm64-CHANNEL-mac.yml` and `x64-CHANNEL-mac.yml`. It rejects wrong +versions, variants, architectures, hashes and inconsistent legacy path fields. +Each referenced ZIP/DMG is streamed back and checked against its SHA-512 and +size. Publication checks the live version, conditionally replaces its ETag, +and reads back the resulting feed. Same-tag macOS artifacts cannot be overwritten +with different bytes. Mutable feeds use `Cache-Control: no-store`. +Canary retention protects the artifacts and blockmaps referenced by live feeds. +An unreadable feed prevents pruning. + +## Client lifecycle + +Checks never download automatically. Apply rechecks the release, downloads it, +and waits for Squirrel.Mac to accept the signed app. Only then does Hermes stop +its app-owned backends and request installation/relaunch. Unrelated quits do +not trigger installation. Downloads and native-verification failures leave +backends running. Concurrent checks cannot replace an apply operation's target. +The existing checkout updater never mutates the sealed app bundle. + +## Release environment + +The existing `release-signing` environment supplies: + +- `CSC_LINK` and `CSC_KEY_PASSWORD`: Developer ID Application signing identity. +- `APPLE_API_KEY_P8`, `APPLE_API_KEY_ID`, `APPLE_API_ISSUER`: notarization. +- `CLOUDFLARE_R2_ACCOUNT_ID`, `CLOUDFLARE_R2_ACCESS_KEY_ID`, + `CLOUDFLARE_R2_SECRET_ACCESS_KEY`: bucket access secrets. +- `CLOUDFLARE_R2_BUCKET`, `CLOUDFLARE_R2_PUBLIC_URL`: repository/environment vars. + +Publishing requires the Apple credentials. The existing after-sign hook owns +notarization, so electron-builder's second notarization path is disabled. +The publish gate verifies the signature, stapled ticket and Gatekeeper assessment. +The Darwin publish job waits for both native builds and serializes channel writes. + +## Verification limits + +Local tests exercise the strategy, native-event ordering, feed validation, +conditional publication and retention with injected OS/network boundaries. +The desktop TypeScript and JavaScript build run on the development host. +These checks are not proof of a signed macOS install or an actual app replacement. +No E2E work, release dispatch or public feed publication is included here. diff --git a/package-lock.json b/package-lock.json index d7693d1c79..67a21bac8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,10 @@ "web", "tests-js" ], + "dependencies": { + "js-yaml": "4.3.1", + "semver": "7.7.4" + }, "devDependencies": { "@eslint/js": "9.39.5", "eslint-plugin-perfectionist": "5.10.0", @@ -107,6 +111,7 @@ "dnd-core": "14.0.1", "dompurify": "3.4.13", "driver.js": "1.8.0", + "electron-updater": "6.8.9", "emojibase-data": "16.0.3", "fflate": "0.8.3", "frimousse": "0.3.0", @@ -233,19 +238,6 @@ "url": "https://opencollective.com/babel" } }, - "apps/desktop/node_modules/@babel/core/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "apps/desktop/node_modules/@babel/generator": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz", @@ -281,19 +273,6 @@ "node": "^22.18.0 || >=24.11.0" } }, - "apps/desktop/node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "apps/desktop/node_modules/@babel/helper-globals": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-8.0.0.tgz", @@ -434,6 +413,16 @@ "global-agent": "^3.0.0" } }, + "apps/desktop/node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "apps/desktop/node_modules/@rolldown/plugin-babel": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/@rolldown/plugin-babel/-/plugin-babel-0.2.3.tgz", @@ -862,6 +851,16 @@ "url": "https://opencollective.com/babel" } }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/generator": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", @@ -896,6 +895,16 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/helper-globals": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", @@ -1780,19 +1789,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@electron/get/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@electron/notarize": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-3.1.1.tgz", @@ -1840,19 +1836,6 @@ "url": "https://github.com/sponsors/gjtorikian/" } }, - "node_modules/@electron/osx-sign/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@electron/rebuild": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.2.0.tgz", @@ -2005,7 +1988,6 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=18" } @@ -2022,7 +2004,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -2039,7 +2020,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -2056,7 +2036,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -2073,7 +2052,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -2090,7 +2068,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -2107,7 +2084,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2124,7 +2100,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2141,7 +2116,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2158,7 +2132,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2175,7 +2148,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2192,7 +2164,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2209,7 +2180,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2226,7 +2196,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2243,7 +2212,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2260,7 +2228,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2277,7 +2244,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2294,7 +2260,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2311,7 +2276,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2328,7 +2292,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2345,7 +2308,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2362,7 +2324,6 @@ "os": [ "openharmony" ], - "peer": true, "engines": { "node": ">=18" } @@ -2379,7 +2340,6 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=18" } @@ -2396,7 +2356,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -2413,7 +2372,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -2430,7 +2388,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -3260,19 +3217,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@mapbox/node-pre-gyp/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@marijn/find-cluster-break": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz", @@ -3401,19 +3345,6 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/@npmcli/fs/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@observablehq/plot": { "version": "0.6.17", "resolved": "https://registry.npmjs.org/@observablehq/plot/-/plot-0.6.17.tgz", @@ -6980,19 +6911,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@typescript-eslint/utils": { "version": "8.64.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.64.0.tgz", @@ -7523,19 +7441,6 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/app-builder-lib/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/app-builder-lib/node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -7603,7 +7508,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-hidden": { @@ -10121,6 +10025,70 @@ "dev": true, "license": "ISC" }, + "node_modules/electron-updater": { + "version": "6.8.9", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.9.tgz", + "integrity": "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==", + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.7.0", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "~7.7.3", + "tiny-typed-emitter": "^2.1.0" + } + }, + "node_modules/electron-updater/node_modules/builder-util-runtime": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", + "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/electron-updater/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-updater/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-updater/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/electron-winstaller": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", @@ -11402,19 +11370,6 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/get-windows/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/get-windows/node_modules/which": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", @@ -11499,20 +11454,6 @@ "node": ">=10.0" } }, - "node_modules/global-agent/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/globals": { "version": "17.7.0", "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", @@ -12702,7 +12643,6 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", - "dev": true, "funding": [ { "type": "github", @@ -12924,7 +12864,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", - "dev": true, "license": "MIT" }, "node_modules/leva": { @@ -13267,6 +13206,19 @@ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "license": "MIT" }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -13353,6 +13305,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/make-fetch-happen": { "version": "13.0.1", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-13.0.1.tgz", @@ -14789,19 +14751,6 @@ "node": ">=22.12.0" } }, - "node_modules/node-abi/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-addon-api": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", @@ -14818,19 +14767,6 @@ "semver": "^7.3.5" } }, - "node_modules/node-api-version/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -14912,19 +14848,6 @@ "node": ">=20" } }, - "node_modules/node-gyp/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-gyp/node_modules/undici": { "version": "6.28.0", "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", @@ -16791,7 +16714,6 @@ "version": "1.6.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" @@ -16833,13 +16755,15 @@ "license": "BSD-3-Clause" }, "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "devOptional": true, + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", "bin": { "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/semver-compare": { @@ -17027,19 +16951,6 @@ "node": ">=10" } }, - "node_modules/simple-update-notifier/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/slice-ansi": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.0.tgz", @@ -17685,6 +17596,12 @@ "semver": "bin/semver" } }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -17890,7 +17807,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -19707,19 +19623,6 @@ "engines": { "node": "20 || >=22" } - }, - "web/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } } } } diff --git a/package.json b/package.json index 406b73b205..0ea4b21a3c 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "tests-js" ], "scripts": { - "postinstall": "echo '\u2705 Node dependencies installed. Run: python run_agent.py --help'", + "postinstall": "echo '✅ Node dependencies installed. Run: python run_agent.py --help'", "install:root": "npm install --workspaces=false", "install:web": "npm install --workspace web", "install:tui": "npm install --workspace ui-tui", @@ -36,11 +36,11 @@ "homepage": "https://github.com/NousResearch/Hermes-Agent#readme", "devDependencies": { "@eslint/js": "9.39.5", - "typescript-eslint": "8.64.0", "eslint-plugin-perfectionist": "5.10.0", "eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-unused-imports": "4.4.1", - "globals": "17.7.0" + "globals": "17.7.0", + "typescript-eslint": "8.64.0" }, "overrides": { "lodash": "4.18.1", @@ -71,5 +71,9 @@ "fsevents@2.3.2": true, "fsevents@2.3.3": true, "get-windows@9.3.0": true + }, + "dependencies": { + "js-yaml": "4.3.1", + "semver": "7.7.4" } } diff --git a/scripts/darwin-feed.mjs b/scripts/darwin-feed.mjs new file mode 100644 index 0000000000..c8e2597e76 --- /dev/null +++ b/scripts/darwin-feed.mjs @@ -0,0 +1,90 @@ +import { isDeepStrictEqual } from 'node:util' +import yaml from 'js-yaml' +import semver from 'semver' +import feedContract from '../apps/desktop/update-feed.cjs' + +const { darwinFeed } = feedContract +const arches = ['arm64', 'x64'] +const hashPattern = /^[A-Za-z0-9+/]{86}==$/ + +export function parseMacFeed(text) { + const feed = yaml.load(text) + if (!feed || typeof feed !== 'object' || !semver.valid(feed.version) || !Array.isArray(feed.files) || !feed.files.length) { + throw new Error('Invalid macOS update feed') + } + for (const file of feed.files) { + if (typeof file.url !== 'string' || !hashPattern.test(file.sha512) || !Number.isSafeInteger(file.size) || file.size <= 0) { + throw new Error('Invalid macOS artifact metadata') + } + } + if (feed.path && !feed.files.some(file => file.url === feed.path && file.sha512 === feed.sha512)) { + throw new Error('Legacy feed path/hash disagrees with files') + } + return feed +} + +export function macFeedReferences(text) { + const feed = parseMacFeed(text) + const references = [] + for (const file of feed.files) { + // Only our immutable artifact namespace is eligible for publication or pruning. + if (!/^\/releases\/tag\/v[0-9A-Za-z.+-]+\/[0-9A-Za-z._+-]+\.(zip|dmg)$/.test(file.url)) { + throw new Error(`Invalid macOS artifact path: ${file.url}`) + } + const key = file.url.slice(1) + references.push(key, `${key}.blockmap`) + } + return references +} + +export function mergeMacFeeds(legs, tag, light = false) { + const version = tag?.startsWith('v') ? tag.slice(1) : '' + if (!semver.valid(version) || !/^v\d+\.\d+\.\d+(?:-canary\.\d{14})?$/.test(tag)) { + throw new Error('Invalid macOS release tag') + } + const selection = darwinFeed(version.includes('-canary.') ? 'canary' : 'stable', light) + const expected = arches.map(arch => `${arch}-${selection.fileName}`) + if (!isDeepStrictEqual(Object.keys(legs).sort(), [...expected].sort())) { + throw new Error('Expected exactly one ARM64 and one x64 macOS feed') + } + const files = new Map() + let first + for (const [index, name] of expected.entries()) { + const leg = parseMacFeed(legs[name]) + if (leg.version !== version) { throw new Error(`Feed version does not match ${tag}`) } + const prefix = `${light ? 'HermesLight' : 'HermesBundled'}-${version}-mac-${arches[index]}` + if (!leg.files.some(file => file.url === `${prefix}.zip`)) { throw new Error(`Missing native ZIP for ${arches[index]}`) } + for (const file of leg.files) { + if (![`${prefix}.zip`, `${prefix}.dmg`].includes(file.url)) { throw new Error(`Wrong variant or architecture: ${file.url}`) } + const prior = files.get(file.url) + const rewritten = { ...file, url: `/releases/tag/${tag}/${file.url}` } + if (prior && !isDeepStrictEqual(prior, rewritten)) { throw new Error(`Conflicting artifact: ${file.url}`) } + files.set(file.url, rewritten) + } + first ??= leg + } + const merged = { ...first, files: [...files.values()] } + if (merged.path) { merged.path = `/releases/tag/${tag}/${merged.path}` } + const text = yaml.dump(merged, { lineWidth: -1, noRefs: true }) + macFeedReferences(text) + return { key: `${selection.directory}/${selection.fileName}`, text, files: merged.files, version } +} + +/** The transport verifies immutable bytes and conditionally replaces one pointer. */ +export async function publishMacFeed(plan, transport) { + const live = await transport.read(plan.key) + if (live) { + const oldFeed = parseMacFeed(live.text) + const nextFeed = parseMacFeed(plan.text) + const order = semver.compare(plan.version, oldFeed.version) + if (order < 0) { throw new Error('Refusing to move the macOS feed backward') } + if (order === 0) { + if (!isDeepStrictEqual(oldFeed, nextFeed)) { throw new Error('Refusing to replace published version with different artifacts') } + return + } + } + for (const file of plan.files) { await transport.verify(file.url.slice(1), file) } + await transport.write(plan.key, plan.text, live?.etag ?? null) + const published = await transport.read(plan.key) + if (!published || published.text !== plan.text) { throw new Error('macOS feed readback differs from publication') } +} diff --git a/scripts/msix-shared.mjs b/scripts/msix-shared.mjs index 606876709c..823f47faac 100644 --- a/scripts/msix-shared.mjs +++ b/scripts/msix-shared.mjs @@ -48,6 +48,8 @@ const CONTENT_TYPES = { * filename suffix. Extensionless keys (inrelease/release/packages — the apt * repo metadata) match by exact basename only, so 'foo-release' or * 'xrelease' never collide with the apt 'Release' file. + * @param {string} filename + * @returns {string | undefined} */ export function contentTypeFor(filename) { const lower = String(filename).toLowerCase() diff --git a/scripts/r2-release.mjs b/scripts/r2-release.mjs index 9be53a19e8..ae3568f71d 100644 --- a/scripts/r2-release.mjs +++ b/scripts/r2-release.mjs @@ -25,8 +25,7 @@ // releases/darwin//*.{dmg,zip,blockmap} // where is stable | canary (from the tag: -canary. → canary). // The publish-win32-updater job merges the win32 legs' staging into the -// win32 feed; the darwin feed merge (r2 finalize) is currently DISABLED -// (no macOS updater arm). +// win32 feed; r2 finalize publishes the validated Darwin channel feed. import { createHash, createHmac } from 'node:crypto' import fs from 'node:fs' @@ -239,121 +238,20 @@ export function feedDirFor(platform, channel) { return `releases/${platform}/${channel}` } -/** - * Merge per-leg electron-updater feed ymls (same channel + platform) into one. - * Each leg's yml (mac: latest-mac.yml / canary-mac.yml) lists only its own - * arch's files[]; the merged yml keeps the top-level fields of the first leg - * (version/releaseDate) and the trailing top-level path/sha512, with the - * files[] entries concatenated and deduped by url. Idempotent: merging an - * already-merged yml is a no-op. - * - * Structure of a feed yml (electron-builder): - * version: ... - * files: - * - url: ... ← entries (indented list items) - * sha512: ... - * size: ... - * path: ... ← trailing top-level fields - * sha512: ... - * releaseDate: ... - */ -export function mergeFeedYmls(ymls) { - if (ymls.length === 0) return '' - const seen = new Set() - const entries = [] - - for (const yml of ymls) { - // Split at the files: marker. Entries are the indented list items - // (lines starting with whitespace + '- url:'); the tail is everything - // after the last entry (top-level path/sha512/releaseDate). - const filesIdx = yml.indexOf('\nfiles:') - if (filesIdx === -1) continue - const body = yml.slice(filesIdx + 1) // starts right after '\nfiles:' - const lines = body.split('\n') - let i = 0 - // Skip the 'files:' line itself. - if (lines[0].trim() === '') i = 1 - // Collect entry blocks: lines starting with '- url:' plus their - // following indented sha512/size lines. - while (i < lines.length) { - const line = lines[i] - if (/^\s*-\s+url:/.test(line)) { - const block = [line] - let j = i + 1 - while (j < lines.length && /^\s+(?:sha512|size):/.test(lines[j])) { - block.push(lines[j]) - j++ - } - const urlMatch = line.match(/url:\s*([^\s]+)/) - if (urlMatch && !seen.has(urlMatch[1])) { - seen.add(urlMatch[1]) - entries.push(block.join('\n')) - } - i = j - } else { - i++ - } - } - } - - const first = ymls[0] - const firstFilesIdx = first.indexOf('\nfiles:') - const head = firstFilesIdx === -1 ? first : first.slice(0, firstFilesIdx) - - // Tail: everything after the LAST entry block in the FIRST yml (the - // top-level path/sha512/releaseDate lines). - let tail = '' - { - const body = first.slice(firstFilesIdx + 1) - const lines = body.split('\n') - let lastEntryEnd = -1 - for (let i = 0; i < lines.length; i++) { - if (/^\s*-\s+url:/.test(lines[i])) { - let j = i + 1 - while (j < lines.length && /^\s+(?:sha512|size):/.test(lines[j])) j++ - lastEntryEnd = j - i = j - 1 - } - } - if (lastEntryEnd !== -1) { - tail = lines.slice(lastEntryEnd).join('\n').replace(/^\n+/, '') - } - } - - const body = ['files:', ...entries].join('\n') - const parts = [head, body] - if (tail.trim() !== '') parts.push(tail) - return parts.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n' -} - -/** - * Rewrite a feed yml's path:/url: entries to ABSOLUTE /releases/tag// - * object keys. The feed manifest lives in releases/darwin// but the - * binaries live once in the tag archive; both updater mechanisms resolve the - * value against the feed host root (electron-updater: new URL(path, baseUrl)). - * `absKey` maps a filename to its absolute key (e.g. /releases/tag/v0.28.0/x). - * Values that already start with '/' are left alone (idempotent). - */ -export function rewriteFeedPaths(ymlText, absKey) { - return ymlText.replace(/^(\s*(?:-\s+)?(?:path|url)):\s*([^\s#]+)\s*$/gm, (_m, key, value) => { - if (value.startsWith('/')) return _m - return `${key}: ${absKey(value)}` - }) -} - // --------------------------------------------------------------------------- // Commands // --------------------------------------------------------------------------- /** APT indexes are mutable; by-hash indexes and versioned packages are not. */ export function cacheControlFor(key) { + if (key.startsWith('releases/darwin/') && key.endsWith('-mac.yml')) return 'no-store' if (!key.startsWith('releases/termux/')) return undefined return key.includes('/by-hash/') || key.includes('/pool/') ? 'public, max-age=31536000, immutable' : 'no-store' } -async function putObject(creds, base, bucket, key, payload, now, contentType) { +async function putObject(creds, base, bucket, key, payload, now, contentType, conditions = {}) { // `payload` is either a small in-memory Buffer (feed manifests from // finalize) or a FILE PATH (binaries via `put`). The msixbundle is // ~2.7GB so path payloads stream from disk (fs.readFileSync throws @@ -376,8 +274,12 @@ async function putObject(creds, base, bucket, key, payload, now, contentType) { // should not be disturbed"). const body = isPath ? fs.createReadStream(payload) : payload const cacheControl = cacheControlFor(key) - const extraHeaders = cacheControl ? { 'Cache-Control': cacheControl } : undefined + const extraHeaders = { ...conditions, ...(cacheControl ? { 'Cache-Control': cacheControl } : {}) } const { res, text } = await signedFetch('PUT', url, { body, bodyHash, contentLength: size, creds, now, contentType, extraHeaders }) + if (res.status === 412 && isPath && conditions['If-None-Match'] === '*') { + await verifyRemoteArtifact(url, creds, now, size, bodyHash, 'sha256', 'hex') + return + } if (!res.ok) throw new Error(`PUT ${key} -> ${res.status}${text ? `: ${text.slice(0, 300)}` : ''}`) }) // HEAD can come back without content-length (intermediaries strip it on @@ -411,7 +313,7 @@ async function putObject(creds, base, bucket, key, payload, now, contentType) { console.log(`✓ r2: ${key} (${size} bytes)`) } -async function cmdPut({ tag, key, file, keyIsFull = false }) { +export async function cmdPut({ tag, key, file, keyIsFull = false }) { const accountId = requiredEnv('CLOUDFLARE_R2_ACCOUNT_ID') const accessKeyId = requiredEnv('CLOUDFLARE_R2_ACCESS_KEY_ID') const secretKey = requiredEnv('CLOUDFLARE_R2_SECRET_ACCESS_KEY') @@ -421,54 +323,53 @@ async function cmdPut({ tag, key, file, keyIsFull = false }) { const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '') const keyPath = keyIsFull ? key : stagingKeyFor(tag, key) - await putObject(creds, base, bucket, keyPath, file, now, contentTypeFor(key)) + const immutableMac = keyPath.startsWith('releases/tag/') && /-mac-(arm64|x64)\.(zip|dmg)(\.blockmap)?$/.test(keyPath) + await putObject(creds, base, bucket, keyPath, file, now, contentTypeFor(key), immutableMac ? { 'If-None-Match': '*' } : {}) } -/** - * finalize: write the per-channel feed MANIFESTS that point at the staged - * binaries. Binaries live ONCE under releases/tag// (staged by the - * matrix legs); the feed dirs carry only the manifests: - * - * releases/darwin//-mac.yml - * merged electron-updater feed; path:/url: entries rewritten to the - * absolute /releases/tag// locations (electron-updater - * resolves them with new URL(path, baseUrl)). - * - * The win32 App Installer feed (.appinstaller + .msixbundle per channel - * dir) is produced by the msixbundle job (scripts/stage-msixbundle.mjs), - * which uploads the manifests directly — nothing for finalize to merge. - * - * Expects --dir to contain the merged METADATA for ONE tag (the build - * matrix uploads only this — the binaries go straight to R2 from each - * leg and are never round-tripped through artifacts): - * *-mac.yml the per-leg electron-updater feed files - */ -async function cmdFinalize({ tag, dir }) { +async function verifyRemoteArtifact(urlValue, creds, now, expectedSize, digest, algorithm = 'sha512', encoding = 'base64') { + const url = new URL(urlValue) + const headers = r2Headers('GET', url.host, url.pathname, '', EMPTY_SHA, now, creds) + const response = await fetch(url, { headers, signal: AbortSignal.timeout(600_000) }) + if (!response.ok || !response.body) throw new Error(`Cannot verify ${url.pathname}: ${response.status}`) + const hash = createHash(algorithm) + let size = 0 + for await (const chunk of response.body) { hash.update(chunk); size += chunk.length } + if (size !== expectedSize || hash.digest(encoding) !== digest) throw new Error(`Artifact checksum mismatch: ${url.pathname}`) +} + +/** Validate both native legs, verify their bytes, then replace the feed pointer. */ +export async function cmdFinalize({ tag, dir, variant }) { + const { mergeMacFeeds, publishMacFeed } = await import('./darwin-feed.mjs') + if (variant && variant !== 'light') throw new Error('Unknown macOS variant') const accountId = requiredEnv('CLOUDFLARE_R2_ACCOUNT_ID') - const accessKeyId = requiredEnv('CLOUDFLARE_R2_ACCESS_KEY_ID') - const secretKey = requiredEnv('CLOUDFLARE_R2_SECRET_ACCESS_KEY') - const bucket = requiredEnv('CLOUDFLARE_R2_BUCKET') - const creds = { accessKeyId, secretKey } - const base = s3Endpoint(accountId) - const channel = channelForTag(tag) - const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '') - - const files = fs.readdirSync(dir).filter((f) => fs.statSync(path.join(dir, f)).isFile()) - // Absolute key of a staged artifact — the feed manifests reference these. - const absKey = (filename) => `/${stagingKeyFor(tag, filename)}` - - // --- darwin: merged feed yml only (dmg/zip/blockmap stay in the tag dir) --- - const macYmls = files.filter((f) => f.endsWith(`-mac.yml`)) - if (macYmls.length > 0) { - const darDir = feedDirFor('darwin', channel) - const macFeedName = `${channel}-mac.yml` - // Rewrite path:/url: to absolute /releases/tag// locations so the - // client fetches binaries from the archive, not the feed dir. - const merged = rewriteFeedPaths(mergeFeedYmls(macYmls.map((f) => fs.readFileSync(path.join(dir, f), 'utf8'))), absKey) - await putObject(creds, base, bucket, `${darDir}/${macFeedName}`, Buffer.from(merged, 'utf8'), now) + const creds = { + accessKeyId: requiredEnv('CLOUDFLARE_R2_ACCESS_KEY_ID'), + secretKey: requiredEnv('CLOUDFLARE_R2_SECRET_ACCESS_KEY') } - - console.log(`✓ r2: finalized ${tag} → ${channel} feed manifests`) + const bucket = requiredEnv('CLOUDFLARE_R2_BUCKET') + const base = s3Endpoint(accountId) + const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '') + const legs = Object.fromEntries(fs.readdirSync(dir).filter(name => name.endsWith('-mac.yml')) + .map(name => [name, fs.readFileSync(path.join(dir, name), 'utf8')])) + const plan = mergeMacFeeds(legs, tag, variant === 'light') + await publishMacFeed(plan, { + read: async key => { + const url = `${base}/${bucket}/${encodeKeyPath(key)}` + const { res, text } = await signedFetch('GET', url, { bodyHash: EMPTY_SHA, creds, now }) + if (res.status === 404) return null + if (!res.ok) throw new Error(`GET ${key} -> ${res.status}`) + const etag = res.headers.get('etag') + if (!etag) throw new Error(`No ETag for ${key}`) + return { text, etag } + }, + verify: async (key, file) => { + await verifyRemoteArtifact(`${base}/${bucket}/${encodeKeyPath(key)}`, creds, now, file.size, file.sha512) + }, + write: (key, text, etag) => putObject(creds, base, bucket, key, Buffer.from(text), now, + 'application/yaml', etag ? { 'If-Match': etag } : { 'If-None-Match': '*' }) + }) + console.log(`✓ r2: finalized ${tag} → ${plan.key}`) } /** Parse a ListObjectsV2 XML body into { keys, lastModified, truncated, nextToken }. */ @@ -624,7 +525,7 @@ export function staleFeedBundleKeys(keys, feedXmlByDir, lastModifiedMs = {}, cut return doomed } -async function cmdPrune({ keepDays, dryRun }) { +export async function cmdPrune({ keepDays, dryRun }) { const accountId = requiredEnv('CLOUDFLARE_R2_ACCOUNT_ID') const accessKeyId = requiredEnv('CLOUDFLARE_R2_ACCESS_KEY_ID') const secretKey = requiredEnv('CLOUDFLARE_R2_SECRET_ACCESS_KEY') @@ -654,6 +555,12 @@ async function cmdPrune({ keepDays, dryRun }) { for (const k of feedReferencedKeys(dir, xml)) protectedKeys.add(k) } + for (const key of keys.filter(key => key.startsWith('releases/darwin/') && key.endsWith('-mac.yml'))) { + const { macFeedReferences } = await import('./darwin-feed.mjs') + const text = await getObject(creds, base, bucket, key, now) + for (const reference of macFeedReferences(text)) protectedKeys.add(reference) + } + const doomed = [ ...canaryDoomedKeys(keys, cutoff), ...staleFeedBundleKeys(keys, feedXmlByDir, lastModified, cutoffMs), @@ -701,7 +608,7 @@ export async function main(argv = process.argv.slice(2)) { const args = {} for (let i = 0; i < rest.length; i++) { const flag = rest[i] - if (['--tag', '--key', '--file', '--prefix', '--keep-days', '--dir'].includes(flag)) { + if (['--tag', '--key', '--file', '--prefix', '--keep-days', '--dir', '--variant'].includes(flag)) { args[flag.slice(2)] = rest[++i] } else if (flag === '--dry-run' || flag === '--key-is-full') { args[flag.slice(2)] = true @@ -715,7 +622,7 @@ export async function main(argv = process.argv.slice(2)) { await cmdPut({ tag, key: args.key, file: args.file, keyIsFull: Boolean(args['key-is-full']) }) } else if (cmd === 'finalize') { if (!args.tag || !args.dir) usage() - await cmdFinalize({ tag: args.tag, dir: args.dir }) + await cmdFinalize({ tag: args.tag, dir: args.dir, variant: args.variant }) } else if (cmd === 'list') { await cmdList({ prefix: args.prefix ?? '' }) } else if (cmd === 'prune-canaries') { diff --git a/tests-js/darwin-feed.test.mjs b/tests-js/darwin-feed.test.mjs new file mode 100644 index 0000000000..0582d5192e --- /dev/null +++ b/tests-js/darwin-feed.test.mjs @@ -0,0 +1,148 @@ +import { createHash } from 'node:crypto' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import yaml from 'js-yaml' +import { macFeedReferences, mergeMacFeeds, parseMacFeed, publishMacFeed } from '../scripts/darwin-feed.mjs' +import { cacheControlFor, cmdFinalize, cmdPrune, cmdPut } from '../scripts/r2-release.mjs' +import feedContract from '../apps/desktop/update-feed.cjs' + +function inputs(version = '0.28.0', light = false) { + const channel = version.includes('-canary.') ? 'canary' : 'stable' + const bytes = new Map() + const legs = Object.fromEntries(['arm64', 'x64'].map((arch, i) => { + const name = `${light ? 'HermesLight' : 'HermesBundled'}-${version}-mac-${arch}.zip` + const data = Buffer.from(`test artifact ${arch}`) + bytes.set(`releases/tag/v${version}/${name}`, data) + const file = { url: name, size: data.length, sha512: createHash('sha512').update(data).digest('base64') } + return [`${arch}-${channel}-mac.yml`, yaml.dump({ version, files: [file], path: name, sha512: file.sha512, releaseDate: `2026-09-0${i + 1}T00:00:00Z`, releaseNotes: 'two lines\nof release notes' })] + })) + return { legs, bytes } +} + +afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs() }) + +function credentials() { + for (const name of ['CLOUDFLARE_R2_ACCOUNT_ID', 'CLOUDFLARE_R2_ACCESS_KEY_ID', 'CLOUDFLARE_R2_SECRET_ACCESS_KEY', 'CLOUDFLARE_R2_BUCKET']) vi.stubEnv(name, 'fixture') +} + +it('never overwrites a published macOS artifact on a same-tag rerun', async () => { + credentials() + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mac-artifact-')) + const name = 'HermesBundled-0.28.0-mac-arm64.zip' + const file = path.join(dir, name) + const bytes = Buffer.from('already published artifact') + fs.writeFileSync(file, bytes) + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + if (options.method === 'PUT') { + expect(options.headers['If-None-Match']).toBe('*') + for await (const chunk of options.body) { expect(chunk.length).toBeGreaterThan(0) } + return new Response('', { status: 412 }) + } + if (options.method === 'HEAD') return new Response(null, { headers: { 'content-length': String(bytes.length) } }) + return new Response(bytes) + })) + try { await cmdPut({ tag: 'v0.28.0', key: name, file }) } + finally { fs.rmSync(dir, { recursive: true, force: true }) } +}) + +it('the real prune command protects live macOS ZIPs and blockmaps, and fails closed', async () => { + credentials() + const plan = mergeMacFeeds(inputs('0.28.0-canary.20200101000000').legs, 'v0.28.0-canary.20200101000000') + const references = macFeedReferences(plan.text) + const stale = 'releases/tag/v0.27.0-canary.20200101000000/unreferenced.zip' + const keys = [plan.key, ...references, stale] + const listing = `${keys.map(key => `${key}`).join('')}false` + const deleted = [] + let readable = true + vi.stubGlobal('fetch', vi.fn(async (url, options) => { + if (new URL(url).search) return new Response(listing) + if (options.method === 'DELETE') { deleted.push(decodeURIComponent(new URL(url).pathname)); return new Response('') } + return readable ? new Response(plan.text) : new Response('', { status: 503 }) + })) + await cmdPrune({ keepDays: 14, dryRun: false }) + expect(deleted).toEqual([`/fixture/${stale}`]) + deleted.length = 0 + readable = false + await expect(cmdPrune({ keepDays: 14, dryRun: false })).rejects.toThrow() + expect(deleted).toEqual([]) +}) + +it('merges native legs with different signatures/dates and preserves release metadata', () => { + const { legs } = inputs() + const plan = mergeMacFeeds(legs, 'v0.28.0') + const feed = parseMacFeed(plan.text) + expect(feed.files).toHaveLength(2) + expect(feed.releaseNotes).toBe('two lines\nof release notes') + expect(plan.key).toBe('releases/darwin/stable/stable-mac.yml') + expect(macFeedReferences(plan.text)).toEqual(feed.files.flatMap(file => [file.url.slice(1), `${file.url.slice(1)}.blockmap`])) + expect(cacheControlFor(plan.key)).toBe('no-store') + const selection = feedContract.darwinFeed('canary', true) + expect(mergeMacFeeds(inputs('0.29.0-canary.20260906000000', true).legs, 'v0.29.0-canary.20260906000000', true).key) + .toBe(`${selection.directory}/${selection.fileName}`) +}) + +it.each(['missing', 'version', 'variant', 'hash', 'legacy', 'traversal'])('rejects %s instead of publishing a partial or wrong feed', kind => { + const { legs } = inputs() + const key = 'arm64-stable-mac.yml' + const feed = yaml.load(legs[key]) + if (kind === 'missing') delete legs[key] + else { + if (kind === 'version') feed.version = '0.27.0' + if (kind === 'variant') feed.files[0].url = feed.files[0].url.replace('HermesBundled', 'HermesLight') + if (kind === 'hash') feed.files[0].sha512 = 'invalid' + if (kind === 'legacy') feed.sha512 = 'wrong' + if (kind === 'traversal') feed.files[0].url = '../other.zip' + legs[key] = yaml.dump(feed) + } + expect(() => mergeMacFeeds(legs, 'v0.28.0')).toThrow() +}) + +it('verifies all artifacts before a conditional pointer write and readback', async () => { + const plan = mergeMacFeeds(inputs().legs, 'v0.28.0') + let live = { text: mergeMacFeeds(inputs('0.27.0').legs, 'v0.27.0').text, etag: 'old' } + const events = [] + await publishMacFeed(plan, { + read: async () => live, + verify: async key => { events.push(key) }, + write: async (key, text, etag) => { expect(etag).toBe('old'); events.push(key); live = { text, etag: 'new' } } + }) + expect(events.at(-1)).toBe(plan.key) + expect(events).toHaveLength(3) + const write = vi.fn() + await expect(publishMacFeed(mergeMacFeeds(inputs('0.27.0').legs, 'v0.27.0'), { read: async () => live, verify: vi.fn(), write })).rejects.toThrow('backward') + await expect(publishMacFeed(plan, { read: async () => null, verify: async () => { throw new Error('corrupt bytes') }, write })).rejects.toThrow('corrupt bytes') + expect(write).not.toHaveBeenCalled() +}) + +it('finalize uses the real signed transport, validates streamed hashes and publishes last', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mac-feed-')) + const { legs, bytes } = inputs() + for (const [name, text] of Object.entries(legs)) fs.writeFileSync(path.join(dir, name), text) + for (const [key, value] of Object.entries({ CLOUDFLARE_R2_ACCOUNT_ID: 'fixture', CLOUDFLARE_R2_ACCESS_KEY_ID: 'fixture', CLOUDFLARE_R2_SECRET_ACCESS_KEY: 'fixture', CLOUDFLARE_R2_BUCKET: 'bucket' })) vi.stubEnv(key, value) + const calls = [] + let published + vi.stubGlobal('fetch', vi.fn(async (url, options) => { + const key = decodeURIComponent(new URL(url).pathname).replace('/bucket/', '') + const method = options.method || 'GET' + calls.push([method, key]) + expect(options.headers.authorization).toContain('AWS4-HMAC-SHA256') + if (method === 'PUT') { + expect(options.headers['If-None-Match']).toBe('*') + expect(options.headers['Cache-Control']).toBe('no-store') + published = options.body.toString() + return new Response('', { status: 200 }) + } + if (method === 'HEAD') return new Response(null, { headers: { 'content-length': Buffer.byteLength(published).toString() } }) + if (key.endsWith('-mac.yml')) return published ? new Response(published, { headers: { etag: 'new' } }) : new Response('', { status: 404 }) + if (bytes.has(key)) return new Response(bytes.get(key)) + throw new Error(`unexpected request ${key}`) + })) + try { + await cmdFinalize({ tag: 'v0.28.0', dir }) + expect(parseMacFeed(published).files).toHaveLength(2) + expect(calls.filter(([method]) => method === 'PUT')).toHaveLength(1) + expect(calls.slice(0, 3).every(([method]) => method === 'GET')).toBe(true) + } finally { fs.rmSync(dir, { recursive: true, force: true }) } +}) diff --git a/tests-js/macos-publish-config.test.mjs b/tests-js/macos-publish-config.test.mjs new file mode 100644 index 0000000000..b546377e14 --- /dev/null +++ b/tests-js/macos-publish-config.test.mjs @@ -0,0 +1,24 @@ +import { execFileSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { expect, it } from 'vitest' +import feedContract from '../apps/desktop/update-feed.cjs' + +const root = fileURLToPath(new URL('../', import.meta.url)) + +it.each([ + ['bundled', 'v0.28.0', 'stable', false], + ['bundled', 'v0.29.0-canary.20260906000000', 'canary', false], + ['light', 'v0.28.0', 'stable', true], + ['light', 'v0.29.0-canary.20260906000000', 'canary', true] +])('packaging and runtime agree for %s %s', (variant, tag, channel, light) => { + const result = execFileSync(process.execPath, ['-e', "const c=require('./apps/desktop/electron-builder.config.cjs'); console.log(JSON.stringify({publish:c.mac.publish,notarize:c.mac.notarize,targets:c.mac.target}))"], { + cwd: root, + encoding: 'utf8', + env: { ...process.env, HERMES_DESKTOP_VARIANT: variant, HERMES_PAYLOAD_TAG: tag, CLOUDFLARE_R2_PUBLIC_URL: 'https://updates.example' } + }) + const config = JSON.parse(result) + const feed = feedContract.darwinFeed(channel, light) + expect(config.publish).toEqual([{ provider: 'generic', url: `https://updates.example/${feed.directory}/`, channel: feed.channel }]) + expect(config.targets).toContain('zip') + expect(config.notarize).toBe(false) +}) diff --git a/tests-js/r2-release.test.mjs b/tests-js/r2-release.test.mjs index 9e72762a88..3b1d49cc1a 100644 --- a/tests-js/r2-release.test.mjs +++ b/tests-js/r2-release.test.mjs @@ -28,12 +28,10 @@ import { encodeKeyPath, feedDirFor, feedReferencedKeys, - mergeFeedYmls, canaryDoomedKeys, publishFeedUploads, referencedFeedBundleFilenames, staleFeedBundleKeys, - rewriteFeedPaths, stagingKeyFor, parseListXml, rfc3986Encode, @@ -231,63 +229,6 @@ test('canonicalRequest reads mixed-case header values (Content-Type)', () => { assert.ok(canon.includes('content-type;host;x-amz-content-sha256;x-amz-date')) }) -test('rewriteFeedPaths rewrites path:/url: to absolute /releases/tag keys, idempotent', () => { - const absKey = (f) => `/releases/tag/v0.28.0/${f}` - const yml = `version: 0.28.0 -files: - - url: HermesBundled-0.28.0-mac-x64.zip - sha512: abc - size: 1 - - url: HermesBundled-0.28.0-mac-x64.dmg - sha512: def - size: 2 -path: HermesBundled-0.28.0-mac-x64.zip -sha512: ghi -releaseDate: '2026-08-18T00:00:00.000Z' -` - const once = rewriteFeedPaths(yml, absKey) - assert.ok(once.includes('url: /releases/tag/v0.28.0/HermesBundled-0.28.0-mac-x64.zip')) - assert.ok(once.includes('url: /releases/tag/v0.28.0/HermesBundled-0.28.0-mac-x64.dmg')) - assert.ok(once.includes('path: /releases/tag/v0.28.0/HermesBundled-0.28.0-mac-x64.zip')) - assert.ok(once.includes('sha512: abc')) // artifact hashes untouched - // Already-absolute values are left alone (a re-finalize must not double-prefix). - assert.equal(rewriteFeedPaths(once, absKey), once) -}) - -test('mergeFeedYmls concatenates files[] lists, dedupes, keeps head', () => { - const x64 = `version: 0.28.0 -files: - - url: HermesBundled-0.28.0-mac-x64.zip - sha512: abc - size: 1 - - url: HermesBundled-0.28.0-mac-x64.dmg - sha512: def - size: 2 -path: HermesBundled-0.28.0-mac-x64.zip -sha512: ghi -releaseDate: '2026-08-18T00:00:00.000Z' -` - const arm64 = `version: 0.28.0 -files: - - url: HermesBundled-0.28.0-mac-arm64.zip - sha512: jkl - size: 3 - - url: HermesBundled-0.28.0-mac-arm64.dmg - sha512: mno - size: 4 -path: HermesBundled-0.28.0-mac-arm64.zip -sha512: pqr -releaseDate: '2026-08-18T00:00:00.000Z' -` - const merged = mergeFeedYmls([x64, arm64]) - assert.ok(merged.includes('url: HermesBundled-0.28.0-mac-x64.zip')) - assert.ok(merged.includes('url: HermesBundled-0.28.0-mac-arm64.zip')) - assert.ok(merged.includes('releaseDate')) - // Idempotent: merging the merged output adds nothing new. - assert.equal(mergeFeedYmls([merged, arm64]), merged) -}) - - // ── C22: artifact first, feed pointer last ────────────────────────────────── test('publishFeedUploads uploads the msixbundle BEFORE the .appinstaller pointer', () => {