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 name: E2E screen recording
description: > description: >
One screen-recording mechanism for every install-e2e runner. mode=start One screen-recording mechanism for every install-e2e runner. mode=start
installs what the OS needs (ffmpeg everywhere; Xvfb on headless linux), installs what the OS needs (Xvfb on headless linux), starts the recorder,
starts the recorder, and exports DISPLAY for later steps. mode=stop and exports DISPLAY for later steps. mode=stop finalizes the recording and
finalizes the recording and fails on a zero-frame file. A missing ffmpeg fails on a zero-frame file. A missing ffmpeg is a hard error - a graceful
is a hard error - a graceful skip makes the missing tool invisible and skip makes the missing tool invisible and the artifact silently loses its
the artifact silently loses its recording. 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: inputs:
mode: mode:
@@ -19,15 +21,29 @@ runs:
using: composite using: composite
steps: steps:
# ---- setup + start ------------------------------------------------------ # ---- setup + start ------------------------------------------------------
- name: Install ffmpeg + Xvfb (linux) - name: Install Xvfb (linux, headless)
if: inputs.mode == 'start' && runner.os == 'Linux' if: inputs.mode == 'start' && runner.os == 'Linux'
shell: bash shell: bash
run: | run: |
set -euo pipefail 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 update -qq
sudo apt-get install -y -qq --no-install-recommends ffmpeg xvfb sudo apt-get install -y -qq --no-install-recommends xvfb
fi }
- 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) - name: Start Xvfb (linux, headless)
if: inputs.mode == 'start' && runner.os == 'Linux' if: inputs.mode == 'start' && runner.os == 'Linux'
@@ -50,14 +66,11 @@ runs:
done done
[ -S "/tmp/.X11-unix/X99" ] || { echo "Xvfb :99 did not come up" >&2; exit 1; } [ -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' if: inputs.mode == 'start' && runner.os == 'macOS'
shell: bash shell: bash
run: | run: |
set -euo pipefail 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 # 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, # see https://github.com/actions/runner-images/issues/14474 - as of this hermes agent commit,
# it's merged but the image isn't updated. # 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" defaults write "$approvalsPlist" "/opt/hca/hosted-compute-agent" -date "3024-01-01 00:00:00 +0000"
killall cfprefsd 2>/dev/null || true 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) - name: Start recording (posix)
if: inputs.mode == 'start' && runner.os != 'Windows' if: inputs.mode == 'start' && runner.os != 'Windows'
shell: bash shell: bash

View File

