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.
This commit is contained in:
ethernet
2026-08-11 17:11:07 -04:00
parent 1c60bdc30d
commit 17dea59026
2 changed files with 73 additions and 118 deletions
+7 -5
View File
@@ -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
+66 -113
View File
@@ -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<Os, OsSpec>} */
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<Os, OsSpec>} 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<Os, {include: MatrixEntry[]}>}
*/
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<Os, {include: MatrixEntry[]}>} */
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`);
}