ci(install-e2e): run a path-filtered four-leg subset on pull requests

install-e2e.yml only ran on the clock, so nothing in front of a merge
installed a release and updated it on a real OS. A pull_request trigger,
path-filtered to the install/update surface (derived from 60 days of
update/install/pm commits), runs the new `pr` route of
generate-e2e-matrix.mjs with only the newest release tag sampled:

  linux   installer-script -> hermes-update (newest release -> PR)
  linux   installer-script -> hermes-update (PR -> NEXT)
  windows installer-script -> hermes-update (PR -> NEXT)
  macos   installer-script -> hermes-update (newest release -> PR)

The bundle-manifest validation job is skipped on PRs (bundled legs need
dispatch-only manifests). The full matrix stays on schedule and release.
This commit is contained in:
teknium1
2026-09-26 17:51:42 -07:00
committed by Teknium
parent 116b3e2f9e
commit 7605349f25
3 changed files with 89 additions and 14 deletions

View File

@@ -36,11 +36,14 @@ name: Install & Update E2E
# * every 12 hours, so upstream drift (a new uv, a Node bump, a PyPI change) # * every 12 hours, so upstream drift (a new uv, a Node bump, a PyPI change)
# surfaces on a schedule rather than in someone's review cycle; # surfaces on a schedule rather than in someone's review cycle;
# * manually, where you can pick the route (a preset or specific legs) and how many # * manually, where you can pick the route (a preset or specific legs) and how many
# releases to sample. # releases to sample;
# # * pull_request, path-filtered to the install/update surface, runs ONLY the
# Deliberately NOT on pull_request: a leg takes ~11 minutes of real toolchain # `pr` route (generate-e2e-matrix.mjs PR_SUBSET): installer-script ->
# installation, and the matrix multiplies that. Updating is release-shaped work, # hermes-update on linux (newest release -> PR, and PR -> NEXT), windows
# so it is gated on releases and the clock instead. # (PR -> NEXT) and macos (newest release -> PR), sampling just the newest tag.
# Four legs in parallel keep a PR's wall clock near one leg (~15 min);
# the full matrix multiplies that, so it stays on the clock and releases.
# A red scheduled run is reported by install-e2e-red.yml.
on: on:
workflow_dispatch: workflow_dispatch:
@@ -116,6 +119,41 @@ on:
schedule: schedule:
# Every 12 hours, off the hour to avoid the top-of-hour runner crunch. # Every 12 hours, off the hour to avoid the top-of-hour runner crunch.
- cron: '20 7,19 * * *' - cron: '20 7,19 * * *'
pull_request:
# The install/update surface: derived from 60 days of commits whose subject
# names update/install/pm/bootstrap/relaunch. Keep it to code a real
# install or `hermes update` executes; hermes_cli/main.py and friends are
# deliberately absent (they change daily and are covered by e2e-upgrade).
paths:
- 'scripts/install*'
- 'scripts/desktop-update/**'
- 'setup-hermes.*'
- 'hermes_bootstrap.py'
- 'pm/**'
- 'hermes_cli/update*'
- 'hermes_cli/*update*'
- 'hermes_cli/pm*'
- 'hermes_cli/*install*'
- 'hermes_cli/_early_recovery.py'
- 'hermes_cli/_launchers.py'
- 'hermes_cli/managed_uv.py'
- 'hermes_cli/venv_sync.py'
- 'hermes_cli/relaunch.py'
- 'hermes_cli/gateway.py'
- 'gateway/status.py'
- 'apps/bootstrap-installer/**'
- 'apps/desktop/electron/*update*'
- 'apps/desktop/electron/updater/**'
- 'pyproject.toml'
- 'uv.lock'
- 'package.json'
- 'package-lock.json'
- 'tests/install/**'
- 'scripts/sandbox/generate-e2e-matrix.mjs'
- 'scripts/sandbox/pick-release-tags.sh'
- 'scripts/ci/setup_toolchain.py'
- '.github/actions/setup-pm/**'
- '.github/workflows/install-e2e*.yml'
permissions: permissions:
contents: read contents: read
@@ -159,7 +197,7 @@ jobs:
# stable release the candidate tag itself is the newest release # stable release the candidate tag itself is the newest release
# tag, and sampling it as an OLD version would produce a no-change # tag, and sampling it as an OLD version would produce a no-change
# update leg — the candidate must never update from itself. # update leg — the candidate must never update from itself.
TAG_COUNT: ${{ inputs.tag-count || 2 }} TAG_COUNT: ${{ github.event_name == 'pull_request' && '1' || inputs.tag-count || 2 }}
INSTALL_REF: ${{ inputs.install-ref }} INSTALL_REF: ${{ inputs.install-ref }}
EXCLUDE_REF: ${{ inputs.exclude-ref }} EXCLUDE_REF: ${{ inputs.exclude-ref }}
RELEASE_MODE: ${{ inputs.release }} RELEASE_MODE: ${{ inputs.release }}
@@ -211,8 +249,8 @@ jobs:
- id: gen - id: gen
env: env:
# Free text on dispatch, so it reaches the script as data, never as script. # Free text on dispatch, so it reaches the script as data, never as script.
# Scheduled runs have no inputs: the default route. # Scheduled runs have no inputs: the default route. PRs run the pr subset.
ROUTE: ${{ inputs.route || 'all' }} ROUTE: ${{ github.event_name == 'pull_request' && 'pr' || inputs.route || 'all' }}
run: | run: |
set -euo pipefail set -euo pipefail
matrices="$(node scripts/sandbox/generate-e2e-matrix.mjs \ matrices="$(node scripts/sandbox/generate-e2e-matrix.mjs \
@@ -230,6 +268,8 @@ jobs:
bundle-inputs: bundle-inputs:
name: Validate pinned bundle transitions name: Validate pinned bundle transitions
# Bundled legs need dispatch-only manifests; a PR never runs them.
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 5 timeout-minutes: 5
outputs: outputs:

View File

@@ -282,9 +282,31 @@ const ROUTE_OSES = /** @type {Record<string, Os[]>} */ ({
}); });
/** /**
* Narrow the matrices to a route: a preset keeps whole OS matrices; anything else selects * The pull_request subset: the script install updated by `hermes update`, on every OS,
* legs by name, so a leg name, a fragment of one, or a pasted job name ("<leg> / e2e") runs * from the two starts that catch a regression before it ships -- the newest release
* just those legs. A route that selects nothing throws rather than yielding a green empty run. * updating to the PR (the jump every user makes next) and the PR updating to a synthetic
* NEXT (the updater the PR itself ships). One leg per {os, start} worth its runner:
* linux both starts, windows the PR's own updater, macos the release jump.
* @type {{os: Os, to: UpdateTarget}[]}
*/
const PR_SUBSET = [
{ os: 'linux', to: 'HEAD' },
{ os: 'linux', to: 'NEXT' },
{ os: 'windows', to: 'NEXT' },
{ os: 'macos', to: 'HEAD' },
];
/** @param {Os} os @param {MatrixEntry} entry */
function inPrSubset(os, entry) {
return entry.install_method === 'installer-script' && entry.update_method === 'hermes-update' &&
PR_SUBSET.some((leg) => leg.os === os && leg.to === entry.update_ref);
}
/**
* Narrow the matrices to a route: a preset keeps whole OS matrices; `pr` keeps the
* PR_SUBSET legs; anything else selects legs by name, so a leg name, a fragment of one, or
* a pasted job name ("<leg> / e2e") runs just those legs. A route that selects nothing
* throws rather than yielding a green empty run.
* *
* @param {Record<Os, {include: MatrixEntry[]}>} matrices * @param {Record<Os, {include: MatrixEntry[]}>} matrices
* @param {string} route * @param {string} route
@@ -293,9 +315,11 @@ const ROUTE_OSES = /** @type {Record<string, Os[]>} */ ({
export function selectRoute(matrices, route) { export function selectRoute(matrices, route) {
const oses = ROUTE_OSES[route]; const oses = ROUTE_OSES[route];
/** @type {(os: Os, entry: MatrixEntry) => boolean} */ /** @type {(os: Os, entry: MatrixEntry) => boolean} */
const keep = oses const keep = route === 'pr'
? (os) => oses.includes(os) ? inPrSubset
: (_os, entry) => entry.name.includes(route) || route.startsWith(`${entry.name} /`); : oses
? (os) => oses.includes(os)
: (_os, entry) => entry.name.includes(route) || route.startsWith(`${entry.name} /`);
/** @type {Record<Os, {include: MatrixEntry[]}>} */ /** @type {Record<Os, {include: MatrixEntry[]}>} */
const picked = { linux: { include: [] }, windows: { include: [] }, macos: { include: [] } }; const picked = { linux: { include: [] }, windows: { include: [] }, macos: { include: [] } };
for (const os of /** @type {Os[]} */ (Object.keys(picked))) { for (const os of /** @type {Os[]} */ (Object.keys(picked))) {

View File

@@ -42,3 +42,14 @@ test('an OS preset runs that OS whole and no other', () => {
test('a route that selects nothing fails instead of running an empty green matrix', () => { test('a route that selects nothing fails instead of running an empty green matrix', () => {
expect(() => generate('windows: no-such-method')).toThrow() expect(() => generate('windows: no-such-method')).toThrow()
}) })
test('the pr route runs one script install updated by hermes update per OS start, nothing else', () => {
const pr: Matrices = generate('pr')
expect(legNames(pr).sort()).toEqual([
'linux: installer-script -> hermes-update (HEAD -> NEXT)',
'linux: installer-script -> hermes-update (v2026.6.19 -> HEAD)',
'macos: installer-script -> hermes-update (v2026.6.19 -> HEAD)',
'windows: installer-script -> hermes-update (HEAD -> NEXT)',
])
})