From 17dea59026caede152bc147c579a0d642872062d Mon Sep 17 00:00:00 2001 From: ethernet Date: Tue, 11 Aug 2026 17:11:07 -0400 Subject: [PATCH] ci(install-e2e): generator drops runtime validation for jsdoc type unions Per review: the method/version vocabulary is now closed TYPE unions (@ts-check + jsdoc typedefs - InstallerVersion, InstallMethod, UpdateMethod - checked with tsc --checkJs, which rejects a SPEC entry outside the unions; verified by corrupting a copy: 6 errors) instead of runtime KNOWN_METHODS/ALLOWED_VERSIONS sets. validateEntry and routeWants are deleted with all the paranoia: the generator always emits every OS matrix and the dispatch route filter moved to plain job-level ifs in install-e2e.yml, where the OS jobs already live. secondUpdate is typed never[] so declaring one is a type error until a leg implements it. Anything types cannot catch is self-evident on the next CI run. --- .github/workflows/install-e2e.yml | 12 +- scripts/sandbox/generate-e2e-matrix.mjs | 179 +++++++++--------------- 2 files changed, 73 insertions(+), 118 deletions(-) diff --git a/.github/workflows/install-e2e.yml b/.github/workflows/install-e2e.yml index bb39cf48d9..229f46dda3 100644 --- a/.github/workflows/install-e2e.yml +++ b/.github/workflows/install-e2e.yml @@ -127,8 +127,7 @@ jobs: run: | set -euo pipefail matrices="$(node scripts/sandbox/generate-e2e-matrix.mjs \ - --tags '${{ needs.pick-releases.outputs.tags }}' \ - --route '${{ inputs.route || 'all' }}')" + --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" @@ -136,7 +135,10 @@ jobs: linux: name: ${{ matrix.name }} - if: fromJSON(needs.generate-matrix.outputs.linux).include[0] != null + # 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 leg breaking is worth knowing about even if another already @@ -152,7 +154,7 @@ jobs: windows: name: ${{ matrix.name }} - if: fromJSON(needs.generate-matrix.outputs.windows).include[0] != null + if: github.event_name != 'workflow_dispatch' || contains(fromJSON('["all", "windows-desktop"]'), inputs.route) needs: generate-matrix strategy: fail-fast: false @@ -169,7 +171,7 @@ jobs: macos: name: ${{ matrix.name }} - if: fromJSON(needs.generate-matrix.outputs.macos).include[0] != null + if: github.event_name != 'workflow_dispatch' || inputs.route == 'all' needs: generate-matrix strategy: fail-fast: false diff --git a/scripts/sandbox/generate-e2e-matrix.mjs b/scripts/sandbox/generate-e2e-matrix.mjs index 85e668e35c..ad836169ef 100644 --- a/scripts/sandbox/generate-e2e-matrix.mjs +++ b/scripts/sandbox/generate-e2e-matrix.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +// @ts-check /** * Expand the install/update support matrix into concrete E2E combinations. * @@ -8,13 +9,15 @@ * 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). + * install-e2e-macos-run.yml). Correctness here is enforced by the type + * unions below (checked via `tsc --checkJs`), not by runtime validation -- + * anything the types can't catch is self-evident on the next CI run. * * 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 + * --tags '[{"ref":"v2026.8.3","desktop":true}]' * * Prints JSON: { linux: {include:[...]}, windows: {include:[...]}, * macos: {include:[...]} } -- every entry is {name, install_method, @@ -28,14 +31,34 @@ 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). + * The closed method/version vocabulary. Workflows key off these exact + * strings, so they are types, not conventions. * - * `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. + * @typedef {'latest'} InstallerVersion + * The artifact published on the website right now -- Hermes-Setup.exe has + * no versioned archive yet. Widen this union when one exists. + * @typedef {'irm-iex' | 'desktop-installer' | 'curl-bash' | 'packaged-app'} InstallMethod + * @typedef {InstallMethod | 'desktop-installer-rerun' | 'hermes-update' | 'app-update' | 'desktop-app'} UpdateMethod + * @typedef {'linux' | 'windows' | 'macos'} Os + * + * @typedef {{method: InstallMethod, versions?: InstallerVersion[]}} InstallEntry + * @typedef {{method: UpdateMethod, versions?: InstallerVersion[]}} UpdateEntry + * `versions` expands the entry into one combination per version + * ("desktop-installer@latest"). + * @typedef {{install: InstallEntry[], update: UpdateEntry[], secondUpdate?: never[]}} OsSpec + * secondUpdate (install -> update -> update again) is a real future axis + * -- the updater that RESULTS from an update must itself update -- typed + * `never[]` so declaring one is a type error until a leg implements it. + * + * @typedef {{ref: string, desktop: boolean}} TagAnnotation + * A picked release tag plus what its own tree ships (annotated by + * pick-releases in install-e2e.yml). + * + * @typedef {{name: string, install_method: string, update_method: string, + * install_ref: string, tag_has_desktop?: boolean}} MatrixEntry */ + +/** @type {Record} */ export const SPEC = { windows: { install: [ @@ -75,118 +98,55 @@ export const SPEC = { }, }; -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); +/** + * Expand one method entry into concrete ids ("desktop-installer@latest"). + * @param {InstallEntry | UpdateEntry} entry + * @returns {string[]} + */ +export function expandMethod(entry) { if (!entry.versions) return [entry.method]; return entry.versions.map((v) => `${entry.method}@${v}`); } -/** Every {os, install, update, secondUpdate} combination in SPEC. */ +/** + * Every {os, install, update} combination in SPEC. + * @param {Record} spec + * @returns {{os: Os, install: string, update: string}[]} + */ export function generateEnvironments(spec) { + /** @type {{os: Os, install: string, update: string}[]} */ 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: '' }); + for (const [os, osSpec] of /** @type {[Os, OsSpec][]} */ (Object.entries(spec))) { + for (const install of osSpec.install.flatMap(expandMethod)) { + for (const update of osSpec.update.flatMap(expandMethod)) { + envs.push({ os, install, update }); } } } 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. + * `tags` (the released versions we test updating FROM) 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. + * + * @param {{os: Os, install: string, update: string}[]} envs + * @param {TagAnnotation[]} tags + * @returns {Record} */ -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: [] }; +export function buildMatrices(envs, tags) { + /** @type {Record} */ + const byOs = { linux: { include: [] }, windows: { include: [] }, macos: { include: [] } }; 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) { + /** @type {MatrixEntry} */ const entry = { name: `${env.os}: ${env.install} -> ${env.update} (${tag.ref} -> HEAD)`, install_method: env.install, @@ -194,27 +154,20 @@ export function buildMatrices(envs, { tags = [], route = 'all' } = {}) { install_ref: tag.ref, }; if (env.os === 'windows') entry.tag_has_desktop = tag.desktop; - bucket.push(entry); + byOs[env.os].include.push(entry); } } - return Object.fromEntries( - Object.entries(byOs).map(([os, include]) => [os, { include }]), - ); + return byOs; } 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 }); + const tags = /** @type {TagAnnotation[]} */ (JSON.parse(values.tags)); + const matrices = buildMatrices(generateEnvironments(SPEC), tags); process.stdout.write(`${JSON.stringify(matrices, null, 2)}\n`); }