Files
TREK/scripts/ci/changed-areas.mjs
T
Maurice 8e695cc267 ci: gate merges on one ci-ok check that covers the image and the chart
The ruleset required only `lint` (shared's eslint and prettier) and
`check-target`. Types, lint, coverage, end-to-end and the S3 contract
were advisory, and the three health PRs merged with red checks. Nothing
on a PR into dev built the Dockerfile or rendered the Helm chart either,
which is how #2610 shipped a Dockerfile no build could parse.

ci-ok needs every gate, test, image and chart job and is meant to be the
one check the ruleset requires. It runs with always(), fails when a job
it needs failed or was cancelled, and passes when they passed or were
skipped. The Sonar scan stays outside: its quality gate reports on the
PR by itself, and an outage there should not block merges.

A required check cannot sit behind a workflow-level path filter: on a
docs-only PR the workflow never starts and the check never reports. The
filter moves into a `changes` job. It diffs the PR's merge commit against
its base (or a push against the previous head) and scripts/ci/
changed-areas.mjs maps the files to code, image and deploy areas. It
fails closed: no usable diff, an empty one or a path it cannot read
cleanly runs everything. Its tests run in the same job.

docker-image builds the Dockerfile with buildx (layer cache written by
pushes only, read by PRs), starts it with the same hardening as
docker-compose.yml, waits up to three minutes for /api/health/ready,
checks the VERSION file and that / serves the client, and prints the
container log. deploy-manifests lints the chart, renders it with the
default values and with each secret, persistence and ingress mode,
validates the output with kubeconform, and runs `docker compose config`
on every compose file. helm and kubeconform are pinned by version and
sha256.

While the file is open: every job runs on ubuntu-24.04 instead of the
moving ubuntu-latest, reads its Node version from .nvmrc (the plugin-sdk
jobs ran 22 under a comment saying it matched the server, which runs
24), download-artifact moves from v6, still on Node 20, to v7, and the
Sonar action is pinned by commit.
2026-10-08 13:04:43 +02:00

118 lines
4.3 KiB
JavaScript

#!/usr/bin/env node
// Decides which parts of the test workflow a change needs.
//
// test.yml used to filter on `paths:` at the workflow level. A required check
// cannot live behind such a filter: when the filter skips the whole workflow,
// the check never reports and the pull request waits for it forever. So the
// workflow always runs, its first job lists the changed files and pipes them
// through this script, and every other job asks the answer whether to run.
// The aggregate `ci-ok` job then treats a skipped job as passed.
//
// Usage:
// node scripts/ci/changed-areas.mjs --files <list> one changed path per line
// node scripts/ci/changed-areas.mjs --all no usable list, run everything
//
// Prints `<area>=true|false` per area and appends the same lines to
// $GITHUB_OUTPUT when it is set.
//
// It fails closed. A list it cannot read is an error, and anything it cannot
// classify with confidence (an empty list, a path git had to quote) turns every
// area on: running a job too often costs minutes, skipping one it needed lets a
// break through.
import { appendFileSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
// The workflow file and this script decide what runs, so a change to either
// runs everything.
const ALWAYS = ['.github/workflows/test.yml', 'scripts/ci/'];
export const AREAS = {
// The type, lint, test, coverage, bundle and end-to-end jobs. npm workspaces
// hoist every dependency into the root lockfile, so a dependency bump touches
// none of the package directories. The Dockerfile and the entrypoint script
// are pinned by server/tests/unit/docker-entrypoint.test.ts.
code: [
'server/',
'client/',
'shared/',
'plugin-sdk/',
'package.json',
'package-lock.json',
'.nvmrc',
'sonar-project.properties',
'Dockerfile',
...ALWAYS,
],
// The image build and its boot check: everything the Dockerfile copies or
// installs from.
image: [
'server/',
'client/',
'shared/',
'package.json',
'package-lock.json',
'.nvmrc',
'Dockerfile',
'.dockerignore',
...ALWAYS,
],
// The Helm chart and the compose files.
deploy: ['charts/', /^docker-compose[^/]*\.ya?ml$/, ...ALWAYS],
};
// Markdown inside the packages (CLAUDE.md, READMEs, PATTERN.md) changes no
// test, no build and no scan.
const isMarkdown = (path) => path.endsWith('.md');
function matches(rule, path) {
if (rule instanceof RegExp) return rule.test(path);
return rule.endsWith('/') ? path.startsWith(rule) : path === rule;
}
/**
* @param {string[] | null} files changed paths, or null when there is no usable list
* @returns {Record<string, boolean>}
*/
export function classify(files) {
const all = Object.fromEntries(Object.keys(AREAS).map((area) => [area, true]));
if (files === null) return all;
const paths = files.map((line) => line.replace(/\r$/, '').trim()).filter(Boolean);
if (paths.length === 0) return all;
// core.quotePath=false keeps ordinary non-ASCII names unquoted. A path that
// still arrives quoted carries control characters, and matching on its
// escaped form could miss it.
if (paths.some((path) => path.startsWith('"'))) return all;
const result = {};
for (const [area, rules] of Object.entries(AREAS)) {
result[area] = paths.some((path) => !isMarkdown(path) && rules.some((rule) => matches(rule, path)));
}
return result;
}
function parseArgs(argv) {
if (argv.length === 1 && argv[0] === '--all') return { all: true };
if (argv.length === 2 && argv[0] === '--files' && argv[1]) return { all: false, list: argv[1] };
throw new Error('usage: changed-areas.mjs --files <list> | --all');
}
function main() {
const args = parseArgs(process.argv.slice(2));
// An unreadable list throws and fails the job instead of running on a guess.
const files = args.all ? null : readFileSync(args.list, 'utf8').split('\n');
const result = classify(files);
const lines = Object.entries(result).map(([area, on]) => `${area}=${on}`);
console.log(lines.join('\n'));
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\n`);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
try {
main();
} catch (err) {
console.error(err instanceof Error ? err.message : err);
process.exit(1);
}
}