Merge origin/main into feat/plugin-catalog
Python plugin CLI/loader/web/tui files taken from main wholesale; the catalog layer is re-ported onto main's decomposed shapes in the following commits. plugin_index.py removed (catalog is the sole discovery system).
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
name: Case Collision Check
|
||||
|
||||
# Rejects PRs that track two files whose paths differ only by case
|
||||
# (README.md vs readme.md, src/Foo.py vs SRC/foo.py).
|
||||
#
|
||||
# Linux is case-sensitive; Windows and macOS (default) are not. A
|
||||
# case-colliding pair lives fine in a Linux checkout and silently breaks
|
||||
# every clone on a case-insensitive host — the filesystem can hold only
|
||||
# one of them, so checkout fails or whichever wins overwrites the other.
|
||||
# Git won't prevent the pair from landing (it only warns at checkout time,
|
||||
# on a case-insensitive FS, for the client doing the checkout), so the only
|
||||
# enforcement point is CI, on Linux, against the index.
|
||||
#
|
||||
# Runs unconditionally (no change-classifier gate): a collision can ship in
|
||||
# any kind of PR — docs, JS, config, not just Python — so gating on a
|
||||
# language lane would be the same "passive rule that cannot enforce a
|
||||
# policy" trap the infographic check exists to close.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check-case-collisions:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Run case-collision checker
|
||||
run: python3 scripts/check-case-collisions.py
|
||||
@@ -49,6 +49,7 @@ jobs:
|
||||
uv_lock: ${{ steps.classify.outputs.uv_lock }}
|
||||
npm_lock: ${{ steps.classify.outputs.npm_lock }}
|
||||
installer: ${{ steps.classify.outputs.installer }}
|
||||
desktop_updater: ${{ steps.classify.outputs.desktop_updater }}
|
||||
rust: ${{ steps.classify.outputs.rust }}
|
||||
docker_meta: ${{ steps.classify.outputs.docker_meta }}
|
||||
mcp_catalog: ${{ steps.classify.outputs.mcp_catalog }}
|
||||
@@ -84,6 +85,11 @@ jobs:
|
||||
needs: detect
|
||||
if: needs.detect.outputs.python == 'true'
|
||||
uses: ./.github/workflows/tests-os.yml
|
||||
with:
|
||||
# The Windows lane spawns the real desktop-update hand-off script
|
||||
# (tests/test_desktop_update_windows_*.py) only when that surface
|
||||
# changed; unit-level windows_only tests always run.
|
||||
desktop_updater: ${{ needs.detect.outputs.desktop_updater == 'true' }}
|
||||
|
||||
lint:
|
||||
name: Python lints
|
||||
@@ -122,14 +128,14 @@ jobs:
|
||||
# Tests-only PRs (~17% of commits) skip this 5-minute job — the longest
|
||||
# single job in the workflow — while still running the full pytest lanes.
|
||||
#
|
||||
# ⛔ TEMPORARILY DISABLED (Aug 2, 2026, Teknium) — the suite is red on
|
||||
# every PR and on main itself since the Aug 1 night engines/npm churn
|
||||
# (#76499 → #76562 → #76575): the mock-backend Electron window never
|
||||
# gets a title, so boot/chat/setup/interim specs all fail identically
|
||||
# regardless of the PR's diff (verified on #76573 and the docs-only
|
||||
# #76582). Tracking issue: #76627 (assigned: Ari). To re-enable,
|
||||
# delete the `false &&` below — nothing else changed.
|
||||
if: ${{ false && (needs.detect.outputs.python_prod == 'true' || needs.detect.outputs.frontend == 'true') }}
|
||||
# Re-disabled (Sep 2026): the Sep 1 re-enable is still incredibly flaky.
|
||||
# Keep this a bare `if: false`. The earlier
|
||||
# `${{ false && (... || ...) }}` form on this reusable-workflow job made
|
||||
# GitHub's workflow parser fail at startup ("An unexpected error has
|
||||
# occurred") — every ci.yaml run repo-wide dispatched 0 jobs from
|
||||
# 24f5a60ed1 until this line changed. To re-enable, restore:
|
||||
# if: ${{ needs.detect.outputs.python_prod == 'true' || needs.detect.outputs.frontend == 'true' }}
|
||||
if: false
|
||||
uses: ./.github/workflows/e2e-desktop.yml
|
||||
|
||||
docs-site:
|
||||
@@ -166,6 +172,16 @@ jobs:
|
||||
needs: detect
|
||||
uses: ./.github/workflows/infographic-check.yml
|
||||
|
||||
profile-artifact-check:
|
||||
name: Profile artifact check
|
||||
needs: detect
|
||||
uses: ./.github/workflows/profile-artifact-check.yml
|
||||
|
||||
case-collision-check:
|
||||
name: Check no case-colliding filenames
|
||||
needs: detect
|
||||
uses: ./.github/workflows/case-collision-check.yml
|
||||
|
||||
lockfile-diff:
|
||||
name: package-lock.json diff
|
||||
needs: detect
|
||||
@@ -228,8 +244,10 @@ jobs:
|
||||
- history-check
|
||||
- contributor-check
|
||||
- uv-lockfile
|
||||
- case-collision-check
|
||||
- lockfile-diff
|
||||
- docker-lint
|
||||
- profile-artifact-check
|
||||
- supply-chain
|
||||
- review-labels
|
||||
- osv-scanner
|
||||
|
||||
@@ -94,6 +94,9 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Reject profile exports in the build context
|
||||
run: python3 scripts/ci/check_profile_archive_boundary.py
|
||||
|
||||
# Retry once on transient Docker Hub / buildkit pull failures
|
||||
# (connection reset, auth token timeout, rate limiting). The action
|
||||
# generates a unique builder name per invocation so the retry doesn't
|
||||
@@ -206,6 +209,9 @@ jobs:
|
||||
- name: Checkout trusted source
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Reject profile exports in the build context
|
||||
run: python3 scripts/ci/check_profile_archive_boundary.py
|
||||
|
||||
# Retry once on transient Docker Hub / buildkit pull failures.
|
||||
# See build job for rationale; same pattern.
|
||||
- name: Set up Docker Buildx
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
# Reusable runner for ONE macOS install/update combination.
|
||||
#
|
||||
# Two driver arms, one runs per dispatch (the other natively skips):
|
||||
#
|
||||
# e2e (script arms) tests/install/installer-script-e2e.sh - the
|
||||
# OS-agnostic git-redirect driver shared with
|
||||
# linux. installer-script(+desktop) installs,
|
||||
# script/updater/hermes-desktop-app-update
|
||||
# updates.
|
||||
# gui-e2e (desktop arm) tests/install/macos-desktop-e2e.sh - the
|
||||
# published Hermes-Setup.dmg, mounted and run,
|
||||
# then the app driven by Playwright for the
|
||||
# app-update methods.
|
||||
#
|
||||
# Method pairs without a driver arm yet NATIVELY SKIP (grey check, no
|
||||
# runner): the capability knowledge lives here, next to the drivers.
|
||||
|
||||
name: install-e2e macos leg
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
install-method:
|
||||
description: 'How OLD gets installed. Supported: installer-script, installer-script+desktop (curl | bash one-liner, optionally with --include-desktop) and desktop-installer@latest (the published Hermes-Setup.dmg).'
|
||||
required: true
|
||||
type: string
|
||||
update-method:
|
||||
description: 'How the install updates to HEAD. Script installs support hermes-update / installer-script / installer-script+desktop / hermes-desktop-app-update; dmg installs support open-app-update / hermes-desktop-app-update.'
|
||||
required: true
|
||||
type: string
|
||||
install-ref:
|
||||
description: 'What to install before updating: a branch, a tag, or a SHA reachable from main.'
|
||||
required: false
|
||||
type: string
|
||||
default: refs/heads/main
|
||||
tag-has-desktop:
|
||||
description: "Whether install-ref ships the desktop app (apps/desktop). The caller annotates this from the tag's own tree; desktop-method legs from pre-desktop releases natively skip."
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
leg-id:
|
||||
description: 'Artifact-safe matrix leg id (from generate-e2e-matrix.mjs legId). Names this leg''s logs + player artifacts so the report job can link a row to its zip.'
|
||||
required: true
|
||||
type: string
|
||||
dmg-url:
|
||||
description: 'Bootstrap dmg to install OLD with. Default: the latest published one — what a user downloads today.'
|
||||
required: false
|
||||
type: string
|
||||
default: https://hermes-assets.nousresearch.com/Hermes-Setup.dmg
|
||||
timeout-minutes:
|
||||
description: 'Job timeout. App-update legs do a full Electron build.'
|
||||
required: false
|
||||
type: number
|
||||
default: 60
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# ---- arm 1: script installs (the shared OS-agnostic driver) --------------
|
||||
e2e:
|
||||
name: install & update
|
||||
if: >-
|
||||
(inputs.install-method == 'installer-script'
|
||||
|| (inputs.install-method == 'installer-script+desktop' && inputs.tag-has-desktop))
|
||||
&& (contains(fromJSON('["hermes-update", "installer-script"]'), inputs.update-method)
|
||||
|| (contains(fromJSON('["installer-script+desktop", "hermes-desktop-app-update"]'), inputs.update-method) && inputs.tag-has-desktop))
|
||||
uses: ./.github/workflows/install-e2e-run.yml
|
||||
with:
|
||||
install-method: ${{ inputs.install-method }}
|
||||
update-method: ${{ inputs.update-method }}
|
||||
install-ref: ${{ inputs.install-ref }}
|
||||
tag-has-desktop: ${{ inputs.tag-has-desktop }}
|
||||
leg-id: ${{ inputs.leg-id }}
|
||||
runner: macos-latest
|
||||
timeout-minutes: ${{ inputs.timeout-minutes }}
|
||||
|
||||
# ---- arm 2: the published dmg, then Playwright drives the app ------------
|
||||
gui-e2e:
|
||||
# Short static name on purpose: name expressions render UNEXPANDED on
|
||||
# skipped jobs.
|
||||
name: Hermes-Setup.dmg
|
||||
if: >-
|
||||
inputs.install-method == 'desktop-installer@latest' && inputs.tag-has-desktop
|
||||
&& contains(fromJSON('["open-app-update", "hermes-desktop-app-update", "hermes-update", "installer-script", "installer-script+desktop"]'), inputs.update-method)
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: ${{ inputs.timeout-minutes }}
|
||||
|
||||
steps:
|
||||
# Full history: the driver bare-clones this checkout as the repo the
|
||||
# installer/updater talk to.
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Start screen recording
|
||||
uses: ./.github/actions/e2e-screen-record
|
||||
with:
|
||||
mode: start
|
||||
output: ${{ runner.temp }}/e2e-logs/recording.mkv
|
||||
|
||||
- name: Stage serve repo (main -> ${{ inputs.install-ref }})
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tests/install/macos-desktop-e2e.sh --phase stage \
|
||||
--update-method '${{ inputs.update-method }}' \
|
||||
--install-ref '${{ inputs.install-ref }}' \
|
||||
--dmg-url '${{ inputs.dmg-url }}'
|
||||
env:
|
||||
HERMES_E2E_LOG_DIR: ${{ runner.temp }}/e2e-logs
|
||||
|
||||
- name: Install ${{ inputs.install-ref }} via Hermes-Setup.dmg
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tests/install/macos-desktop-e2e.sh --phase install \
|
||||
--update-method '${{ inputs.update-method }}' \
|
||||
--install-ref '${{ inputs.install-ref }}' \
|
||||
--dmg-url '${{ inputs.dmg-url }}'
|
||||
env:
|
||||
HERMES_E2E_LOG_DIR: ${{ runner.temp }}/e2e-logs
|
||||
|
||||
- name: Update ${{ inputs.install-ref }} -> HEAD (${{ inputs.update-method }})
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tests/install/macos-desktop-e2e.sh --phase update \
|
||||
--update-method '${{ inputs.update-method }}' \
|
||||
--install-ref '${{ inputs.install-ref }}' \
|
||||
--dmg-url '${{ inputs.dmg-url }}'
|
||||
env:
|
||||
HERMES_E2E_LOG_DIR: ${{ runner.temp }}/e2e-logs
|
||||
|
||||
- name: Stop screen recording
|
||||
if: always()
|
||||
uses: ./.github/actions/e2e-screen-record
|
||||
with:
|
||||
mode: stop
|
||||
output: ${{ runner.temp }}/e2e-logs/recording.mkv
|
||||
|
||||
- name: Remux recording for browser playback
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -f "${{ runner.temp }}/e2e-logs/recording.mkv" ]; then
|
||||
ffmpeg -y -hide_banner -loglevel error -i "${{ runner.temp }}/e2e-logs/recording.mkv" \
|
||||
-c copy "${{ runner.temp }}/e2e-logs/recording.mp4"
|
||||
fi
|
||||
|
||||
# Artifact names cannot contain '/'; install-ref may be a full ref.
|
||||
- name: Build artifact name
|
||||
id: artifact
|
||||
if: always()
|
||||
run: |
|
||||
echo "name=install-e2e-logs-${{ inputs.leg-id }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ${{ steps.artifact.outputs.name }}
|
||||
path: ${{ runner.temp }}/e2e-logs
|
||||
retention-days: 14
|
||||
if-no-files-found: ignore
|
||||
@@ -1,12 +1,28 @@
|
||||
name: Install & Update E2E (reusable)
|
||||
|
||||
# Runs ONE update route against ONE starting commit, in the dev sandbox, with a
|
||||
# real install (uv, a managed Python, Node, the venv) behind it.
|
||||
# Runs ONE {install-method, update-method} combination against ONE starting
|
||||
# commit, with a real install (uv, a managed Python, Node, the venv) behind
|
||||
# it.
|
||||
#
|
||||
# Reusable so callers can fan out over the combinations that matter -- update
|
||||
# from the tip vs. from an older release, `hermes update` vs. re-running the
|
||||
# installer -- without duplicating the runner setup. Each leg is independent:
|
||||
# its own sandbox, its own install, nothing rewound or shared.
|
||||
# Reusable so callers can fan out over the combinations that matter --
|
||||
# update from the tip vs. from an older release, `hermes update` vs.
|
||||
# re-running the installer -- without duplicating the runner setup. Each leg
|
||||
# is independent: its own isolated HOME, its own install, nothing rewound
|
||||
# or shared.
|
||||
#
|
||||
# No sandbox: tests/install/installer-script-e2e.sh points every git
|
||||
# process at a local bare clone (url.<file://serve.git>.insteadOf in a
|
||||
# driver-owned GIT_CONFIG_GLOBAL) and isolates HOME, so the installer and
|
||||
# updater run byte-for-byte against their real URLs on the bare runner --
|
||||
# which is disposable, and therefore IS the sandbox. That also makes this
|
||||
# workflow OS-agnostic: the same driver runs on ubuntu and macos runners.
|
||||
#
|
||||
# Method ids come from scripts/sandbox/generate-e2e-matrix.mjs. Supported
|
||||
# today: install via installer-script, update via hermes-update or
|
||||
# installer-script (re-run the one-liner).
|
||||
# Anything else NATIVELY SKIPS (grey check, no runner): capability
|
||||
# knowledge lives here, next to the driver, so the caller can dispatch
|
||||
# every declared combination without knowing which ones work.
|
||||
#
|
||||
# Call it:
|
||||
#
|
||||
@@ -14,14 +30,19 @@ name: Install & Update E2E (reusable)
|
||||
# tip:
|
||||
# uses: ./.github/workflows/install-e2e-run.yml
|
||||
# with:
|
||||
# route: update
|
||||
# install-method: installer-script
|
||||
# update-method: hermes-update
|
||||
# install-ref: refs/heads/main
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
route:
|
||||
description: 'Update path to exercise: update (hermes update) or installer (re-run install.sh).'
|
||||
install-method:
|
||||
description: 'How the starting version gets installed. Supported: installer-script (the real curl | install.sh one-liner) and installer-script+desktop (the same one-liner with --include-desktop).'
|
||||
required: true
|
||||
type: string
|
||||
update-method:
|
||||
description: 'How the install updates to HEAD. Supported: hermes-update (the updater), installer-script (re-run the one-liner), installer-script+desktop (re-run with --include-desktop), hermes-desktop-app-update (launch via hermes desktop under Playwright, click Update now). open-app-update runs only where an OS entry point exists (see the per-OS run workflows); pairs without one skip.'
|
||||
required: true
|
||||
type: string
|
||||
install-ref:
|
||||
@@ -29,84 +50,100 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: refs/heads/main
|
||||
leg-id:
|
||||
description: 'Artifact-safe matrix leg id (from generate-e2e-matrix.mjs legId). Names this leg''s logs + player artifacts so the report job can link a row to its zip.'
|
||||
required: true
|
||||
type: string
|
||||
tag-has-desktop:
|
||||
description: "Whether install-ref ships the desktop app (apps/desktop). The caller annotates this from the tag's own tree; desktop-method legs from pre-desktop releases natively skip."
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
runner:
|
||||
description: 'Runner label.'
|
||||
required: false
|
||||
type: string
|
||||
default: ubuntu-latest
|
||||
timeout-minutes:
|
||||
description: 'Job timeout. A cold run installs real toolchains twice.'
|
||||
description: 'Job timeout. A cold run installs real toolchains twice, and app-update legs add a full Electron build + launch.'
|
||||
required: false
|
||||
type: number
|
||||
default: 45
|
||||
default: 60
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
name: ${{ inputs.route }} from ${{ inputs.install-ref }}
|
||||
name: install & update
|
||||
# The pairs the driver can run today; anything else natively skips.
|
||||
# Desktop-surface methods (+desktop installs,
|
||||
# hermes-desktop-app-update) also need the starting tag to ship
|
||||
# apps/desktop (their flags shipped with it).
|
||||
if: >-
|
||||
(inputs.install-method == 'installer-script'
|
||||
|| (inputs.install-method == 'installer-script+desktop' && inputs.tag-has-desktop))
|
||||
&& (contains(fromJSON('["hermes-update", "installer-script"]'), inputs.update-method)
|
||||
|| (contains(fromJSON('["installer-script+desktop", "hermes-desktop-app-update"]'), inputs.update-method) && inputs.tag-has-desktop))
|
||||
runs-on: ${{ inputs.runner }}
|
||||
timeout-minutes: ${{ inputs.timeout-minutes }}
|
||||
|
||||
steps:
|
||||
# Full history: the sandbox fetches the starting commit and the test
|
||||
# compares against this commit, so a shallow clone is not enough.
|
||||
# Full history: the driver bare-clones this checkout as the repo the
|
||||
# installer/updater talk to, and both OLD and HEAD must be reachable
|
||||
# in that clone. A shallow clone cannot serve either need.
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# bubblewrap + slirp4netns are what the sandbox is built on; util-linux
|
||||
# supplies the `unshare` that builds the multi-uid userns for the
|
||||
# user-level (non-root) install.
|
||||
- name: Install sandbox dependencies
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq bubblewrap slirp4netns uidmap util-linux
|
||||
|
||||
# Ubuntu 24.04 restricts unprivileged user namespaces through AppArmor,
|
||||
# which is exactly what bwrap needs. Report the state before touching it
|
||||
# so a future runner-image change is visible in the log rather than
|
||||
# silently altering what this job proves.
|
||||
- name: Permit unprivileged user namespaces
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "--- kernel userns settings (before)"
|
||||
sysctl kernel.unprivileged_userns_clone 2>/dev/null || echo " (sysctl absent)"
|
||||
sysctl kernel.apparmor_restrict_unprivileged_userns 2>/dev/null || echo " (sysctl absent)"
|
||||
if sysctl -n kernel.apparmor_restrict_unprivileged_userns >/dev/null 2>&1; then
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
fi
|
||||
echo "--- subuid/subgid for $(id -un)"
|
||||
grep "^$(id -un):" /etc/subuid /etc/subgid || echo " (none — sandbox will say so)"
|
||||
# One recording mechanism on every OS (Xvfb gives headless linux a
|
||||
# display; the same display serves any app the driver launches).
|
||||
- name: Start screen recording
|
||||
uses: ./.github/actions/e2e-screen-record
|
||||
with:
|
||||
mode: start
|
||||
output: ${{ runner.temp }}/e2e-logs/recording.mkv
|
||||
|
||||
- name: Run install + update E2E
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tests/install/install-update-e2e.sh \
|
||||
--route '${{ inputs.route }}' \
|
||||
tests/install/installer-script-e2e.sh \
|
||||
--install-method '${{ inputs.install-method }}' \
|
||||
--update-method '${{ inputs.update-method }}' \
|
||||
--install-ref '${{ inputs.install-ref }}'
|
||||
env:
|
||||
# Outside the workspace on purpose: the script creates this directory
|
||||
# up front, and an untracked dir inside the repo makes the worktree
|
||||
# dirty -- which dev-sandbox reacts to by snapshotting the working
|
||||
# copy into a fresh fake-main commit on every invocation, moving the
|
||||
# update target mid-run.
|
||||
# Outside the workspace on purpose: logs written into the repo
|
||||
# would trip the driver's own dirty-tree guard.
|
||||
HERMES_E2E_LOG_DIR: ${{ runner.temp }}/e2e-logs
|
||||
|
||||
# Artifact names cannot contain '/', and install-ref may be a full ref
|
||||
# like refs/heads/main. GitHub Actions expressions have no string-replace
|
||||
# function, so build the safe name here. Runs even on failure -- that is
|
||||
# exactly when the logs are wanted.
|
||||
- name: Stop screen recording
|
||||
if: always()
|
||||
uses: ./.github/actions/e2e-screen-record
|
||||
with:
|
||||
mode: stop
|
||||
output: ${{ runner.temp }}/e2e-logs/recording.mkv
|
||||
|
||||
# Browsers cannot play Matroska: remux (copy codec, no re-encode) so
|
||||
# the artifact zip feeds the static playback.html leg player directly.
|
||||
- name: Remux recording for browser playback
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -f "${{ runner.temp }}/e2e-logs/recording.mkv" ]; then
|
||||
ffmpeg -y -hide_banner -loglevel error -i "${{ runner.temp }}/e2e-logs/recording.mkv" \
|
||||
-c copy "${{ runner.temp }}/e2e-logs/recording.mp4"
|
||||
fi
|
||||
|
||||
# The leg player: ONE static HTML per run, uploaded up front by the
|
||||
# leg-player job in install-e2e.yml (archive: false, so GitHub names
|
||||
# the artifact after the file: playback.html). The report job links
|
||||
# every ran leg to it with the leg's zip as a #zip= hash param.
|
||||
- name: Build artifact name
|
||||
if: always()
|
||||
id: artifact
|
||||
run: |
|
||||
set -euo pipefail
|
||||
safe_ref='${{ inputs.install-ref }}'
|
||||
safe_ref="${safe_ref//\//-}"
|
||||
echo "name=install-e2e-${{ inputs.route }}-${safe_ref}" >> "$GITHUB_OUTPUT"
|
||||
echo "name=install-e2e-logs-${{ inputs.leg-id }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# The installer's own transcripts say far more than the assertion that
|
||||
# tripped when a real install breaks.
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
# Reusable runner for ONE Windows install/update combination.
|
||||
#
|
||||
# One job, two orthogonal axes: tests/install/windows-e2e.ps1 dispatches its
|
||||
# install phase on install-method and its update phase on update-method, so
|
||||
# implementing a new pair is a driver function + a gate edit here - never a
|
||||
# new job. The driver's phases share state via the workroot, and every leg
|
||||
# runs the REAL user surface for its methods:
|
||||
#
|
||||
# desktop-installer@latest the website's Hermes-Setup.exe, downloaded and
|
||||
# run headed, AutoHotkey clicks Install ->
|
||||
# Launch, the real Electron window must appear.
|
||||
# installer-script the irm | iex one-liner: the install.ps1
|
||||
# shipped AT the OLD ref, headless.
|
||||
# installer-script+desktop the same one-liner with -IncludeDesktop:
|
||||
# builds Hermes.exe AND registers Start Menu /
|
||||
# Desktop shortcuts.
|
||||
# hermes-update venv hermes.exe update.
|
||||
# open-app-update the app's own Update button, app launched
|
||||
# from the installed exe under Playwright's
|
||||
# Electron driver (Settings -> About ->
|
||||
# "Update now"); the production hand-off chain
|
||||
# runs untouched.
|
||||
# hermes-desktop-app-update the same button, app launched via `hermes
|
||||
# desktop`: the driver captures the product's
|
||||
# own spawn (argv/cwd/env) and re-executes it
|
||||
# under Playwright.
|
||||
#
|
||||
# Method pairs without a driver arm yet NATIVELY SKIP (grey check, no
|
||||
# runner): the capability knowledge lives here, next to the driver, so the
|
||||
# caller can dispatch every declared combination without knowing which ones
|
||||
# work.
|
||||
#
|
||||
# Call it:
|
||||
#
|
||||
# jobs:
|
||||
# windows:
|
||||
# uses: ./.github/workflows/install-e2e-windows-run.yml
|
||||
# with:
|
||||
# install-method: desktop-installer@latest
|
||||
# update-method: open-app-update
|
||||
# install-ref: v2026.8.3
|
||||
|
||||
name: install-e2e windows leg
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
install-method:
|
||||
description: 'How OLD gets installed. Supported: desktop-installer@latest (website exe, AHK-clicked), installer-script (irm | iex install.ps1) and installer-script+desktop (the same with -IncludeDesktop). Declared-but-TODO methods skip.'
|
||||
required: true
|
||||
type: string
|
||||
update-method:
|
||||
description: 'How the install updates to HEAD. Supported: open-app-update (Update button under Playwright, from a desktop-bearing install), hermes-desktop-app-update (same button, app launched via hermes desktop), hermes-update, installer-script, installer-script+desktop, desktop-installer@latest (re-download Hermes-Setup.exe, AHK clicks Install over the existing install).'
|
||||
required: true
|
||||
type: string
|
||||
install-ref:
|
||||
description: 'Ref to install as OLD (served as main while the installer runs). auto = the newest release tag in the checkout.'
|
||||
required: false
|
||||
type: string
|
||||
default: auto
|
||||
tag-has-desktop:
|
||||
description: "Whether install-ref ships the desktop app (apps/desktop). The caller annotates this from the tag's own tree; desktop-method legs from pre-desktop releases natively skip."
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
leg-id:
|
||||
description: 'Artifact-safe matrix leg id (from generate-e2e-matrix.mjs legId). Names this leg''s logs + player artifacts so the report job can link a row to its zip.'
|
||||
required: true
|
||||
type: string
|
||||
setup-exe-url:
|
||||
description: 'Bootstrap installer to install OLD with. Default: the latest published one — what a user downloads today.'
|
||||
required: false
|
||||
type: string
|
||||
default: https://hermes-assets.nousresearch.com/Hermes-Setup.exe
|
||||
timeout-minutes:
|
||||
description: 'Job timeout. The install leg does real toolchain work and the update leg a full Electron rebuild.'
|
||||
required: false
|
||||
type: number
|
||||
default: 60
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
# Short static name on purpose: name expressions render UNEXPANDED on
|
||||
# skipped jobs.
|
||||
name: e2e
|
||||
# The implemented {install x update} pairs. Two rules feed the table:
|
||||
# * every desktop-surface method needs the starting tag to ship
|
||||
# apps/desktop (pre-desktop releases have no window to launch, no
|
||||
# Update button to click, no -IncludeDesktop to pass);
|
||||
# * open-app-update needs an OS entry point, which only the
|
||||
# desktop-bearing installs create.
|
||||
if: >-
|
||||
(inputs.install-method == 'installer-script'
|
||||
|| (contains(fromJSON('["installer-script+desktop", "desktop-installer@latest"]'), inputs.install-method) && inputs.tag-has-desktop))
|
||||
&& (contains(fromJSON('["hermes-update", "installer-script"]'), inputs.update-method)
|
||||
|| (contains(fromJSON('["installer-script+desktop", "hermes-desktop-app-update", "desktop-installer@latest"]'), inputs.update-method) && inputs.tag-has-desktop)
|
||||
|| (inputs.update-method == 'open-app-update' && inputs.tag-has-desktop
|
||||
&& contains(fromJSON('["desktop-installer@latest", "installer-script+desktop"]'), inputs.install-method)))
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: ${{ inputs.timeout-minutes }}
|
||||
|
||||
env:
|
||||
# Sibling of the checkout (D:\a\hermes-agent\hermes-desktop-gui-e2e):
|
||||
# outside the repo so the staged bare clone and the install never
|
||||
# collide with the checkout itself. NOTE: ${{ runner.temp }} is NOT
|
||||
# available in job-level env (only github/inputs/matrix/needs/
|
||||
# secrets/strategy/vars).
|
||||
HERMES_E2E_WORKROOT: ${{ github.workspace }}\..\hermes-desktop-gui-e2e
|
||||
|
||||
steps:
|
||||
# Full history: the driver bare-clones this checkout as the repo the
|
||||
# installer/updater talk to, and both OLD and HEAD must be reachable
|
||||
# in that clone. A shallow checkout cannot serve either need.
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# One recording mechanism on every OS: the composite action installs
|
||||
# ffmpeg (cached - winget's download is the slow part), starts the
|
||||
# capture, and record-stop fails on a zero-frame file so a silently
|
||||
# missing recording cannot go green.
|
||||
- name: Start screen recording
|
||||
uses: ./.github/actions/e2e-screen-record
|
||||
with:
|
||||
mode: start
|
||||
output: ${{ github.workspace }}\gui-e2e-proof\recording.mkv
|
||||
|
||||
- name: Stage serve repo (main -> ${{ inputs.install-ref }})
|
||||
shell: powershell
|
||||
run: powershell -NoProfile -ExecutionPolicy Bypass -File tests\install\windows-e2e.ps1 -Phase stage -InstallMethod "${{ inputs.install-method }}" -Route "${{ inputs.update-method }}" -InstallRef "${{ inputs.install-ref }}" -SetupExeUrl ${{ inputs.setup-exe-url }}
|
||||
|
||||
- name: Install ${{ inputs.install-ref }} (${{ inputs.install-method }})
|
||||
shell: powershell
|
||||
run: powershell -NoProfile -ExecutionPolicy Bypass -File tests\install\windows-e2e.ps1 -Phase install -InstallMethod "${{ inputs.install-method }}" -Route "${{ inputs.update-method }}" -InstallRef "${{ inputs.install-ref }}" -SetupExeUrl ${{ inputs.setup-exe-url }}
|
||||
|
||||
- name: Update ${{ inputs.install-ref }} -> HEAD (${{ inputs.update-method }})
|
||||
id: update
|
||||
shell: powershell
|
||||
run: powershell -NoProfile -ExecutionPolicy Bypass -File tests\install\windows-e2e.ps1 -Phase update -InstallMethod "${{ inputs.install-method }}" -Route "${{ inputs.update-method }}" -InstallRef "${{ inputs.install-ref }}" -SetupExeUrl ${{ inputs.setup-exe-url }}
|
||||
|
||||
- name: Stage known-failure receipt
|
||||
if: steps.update.outputs.known_failure != ''
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Path gui-e2e-proof -Force | Out-Null
|
||||
Copy-Item -LiteralPath (Join-Path $env:HERMES_E2E_WORKROOT 'known-failure.json') -Destination gui-e2e-proof/known-failure.json
|
||||
|
||||
- name: Upload known-failure receipt
|
||||
if: steps.update.outputs.known_failure != ''
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: install-e2e-known-${{ steps.update.outputs.known_failure }}--${{ inputs.leg-id }}
|
||||
path: gui-e2e-proof/known-failure.json
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
- name: Stop screen recording
|
||||
if: always()
|
||||
uses: ./.github/actions/e2e-screen-record
|
||||
with:
|
||||
mode: stop
|
||||
output: ${{ github.workspace }}\gui-e2e-proof\recording.mkv
|
||||
|
||||
- name: Remux recording for browser playback
|
||||
if: always()
|
||||
shell: pwsh
|
||||
run: |
|
||||
$mkv = "$env:GITHUB_WORKSPACE\gui-e2e-proof\recording.mkv"
|
||||
if (Test-Path -LiteralPath $mkv) {
|
||||
& ffmpeg -y -hide_banner -loglevel error -i $mkv -c copy "$env:GITHUB_WORKSPACE\gui-e2e-proof\recording.mp4"
|
||||
}
|
||||
|
||||
- name: Collect proof + logs
|
||||
if: always()
|
||||
shell: powershell
|
||||
run: |
|
||||
$out = "gui-e2e-proof"
|
||||
New-Item -ItemType Directory -Path $out -Force | Out-Null
|
||||
$work = $env:HERMES_E2E_WORKROOT
|
||||
$home_ = Join-Path $work "hermes-home"
|
||||
foreach ($pair in @(
|
||||
@{ src = (Join-Path $work "proof"); dst = "proof" },
|
||||
@{ src = (Join-Path $work "logs"); dst = "driver-logs" },
|
||||
@{ src = (Join-Path $work "shas.json"); dst = "shas.json" },
|
||||
@{ src = (Join-Path $home_ "logs"); dst = "logs" },
|
||||
@{ src = (Join-Path $home_ ".hermes-update-result.json"); dst = ".hermes-update-result.json" }
|
||||
)) {
|
||||
if (Test-Path $pair.src) { Copy-Item $pair.src (Join-Path $out $pair.dst) -Recurse -Force }
|
||||
}
|
||||
|
||||
- name: Upload proof + logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: install-e2e-logs-${{ inputs.leg-id }}
|
||||
path: gui-e2e-proof
|
||||
retention-days: 14
|
||||
if-no-files-found: ignore
|
||||
@@ -2,15 +2,35 @@ name: Install & Update E2E
|
||||
|
||||
# Can a user on a released version get to this commit?
|
||||
#
|
||||
# For each release we sample, a leg installs that release through the real
|
||||
# `curl | install.sh` one-liner (uv, a managed Python, Node, the venv) inside
|
||||
# scripts/dev-sandbox.sh, then applies one update route and requires the
|
||||
# checkout to land on this commit with a working `hermes`.
|
||||
# The support matrix -- every {os, install-method, update-method} combination
|
||||
# a user could be on -- lives in scripts/sandbox/generate-e2e-matrix.mjs.
|
||||
# generate-matrix expands it against the picked release tags into one leg
|
||||
# per {combination, tag}, split into one matrix job per OS:
|
||||
#
|
||||
# Matrix: linux the real curl|bash install one-liner, isolated by a
|
||||
# git URL redirect to a local bare clone
|
||||
# (install-e2e-run.yml)
|
||||
# Matrix: windows the real desktop user flow: website Hermes-Setup.exe
|
||||
# clicked by AutoHotkey, update via the app, Playwright
|
||||
# clicking "Update now" (install-e2e-windows-run.yml)
|
||||
# Matrix: macos script installs on the shared OS-agnostic driver,
|
||||
# plus the real desktop user flow: website
|
||||
# Hermes-Setup.dmg mounted and run, updates via the
|
||||
# app under Playwright (install-e2e-macos-run.yml)
|
||||
#
|
||||
# Every combination is dispatched to its OS's run workflow; the run
|
||||
# workflow natively skips (grey) what its driver cannot run yet -- an
|
||||
# unimplemented method pair, or a starting tag that predates the surface
|
||||
# under test (pick-releases annotates each tag with what its tree ships,
|
||||
# e.g. whether the desktop app exists yet). Capability knowledge lives
|
||||
# next to each driver, never here and never in the generator: declaring a
|
||||
# method is a spec edit, implementing one is flipping the run workflow's
|
||||
# gate.
|
||||
#
|
||||
# The starting versions are chosen at runtime from the repo's release tags
|
||||
# (scripts/sandbox/pick-release-tags.sh): newest, oldest, and a spread between.
|
||||
# A hardcoded list would stop covering the newest release the day after it
|
||||
# ships, and would pin an "oldest" that nobody still runs.
|
||||
# (scripts/sandbox/pick-release-tags.sh): newest, oldest, and a spread
|
||||
# between. A hardcoded list would stop covering the newest release the day
|
||||
# after it ships, and would pin an "oldest" that nobody still runs.
|
||||
#
|
||||
# Triggers:
|
||||
# * every 12 hours, so upstream drift (a new uv, a Node bump, a PyPI change)
|
||||
@@ -27,16 +47,21 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
route:
|
||||
description: 'Which update route to exercise.'
|
||||
description: 'Which combinations to run. all = every OS; both/update/installer = the linux legs; windows-desktop = the windows legs; macos-desktop = the macos legs.'
|
||||
required: false
|
||||
type: choice
|
||||
default: both
|
||||
options: [both, update, installer]
|
||||
default: all
|
||||
options: [all, both, update, installer, windows-desktop, macos-desktop]
|
||||
tag-count:
|
||||
description: 'How many release tags to sample (newest, oldest, and a spread between).'
|
||||
required: false
|
||||
type: string
|
||||
default: '5'
|
||||
default: '3'
|
||||
install-ref:
|
||||
description: 'Optional exact release tag for a focused reproduction; overrides tag-count.'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
schedule:
|
||||
# Every 12 hours, off the hour to avoid the top-of-hour runner crunch.
|
||||
- cron: '20 7,19 * * *'
|
||||
@@ -54,8 +79,9 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Which released versions do we test updating FROM? Resolved once and shared
|
||||
# by both route matrices, so the two routes cover the same set.
|
||||
# Which released versions do we test updating FROM? Resolved once,
|
||||
# annotated with what each tag's own tree supports, and shared by every
|
||||
# OS's matrix so all combos cover the same set.
|
||||
pick-releases:
|
||||
name: Pick release tags
|
||||
runs-on: ubuntu-latest
|
||||
@@ -63,9 +89,10 @@ jobs:
|
||||
outputs:
|
||||
tags: ${{ steps.pick.outputs.tags }}
|
||||
steps:
|
||||
# This job only reads tag names and runs one script, so take the cheap
|
||||
# checkout: no blobs (filter), no other files (sparse), but DO fetch tags
|
||||
# -- they are the whole input, and the default shallow checkout has none.
|
||||
# This job only reads tag names and trees, so take the cheap
|
||||
# checkout: no blobs (filter), no other files (sparse), but DO fetch
|
||||
# tags -- they are the whole input, and the default shallow checkout
|
||||
# has none.
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
filter: blob:none
|
||||
@@ -73,38 +100,179 @@ jobs:
|
||||
sparse-checkout: scripts/sandbox/pick-release-tags.sh
|
||||
sparse-checkout-cone-mode: false
|
||||
- id: pick
|
||||
env:
|
||||
# Dispatch inputs never touch shell syntax directly: TAG_COUNT
|
||||
# arrives via the environment and is validated decimal-only (bash
|
||||
# arithmetic reads a leading zero as octal). GitHub's 256-job cap
|
||||
# applies to each per-OS matrix separately; at 10 tags the largest
|
||||
# is windows at 180 (first over the cap at 15 tags = 270).
|
||||
TAG_COUNT: ${{ inputs.tag-count || 2 }}
|
||||
INSTALL_REF: ${{ inputs.install-ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tags="$(scripts/sandbox/pick-release-tags.sh --count '${{ inputs.tag-count || 5 }}')"
|
||||
[[ "$TAG_COUNT" =~ ^(10|[1-9])$ ]] || { echo "tag-count must be 1-10, got: $TAG_COUNT" >&2; exit 1; }
|
||||
if [ -n "$INSTALL_REF" ]; then
|
||||
[[ "$INSTALL_REF" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?$ ]] || { echo 'install-ref must be an exact release tag' >&2; exit 1; }
|
||||
git rev-parse --verify "refs/tags/$INSTALL_REF^{commit}" >/dev/null
|
||||
tags="$(jq -cn --arg ref "$INSTALL_REF" '[$ref]')"
|
||||
else
|
||||
tags="$(scripts/sandbox/pick-release-tags.sh --count "$TAG_COUNT")"
|
||||
fi
|
||||
echo "Testing updates from: $tags"
|
||||
echo "tags=$tags" >> "$GITHUB_OUTPUT"
|
||||
# Annotate each tag with what its own tree supports, so run
|
||||
# workflows can natively skip surfaces the starting version does
|
||||
# not have. Today: does the release ship the desktop app
|
||||
# (apps/desktop, #20059)? Cheaper here -- the tags are already
|
||||
# fetched -- than a probe job per leg.
|
||||
enriched="$(for t in $(echo "$tags" | jq -r '.[]'); do
|
||||
if git ls-tree -d "$t" apps/desktop | grep -q .; then d=true; else d=false; fi
|
||||
echo "{\"ref\":\"$t\",\"desktop\":$d}"
|
||||
done | jq -sc .)"
|
||||
echo "Annotated: $enriched"
|
||||
echo "tags=$enriched" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# `hermes update` -- the route most users take.
|
||||
update:
|
||||
if: github.event_name != 'workflow_dispatch' || inputs.route != 'installer'
|
||||
# Expand the support matrix against the picked tags: one leg per
|
||||
# {os, install-method, update-method, tag}, split into a matrix per OS.
|
||||
generate-matrix:
|
||||
name: Expand combinations
|
||||
needs: pick-releases
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
linux: ${{ steps.gen.outputs.linux }}
|
||||
windows: ${{ steps.gen.outputs.windows }}
|
||||
macos: ${{ steps.gen.outputs.macos }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
sparse-checkout: scripts/sandbox/generate-e2e-matrix.mjs
|
||||
sparse-checkout-cone-mode: false
|
||||
- id: gen
|
||||
run: |
|
||||
set -euo pipefail
|
||||
matrices="$(node scripts/sandbox/generate-e2e-matrix.mjs \
|
||||
--tags '${{ needs.pick-releases.outputs.tags }}')"
|
||||
echo "$matrices"
|
||||
for key in linux windows macos; do
|
||||
echo "$key=$(echo "$matrices" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>console.log(JSON.stringify(JSON.parse(d)[process.argv[1]])))' "$key")" >> "$GITHUB_OUTPUT"
|
||||
done
|
||||
# The plan, human-readable: a combination x starting-tag chart on
|
||||
# the run's summary page.
|
||||
node scripts/sandbox/generate-e2e-matrix.mjs \
|
||||
--tags '${{ needs.pick-releases.outputs.tags }}' \
|
||||
--format markdown >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
linux:
|
||||
name: ${{ matrix.name }}
|
||||
# The update/installer route choices map to the linux update methods;
|
||||
# either way the whole linux matrix runs (legs are cheap and the
|
||||
# distinction wasn't worth a filter layer in the generator).
|
||||
if: github.event_name != 'workflow_dispatch' || contains(fromJSON('["all", "both", "update", "installer"]'), inputs.route)
|
||||
needs: generate-matrix
|
||||
strategy:
|
||||
# One release breaking is worth knowing about even if another already
|
||||
# One leg breaking is worth knowing about even if another already
|
||||
# failed, so let every leg report.
|
||||
fail-fast: false
|
||||
matrix:
|
||||
install-ref: ${{ fromJSON(needs.pick-releases.outputs.tags) }}
|
||||
matrix: ${{ fromJSON(needs.generate-matrix.outputs.linux) }}
|
||||
uses: ./.github/workflows/install-e2e-run.yml
|
||||
with:
|
||||
route: update
|
||||
install-ref: ${{ matrix.install-ref }}
|
||||
install-method: ${{ matrix.install_method }}
|
||||
update-method: ${{ matrix.update_method }}
|
||||
install-ref: ${{ matrix.install_ref }}
|
||||
tag-has-desktop: ${{ matrix.tag_has_desktop }}
|
||||
leg-id: ${{ matrix.leg_id }}
|
||||
|
||||
# Re-running the curl one-liner over an existing checkout: autostash + pull
|
||||
# rather than the updater's own git handling.
|
||||
installer:
|
||||
if: github.event_name != 'workflow_dispatch' || inputs.route != 'update'
|
||||
needs: pick-releases
|
||||
windows:
|
||||
name: ${{ matrix.name }}
|
||||
if: github.event_name != 'workflow_dispatch' || contains(fromJSON('["all", "windows-desktop"]'), inputs.route)
|
||||
needs: generate-matrix
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 3
|
||||
matrix:
|
||||
install-ref: ${{ fromJSON(needs.pick-releases.outputs.tags) }}
|
||||
uses: ./.github/workflows/install-e2e-run.yml
|
||||
matrix: ${{ fromJSON(needs.generate-matrix.outputs.windows) }}
|
||||
uses: ./.github/workflows/install-e2e-windows-run.yml
|
||||
with:
|
||||
route: installer
|
||||
install-ref: ${{ matrix.install-ref }}
|
||||
install-method: ${{ matrix.install_method }}
|
||||
update-method: ${{ matrix.update_method }}
|
||||
install-ref: ${{ matrix.install_ref }}
|
||||
tag-has-desktop: ${{ matrix.tag_has_desktop }}
|
||||
leg-id: ${{ matrix.leg_id }}
|
||||
|
||||
macos:
|
||||
name: ${{ matrix.name }}
|
||||
if: github.event_name != 'workflow_dispatch' || contains(fromJSON('["all", "macos-desktop"]'), inputs.route)
|
||||
needs: generate-matrix
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJSON(needs.generate-matrix.outputs.macos) }}
|
||||
# Two driver arms: the OS-agnostic script driver (shared with linux)
|
||||
# and the published-dmg GUI driver; the run workflow routes.
|
||||
uses: ./.github/workflows/install-e2e-macos-run.yml
|
||||
with:
|
||||
install-method: ${{ matrix.install_method }}
|
||||
update-method: ${{ matrix.update_method }}
|
||||
install-ref: ${{ matrix.install_ref }}
|
||||
tag-has-desktop: ${{ matrix.tag_has_desktop }}
|
||||
leg-id: ${{ matrix.leg_id }}
|
||||
|
||||
# The leg player: one static HTML for the whole run. Uploaded BEFORE the
|
||||
# matrix legs so it exists even when every leg dies; the report job links
|
||||
# every ran leg to it with that leg's logs zip as a #zip= hash param
|
||||
# (hash survives the artifact URL's server-side redirect, the query does
|
||||
# not). archive: false makes GitHub name the artifact after the FILE
|
||||
# (playback.html), ignoring the name: input -- harmless, the renderer
|
||||
# looks it up by that name.
|
||||
leg-player:
|
||||
name: Upload leg player
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
sparse-checkout: tests/install/e2e-assets/playback.html
|
||||
sparse-checkout-cone-mode: false
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: install-e2e-player
|
||||
path: tests/install/e2e-assets/playback.html
|
||||
archive: false
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
|
||||
# The outcome, human-readable: the plan chart again, with each cell
|
||||
# replaced by how that leg actually concluded. Per-leg conclusions are
|
||||
# NOT reachable through `needs` (a matrix job's result collapses to one
|
||||
# aggregate), so the table body comes from the run's own job list; the
|
||||
# `needs` results only sequence this job after every leg and provide
|
||||
# the per-OS aggregates.
|
||||
report:
|
||||
name: Result chart
|
||||
if: always()
|
||||
needs: [leg-player, pick-releases, linux, windows, macos]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
sparse-checkout: |
|
||||
scripts/sandbox/generate-e2e-matrix.mjs
|
||||
tests/install/e2e-assets/known-failures.json
|
||||
sparse-checkout-cone-mode: false
|
||||
- env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
{
|
||||
echo "OS jobs: linux ${{ needs.linux.result }}, windows ${{ needs.windows.result }}, macos ${{ needs.macos.result }}"
|
||||
echo
|
||||
# The tag annotations let the chart say WHY a cell skipped
|
||||
# (pre-desktop vs declared TODO) instead of a flat "skip".
|
||||
gh api "repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/jobs?per_page=100" \
|
||||
--paginate --jq '.jobs[] | {name, conclusion}' > /tmp/e2e-jobs.ndjson
|
||||
gh api "repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts?per_page=100" \
|
||||
--paginate --jq '.artifacts[] | {name, id}' > /tmp/e2e-artifacts.ndjson
|
||||
echo 'Legend: ✅ upgrade passed · known [n] = exact historical failure, see footnote · ❌ unexpected failure · pre-desktop / TODO = why a leg skipped · 📼 opens the leg player (recording + synced logs)'
|
||||
echo
|
||||
node scripts/sandbox/generate-e2e-matrix.mjs --format results \
|
||||
--tags '${{ needs.pick-releases.outputs.tags }}' \
|
||||
--artifacts /tmp/e2e-artifacts.ndjson < /tmp/e2e-jobs.ndjson
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -36,3 +36,11 @@ jobs:
|
||||
- name: 8.3 short-path normalization (Windows PowerShell 5.1)
|
||||
shell: powershell
|
||||
run: powershell -NoProfile -ExecutionPolicy Bypass -File scripts/tests/test-install-ps1-longpath.ps1
|
||||
|
||||
- name: System Node and npm compatibility (pwsh 7)
|
||||
shell: pwsh
|
||||
run: pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/tests/test-install-ps1-node-compatibility.ps1
|
||||
|
||||
- name: System Node and npm compatibility (Windows PowerShell 5.1)
|
||||
shell: powershell
|
||||
run: powershell -NoProfile -ExecutionPolicy Bypass -File scripts/tests/test-install-ps1-node-compatibility.ps1
|
||||
|
||||
@@ -178,3 +178,25 @@ jobs:
|
||||
|
||||
- name: Run footgun checker
|
||||
run: python scripts/check-windows-footguns.py --all
|
||||
|
||||
# The Sep 2026 decomposition kept old import paths alive for external plugins
|
||||
# (PLUGIN-COMPAT blocks, see COMPAT_MANIFEST.md). They are removed on schedule by
|
||||
# reverting one commit, so in-tree code must never depend on them.
|
||||
- name: Forbid in-tree use of plugin-compat pointers
|
||||
run: python scripts/check_compat_pointers.py
|
||||
|
||||
# Advisory: dropped public names / methods / test defs vs the PR base, printed into the log.
|
||||
# A refactor that silently removes a public symbol breaks plugins that import it; the Sep 2026
|
||||
# decomposition opened with 1,703 such drops that reviewers had to find by hand.
|
||||
# Advisory: it never fails the job. The checkout above is depth-1, so deepen both sides until
|
||||
# a merge-base exists (the script refuses to report a clean diff without one, by design).
|
||||
- name: Public-surface diff vs base (advisory)
|
||||
if: github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
git fetch --no-tags --deepen=200 origin "${{ github.base_ref }}" HEAD
|
||||
for i in 1 2 3; do
|
||||
git merge-base "origin/${{ github.base_ref }}" HEAD >/dev/null 2>&1 && break
|
||||
git fetch --no-tags --deepen=1000 origin "${{ github.base_ref }}" HEAD
|
||||
done
|
||||
python scripts/ci/check_public_surface.py --base "origin/${{ github.base_ref }}" --head HEAD
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
name: Profile Artifact Boundary
|
||||
|
||||
# A reusable, unconditional guard for the incident class in #92457. Ignore
|
||||
# files reduce accidental staging; this job is the enforcement boundary that
|
||||
# still catches `git add -f` and generated files present during a build.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check-profile-artifacts:
|
||||
name: Reject profile archives
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Reject profile exports in the checkout
|
||||
run: python3 scripts/ci/check_profile_archive_boundary.py
|
||||
@@ -27,6 +27,19 @@ name: OS-specific tests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
desktop_updater:
|
||||
description: >-
|
||||
Run the Windows desktop-update hand-off integration tests
|
||||
(tests/test_desktop_update_windows_*.py). These spawn the real
|
||||
scripts/desktop-update/windows.ps1 and poll its loopback server, so
|
||||
they carry process-timing noise a shared runner amplifies; the
|
||||
caller gates them on the classifier's desktop_updater lane so a PR
|
||||
that never touched that surface cannot be failed by it. Push /
|
||||
dispatch runs fail open (classifier sets every lane true).
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -134,9 +147,23 @@ jobs:
|
||||
# would therefore abort the script on any non-zero exit and the
|
||||
# exit-5 branch below would be unreachable dead code — the job
|
||||
# would still fail red, but the diagnostic would never print.
|
||||
# Desktop-update hand-off integration tests spawn the real
|
||||
# windows.ps1; deselect them unless the PR touched that surface
|
||||
# (see the workflow_call input). ``--ignore-glob`` keeps the file
|
||||
# list above intact, so a renamed test file still trips the
|
||||
# zero-tests guard rather than silently vanishing.
|
||||
# (bash 3.2 on the macOS runner: an empty array under ``set -u`` is
|
||||
# an unbound-variable error, hence the ``${arr[@]+...}`` idiom.)
|
||||
EXTRA_ARGS=()
|
||||
if [ "${{ inputs.desktop_updater }}" != "true" ]; then
|
||||
echo "desktop_updater lane off: skipping tests/test_desktop_update_windows_*.py"
|
||||
EXTRA_ARGS+=(--ignore-glob='*test_desktop_update_windows_*.py')
|
||||
fi
|
||||
|
||||
status=0
|
||||
uv run --no-sync python -m pytest \
|
||||
"$@" \
|
||||
${EXTRA_ARGS[@]+"${EXTRA_ARGS[@]}"} \
|
||||
-m "${{ matrix.marker }} and not integration" \
|
||||
-v --tb=short || status=$?
|
||||
if [ "$status" -eq 5 ]; then
|
||||
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: uv sync --locked --python 3.11 --extra dev
|
||||
command: uv sync --locked --python 3.11 --extra dev --extra messaging
|
||||
|
||||
- name: Run venv-holder live E2E
|
||||
shell: bash
|
||||
@@ -58,4 +58,23 @@ jobs:
|
||||
set -uo pipefail
|
||||
uv run --no-sync python -m pytest \
|
||||
tests/hermes_cli/test_venv_holder_windows_live.py \
|
||||
tests/hermes_cli/test_taskkill_identity_windows_live.py \
|
||||
tests/hermes_cli/test_git_trampoline_windows_live.py \
|
||||
"tests/hermes_cli/test_managed_uv.py::TestWindowsRuntimeSelfLock" \
|
||||
-o addopts= -v -p no:cacheprovider
|
||||
|
||||
- name: Run Telegram CLOSE-WAIT reconnect live E2E (#87057)
|
||||
shell: bash
|
||||
run: |
|
||||
set -uo pipefail
|
||||
uv run --no-sync python -m pytest \
|
||||
tests/gateway/test_telegram_closewait_windows_live.py \
|
||||
-o addopts= -v -p no:cacheprovider
|
||||
|
||||
- name: Run background-executor spawn parity live E2E (#70716)
|
||||
shell: bash
|
||||
run: |
|
||||
set -uo pipefail
|
||||
uv run --no-sync python -m pytest \
|
||||
tests/tools/test_process_registry_windows_live.py \
|
||||
-o addopts= -v -p no:cacheprovider
|
||||
|
||||
Reference in New Issue
Block a user