Merge branch 'banana' into ethie/pm-clean

This commit is contained in:
ethernet
2026-09-14 13:44:40 -04:00
22 changed files with 257 additions and 76 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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).

View File

@@ -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

View File

@@ -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'

View File

@@ -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 |
| --- | --- |

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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')
})

View File

@@ -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
View File

@@ -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",

View File

@@ -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

View File

@@ -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")

View File

@@ -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:

View File

@@ -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"
}
}

View File

@@ -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):

View File

@@ -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>`.

View File

@@ -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:

View File

@@ -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())