From 1c60bdc30d07ca68c24aabebada7d1c43df8f371 Mon Sep 17 00:00:00 2001 From: ethernet Date: Tue, 11 Aug 2026 16:54:54 -0400 Subject: [PATCH] ci(install-e2e): back to the generator - workflows stay generic, names carry everything Revert the hardcoded 16-job experiment: the combination spec belongs in scripts/sandbox/generate-e2e-matrix.mjs (restored), not copy-pasted YAML blocks. What survives from the experiment: * leg names carry everything - 'os: install -> update (tag -> HEAD)' - generated per entry, since slash-joined names are all the graph renders; * pick-releases annotates each tag ({ref, desktop}) and the generator threads tag_has_desktop onto windows entries, so the windows run workflow still gates pre-desktop tags without a probe job; * the per-OS run workflows are untouched: single job, static 'e2e' name, native skip gates own all capability knowledge. install-e2e.yml is one generate job + three per-OS matrix fanouts. Generator shape (4/16/12 legs for 2 tags), annotation threading, and all six error paths verified; all four workflows pass actionlint; driver parses clean pure-ASCII. --- .github/workflows/install-e2e-macos-run.yml | 2 +- .github/workflows/install-e2e-run.yml | 2 +- .github/workflows/install-e2e.yml | 333 +++++--------------- scripts/sandbox/generate-e2e-matrix.mjs | 223 +++++++++++++ tests/install/windows-desktop-gui-e2e.ps1 | 7 +- 5 files changed, 309 insertions(+), 258 deletions(-) create mode 100644 scripts/sandbox/generate-e2e-matrix.mjs diff --git a/.github/workflows/install-e2e-macos-run.yml b/.github/workflows/install-e2e-macos-run.yml index 450cf42bc0..51e28efb76 100644 --- a/.github/workflows/install-e2e-macos-run.yml +++ b/.github/workflows/install-e2e-macos-run.yml @@ -10,7 +10,7 @@ name: Install & Update E2E — macOS (reusable) # next to where the driver will be, and implementing a method flips this # workflow's job-level `if`. # -# Declared methods (see install-e2e.yml's macos-* jobs): +# Declared methods (see the generator's SPEC): # install: curl-bash, packaged-app # update: curl-bash, hermes-update, app-update diff --git a/.github/workflows/install-e2e-run.yml b/.github/workflows/install-e2e-run.yml index c7077870ca..db88cfbcd0 100644 --- a/.github/workflows/install-e2e-run.yml +++ b/.github/workflows/install-e2e-run.yml @@ -10,7 +10,7 @@ name: Install & Update E2E (reusable) # is independent: its own sandbox, its own install, nothing rewound or # shared. # -# Method ids come from install-e2e.yml's combination jobs. Supported +# Method ids come from scripts/sandbox/generate-e2e-matrix.mjs. Supported # today: install via curl-bash, update via hermes-update or curl-bash. # Anything else NATIVELY SKIPS (grey check, no runner): capability # knowledge lives here, next to the driver, so the caller can dispatch diff --git a/.github/workflows/install-e2e.yml b/.github/workflows/install-e2e.yml index bf29956dd2..bb39cf48d9 100644 --- a/.github/workflows/install-e2e.yml +++ b/.github/workflows/install-e2e.yml @@ -2,28 +2,26 @@ name: Install & Update E2E # Can a user on a released version get to this commit? # -# One matrix job per {os, install-method -> update-method} combination a -# user could be on, hardcoded HERE so the workflow is the support matrix -# and the Actions graph draws one box per combination. Each box fans out -# over the release tags picked at runtime, so every leg reads -# " -> HEAD": install that tag the combo's way, update to this commit -# the combo's way. +# 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-* a real curl|bash install in the bubblewrap sandbox -# (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-* no driver yet (install-e2e-macos-run.yml) +# Matrix: linux a real curl|bash install in the bubblewrap sandbox +# (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 no driver yet (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 (e.g. windows desktop legs from releases before the desktop -# app existed). Capability knowledge lives next to each driver, never -# here: adding a method = adding one job below; implementing one = flipping -# the run workflow's gate. +# 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 @@ -72,8 +70,9 @@ concurrency: cancel-in-progress: true jobs: - # Which released versions do we test updating FROM? Resolved once and - # shared by every combination's matrix, so all combos 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 @@ -81,9 +80,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 @@ -95,11 +95,11 @@ jobs: set -euo pipefail tags="$(scripts/sandbox/pick-release-tags.sh --count '${{ inputs.tag-count || 5 }}')" echo "Testing updates from: $tags" - # Annotate each tag with what its own tree supports, so legs can - # natively skip surfaces the starting version does not have. - # Today: does the release ship the desktop app (apps/desktop, - # #20059)? Cheaper and simpler here -- the tags are already - # checked out -- than a probe job per windows leg. + # 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}" @@ -107,248 +107,75 @@ jobs: echo "Annotated: $enriched" echo "tags=$enriched" >> "$GITHUB_OUTPUT" - # ---- linux: install via curl-bash --------------------------------------- - - linux-curl-bash-to-hermes-update: - name: "linux: curl-bash -> hermes-update (${{ matrix.tag.ref }} -> HEAD)" - if: github.event_name != 'workflow_dispatch' || contains(fromJSON('["all", "both", "update"]'), inputs.route) + # 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 }}' \ + --route '${{ inputs.route || 'all' }}')" + 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 + + linux: + name: ${{ matrix.name }} + if: fromJSON(needs.generate-matrix.outputs.linux).include[0] != null + needs: generate-matrix strategy: # One leg breaking is worth knowing about even if another already # failed, so let every leg report. fail-fast: false - matrix: - tag: ${{ fromJSON(needs.pick-releases.outputs.tags) }} + max-parallel: 4 + matrix: ${{ fromJSON(needs.generate-matrix.outputs.linux) }} uses: ./.github/workflows/install-e2e-run.yml with: - install-method: curl-bash - update-method: hermes-update - install-ref: ${{ matrix.tag.ref }} + install-method: ${{ matrix.install_method }} + update-method: ${{ matrix.update_method }} + install-ref: ${{ matrix.install_ref }} - linux-curl-bash-to-curl-bash: - name: "linux: curl-bash -> curl-bash (${{ matrix.tag.ref }} -> HEAD)" - if: github.event_name != 'workflow_dispatch' || contains(fromJSON('["all", "both", "installer"]'), inputs.route) - needs: pick-releases - strategy: - fail-fast: false - max-parallel: 3 - matrix: - tag: ${{ fromJSON(needs.pick-releases.outputs.tags) }} - uses: ./.github/workflows/install-e2e-run.yml - with: - install-method: curl-bash - update-method: curl-bash - install-ref: ${{ matrix.tag.ref }} - - # ---- windows: install via the website's Hermes-Setup.exe ---------------- - - windows-desktop-installer-to-desktop-app: - name: "windows: desktop-installer@latest -> desktop-app (${{ matrix.tag.ref }} -> HEAD)" - if: github.event_name != 'workflow_dispatch' || contains(fromJSON('["all", "windows-desktop"]'), inputs.route) - needs: pick-releases + windows: + name: ${{ matrix.name }} + if: fromJSON(needs.generate-matrix.outputs.windows).include[0] != null + needs: generate-matrix strategy: fail-fast: false # 2x-cost runners at ~14 min a leg: two at a time is throughput - # enough for a 5-tag axis without hogging the Windows pool. + # enough without hogging the Windows pool. max-parallel: 2 - matrix: - tag: ${{ fromJSON(needs.pick-releases.outputs.tags) }} + matrix: ${{ fromJSON(needs.generate-matrix.outputs.windows) }} uses: ./.github/workflows/install-e2e-windows-run.yml with: - tag-has-desktop: ${{ matrix.tag.desktop }} - install-method: desktop-installer@latest - update-method: desktop-app - install-ref: ${{ matrix.tag.ref }} + install-method: ${{ matrix.install_method }} + update-method: ${{ matrix.update_method }} + install-ref: ${{ matrix.install_ref }} + tag-has-desktop: ${{ matrix.tag_has_desktop }} - windows-desktop-installer-to-installer-rerun: - name: "windows: desktop-installer@latest -> desktop-installer-rerun@latest (${{ matrix.tag.ref }} -> HEAD)" - if: github.event_name != 'workflow_dispatch' || contains(fromJSON('["all", "windows-desktop"]'), inputs.route) - needs: pick-releases + macos: + name: ${{ matrix.name }} + if: fromJSON(needs.generate-matrix.outputs.macos).include[0] != null + needs: generate-matrix strategy: fail-fast: false - matrix: - tag: ${{ fromJSON(needs.pick-releases.outputs.tags) }} - uses: ./.github/workflows/install-e2e-windows-run.yml - with: - tag-has-desktop: ${{ matrix.tag.desktop }} - install-method: desktop-installer@latest - update-method: desktop-installer-rerun@latest - install-ref: ${{ matrix.tag.ref }} - - windows-desktop-installer-to-hermes-update: - name: "windows: desktop-installer@latest -> hermes-update (${{ matrix.tag.ref }} -> HEAD)" - if: github.event_name != 'workflow_dispatch' || contains(fromJSON('["all", "windows-desktop"]'), inputs.route) - needs: pick-releases - strategy: - fail-fast: false - matrix: - tag: ${{ fromJSON(needs.pick-releases.outputs.tags) }} - uses: ./.github/workflows/install-e2e-windows-run.yml - with: - tag-has-desktop: ${{ matrix.tag.desktop }} - install-method: desktop-installer@latest - update-method: hermes-update - install-ref: ${{ matrix.tag.ref }} - - windows-desktop-installer-to-irm-iex: - name: "windows: desktop-installer@latest -> irm-iex (${{ matrix.tag.ref }} -> HEAD)" - if: github.event_name != 'workflow_dispatch' || contains(fromJSON('["all", "windows-desktop"]'), inputs.route) - needs: pick-releases - strategy: - fail-fast: false - matrix: - tag: ${{ fromJSON(needs.pick-releases.outputs.tags) }} - uses: ./.github/workflows/install-e2e-windows-run.yml - with: - tag-has-desktop: ${{ matrix.tag.desktop }} - install-method: desktop-installer@latest - update-method: irm-iex - install-ref: ${{ matrix.tag.ref }} - - # ---- windows: install via irm | iex -------------------------------------- - - windows-irm-iex-to-desktop-app: - name: "windows: irm-iex -> desktop-app (${{ matrix.tag.ref }} -> HEAD)" - if: github.event_name != 'workflow_dispatch' || contains(fromJSON('["all", "windows-desktop"]'), inputs.route) - needs: pick-releases - strategy: - fail-fast: false - matrix: - tag: ${{ fromJSON(needs.pick-releases.outputs.tags) }} - uses: ./.github/workflows/install-e2e-windows-run.yml - with: - tag-has-desktop: ${{ matrix.tag.desktop }} - install-method: irm-iex - update-method: desktop-app - install-ref: ${{ matrix.tag.ref }} - - windows-irm-iex-to-installer-rerun: - name: "windows: irm-iex -> desktop-installer-rerun@latest (${{ matrix.tag.ref }} -> HEAD)" - if: github.event_name != 'workflow_dispatch' || contains(fromJSON('["all", "windows-desktop"]'), inputs.route) - needs: pick-releases - strategy: - fail-fast: false - matrix: - tag: ${{ fromJSON(needs.pick-releases.outputs.tags) }} - uses: ./.github/workflows/install-e2e-windows-run.yml - with: - tag-has-desktop: ${{ matrix.tag.desktop }} - install-method: irm-iex - update-method: desktop-installer-rerun@latest - install-ref: ${{ matrix.tag.ref }} - - windows-irm-iex-to-hermes-update: - name: "windows: irm-iex -> hermes-update (${{ matrix.tag.ref }} -> HEAD)" - if: github.event_name != 'workflow_dispatch' || contains(fromJSON('["all", "windows-desktop"]'), inputs.route) - needs: pick-releases - strategy: - fail-fast: false - matrix: - tag: ${{ fromJSON(needs.pick-releases.outputs.tags) }} - uses: ./.github/workflows/install-e2e-windows-run.yml - with: - tag-has-desktop: ${{ matrix.tag.desktop }} - install-method: irm-iex - update-method: hermes-update - install-ref: ${{ matrix.tag.ref }} - - windows-irm-iex-to-irm-iex: - name: "windows: irm-iex -> irm-iex (${{ matrix.tag.ref }} -> HEAD)" - if: github.event_name != 'workflow_dispatch' || contains(fromJSON('["all", "windows-desktop"]'), inputs.route) - needs: pick-releases - strategy: - fail-fast: false - matrix: - tag: ${{ fromJSON(needs.pick-releases.outputs.tags) }} - uses: ./.github/workflows/install-e2e-windows-run.yml - with: - tag-has-desktop: ${{ matrix.tag.desktop }} - install-method: irm-iex - update-method: irm-iex - install-ref: ${{ matrix.tag.ref }} - - # ---- macos: no driver yet (every leg natively skips in the run workflow) - - - macos-curl-bash-to-curl-bash: - name: "macos: curl-bash -> curl-bash (${{ matrix.tag.ref }} -> HEAD)" - if: github.event_name != 'workflow_dispatch' || inputs.route == 'all' - needs: pick-releases - strategy: - fail-fast: false - matrix: - tag: ${{ fromJSON(needs.pick-releases.outputs.tags) }} + matrix: ${{ fromJSON(needs.generate-matrix.outputs.macos) }} uses: ./.github/workflows/install-e2e-macos-run.yml with: - install-method: curl-bash - update-method: curl-bash - install-ref: ${{ matrix.tag.ref }} - - macos-curl-bash-to-hermes-update: - name: "macos: curl-bash -> hermes-update (${{ matrix.tag.ref }} -> HEAD)" - if: github.event_name != 'workflow_dispatch' || inputs.route == 'all' - needs: pick-releases - strategy: - fail-fast: false - matrix: - tag: ${{ fromJSON(needs.pick-releases.outputs.tags) }} - uses: ./.github/workflows/install-e2e-macos-run.yml - with: - install-method: curl-bash - update-method: hermes-update - install-ref: ${{ matrix.tag.ref }} - - macos-curl-bash-to-app-update: - name: "macos: curl-bash -> app-update (${{ matrix.tag.ref }} -> HEAD)" - if: github.event_name != 'workflow_dispatch' || inputs.route == 'all' - needs: pick-releases - strategy: - fail-fast: false - matrix: - tag: ${{ fromJSON(needs.pick-releases.outputs.tags) }} - uses: ./.github/workflows/install-e2e-macos-run.yml - with: - install-method: curl-bash - update-method: app-update - install-ref: ${{ matrix.tag.ref }} - - macos-packaged-app-to-curl-bash: - name: "macos: packaged-app -> curl-bash (${{ matrix.tag.ref }} -> HEAD)" - if: github.event_name != 'workflow_dispatch' || inputs.route == 'all' - needs: pick-releases - strategy: - fail-fast: false - matrix: - tag: ${{ fromJSON(needs.pick-releases.outputs.tags) }} - uses: ./.github/workflows/install-e2e-macos-run.yml - with: - install-method: packaged-app - update-method: curl-bash - install-ref: ${{ matrix.tag.ref }} - - macos-packaged-app-to-hermes-update: - name: "macos: packaged-app -> hermes-update (${{ matrix.tag.ref }} -> HEAD)" - if: github.event_name != 'workflow_dispatch' || inputs.route == 'all' - needs: pick-releases - strategy: - fail-fast: false - matrix: - tag: ${{ fromJSON(needs.pick-releases.outputs.tags) }} - uses: ./.github/workflows/install-e2e-macos-run.yml - with: - install-method: packaged-app - update-method: hermes-update - install-ref: ${{ matrix.tag.ref }} - - macos-packaged-app-to-app-update: - name: "macos: packaged-app -> app-update (${{ matrix.tag.ref }} -> HEAD)" - if: github.event_name != 'workflow_dispatch' || inputs.route == 'all' - needs: pick-releases - strategy: - fail-fast: false - matrix: - tag: ${{ fromJSON(needs.pick-releases.outputs.tags) }} - uses: ./.github/workflows/install-e2e-macos-run.yml - with: - install-method: packaged-app - update-method: app-update - install-ref: ${{ matrix.tag.ref }} + install-method: ${{ matrix.install_method }} + update-method: ${{ matrix.update_method }} + install-ref: ${{ matrix.install_ref }} diff --git a/scripts/sandbox/generate-e2e-matrix.mjs b/scripts/sandbox/generate-e2e-matrix.mjs new file mode 100644 index 0000000000..85e668e35c --- /dev/null +++ b/scripts/sandbox/generate-e2e-matrix.mjs @@ -0,0 +1,223 @@ +#!/usr/bin/env node +/** + * Expand the install/update support matrix into concrete E2E combinations. + * + * One source of truth for every {os, install-method, update-method} pair a + * user could be on. This file only DECLARES and EXPANDS: it knows nothing + * about which combinations CI can drive. Every combination is dispatched to + * its OS's run workflow, and THAT workflow natively skips the method pairs + * its driver cannot run yet -- capability knowledge lives next to each + * driver (install-e2e-run.yml, install-e2e-windows-run.yml, + * install-e2e-macos-run.yml). + * + * Used by .github/workflows/install-e2e.yml, which runs it with the picked + * release tags (annotated at pick time with what each tag's tree ships): + * + * node scripts/sandbox/generate-e2e-matrix.mjs \ + * --tags '[{"ref":"v2026.8.3","desktop":true}]' --route all + * + * Prints JSON: { linux: {include:[...]}, windows: {include:[...]}, + * macos: {include:[...]} } -- every entry is {name, install_method, + * update_method, install_ref}, and windows entries add tag_has_desktop + * (from the tag annotation) so the run workflow can natively skip + * desktop-surface legs from releases that predate the desktop app. + */ + +import path from 'node:path'; +import { parseArgs } from 'node:util'; +import { fileURLToPath } from 'node:url'; + +/** + * Method ids are strict machine strings -- workflows and the IMPLEMENTED + * table key off them, so an unknown id must fail loudly (see KNOWN_METHODS). + * + * `versions` on an entry expands it into one combination per version. Only + * "latest" is allowed today: it means "the artifact published on the website + * right now" (Hermes-Setup.exe has no versioned archive yet). When archived + * installer versions exist, widen ALLOWED_VERSIONS. + */ +export const SPEC = { + windows: { + install: [ + // irm https://hermes.nousresearch.com/install.ps1 | iex + { method: 'irm-iex' }, + // Website Hermes-Setup.exe, clicked through the GUI. + { method: 'desktop-installer', versions: ['latest'] }, + ], + update: [ + { method: 'irm-iex' }, + // Run the bootstrap exe again over an existing install (--update flow). + { method: 'desktop-installer-rerun', versions: ['latest'] }, + { method: 'hermes-update' }, + // Settings -> About -> "Update now" inside the running desktop app. + { method: 'desktop-app' }, + ], + }, + macos: { + install: [ + { method: 'curl-bash' }, + { method: 'packaged-app' }, + ], + update: [ + { method: 'curl-bash' }, + { method: 'hermes-update' }, + { method: 'app-update' }, + ], + }, + linux: { + install: [ + { method: 'curl-bash' }, + ], + update: [ + { method: 'curl-bash' }, + { method: 'hermes-update' }, + ], + }, +}; + +const KNOWN_METHODS = new Set([ + 'irm-iex', + 'desktop-installer', + 'desktop-installer-rerun', + 'hermes-update', + 'desktop-app', + 'curl-bash', + 'packaged-app', + 'app-update', +]); + +const ALLOWED_VERSIONS = new Set(['latest']); + +function validateEntry(os, kind, entry) { + if (typeof entry.method !== 'string' || !KNOWN_METHODS.has(entry.method)) { + throw new Error(`${os}.${kind}: unknown method id ${JSON.stringify(entry.method)} -- add it to KNOWN_METHODS if intentional`); + } + if ('versions' in entry) { + if (!Array.isArray(entry.versions) || entry.versions.length === 0) { + throw new Error(`${os}.${kind}.${entry.method}: versions must be a non-empty array`); + } + for (const v of entry.versions) { + if (!ALLOWED_VERSIONS.has(v)) { + throw new Error(`${os}.${kind}.${entry.method}: version ${JSON.stringify(v)} not allowed -- only ${[...ALLOWED_VERSIONS].join(', ')} until versioned installer archives exist`); + } + } + } + const unknown = Object.keys(entry).filter((k) => k !== 'method' && k !== 'versions'); + if (unknown.length) { + throw new Error(`${os}.${kind}.${entry.method}: unknown keys ${unknown.join(', ')}`); + } +} + +/** Expand one method entry into concrete ids ("desktop-installer@latest"). */ +export function expandMethod(os, kind, entry) { + validateEntry(os, kind, entry); + if (!entry.versions) return [entry.method]; + return entry.versions.map((v) => `${entry.method}@${v}`); +} + +/** Every {os, install, update, secondUpdate} combination in SPEC. */ +export function generateEnvironments(spec) { + const envs = []; + for (const [os, osSpec] of Object.entries(spec)) { + const { install, update, secondUpdate = [], ...unknown } = osSpec; + if (Object.keys(unknown).length) { + throw new Error(`${os}: unknown spec keys ${Object.keys(unknown).join(', ')}`); + } + // Chained second updates (install -> update -> update again) are a real + // axis -- the updater that RESULTS from an update must itself update -- + // but nothing implements them yet. Refuse a spec that declares them so + // the first implementation is forced to come through here. + if (!Array.isArray(secondUpdate) || secondUpdate.length !== 0) { + throw new Error(`${os}: secondUpdate must be empty until a second-update leg is implemented`); + } + const installs = install.flatMap((e) => expandMethod(os, 'install', e)); + const updates = update.flatMap((e) => expandMethod(os, 'update', e)); + for (const i of installs) { + for (const u of updates) { + envs.push({ os, install: i, update: u, secondUpdate: '' }); + } + } + } + return envs; +} + +/** Mirror of install-e2e.yml's dispatch `route` choice. */ +function routeWants(route, env) { + switch (route) { + case 'all': + return true; + case 'both': + return env.os === 'linux'; + case 'update': + return env.os === 'linux' && env.update === 'hermes-update'; + case 'installer': + return env.os === 'linux' && env.update === 'curl-bash'; + case 'windows-desktop': + return env.os === 'windows'; + default: + throw new Error(`unknown route filter: ${JSON.stringify(route)}`); + } +} + +/** + * Split the combinations into one matrix per OS. + * + * `tags` (the released versions we test updating FROM, as {ref, desktop} + * annotation objects) is the OUTER axis: for each tag, for each + * combination, one dispatch that installs the tag and updates to HEAD. + * No capability filtering happens here -- every declared combination is + * dispatched, and the OS's run workflow natively skips what its driver + * cannot run yet. Entry names carry everything (os, method pair, tag + * transition) because slash-joined leg names are all the graph renders. + */ +export function buildMatrices(envs, { tags = [], route = 'all' } = {}) { + for (const t of tags) { + if (typeof t?.ref !== 'string' || typeof t?.desktop !== 'boolean') { + throw new Error(`tags must be {ref, desktop} annotation objects, got ${JSON.stringify(t)}`); + } + } + const byOs = { linux: [], windows: [], macos: [] }; + for (const env of envs) { + if (!routeWants(route, env)) continue; + const bucket = byOs[env.os]; + if (!bucket) { + throw new Error(`no matrix bucket for os ${JSON.stringify(env.os)}`); + } + if (tags.length === 0) { + throw new Error('combinations were selected but no --tags given'); + } + for (const tag of tags) { + const entry = { + name: `${env.os}: ${env.install} -> ${env.update} (${tag.ref} -> HEAD)`, + install_method: env.install, + update_method: env.update, + install_ref: tag.ref, + }; + if (env.os === 'windows') entry.tag_has_desktop = tag.desktop; + bucket.push(entry); + } + } + return Object.fromEntries( + Object.entries(byOs).map(([os, include]) => [os, { include }]), + ); +} + +function main() { + const { values } = parseArgs({ + options: { + tags: { type: 'string', default: '[]' }, + route: { type: 'string', default: 'all' }, + }, + }); + const tags = JSON.parse(values.tags); + if (!Array.isArray(tags)) { + throw new Error('--tags must be a JSON array of {ref, desktop} annotation objects'); + } + const envs = generateEnvironments(SPEC); + const matrices = buildMatrices(envs, { tags, route: values.route }); + process.stdout.write(`${JSON.stringify(matrices, null, 2)}\n`); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) { + main(); +} diff --git a/tests/install/windows-desktop-gui-e2e.ps1 b/tests/install/windows-desktop-gui-e2e.ps1 index 0813903f36..afb8b5c29f 100644 --- a/tests/install/windows-desktop-gui-e2e.ps1 +++ b/tests/install/windows-desktop-gui-e2e.ps1 @@ -72,9 +72,10 @@ param( [string]$Phase = "all", # Update method to exercise in the update-gui phase, named by the same - # ids install-e2e.yml's combination jobs use. Only "desktop-app" (the - # app's own Update button) is implemented; the others are declared arms - # so the surface is stable when they land. + # ids the combination generator (scripts/sandbox/generate-e2e-matrix + # .mjs) declares. Only "desktop-app" (the app's own Update button) is + # implemented; the others are declared arms so the surface is stable + # when they land. [ValidateSet("desktop-app", "hermes-update", "desktop-installer-rerun@latest", "irm-iex")] [string]$Route = "desktop-app",