@@ -4,6 +4,9 @@ inputs:
toolchain: toolchain:
description: python (Python and uv), node (Node and npm), or all. description: python (Python and uv), node (Node and npm), or all.
default: python 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: archive-inputs:
description: Use protected R2 credentials to preserve and retrieve the selected toolchain before installation. description: Use protected R2 credentials to preserve and retrieve the selected toolchain before installation.
default: 'false' default: 'false'
@@ -13,6 +16,9 @@ inputs:
cache: cache:
description: Cache the verified PM tool store. description: Cache the verified PM tool store.
default: 'true' default: 'true'
save-tools-cache:
description: Save the verified PM tools on success. Set false for restore-only jobs.
default: 'true'
cache-python: cache-python:
description: Cache uv downloads and built wheels; never cache the dependency environment. description: Cache uv downloads and built wheels; never cache the dependency environment.
default: 'true' default: 'true'
@@ -63,7 +69,7 @@ outputs:
value: ${{ steps.prepare.outputs.target }} value: ${{ steps.prepare.outputs.target }}
tools-cache-hit: tools-cache-hit:
description: Exact PM store 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: python-cache-hit:
description: Exact uv dependency cache hit. description: Exact uv dependency cache hit.
value: ${{ steps.python-cache.outputs.cache-hit || steps.python-cache-restore.outputs.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_ACTION: ${{ github.action_path }}
_PM_TOOLCHAIN: ${{ inputs.toolchain }} _PM_TOOLCHAIN: ${{ inputs.toolchain }}
_PM_EXTRAS: ${{ inputs.extras }} _PM_EXTRAS: ${{ inputs.extras }}
_PM_PACKAGES: ${{ inputs.packages }}
run: | run: |
set -euo pipefail set -euo pipefail
# Windows provides python, not necessarily python3. The host resolver # Windows provides python, not necessarily python3. The host resolver
# in PM still selects ARM64 when this bootstrap Python runs under x64. # in PM still selects ARM64 when this bootstrap Python runs under x64.
if [ "$RUNNER_OS" = Windows ]; then bootstrap=python; else bootstrap=python3; fi if [ "$RUNNER_OS" = Windows ]; then bootstrap=python; else bootstrap=python3; fi
"$bootstrap" -S "$_PM_ACTION/../../../scripts/ci/setup_toolchain.py" prepare \ "$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 - name: Cache verified PM tools
id: tools-cache id: tools-cache
if: inputs.cache == 'true' if: inputs.cache == 'true' && inputs.save-tools-cache == 'true'
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with: with:
path: ${{ steps.prepare.outputs.store }} 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 - name: Preserve and retrieve the pinned toolchain
if: inputs.archive-inputs == 'true' if: inputs.archive-inputs == 'true'
@@ -115,9 +130,10 @@ runs:
_PM_ACTION: ${{ github.action_path }} _PM_ACTION: ${{ github.action_path }}
_PM_BOOTSTRAP: ${{ steps.prepare.outputs.bootstrap-python }} _PM_BOOTSTRAP: ${{ steps.prepare.outputs.bootstrap-python }}
_PM_TOOLCHAIN: ${{ inputs.toolchain }} _PM_TOOLCHAIN: ${{ inputs.toolchain }}
_PM_PACKAGES: ${{ inputs.packages }}
run: | run: |
"$_PM_BOOTSTRAP" -S "$_PM_ACTION/../../../scripts/ci/setup_toolchain.py" archive-inputs \ "$_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 - name: Install and verify tools through PM
id: install id: install
@@ -126,9 +142,10 @@ runs:
_PM_ACTION: ${{ github.action_path }} _PM_ACTION: ${{ github.action_path }}
_PM_BOOTSTRAP: ${{ steps.prepare.outputs.bootstrap-python }} _PM_BOOTSTRAP: ${{ steps.prepare.outputs.bootstrap-python }}
_PM_TOOLCHAIN: ${{ inputs.toolchain }} _PM_TOOLCHAIN: ${{ inputs.toolchain }}
_PM_PACKAGES: ${{ inputs.packages }}
run: | run: |
"$_PM_BOOTSTRAP" -S "$_PM_ACTION/../../../scripts/ci/setup_toolchain.py" install \ "$_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 - name: Cache uv dependency downloads and builds
id: python-cache id: python-cache

View File

@@ -94,6 +94,7 @@ jobs:
uses: ./.github/actions/setup-pm uses: ./.github/actions/setup-pm
with: with:
toolchain: all toolchain: all
packages: ffmpeg
cache: false cache: false
cache-python: false cache-python: false
cache-node: false cache-node: false
@@ -101,7 +102,7 @@ jobs:
- name: Install locked chat driver dependencies - name: Install locked chat driver dependencies
id: dependencies id: dependencies
shell: bash 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 - name: Fetch one exact downloadable artifact
id: artifact id: artifact

View File

@@ -462,11 +462,12 @@ jobs:
producer: desktop producer: desktop
key: ${{ steps.build-cache.outputs.cache-key }} key: ${{ steps.build-cache.outputs.cache-key }}
- name: Cache verified payload signatures - name: Restore verified payload signatures
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 id: payload-signatures
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with: with:
path: ${{ github.workspace }}/.cache/electron-builder-payload-signatures 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 }} key: payload-signatures-v1-${{ runner.os }}-${{ matrix.target.label }}-${{ github.run_id }}-${{ github.run_attempt }}
restore-keys: | restore-keys: |
payload-signatures-v1-${{ runner.os }}-${{ matrix.target.label }}- payload-signatures-v1-${{ runner.os }}-${{ matrix.target.label }}-
@@ -520,6 +521,14 @@ jobs:
working-directory: apps/desktop 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 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 - name: Audit bundle architecture
shell: bash shell: bash
run: | run: |
@@ -1061,6 +1070,8 @@ jobs:
toolchain: all toolchain: all
archive-inputs: ${{ inputs.disposable_run == '' }} archive-inputs: ${{ inputs.disposable_run == '' }}
cache-python: false cache-python: false
save-tools-cache: false
save-node-cache: false
- name: Install the locked Windows bundle tooling - name: Install the locked Windows bundle tooling
uses: ./.github/actions/retry uses: ./.github/actions/retry
@@ -1093,8 +1104,8 @@ jobs:
*) echo "ELECTRON_DEFAULT_CACHE=$HOME/.cache/electron" >> "$GITHUB_ENV" ;; *) echo "ELECTRON_DEFAULT_CACHE=$HOME/.cache/electron" >> "$GITHUB_ENV" ;;
esac esac
- name: Cache electron + electron-builder toolchain - name: Restore electron + electron-builder toolchain
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with: with:
path: | path: |
${{ github.workspace }}/.cache/electron-builder ${{ github.workspace }}/.cache/electron-builder

