diff --git a/.github/actions/e2e-screen-record/action.yml b/.github/actions/e2e-screen-record/action.yml index cf4eba87e7..bca9b4e24c 100644 --- a/.github/actions/e2e-screen-record/action.yml +++ b/.github/actions/e2e-screen-record/action.yml @@ -1,11 +1,13 @@ name: E2E screen recording description: > One screen-recording mechanism for every install-e2e runner. mode=start - installs what the OS needs (ffmpeg everywhere; Xvfb on headless linux), - starts the recorder, and exports DISPLAY for later steps. mode=stop - finalizes the recording and fails on a zero-frame file. A missing ffmpeg - is a hard error - a graceful skip makes the missing tool invisible and - the artifact silently loses its recording. + installs what the OS needs (Xvfb on headless linux), starts the recorder, + and exports DISPLAY for later steps. mode=stop finalizes the recording and + fails on a zero-frame file. A missing ffmpeg is a hard error - a graceful + skip makes the missing tool invisible and the artifact silently loses its + recording. ffmpeg itself comes from the PM toolchain (locked, sha256-pinned, + native per-OS incl. win32-arm64) - the caller must run actions/setup-pm with + `packages: ffmpeg` first. inputs: mode: @@ -19,15 +21,29 @@ runs: using: composite steps: # ---- setup + start ------------------------------------------------------ - - name: Install ffmpeg + Xvfb (linux) + - name: Install Xvfb (linux, headless) if: inputs.mode == 'start' && runner.os == 'Linux' shell: bash run: | set -euo pipefail - if ! command -v ffmpeg >/dev/null 2>&1 || ! command -v Xvfb >/dev/null 2>&1; then + command -v Xvfb >/dev/null 2>&1 || { sudo apt-get update -qq - sudo apt-get install -y -qq --no-install-recommends ffmpeg xvfb - fi + sudo apt-get install -y -qq --no-install-recommends xvfb + } + + - name: Verify ffmpeg from the PM toolchain + if: inputs.mode == 'start' + shell: bash + run: | + set -euo pipefail + # PM's ffmpeg rides GITHUB_PATH from actions/setup-pm (packages: + # ffmpeg). Do not trust "preinstalled" claims - verify, fail loud. + # A graceful skip makes the missing tool invisible and the artifact + # silently loses its recording. + command -v ffmpeg >/dev/null 2>&1 || { + echo "::error::ffmpeg not on PATH - run actions/setup-pm with packages: ffmpeg before this step" >&2 + exit 1 + } - name: Start Xvfb (linux, headless) if: inputs.mode == 'start' && runner.os == 'Linux' @@ -50,14 +66,11 @@ runs: done [ -S "/tmp/.X11-unix/X99" ] || { echo "Xvfb :99 did not come up" >&2; exit 1; } - - name: Verify ffmpeg (macos) + - name: Prepare macOS screen-capture approvals if: inputs.mode == 'start' && runner.os == 'macOS' shell: bash run: | set -euo pipefail - # Do not trust "preinstalled" claims - verify, install on miss. - command -v ffmpeg >/dev/null 2>&1 || brew install --quiet ffmpeg - # until new runner image is published by github, we have to hack on screen record approvals # see https://github.com/actions/runner-images/issues/14474 - as of this hermes agent commit, # it's merged but the image isn't updated. @@ -66,28 +79,6 @@ runs: defaults write "$approvalsPlist" "/opt/hca/hosted-compute-agent" -date "3024-01-01 00:00:00 +0000" killall cfprefsd 2>/dev/null || true - - name: Restore cached ffmpeg (windows) - if: inputs.mode == 'start' && runner.os == 'Windows' - id: ffmpeg-cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 - with: - path: ${{ runner.temp }}\test-bins\ffmpeg - key: e2e-ffmpeg-${{ runner.os }}-v1 - - - name: Install ffmpeg (windows) - if: inputs.mode == 'start' && runner.os == 'Windows' && steps.ffmpeg-cache.outputs.cache-hit != 'true' - shell: pwsh - run: | - $bins = "$env:RUNNER_TEMP\test-bins\ffmpeg" - New-Item -ItemType Directory -Path $bins -Force | Out-Null - winget install -e --id Gyan.FFmpeg --silent --accept-source-agreements --accept-package-agreements --disable-interactivity --location "$env:RUNNER_TEMP\ffmpeg_dir" - Copy-Item -Path "$env:RUNNER_TEMP\ffmpeg_dir\*\*" -Destination $bins -Recurse -Force - - - name: Add ffmpeg to PATH (windows) - if: inputs.mode == 'start' && runner.os == 'Windows' - shell: pwsh - run: Add-Content -Path $env:GITHUB_PATH -Value "$env:RUNNER_TEMP\test-bins\ffmpeg\bin" - - name: Start recording (posix) if: inputs.mode == 'start' && runner.os != 'Windows' shell: bash diff --git a/.github/actions/setup-pm/action.yml b/.github/actions/setup-pm/action.yml index 9fcc17c7c2..4d27818cf1 100644 --- a/.github/actions/setup-pm/action.yml +++ b/.github/actions/setup-pm/action.yml @@ -4,6 +4,9 @@ inputs: toolchain: description: python (Python and uv), node (Node and npm), or all. default: python + packages: + description: Comma-separated extra PM tools to install beyond the toolchain roots (e.g. ffmpeg). Rides the tools cache like npm/uv. + default: '' archive-inputs: description: Use protected R2 credentials to preserve and retrieve the selected toolchain before installation. default: 'false' @@ -13,6 +16,9 @@ inputs: cache: description: Cache the verified PM tool store. default: 'true' + save-tools-cache: + description: Save the verified PM tools on success. Set false for restore-only jobs. + default: 'true' cache-python: description: Cache uv downloads and built wheels; never cache the dependency environment. default: 'true' @@ -63,7 +69,7 @@ outputs: value: ${{ steps.prepare.outputs.target }} tools-cache-hit: description: Exact PM store cache hit. - value: ${{ steps.tools-cache.outputs.cache-hit }} + value: ${{ steps.tools-cache.outputs.cache-hit || steps.tools-cache-restore.outputs.cache-hit }} python-cache-hit: description: Exact uv dependency cache hit. value: ${{ steps.python-cache.outputs.cache-hit || steps.python-cache-restore.outputs.cache-hit }} @@ -92,21 +98,30 @@ runs: _PM_ACTION: ${{ github.action_path }} _PM_TOOLCHAIN: ${{ inputs.toolchain }} _PM_EXTRAS: ${{ inputs.extras }} + _PM_PACKAGES: ${{ inputs.packages }} run: | set -euo pipefail # Windows provides python, not necessarily python3. The host resolver # in PM still selects ARM64 when this bootstrap Python runs under x64. if [ "$RUNNER_OS" = Windows ]; then bootstrap=python; else bootstrap=python3; fi "$bootstrap" -S "$_PM_ACTION/../../../scripts/ci/setup_toolchain.py" prepare \ - --toolchain "$_PM_TOOLCHAIN" --extras "$_PM_EXTRAS" --home "$RUNNER_TEMP/setup-pm" + --toolchain "$_PM_TOOLCHAIN" --extras "$_PM_EXTRAS" --packages "$_PM_PACKAGES" --home "$RUNNER_TEMP/setup-pm" - name: Cache verified PM tools id: tools-cache - if: inputs.cache == 'true' + if: inputs.cache == 'true' && inputs.save-tools-cache == 'true' uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.prepare.outputs.store }} - key: setup-pm-tools-v1-${{ steps.prepare.outputs.target }}-${{ inputs.toolchain }}-${{ hashFiles('pm/lock.json') }}-${{ inputs.cache-suffix }} + key: setup-pm-tools-v1-${{ steps.prepare.outputs.target }}-${{ inputs.toolchain }}${{ steps.prepare.outputs.extra-packages }}-${{ hashFiles('pm/lock.json') }}-${{ inputs.cache-suffix }} + + - name: Restore verified PM tools without saving + id: tools-cache-restore + if: inputs.cache == 'true' && inputs.save-tools-cache == 'false' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ steps.prepare.outputs.store }} + key: setup-pm-tools-v1-${{ steps.prepare.outputs.target }}-${{ inputs.toolchain }}${{ steps.prepare.outputs.extra-packages }}-${{ hashFiles('pm/lock.json') }}-${{ inputs.cache-suffix }} - name: Preserve and retrieve the pinned toolchain if: inputs.archive-inputs == 'true' @@ -115,9 +130,10 @@ runs: _PM_ACTION: ${{ github.action_path }} _PM_BOOTSTRAP: ${{ steps.prepare.outputs.bootstrap-python }} _PM_TOOLCHAIN: ${{ inputs.toolchain }} + _PM_PACKAGES: ${{ inputs.packages }} run: | "$_PM_BOOTSTRAP" -S "$_PM_ACTION/../../../scripts/ci/setup_toolchain.py" archive-inputs \ - --toolchain "$_PM_TOOLCHAIN" --home "$HERMES_HOME" + --toolchain "$_PM_TOOLCHAIN" --packages "$_PM_PACKAGES" --home "$HERMES_HOME" - name: Install and verify tools through PM id: install @@ -126,9 +142,10 @@ runs: _PM_ACTION: ${{ github.action_path }} _PM_BOOTSTRAP: ${{ steps.prepare.outputs.bootstrap-python }} _PM_TOOLCHAIN: ${{ inputs.toolchain }} + _PM_PACKAGES: ${{ inputs.packages }} run: | "$_PM_BOOTSTRAP" -S "$_PM_ACTION/../../../scripts/ci/setup_toolchain.py" install \ - --toolchain "$_PM_TOOLCHAIN" --home "$HERMES_HOME" + --toolchain "$_PM_TOOLCHAIN" --packages "$_PM_PACKAGES" --home "$HERMES_HOME" - name: Cache uv dependency downloads and builds id: python-cache diff --git a/.github/workflows/desktop-bundle-smoke.yml b/.github/workflows/desktop-bundle-smoke.yml index d622f17f5d..04735cefd8 100644 --- a/.github/workflows/desktop-bundle-smoke.yml +++ b/.github/workflows/desktop-bundle-smoke.yml @@ -94,6 +94,7 @@ jobs: uses: ./.github/actions/setup-pm with: toolchain: all + packages: ffmpeg cache: false cache-python: false cache-node: false @@ -101,7 +102,7 @@ jobs: - name: Install locked chat driver dependencies id: dependencies shell: bash - run: npm ci --workspace apps/desktop --include-workspace-root --include=dev --ignore-scripts --no-audit --no-fund + run: npm ci --workspace tests-js --include-workspace-root --omit=dev --ignore-scripts --no-audit --no-fund - name: Fetch one exact downloadable artifact id: artifact diff --git a/.github/workflows/desktop-bundled-release.yml b/.github/workflows/desktop-bundled-release.yml index 1e4c1ad4de..7c156322a0 100644 --- a/.github/workflows/desktop-bundled-release.yml +++ b/.github/workflows/desktop-bundled-release.yml @@ -462,11 +462,12 @@ jobs: producer: desktop key: ${{ steps.build-cache.outputs.cache-key }} - - name: Cache verified payload signatures - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + - name: Restore verified payload signatures + id: payload-signatures + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ github.workspace }}/.cache/electron-builder-payload-signatures - # Each run saves additions. Content and signing policy select entries. + # Release runs save additions. Content and signing policy select entries. key: payload-signatures-v1-${{ runner.os }}-${{ matrix.target.label }}-${{ github.run_id }}-${{ github.run_attempt }} restore-keys: | payload-signatures-v1-${{ runner.os }}-${{ matrix.target.label }}- @@ -520,6 +521,14 @@ jobs: working-directory: apps/desktop run: node ../../node_modules/vitest/vitest.mjs run --project electron scripts/payload-sign-cache.test.mjs scripts/batch-sign-binaries.test.mjs + # Commit jobs share these steps but have read-only cache tokens. + - name: Save verified payload signatures + if: inputs.build_commit == '' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ github.workspace }}/.cache/electron-builder-payload-signatures + key: ${{ steps.payload-signatures.outputs.cache-primary-key }} + - name: Audit bundle architecture shell: bash run: | @@ -1061,6 +1070,8 @@ jobs: toolchain: all archive-inputs: ${{ inputs.disposable_run == '' }} cache-python: false + save-tools-cache: false + save-node-cache: false - name: Install the locked Windows bundle tooling uses: ./.github/actions/retry @@ -1093,8 +1104,8 @@ jobs: *) echo "ELECTRON_DEFAULT_CACHE=$HOME/.cache/electron" >> "$GITHUB_ENV" ;; esac - - name: Cache electron + electron-builder toolchain - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + - name: Restore electron + electron-builder toolchain + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ${{ github.workspace }}/.cache/electron-builder diff --git a/.github/workflows/install-e2e-macos-run.yml b/.github/workflows/install-e2e-macos-run.yml index b2f00caa4d..0f4210d413 100644 --- a/.github/workflows/install-e2e-macos-run.yml +++ b/.github/workflows/install-e2e-macos-run.yml @@ -112,13 +112,14 @@ jobs: uses: ./.github/actions/setup-pm with: toolchain: all + packages: ffmpeg cache: false cache-python: false cache-node: false - name: Install locked chat driver dependencies shell: bash - run: npm ci --workspace apps/desktop --include-workspace-root --include=dev --ignore-scripts --no-audit --no-fund + run: npm ci --workspace tests-js --include-workspace-root --omit=dev --ignore-scripts --no-audit --no-fund - name: Start screen recording uses: ./.github/actions/e2e-screen-record @@ -208,13 +209,14 @@ jobs: uses: ./.github/actions/setup-pm with: toolchain: all + packages: ffmpeg cache: false cache-python: false cache-node: false - name: Install locked chat driver dependencies shell: bash - run: npm ci --workspace apps/desktop --include-workspace-root --include=dev --ignore-scripts --no-audit --no-fund + run: npm ci --workspace tests-js --include-workspace-root --omit=dev --ignore-scripts --no-audit --no-fund - name: Start screen recording uses: ./.github/actions/e2e-screen-record diff --git a/.github/workflows/install-e2e-run.yml b/.github/workflows/install-e2e-run.yml index 505b4be0e3..a5b517a87f 100644 --- a/.github/workflows/install-e2e-run.yml +++ b/.github/workflows/install-e2e-run.yml @@ -102,13 +102,14 @@ jobs: uses: ./.github/actions/setup-pm with: toolchain: all + packages: ffmpeg cache: false cache-python: false cache-node: false - name: Install locked chat driver dependencies shell: bash - run: npm ci --workspace apps/desktop --include-workspace-root --include=dev --ignore-scripts --no-audit --no-fund + run: npm ci --workspace tests-js --include-workspace-root --omit=dev --ignore-scripts --no-audit --no-fund # One recording mechanism on every OS (Xvfb gives headless linux a # display; the same display serves any app the driver launches). diff --git a/.github/workflows/install-e2e-windows-run.yml b/.github/workflows/install-e2e-windows-run.yml index 1f3c807e13..0e8672d1f2 100644 --- a/.github/workflows/install-e2e-windows-run.yml +++ b/.github/workflows/install-e2e-windows-run.yml @@ -105,12 +105,13 @@ jobs: uses: ./.github/actions/setup-pm with: toolchain: all + packages: ffmpeg cache: false cache-python: false cache-node: false - name: Install locked chat driver dependencies shell: bash - run: npm ci --workspace apps/desktop --include-workspace-root --include=dev --ignore-scripts --no-audit --no-fund + run: npm ci --workspace tests-js --include-workspace-root --omit=dev --ignore-scripts --no-audit --no-fund - name: Verify native helpers shell: pwsh run: | @@ -188,16 +189,17 @@ jobs: uses: ./.github/actions/setup-pm with: toolchain: all + packages: ffmpeg cache: false cache-python: false cache-node: false - name: Install locked chat driver dependencies shell: bash - run: npm ci --workspace apps/desktop --include-workspace-root --include=dev --ignore-scripts --no-audit --no-fund + run: npm ci --workspace tests-js --include-workspace-root --omit=dev --ignore-scripts --no-audit --no-fund - # One recording mechanism on every OS: the composite action installs - # ffmpeg (cached - winget's download is the slow part), starts the + # One recording mechanism on every OS: the composite action verifies + # ffmpeg from the PM toolchain (packages: ffmpeg above), starts the # capture, and record-stop fails on a zero-frame file so a silently # missing recording cannot go green. - name: Start screen recording diff --git a/apps/desktop/electron/guest-onboarding.ts b/apps/desktop/electron/guest-onboarding.ts index ae399eb280..997604a59e 100644 --- a/apps/desktop/electron/guest-onboarding.ts +++ b/apps/desktop/electron/guest-onboarding.ts @@ -8,7 +8,8 @@ export const GUEST_ONBOARDING_ENV = 'HERMES_GUEST_ONBOARDING' export const GUEST_ONBOARDING_FLAG = '--guest-onboarding' // Skip the first-run film. A rehearsal aid: the intro is a one-time reveal, // so anyone iterating on the guided chat behind it otherwise sits through it -// on every fresh HERMES_HOME. Renderer-only; the backend never sees it. +// on every fresh HERMES_HOME. The guide still runs — only the film is +// skipped. Renderer-only; the backend never sees it. export const SKIP_INTRO_ENV = 'HERMES_SKIP_INTRO' export const SKIP_INTRO_FLAG = '--skip-intro' diff --git a/apps/desktop/src/components/intro-reveal/README.md b/apps/desktop/src/components/intro-reveal/README.md index 87970ecc11..f373bda644 100644 --- a/apps/desktop/src/components/intro-reveal/README.md +++ b/apps/desktop/src/components/intro-reveal/README.md @@ -10,6 +10,8 @@ Eligibility is `guestOnboardingEnabled && !firstRunSkipped && !hasSeenIntroRevea Electron sets the flag from `HERMES_GUEST_ONBOARDING=1` or `--guest-onboarding`. The gate queues the guided chat on completion; the chat gate acknowledges the free-tier notice as the cinematic starts. With the flag off, neither gate starts. +`HERMES_SKIP_INTRO=1` turns the film off but keeps the guided chat: the gate +records the film as watched and queues the guide directly. | Piece | Path | | --- | --- | diff --git a/apps/desktop/src/components/intro-reveal/index.tsx b/apps/desktop/src/components/intro-reveal/index.tsx index e6b3b61e1f..432d22571c 100644 --- a/apps/desktop/src/components/intro-reveal/index.tsx +++ b/apps/desktop/src/components/intro-reveal/index.tsx @@ -7,12 +7,13 @@ import { finishIntroReveal, installIntroRevealBridgeListeners, isIntroRevealEnabled, + isIntroRevealSkipped, leaveIntroReveal, shouldPlayFirstRunIntro, startIntroReveal } from '@/store/intro-reveal' import { $desktopOnboarding } from '@/store/onboarding' -import { beginOnboardingFlow, queueGuideAfterIntro } from '@/store/onboarding-gate' +import { beginOnboardingFlow, beginOnboardingFlowWithoutIntro, queueGuideAfterIntro } from '@/store/onboarding-gate' import { INTRO_DEADMAN_MS, INTRO_EXIT_MS } from './timeline' @@ -48,7 +49,23 @@ export function IntroRevealGate({ enabled }: IntroRevealGateProps) { }, [enabled]) useEffect(() => { - if (enabled && intro.phase === 'hidden' && shouldPlayFirstRunIntro(onboarding.firstRunSkipped)) { + if (!enabled) { + return + } + + // skipIntro turns the film off; the guided chat behind it must still run. + // Take the guide's shape on this tick, exactly like the film's completion + // edge, so no full-size shell paints while the guide session comes up. + if (isIntroRevealSkipped()) { + if (intro.phase === 'hidden') { + beginOnboardingFlowWithoutIntro(onboarding.firstRunSkipped) + takeGuideShape() + } + + return + } + + if (intro.phase === 'hidden' && shouldPlayFirstRunIntro(onboarding.firstRunSkipped)) { beginOnboardingFlow() startIntroReveal() } diff --git a/apps/desktop/src/store/intro-reveal.ts b/apps/desktop/src/store/intro-reveal.ts index 5d073e14a7..026af1a12d 100644 --- a/apps/desktop/src/store/intro-reveal.ts +++ b/apps/desktop/src/store/intro-reveal.ts @@ -31,10 +31,24 @@ export function hasSeenIntroReveal(): boolean { return readKey(SEEN_KEY) === '1' } +/** The film will never play for this identity (HERMES_SKIP_INTRO). Record it + * as seen so a later launch without the flag cannot replay it over the + * guided flow that ran in its place. */ +export function markIntroRevealSeen(): void { + writeKey(SEEN_KEY, '1') +} + export function isIntroRevealEnabled(): boolean { return isOnboardingEnabled() && window.hermesDesktop?.skipIntro !== true } +/** The guided flow is on but its intro film was turned off at launch + * (HERMES_SKIP_INTRO). Distinct from isIntroRevealEnabled(): false there also + * covers onboarding being off entirely, where nothing may queue. */ +export function isIntroRevealSkipped(): boolean { + return isOnboardingEnabled() && window.hermesDesktop?.skipIntro === true +} + export function shouldPlayFirstRunIntro(firstRunSkipped: boolean): boolean { return isIntroRevealEnabled() && !firstRunSkipped && !hasSeenIntroReveal() } diff --git a/apps/desktop/src/store/onboarding-gate.test.ts b/apps/desktop/src/store/onboarding-gate.test.ts index e928cdd788..9ad83bb01d 100644 --- a/apps/desktop/src/store/onboarding-gate.test.ts +++ b/apps/desktop/src/store/onboarding-gate.test.ts @@ -35,3 +35,40 @@ it('restores every persisted onboarding phase and rejects unknown phases', async expect($onboardingGate.get().phase).toBe('idle') }) + +it('a skipped intro still queues the guided flow, and never replays the film', async () => { + const { beginOnboardingFlowWithoutIntro } = await import('./onboarding-gate') + const { hasSeenIntroReveal } = await import('./intro-reveal') + + storage.clear() + beginOnboardingFlowWithoutIntro(false) + const { $onboardingGate } = await import('./onboarding-gate') + + expect($onboardingGate.get()).toEqual({ phase: 'cinematic', guideQueued: true }) + // The film is recorded as watched: a later launch without HERMES_SKIP_INTRO + // must adopt the persisted guide, not play the film over it. + expect(hasSeenIntroReveal()).toBe(true) + + // A relaunch boots loadGate() over the same persisted state and re-queues the guide. + vi.resetModules() + const { $onboardingGate: relaunched } = await import('./onboarding-gate') + + expect(relaunched.get().guideQueued).toBe(true) + expect(relaunched.get().phase).toBe('cinematic') +}) + +it('a skipped intro does nothing when onboarding is off or the first run was skipped', async () => { + vi.doMock('@/lib/onboarding-enabled', () => ({ isOnboardingEnabled: () => false })) + vi.resetModules() + const off = await import('./onboarding-gate') + + off.beginOnboardingFlowWithoutIntro(false) + expect(off.$onboardingGate.get().phase).toBe('idle') + + vi.doUnmock('@/lib/onboarding-enabled') + vi.resetModules() + const on = await import('./onboarding-gate') + + on.beginOnboardingFlowWithoutIntro(true) + expect(on.$onboardingGate.get().phase).toBe('idle') +}) diff --git a/apps/desktop/src/store/onboarding-gate.ts b/apps/desktop/src/store/onboarding-gate.ts index 1ee032ff49..f90d655bec 100644 --- a/apps/desktop/src/store/onboarding-gate.ts +++ b/apps/desktop/src/store/onboarding-gate.ts @@ -3,7 +3,7 @@ import { atom } from 'nanostores' import { isOnboardingEnabled } from '@/lib/onboarding-enabled' import { readKey, writeKey } from '@/lib/storage' -import { hasSeenIntroReveal } from './intro-reveal' +import { hasSeenIntroReveal, markIntroRevealSeen } from './intro-reveal' import { DEFAULT_ANSWERS, setOnboardingAnswers } from './onboarding-answers' const PHASE_KEY = 'hermes-onboarding-phase-v1' @@ -62,6 +62,28 @@ export function beginOnboardingFlow(): void { } } +/** The guided first launch without its intro film (HERMES_SKIP_INTRO). Same + * eligibility as the film path minus the film itself: the film is recorded as + * watched and the film-to-guide seam fires immediately, instead of waiting + * for a completion that never comes. */ +export function beginOnboardingFlowWithoutIntro(firstRunSkipped: boolean): void { + if (!isOnboardingEnabled() || firstRunSkipped) { + return + } + + beginOnboardingFlow() + + // A prior launch quit mid-film and left the phase at cinematic; the guide + // is owed directly. Everything else (guided/skipped/handoff/done) already + // had its turn and must not re-queue. + if ($onboardingGate.get().phase !== 'cinematic') { + return + } + + markIntroRevealSeen() + queueGuideAfterIntro() +} + export function queueGuideAfterIntro(): void { const state = $onboardingGate.get() diff --git a/package-lock.json b/package-lock.json index 35399cc3d8..c7b1c160ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3424,7 +3424,6 @@ "version": "1.62.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", - "dev": true, "license": "Apache-2.0", "dependencies": { "playwright": "1.62.1" @@ -11022,7 +11021,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -15313,7 +15311,6 @@ "version": "1.62.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", - "dev": true, "license": "Apache-2.0", "dependencies": { "playwright-core": "1.62.1" @@ -15332,7 +15329,6 @@ "version": "1.62.1", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", - "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -19210,6 +19206,10 @@ }, "tests-js": { "name": "@hermes/root-tests", + "dependencies": { + "@playwright/test": "1.62.1", + "zod": "4.4.3" + }, "devDependencies": { "@types/plist": "3.0.5", "plist": "3.1.1", diff --git a/scripts/build/mint_launchers.py b/scripts/build/mint_launchers.py index c195b53f54..b8d59cceb7 100644 --- a/scripts/build/mint_launchers.py +++ b/scripts/build/mint_launchers.py @@ -1,4 +1,4 @@ -"""Mint the bundled payload's win32 CLI launchers with distlib. +r"""Mint the bundled payload's win32 CLI launchers with distlib. Run by scripts/bundles/desktop.py (step 5b) with the payload's OWN store python as the minting interpreter — the store python is the same diff --git a/scripts/ci/setup_toolchain.py b/scripts/ci/setup_toolchain.py index 8559835adb..4ee73e5b6d 100644 --- a/scripts/ci/setup_toolchain.py +++ b/scripts/ci/setup_toolchain.py @@ -21,8 +21,20 @@ from pm.store import current_target _ROOTS = {"python": ["uv"], "node": ["npm"], "all": ["npm", "uv"]} -def packages(toolchain: str) -> list[str]: - return sorted(package.name for package in walk(_ROOTS[toolchain])) +def parse_package_list(value: str) -> list[str]: + """Comma-separated extra PM tools (e.g. `ffmpeg`) beyond the toolchain roots.""" + if value == "": + return [] + for name in value.split(","): + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", name.strip()): + raise argparse.ArgumentTypeError(f"invalid pm tool name: {name!r}") + return sorted({name.strip() for name in value.split(",")}) + + +def packages(toolchain: str, extra: list[str] | None = None) -> list[str]: + # walk() resolves the deps-first closure, so an extra tool's dependencies + # (ffmpeg -> none) come along without listing them. + return sorted(package.name for package in walk(_ROOTS[toolchain] + (extra or []))) def file_commands(destination: str, values: dict) -> None: @@ -57,7 +69,7 @@ def prepare(args) -> None: home = args.home.resolve() lock = Lockfile(lockfile_path()) target = current_target() - names = packages(args.toolchain) + names = packages(args.toolchain, args.packages) values = { "packages": json.dumps(names), "target": target, "arch": target.split("-")[1], "store": str(home / "tools"), @@ -67,6 +79,9 @@ def prepare(args) -> None: if not version or not lock.artifacts(name, target): raise ValueError(f"{name} has no pinned artifact for {target}") values[f"{name}-version"] = version.partition("+")[0] if name == "python" else version + # Cache-key fragment for the tools cache: empty when no extras, so every + # existing key stays byte-identical. + values["extra-packages"] = "".join(f"-{name}" for name in (args.packages or [])) # The OS image belongs in the uv cache identity: built wheels can link # against its system libraries. Unlike npm's cache, these are not just JS. values["os-version"] = platform.platform() @@ -104,7 +119,7 @@ def archive_inputs(args) -> None: from scripts.ci.archive_inputs import Archive, pinned_inputs, stage_inputs from scripts.releases import r2 - pins = pinned_inputs(repo_root(), target=current_target(), packages=set(packages(args.toolchain))) + pins = pinned_inputs(repo_root(), target=current_target(), packages=set(packages(args.toolchain, args.packages))) stage_inputs(pins, archive=Archive(*r2.credentials()), store=Store(args.home.resolve() / "tools")) @@ -120,8 +135,8 @@ def install(args) -> None: from pm.paths import facts_path, store_root from pm.registry import get_package - names = packages(args.toolchain) - for name in _ROOTS[args.toolchain]: + names = packages(args.toolchain, args.packages) + for name in _ROOTS[args.toolchain] + args.packages: ensure(name, explicit=True, progress=_live_progress(name)) facts = Facts(facts_path()) target = current_target() @@ -206,6 +221,7 @@ def main() -> None: parser.add_argument("--toolchain", choices=["python", "node", "all"], default="python") parser.add_argument("--home", type=Path, required=True) parser.add_argument("--extras", type=parse_extras, default="") + parser.add_argument("--packages", type=parse_package_list, default=[], help="extra PM tools beyond the toolchain roots (e.g. ffmpeg)") args = parser.parse_args() if args.toolchain == "node" and args.extras is not None: parser.error("extras require the python or all toolchain") diff --git a/scripts/releases/commit_build.py b/scripts/releases/commit_build.py index 279c5c5355..71c1efb3e2 100644 --- a/scripts/releases/commit_build.py +++ b/scripts/releases/commit_build.py @@ -120,7 +120,10 @@ def cmd_build_commit(args) -> None: encoding="utf-8", errors="replace", check=True, timeout=60) print((result.stdout or "").strip() or f"Dispatched commit build {commit}. No release was created.") except (OSError, ValueError, subprocess.SubprocessError) as exc: - raise SystemExit(f"release: commit build refused: {exc}") from exc + stderr = "" + if isinstance(exc, subprocess.CalledProcessError): + stderr = "\n" + exc.stderr + raise SystemExit(f"release: commit build refused: {exc}{stderr}") from exc def main() -> None: diff --git a/tests-js/package.json b/tests-js/package.json index 06d833ebb5..34dafd459d 100644 --- a/tests-js/package.json +++ b/tests-js/package.json @@ -15,5 +15,9 @@ "plist": "3.1.1", "typescript": "6.0.3", "vitest": "4.1.10" + }, + "dependencies": { + "@playwright/test": "1.62.1", + "zod": "4.4.3" } } diff --git a/tests/ci/test_desktop_bundle_smoke.py b/tests/ci/test_desktop_bundle_smoke.py index 7db818b3c4..597b3276b0 100644 --- a/tests/ci/test_desktop_bundle_smoke.py +++ b/tests/ci/test_desktop_bundle_smoke.py @@ -115,6 +115,32 @@ def test_native_consumers_and_publication_fail_closed_across_trust_skips(tmp_pat assert (result.returncode == 0) is expected +def test_signature_cache_saves_only_in_the_writable_build(): + jobs = _workflow()['jobs'] + for branch, commit in [('release', ''), ('commit', SHA)]: + job = jobs[f'build-win32-{branch}'] + steps = job['steps'] + cache_steps = [step for step in steps + if 'payload-signatures' in step.get('with', {}).get('path', '')] + assert all(not step['uses'].startswith('actions/cache@') for step in cache_steps) + restore = next(step for step in cache_steps if step['uses'].startswith('actions/cache/restore@')) + save = next(step for step in cache_steps if step['uses'].startswith('actions/cache/save@')) + assert save['with']['path'] == restore['with']['path'] + assert save['with']['key'] == '${{ steps.' + restore['id'] + '.outputs.cache-primary-key }}' + assert gate(save['if'], {'build_commit': commit}, {}, job_if=False) is (job['cache-mode'] == 'write') + verify = next(step for step in steps if step.get('name') == 'Verify native signature cache contracts') + assert steps.index(restore) < steps.index(verify) < steps.index(save) + assembly = jobs['assemble-win32-bundle'] + assert assembly['cache-mode'] == 'read' + setup = next(step for step in assembly['steps'] if step.get('uses') == './.github/actions/setup-pm') + assert setup['with']['cache-python'] is False + assert setup['with']['save-tools-cache'] is False and setup['with']['save-node-cache'] is False + action = hermes_yaml.safe_load((ROOT / '.github/actions/setup-pm/action.yml').read_text()) + tools = next(step for step in action['runs']['steps'] if step.get('id') == 'tools-cache') + assert "inputs.save-tools-cache == 'true'" in tools['if'] + assert all(not step.get('uses', '').startswith('actions/cache@') for step in assembly['steps']) + + def test_smoke_matrix_native_routes_and_driver_only_dependencies(): workflow = smoke_workflow() jobs = _workflow()['jobs'] @@ -134,9 +160,22 @@ def test_smoke_matrix_native_routes_and_driver_only_dependencies(): checkout = next(step for step in job['steps'] if 'actions/checkout@' in step.get('uses', '')) assert checkout['with']['persist-credentials'] is False assert checkout['with']['ref'] == '${{ inputs.sha }}' + recording = next(step for step in job['steps'] if step.get('id') == 'recording') + assert 'save-cache' not in recording['with'] upload = next(step for step in job['steps'] if 'actions/upload-artifact@' in step.get('uses', '')) assert upload['if'] == 'always()' and upload['with']['path'].endswith('/out') + recorder = hermes_yaml.safe_load((ROOT / '.github/actions/e2e-screen-record/action.yml').read_text()) + assert all(not step.get('uses', '').startswith('actions/cache') for step in recorder['runs']['steps']) + assert 'save-cache' not in recorder['inputs'] + # ffmpeg comes from the PM toolchain: the action must verify, not install. + verify = next(step for step in recorder['runs']['steps'] if step.get('name') == 'Verify ffmpeg from the PM toolchain') + assert verify['if'] == "inputs.mode == 'start'" + for step in recorder['runs']['steps']: + run = step.get('run', '') + assert 'ffmpeg' not in run or 'winget' not in run and 'brew install' not in run and 'apt-get' not in run, \ + f"step {step.get('name')} installs ffmpeg through an OS package manager" + workflows = [workflow] + [hermes_yaml.safe_load((ROOT / '.github/workflows' / name).read_text(encoding='utf-8-sig')) for name in ('install-e2e-run.yml', 'install-e2e-macos-run.yml', 'install-e2e-windows-run.yml')] for document in workflows: @@ -146,12 +185,13 @@ def test_smoke_matrix_native_routes_and_driver_only_dependencies(): steps = job['steps'] setup = next(step for step in steps if step.get('uses') == './.github/actions/setup-pm') assert setup['with']['toolchain'] == 'all' and not setup['with'].get('extras') + assert setup['with']['packages'] == 'ffmpeg' assert all(setup['with'][key] is False for key in ('cache', 'cache-node', 'cache-python')) install = next(step for step in steps if step.get('name') == 'Install locked chat driver dependencies') assert steps.index(setup) < steps.index(install) args = install['run'].split() - assert args[:2] == ['npm', 'ci'] and args[args.index('--workspace') + 1] == 'apps/desktop' - assert {'--include-workspace-root', '--include=dev', '--ignore-scripts', '--no-audit', '--no-fund'} <= set(args) + assert args[:2] == ['npm', 'ci'] and args[args.index('--workspace') + 1] == 'tests-js' + assert {'--include-workspace-root', '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund'} <= set(args) def transport_env(tmp_path, server, *, commit=False): diff --git a/tests/install/README.md b/tests/install/README.md index 84bcb88e15..99a2618071 100644 --- a/tests/install/README.md +++ b/tests/install/README.md @@ -212,4 +212,4 @@ contract and not exercised. ## Artifacts -Each leg uploads its logs as an artifact. Every leg also records the screen for its whole run: the composite action `.github/actions/e2e-screen-record` installs ffmpeg, records with the OS's capture backend (x11grab on linux, gdigrab on windows, avfoundation on macos), and fails the leg if the recording is missing or has zero frames. Linux runners have no display, so the action starts `Xvfb :99` first and exports `DISPLAY` for every later step — the app under test and the recorder share that display. The windows GUI leg also uploads screenshots and the update result file. Get them with `gh run download `. +Each leg uploads its logs as an artifact. Every leg also records the screen for its whole run: the composite action `.github/actions/e2e-screen-record` records with the OS's capture backend (x11grab on linux, gdigrab on windows, avfoundation on macos), and fails the leg if the recording is missing or has zero frames. ffmpeg itself comes from the PM toolchain — jobs pass `packages: ffmpeg` to `actions/setup-pm`, which installs the locked, sha256-pinned build (native per-OS, including win32-arm64) and puts it on `PATH`; the action only verifies it is there. Linux runners have no display, so the action starts `Xvfb :99` first and exports `DISPLAY` for every later step — the app under test and the recorder share that display. The windows GUI leg also uploads screenshots and the update result file. Get them with `gh run download `. diff --git a/tests/scripts/test_archive_inputs.py b/tests/scripts/test_archive_inputs.py index bbe8ed5e74..b61309cb9c 100644 --- a/tests/scripts/test_archive_inputs.py +++ b/tests/scripts/test_archive_inputs.py @@ -267,7 +267,7 @@ def test_ci_toolchain_seed_runs_before_the_tool_installer(tmp_path, upstream, r2 write_pins(repo, packages) monkeypatch.setattr(paths, "repo_root", lambda: repo) home = tmp_path / "ci-home" - setup_toolchain.archive_inputs(SimpleNamespace(home=home, toolchain="python")) + setup_toolchain.archive_inputs(SimpleNamespace(home=home, toolchain="python", packages=[])) (root / "lib.deb").unlink() store = Store(home / "tools") with store.scratch() as scratch: diff --git a/tests/scripts/test_pm_build_consumers.py b/tests/scripts/test_pm_build_consumers.py index 6e6995cf5e..c767ab37a1 100644 --- a/tests/scripts/test_pm_build_consumers.py +++ b/tests/scripts/test_pm_build_consumers.py @@ -24,7 +24,7 @@ def test_ci_setup_exports_no_installer_path_or_policy(tmp_path, monkeypatch, loc from types import SimpleNamespace from scripts.ci import setup_toolchain - monkeypatch.setattr(setup_toolchain, "packages", lambda _: ["python", "uv"]) + monkeypatch.setattr(setup_toolchain, "packages", lambda toolchain, extra=None: ["python", "uv"]) manager = importlib.import_module("pm.ensure") ensured = [] monkeypatch.setattr(manager, "ensure", lambda name, **kwargs: ensured.append((name, kwargs["explicit"]))) @@ -45,7 +45,7 @@ def test_ci_setup_exports_no_installer_path_or_policy(tmp_path, monkeypatch, loc files = {name: tmp_path / name for name in ("GITHUB_ENV", "GITHUB_OUTPUT", "GITHUB_PATH")} for name, path in files.items(): monkeypatch.setenv(name, str(path)) - setup_toolchain.install(SimpleNamespace(toolchain="python", home=tmp_path / "ci")) + setup_toolchain.install(SimpleNamespace(toolchain="python", packages=[], home=tmp_path / "ci")) assert ensured == [("uv", True)] # ensure already walks uv's Python dependency. assert composed == [("python",)] outputs = dict(line.split("=", 1) for line in files["GITHUB_OUTPUT"].read_text(encoding="utf-8").splitlines())