ci: fail at the end rather than part way through, so the scan still runs (#174)
`sonarqube.yml` already had a documented design for this: run every suite, produce coverage, scan, summarise, then fail at the end from recorded step outcomes. The comments on the gate spell it out and #142 fixed it once already. The integration suite was never wired into it — no `id`, no `continue-on-error`, and absent from the gate, where the unit and end-to-end suites had all three. So it was the one suite whose failure aborted the job. On every push since #154 started, step 9 failed and steps 10 through 15 were skipped, which means there has been no SonarQube analysis at all for the duration — not a degraded one, none. The end-to-end suite has not run in CI either, which is separately why #116 cannot be verified: the step that would demonstrate its fix is skipped rather than failing. Fixing only that step would have left the same shape in four other places, so every step from the first suite to the scan is now guarded and named in the gate: starting the backend, installing browsers, merging frontend coverage, and the scan itself, which until now took the summaries down with it. The preconditions before the suites — checkout, installs, build checks, migrations — still fail hard, because when they fail there is genuinely nothing to analyse. The job still fails. It fails at the end, having produced everything it could. The integration suite also gains the summary the other two already had. It was the only suite without one, so the failure this workflow has been stuck on presented as 36 assertion errors about categories and price filters rather than as a count — #154 records how expensive that misdirection was to read. `summarize-jest.js` already takes a label, so this is reuse. `tests/unit/workflowGate.test.ts` asserts the pairing that makes the design work: a step carries `continue-on-error` so it cannot abort the job, and the gate names it so it can still fail the job. Both halves are needed and nothing connected them, which is how this happened — and the other direction is worse, since a step guarded but unnamed cannot fail the job at all. The test checks the invariant rather than a list of names, for the same reason `composeEnvironment.test.ts` reads the real list rather than a copy. Verified by mutation: dropping the integration suite from the gate, un-guarding it, and removing `always()` each fail it. Confirmed by running the new flag combination rather than assuming it: the integration suite writes `integration-results.json`, the summariser reads it and exits 0, and both that file and `coverage/integration/lcov.info` are still written when the suite fails — which is what makes scanning with a failing suite produce real coverage rather than a fabricated regression. Closes #174
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* The guard for #174.
|
||||
*
|
||||
* `sonarqube.yml` runs every suite to completion and then fails at the end from
|
||||
* recorded step outcomes, so that a failing suite still produces coverage, a
|
||||
* scan and its summaries. Two halves make that work: a step carries
|
||||
* `continue-on-error: true` so it cannot abort the job, and the final gate names
|
||||
* it so that it can still fail the job.
|
||||
*
|
||||
* Both halves are needed and nothing connected them. The integration suite had
|
||||
* neither, so it aborted the job where the other two suites did not — and for
|
||||
* the whole of #154 that meant no SonarQube analysis at all, on any commit.
|
||||
* Worse is the other direction: a step guarded but left out of the gate cannot
|
||||
* fail the job *ever*, which turns a broken suite into a green run.
|
||||
*
|
||||
* So this asserts the pairing rather than a list of step names. A list would
|
||||
* pass forever while the next step added went unguarded in exactly the same way,
|
||||
* which is the mistake `composeEnvironment.test.ts` exists to prevent for
|
||||
* docker-compose and the one repeated here.
|
||||
*/
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
|
||||
const WORKFLOW = path.join(REPO_ROOT, '.gitea', 'workflows', 'sonarqube.yml');
|
||||
|
||||
interface Step {
|
||||
name: string;
|
||||
id: string | null;
|
||||
guarded: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed by hand rather than with a YAML library, because there is no YAML
|
||||
* dependency in this workspace and adding one to read four fields would be the
|
||||
* larger change. The shape being read is fixed and shallow.
|
||||
*
|
||||
* Indentation is what separates a step's own keys from anything nested inside
|
||||
* its `run:` block — a `continue-on-error:` written inside a shell script would
|
||||
* be more deeply indented and is not matched.
|
||||
*/
|
||||
function parseSteps(source: string): Step[] {
|
||||
const steps: Step[] = [];
|
||||
let current: Step | null = null;
|
||||
|
||||
// Tolerates carriage returns: a checkout with CRLF endings would otherwise
|
||||
// leave a stray return that the end anchors cannot match, and every
|
||||
// assertion below would silently find nothing. That is what the "parsed
|
||||
// some steps at all" case exists to catch.
|
||||
for (const line of source.split(/\r?\n/)) {
|
||||
const named = /^ {6}- name: (.+?)\s*$/.exec(line);
|
||||
if (named?.[1] !== undefined) {
|
||||
current = { name: named[1], id: null, guarded: false };
|
||||
steps.push(current);
|
||||
continue;
|
||||
}
|
||||
if (!current) continue;
|
||||
|
||||
const id = /^ {8}id: (\S+)\s*$/.exec(line);
|
||||
if (id?.[1] !== undefined) current.id = id[1];
|
||||
|
||||
if (/^ {8}continue-on-error: true\s*$/.test(line)) current.guarded = true;
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
/**
|
||||
* The gate's `if:` expression, as one string.
|
||||
*
|
||||
* Folded across lines in the file for readability, so this collects everything
|
||||
* indented under the `if:` rather than reading a single line.
|
||||
*/
|
||||
function gateCondition(source: string): string {
|
||||
const lines = source.split(/\r?\n/);
|
||||
const start = lines.findIndex((line) => /^ {6}- name: Fail if any guarded step failed\s*$/.test(line));
|
||||
if (start === -1) return '';
|
||||
|
||||
const collected: string[] = [];
|
||||
let inside = false;
|
||||
for (const line of lines.slice(start + 1)) {
|
||||
if (/^ {8}if:/.test(line)) {
|
||||
inside = true;
|
||||
collected.push(line);
|
||||
continue;
|
||||
}
|
||||
if (!inside) continue;
|
||||
// The next key at step level ends the folded block.
|
||||
if (/^ {8}\S/.test(line)) break;
|
||||
collected.push(line);
|
||||
}
|
||||
return collected.join(' ');
|
||||
}
|
||||
|
||||
const source = readFileSync(WORKFLOW, 'utf8');
|
||||
const steps = parseSteps(source);
|
||||
const condition = gateCondition(source);
|
||||
|
||||
describe('sonarqube.yml fails at the end rather than part way through', () => {
|
||||
// Guards the guard: a parser that matched nothing would make every assertion
|
||||
// below vacuously true.
|
||||
it('parsed the workflow at all', () => {
|
||||
expect(steps.length).toBeGreaterThan(10);
|
||||
expect(condition).toContain('always()');
|
||||
});
|
||||
|
||||
it('has a gate step', () => {
|
||||
expect(condition).not.toBe('');
|
||||
});
|
||||
|
||||
/**
|
||||
* `always()` is load-bearing, and its absence has bitten once already. A step
|
||||
* whose `if:` omits it still implicitly requires every previous step to have
|
||||
* succeeded, which would leave this gate as dead code in exactly the case it
|
||||
* exists for. See #142.
|
||||
*/
|
||||
it('evaluates the gate even after a failure', () => {
|
||||
expect(condition).toContain('always()');
|
||||
});
|
||||
|
||||
it('gives every guarded step an id, or the gate cannot name it', () => {
|
||||
const anonymous = steps.filter((step) => step.guarded && step.id === null);
|
||||
expect(anonymous.map((step) => step.name)).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The half that turns a broken suite into a green run. A guarded step missing
|
||||
* from the gate cannot fail the job at all.
|
||||
*/
|
||||
it('names every guarded step in the gate', () => {
|
||||
const guarded = steps.filter((step) => step.guarded && step.id !== null);
|
||||
expect(guarded.length).toBeGreaterThan(0);
|
||||
|
||||
const unnamed = guarded.filter((step) => !condition.includes(`steps.${step.id}.outcome`));
|
||||
expect(unnamed.map((step) => step.name)).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The other direction: a gate naming a step that no longer exists reads as
|
||||
* coverage it does not have, and the expression silently evaluates that clause
|
||||
* to nothing.
|
||||
*/
|
||||
it('names nothing in the gate that is not a step', () => {
|
||||
const ids = new Set(steps.map((step) => step.id).filter((id): id is string => id !== null));
|
||||
const named = [...condition.matchAll(/steps\.([A-Za-z0-9_-]+)\.outcome/g)].map((match) => match[1]);
|
||||
expect(named.length).toBeGreaterThan(0);
|
||||
|
||||
const unknown = named.filter((id) => id !== undefined && !ids.has(id));
|
||||
expect(unknown).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Named individually, unlike the structural rules above, because these three
|
||||
* are the reason the design exists. Un-guarding any of them would restore the
|
||||
* behaviour #174 fixed — and it was the integration suite, absent from this
|
||||
* list for as long as it existed, that actually did it.
|
||||
*/
|
||||
it.each(['unit', 'integration', 'e2e'])('runs the %s suite to completion', (id) => {
|
||||
const step = steps.find((candidate) => candidate.id === id);
|
||||
expect(step).toBeDefined();
|
||||
expect(step?.guarded).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* The scan is the output this whole arrangement protects. Anything between the
|
||||
* first suite and it that can abort the job takes it down, which is what #174
|
||||
* was.
|
||||
*/
|
||||
it('guards every step between the first suite and the scan', () => {
|
||||
const first = steps.findIndex((step) => step.id === 'unit');
|
||||
const scan = steps.findIndex((step) => step.id === 'scan');
|
||||
expect(first).toBeGreaterThan(-1);
|
||||
expect(scan).toBeGreaterThan(first);
|
||||
|
||||
// `if:`-only steps run conditionally and cannot abort the job on their own,
|
||||
// so they need no guard. Backend log is the one such step in this range.
|
||||
const unguarded = steps
|
||||
.slice(first, scan + 1)
|
||||
.filter((step) => !step.guarded && step.name !== 'Backend log');
|
||||
expect(unguarded.map((step) => step.name)).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user