Files
TREK/scripts/ci/changed-areas.test.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

101 lines
4.2 KiB
JavaScript

import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { after, describe, it } from 'node:test';
import { fileURLToPath } from 'node:url';
import { AREAS, classify } from './changed-areas.mjs';
const script = fileURLToPath(new URL('./changed-areas.mjs', import.meta.url));
const scratch = mkdtempSync(join(tmpdir(), 'changed-areas-'));
after(() => rmSync(scratch, { recursive: true, force: true }));
const none = Object.fromEntries(Object.keys(AREAS).map((area) => [area, false]));
const every = Object.fromEntries(Object.keys(AREAS).map((area) => [area, true]));
function run(args, env = {}) {
return spawnSync(process.execPath, [script, ...args], {
encoding: 'utf8',
env: { ...process.env, GITHUB_OUTPUT: '', ...env },
});
}
describe('classify', () => {
it('runs nothing for a docs-only change', () => {
assert.deepEqual(classify(['wiki/Home.md', 'README.md', 'server/CLAUDE.md', 'docs/logo-trek-dark.svg']), none);
});
it('runs the tests and the image for a server change', () => {
assert.deepEqual(classify(['server/src/nest/weather/weather.service.ts']), { code: true, image: true, deploy: false });
});
it('runs the tests but not the image for a plugin-sdk change', () => {
assert.deepEqual(classify(['plugin-sdk/src/index.ts']), { code: true, image: false, deploy: false });
});
it('runs the image but not the tests for a .dockerignore change', () => {
assert.deepEqual(classify(['.dockerignore']), { code: false, image: true, deploy: false });
});
it('runs the chart and compose checks for a chart template or a compose file', () => {
assert.deepEqual(classify(['charts/trek/templates/deployment.yaml']), { code: false, image: false, deploy: true });
assert.deepEqual(classify(['docker-compose.minio-test.yml']), { code: false, image: false, deploy: true });
assert.equal(classify(['charts/README.md']).deploy, false);
assert.equal(classify(['server/docker-compose.yml']).deploy, false);
});
it('runs everything when the workflow or the classifier itself changes', () => {
assert.deepEqual(classify(['.github/workflows/test.yml']), every);
assert.deepEqual(classify(['scripts/ci/changed-areas.mjs']), every);
});
it('matches whole file names, not prefixes of them', () => {
assert.deepEqual(classify(['package.json.bak', 'Dockerfile.old', 'serverless/x.ts']), none);
});
it('runs everything when there is no list, an empty one or a quoted path', () => {
assert.deepEqual(classify(null), every);
assert.deepEqual(classify(['', ' ']), every);
assert.deepEqual(classify(['"wiki/\\303\\244.md"']), every);
});
it('reads CRLF lists the same as LF ones', () => {
assert.deepEqual(classify(['wiki/Home.md\r', 'server/src/index.ts\r']), { code: true, image: true, deploy: false });
});
});
describe('the command line', () => {
it('writes the areas to stdout and to GITHUB_OUTPUT', () => {
const list = join(scratch, 'list.txt');
const output = join(scratch, 'output.txt');
writeFileSync(list, 'charts/trek/values.yaml\n');
writeFileSync(output, '');
const result = run(['--files', list], { GITHUB_OUTPUT: output });
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, 'code=false\nimage=false\ndeploy=true\n');
assert.equal(readFileSync(output, 'utf8'), 'code=false\nimage=false\ndeploy=true\n');
});
it('turns everything on with --all', () => {
const result = run(['--all']);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, 'code=true\nimage=true\ndeploy=true\n');
});
it('fails on a list it cannot read', () => {
const result = run(['--files', join(scratch, 'missing.txt')]);
assert.equal(result.status, 1);
assert.match(result.stderr, /ENOENT/);
assert.equal(result.stdout, '');
});
it('fails on arguments it does not know', () => {
for (const args of [[], ['--files'], ['--all', '--files', 'x'], ['list.txt']]) {
const result = run(args);
assert.equal(result.status, 1, `args ${JSON.stringify(args)}`);
assert.match(result.stderr, /usage/);
}
});
});