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:
@@ -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
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user