View File

@@ -112,13 +112,14 @@ jobs:
uses: ./.github/actions/setup-pm uses: ./.github/actions/setup-pm
with: with:
toolchain: all toolchain: all
packages: ffmpeg
cache: false cache: false
cache-python: false cache-python: false
cache-node: false cache-node: false
- name: Install locked chat driver dependencies - name: Install locked chat driver dependencies
shell: bash 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 - name: Start screen recording
uses: ./.github/actions/e2e-screen-record uses: ./.github/actions/e2e-screen-record
@@ -208,13 +209,14 @@ jobs:
uses: ./.github/actions/setup-pm uses: ./.github/actions/setup-pm
with: with:
toolchain: all toolchain: all
packages: ffmpeg
cache: false cache: false
cache-python: false cache-python: false
cache-node: false cache-node: false
- name: Install locked chat driver dependencies - name: Install locked chat driver dependencies
shell: bash 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 - name: Start screen recording
uses: ./.github/actions/e2e-screen-record uses: ./.github/actions/e2e-screen-record

View File

@@ -102,13 +102,14 @@ jobs:
uses: ./.github/actions/setup-pm uses: ./.github/actions/setup-pm
with: with:
toolchain: all toolchain: all
packages: ffmpeg
cache: false cache: false
cache-python: false cache-python: false
cache-node: false cache-node: false
- name: Install locked chat driver dependencies - name: Install locked chat driver dependencies
shell: bash 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 # One recording mechanism on every OS (Xvfb gives headless linux a
# display; the same display serves any app the driver launches). # display; the same display serves any app the driver launches).

View File

@@ -105,12 +105,13 @@ jobs:
uses: ./.github/actions/setup-pm uses: ./.github/actions/setup-pm
with: with:
toolchain: all toolchain: all
packages: ffmpeg
cache: false cache: false
cache-python: false cache-python: false
cache-node: false cache-node: false
- name: Install locked chat driver dependencies - name: Install locked chat driver dependencies
shell: bash 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 - name: Verify native helpers
shell: pwsh shell: pwsh
run: | run: |
@@ -188,16 +189,17 @@ jobs:
uses: ./.github/actions/setup-pm uses: ./.github/actions/setup-pm
with: with:
toolchain: all toolchain: all
packages: ffmpeg
cache: false cache: false
cache-python: false cache-python: false
cache-node: false cache-node: false
- name: Install locked chat driver dependencies - name: Install locked chat driver dependencies
shell: bash 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 # One recording mechanism on every OS: the composite action verifies
# ffmpeg (cached - winget's download is the slow part), starts the # ffmpeg from the PM toolchain (packages: ffmpeg above), starts the
# capture, and record-stop fails on a zero-frame file so a silently # capture, and record-stop fails on a zero-frame file so a silently
# missing recording cannot go green. # missing recording cannot go green.
- name: Start screen recording - 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' export const GUEST_ONBOARDING_FLAG = '--guest-onboarding'
// Skip the first-run film. A rehearsal aid: the intro is a one-time reveal, // 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 // 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_ENV = 'HERMES_SKIP_INTRO'
export const SKIP_INTRO_FLAG = '--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`. 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 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. 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 | | Piece | Path |
| --- | --- | | --- | --- |

View File

