Files
redefined-designs/backend/tests/unit/workflowGate.test.ts
T
bermudalambandClaude Opus 5 0872cad4df fix(ci): stop pull request scans overwriting the dashboard's picture of main (#197)
SonarQube Community has no branch analysis. Every scan published under a project key replaces that project's single analysis, whatever revision it came from — so each pull request, and each push to one, overwrote the dashboard's analysis of main with the branch. The new-code period, the gate result, the coverage percentages and the hotspot list then all described whatever was scanned last, with nothing on the dashboard saying which revision that was. A gate that went green on a feature branch read exactly like a gate that went green on main.

It was caught only by luck: #180's hotspots reported line numbers that landed on a comment and a blank line in main, which is the kind of nonsense a person notices. Everything else it misreported would have looked fine.

scripts/scan-local.sh has always refused to do this, defaulting to a scratch key, and its header says why in as many words. CI walked into the hazard that script guards against. Now the two tell the same story.

The suites still run on pull requests, which is where their value is — only publishing is restricted. The measures report is skipped alongside the scan, because with nothing published it would print main's numbers into a pull request's log, which is noise at best and misread as the branch's own at worst.

A test asserts both steps carry the restriction and that the three suites do not, because the failure leaves no trace and the `if:` is one line for somebody to drop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 09:33:09 -05:00

220 lines
8.5 KiB
TypeScript

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;
condition: string | null;
}
/**
* 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, condition: null };
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;
const condition = /^ {8}if: (.+?)\s*$/.exec(line);
if (condition?.[1] !== undefined) current.condition = condition[1];
}
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([]);
});
});
/**
* The guard for #197.
*
* SonarQube Community has no branch analysis: every scan published under a
* project key replaces that project's single analysis, whatever revision it came
* from. Publishing from a pull request therefore overwrites the dashboard's
* picture of main with the branch — silently, because nothing on the dashboard
* says which revision it describes. It was caught only because hotspot line
* numbers landed on a blank line.
*
* scripts/scan-local.sh has always refused to do this, defaulting to a scratch
* key. This asserts CI refuses too, because the failure leaves no trace and the
* `if:` is one line for somebody to drop.
*/
describe('publishing is restricted to non-pull-request runs', () => {
it.each(['SonarQube Scan', 'Report SonarQube measures'])('%s does not run on a pull request', (name) => {
const step = steps.find((candidate) => candidate.name === name);
expect(step).toBeDefined();
expect(step?.condition).toContain("github.event_name != 'pull_request'");
});
// The suites are the reason pull requests run this workflow at all. Gating
// them would turn a fix for a reporting problem into a loss of every check.
it.each(['unit', 'integration', 'e2e'])('the %s suite still runs on pull requests', (id) => {
const step = steps.find((candidate) => candidate.id === id);
expect(step).toBeDefined();
expect(step?.condition ?? '').not.toContain('pull_request');
});
});