Merge branch 'banana' into ethie/pm-clean
This commit is contained in:
61
.github/actions/e2e-screen-record/action.yml
vendored
61
.github/actions/e2e-screen-record/action.yml
vendored
@@ -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
|
||||
|
||||
29
.github/actions/setup-pm/action.yml
vendored
29
.github/actions/setup-pm/action.yml
vendored
@@ -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
|
||||
|
||||
3
.github/workflows/desktop-bundle-smoke.yml
vendored
3
.github/workflows/desktop-bundle-smoke.yml
vendored
@@ -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
|
||||
|
||||
21
.github/workflows/desktop-bundled-release.yml
vendored
21
.github/workflows/desktop-bundled-release.yml
vendored
@@ -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
|
||||
|
||||
6
.github/workflows/install-e2e-macos-run.yml
vendored
6
.github/workflows/install-e2e-macos-run.yml
vendored
@@ -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
|
||||
|
||||
3
.github/workflows/install-e2e-run.yml
vendored
3
.github/workflows/install-e2e-run.yml
vendored
@@ -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).
|
||||
|
||||
10
.github/workflows/install-e2e-windows-run.yml
vendored
10
.github/workflows/install-e2e-windows-run.yml
vendored
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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 |
|
||||
| --- | --- |
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 <run-id>`.
|
||||
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 <run-id>`.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user