@@ -7,12 +7,13 @@ import {
finishIntroReveal, finishIntroReveal,
installIntroRevealBridgeListeners, installIntroRevealBridgeListeners,
isIntroRevealEnabled, isIntroRevealEnabled,
isIntroRevealSkipped,
leaveIntroReveal, leaveIntroReveal,
shouldPlayFirstRunIntro, shouldPlayFirstRunIntro,
startIntroReveal startIntroReveal
} from '@/store/intro-reveal' } from '@/store/intro-reveal'
import { $desktopOnboarding } from '@/store/onboarding' 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' import { INTRO_DEADMAN_MS, INTRO_EXIT_MS } from './timeline'
@@ -48,7 +49,23 @@ export function IntroRevealGate({ enabled }: IntroRevealGateProps) {
}, [enabled]) }, [enabled])
useEffect(() => { 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() beginOnboardingFlow()
startIntroReveal() startIntroReveal()
} }

View File

@@ -31,10 +31,24 @@ export function hasSeenIntroReveal(): boolean {
return readKey(SEEN_KEY) === '1' 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 { export function isIntroRevealEnabled(): boolean {
return isOnboardingEnabled() && window.hermesDesktop?.skipIntro !== true 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 { export function shouldPlayFirstRunIntro(firstRunSkipped: boolean): boolean {
return isIntroRevealEnabled() && !firstRunSkipped && !hasSeenIntroReveal() 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') 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 { isOnboardingEnabled } from '@/lib/onboarding-enabled'
import { readKey, writeKey } from '@/lib/storage' import { readKey, writeKey } from '@/lib/storage'
import { hasSeenIntroReveal } from './intro-reveal' import { hasSeenIntroReveal, markIntroRevealSeen } from './intro-reveal'
import { DEFAULT_ANSWERS, setOnboardingAnswers } from './onboarding-answers' import { DEFAULT_ANSWERS, setOnboardingAnswers } from './onboarding-answers'
const PHASE_KEY = 'hermes-onboarding-phase-v1' 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 { export function queueGuideAfterIntro(): void {
const state = $onboardingGate.get() const state = $onboardingGate.get()

8
package-lock.json generated
View File

@@ -3424,7 +3424,6 @@
"version": "1.62.1", "version": "1.62.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright": "1.62.1" "playwright": "1.62.1"
@@ -11022,7 +11021,6 @@
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -15313,7 +15311,6 @@
"version": "1.62.1", "version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.62.1" "playwright-core": "1.62.1"
@@ -15332,7 +15329,6 @@
"version": "1.62.1", "version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"playwright-core": "cli.js" "playwright-core": "cli.js"
@@ -19210,6 +19206,10 @@
}, },
"tests-js": { "tests-js": {
"name": "@hermes/root-tests", "name": "@hermes/root-tests",
"dependencies": {
"@playwright/test": "1.62.1",
"zod": "4.4.3"
},
"devDependencies": { "devDependencies": {
"@types/plist": "3.0.5", "@types/plist": "3.0.5",
"plist": "3.1.1", "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 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 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"]} _ROOTS = {"python": ["uv"], "node": ["npm"], "all": ["npm", "uv"]}
def packages(toolchain: str) -> list[str]: def parse_package_list(value: str) -> list[str]:
return sorted(package.name for package in walk(_ROOTS[toolchain])) """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: def file_commands(destination: str, values: dict) -> None:
@@ -57,7 +69,7 @@ def prepare(args) -> None:
home = args.home.resolve() home = args.home.resolve()
lock = Lockfile(lockfile_path()) lock = Lockfile(lockfile_path())
target = current_target() target = current_target()
names = packages(args.toolchain) names = packages(args.toolchain, args.packages)
values = { values = {
"packages": json.dumps(names), "target": target, "arch": target.split("-")[1], "packages": json.dumps(names), "target": target, "arch": target.split("-")[1],
"store": str(home / "tools"), "store": str(home / "tools"),
@@ -67,6 +79,9 @@ def prepare(args) -> None:
if not version or not lock.artifacts(name, target): if not version or not lock.artifacts(name, target):
raise ValueError(f"{name} has no pinned artifact for {target}") raise ValueError(f"{name} has no pinned artifact for {target}")
values[f"{name}-version"] = version.partition("+")[0] if name == "python" else version 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 # 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. # against its system libraries. Unlike npm's cache, these are not just JS.
values["os-version"] = platform.platform() 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.ci.archive_inputs import Archive, pinned_inputs, stage_inputs
from scripts.releases import r2 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")) 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.paths import facts_path, store_root
from pm.registry import get_package from pm.registry import get_package
names = packages(args.toolchain) names = packages(args.toolchain, args.packages)
for name in _ROOTS[args.toolchain]: for name in _ROOTS[args.toolchain] + args.packages:
ensure(name, explicit=True, progress=_live_progress(name)) ensure(name, explicit=True, progress=_live_progress(name))
facts = Facts(facts_path()) facts = Facts(facts_path())
target = current_target() target = current_target()
@@ -206,6 +221,7 @@ def main() -> None:
parser.add_argument("--toolchain", choices=["python", "node", "all"], default="python") parser.add_argument("--toolchain", choices=["python", "node", "all"], default="python")
parser.add_argument("--home", type=Path, required=True) parser.add_argument("--home", type=Path, required=True)
parser.add_argument("--extras", type=parse_extras, default="") 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() args = parser.parse_args()
if args.toolchain == "node" and args.extras is not None: if args.toolchain == "node" and args.extras is not None:
parser.error("extras require the python or all toolchain") 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) encoding="utf-8", errors="replace", check=True, timeout=60)
print((result.stdout or "").strip() or f"Dispatched commit build {commit}. No release was created.") print((result.stdout or "").strip() or f"Dispatched commit build {commit}. No release was created.")
except (OSError, ValueError, subprocess.SubprocessError) as exc: 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: def main() -> None:

View File

@@ -15,5 +15,9 @@
"plist": "3.1.1", "plist": "3.1.1",
"typescript": "6.0.3", "typescript": "6.0.3",
"vitest": "4.1.10" "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 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(): def test_smoke_matrix_native_routes_and_driver_only_dependencies():
workflow = smoke_workflow() workflow = smoke_workflow()
jobs = _workflow()['jobs'] 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', '')) 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']['persist-credentials'] is False
assert checkout['with']['ref'] == '${{ inputs.sha }}' 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', '')) 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') 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')) 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 name in ('install-e2e-run.yml', 'install-e2e-macos-run.yml', 'install-e2e-windows-run.yml')]
for document in workflows: for document in workflows:
@@ -146,12 +185,13 @@ def test_smoke_matrix_native_routes_and_driver_only_dependencies():
steps = job['steps'] steps = job['steps']
setup = next(step for step in steps if step.get('uses') == './.github/actions/setup-pm') 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']['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')) 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') install = next(step for step in steps if step.get('name') == 'Install locked chat driver dependencies')
assert steps.index(setup) < steps.index(install) assert steps.index(setup) < steps.index(install)
args = install['run'].split() args = install['run'].split()
assert args[:2] == ['npm', 'ci'] and args[args.index('--workspace') + 1] == 'apps/desktop' assert args[:2] == ['npm', 'ci'] and args[args.index('--workspace') + 1] == 'tests-js'
assert {'--include-workspace-root', '--include=dev', '--ignore-scripts', '--no-audit', '--no-fund'} <= set(args) assert {'--include-workspace-root', '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund'} <= set(args)
def transport_env(tmp_path, server, *, commit=False): def transport_env(tmp_path, server, *, commit=False):

View File

@@ -212,4 +212,4 @@ contract and not exercised.
## Artifacts ## 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) write_pins(repo, packages)
monkeypatch.setattr(paths, "repo_root", lambda: repo) monkeypatch.setattr(paths, "repo_root", lambda: repo)
home = tmp_path / "ci-home" 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() (root / "lib.deb").unlink()
store = Store(home / "tools") store = Store(home / "tools")
with store.scratch() as scratch: 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 types import SimpleNamespace
from scripts.ci import setup_toolchain 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") manager = importlib.import_module("pm.ensure")
ensured = [] ensured = []
monkeypatch.setattr(manager, "ensure", lambda name, **kwargs: ensured.append((name, kwargs["explicit"]))) 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")} files = {name: tmp_path / name for name in ("GITHUB_ENV", "GITHUB_OUTPUT", "GITHUB_PATH")}
for name, path in files.items(): for name, path in files.items():
monkeypatch.setenv(name, str(path)) 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 ensured == [("uv", True)] # ensure already walks uv's Python dependency.
assert composed == [("python",)] assert composed == [("python",)]
outputs = dict(line.split("=", 1) for line in files["GITHUB_OUTPUT"].read_text(encoding="utf-8").splitlines()) outputs = dict(line.split("=", 1) for line in files["GITHUB_OUTPUT"].read_text(encoding="utf-8").splitlines())