qwen-code/scripts/tests/workflow-size.test.js
Shaojin Wen a5d77eb817
chore(ci): migrate autofix prose to the design record and ratchet growth (#9677)
* chore(ci): migrate autofix prose to the design record and ratchet growth

`qwen-autofix.yml` was at 462,720 bytes — 90% of GitHub's 500 KB
start-runs limit and 7,280 under this repo's gate. The prose migration in
#9517 had regained 78 KB; a single feature commit gave 25 KB of it back
two days later, 53% of it comment lines that belonged in the design
record. Nothing objected, because the gate is a ceiling and only speaks
when a file is already at the wall.

Two changes, addressing the level and the slope.

Level: 76 comment blocks of 8+ lines move into qwen-autofix.md as
af-073..af-148, each leaving its opening two lines plus the usual
pointer. The file drops to 419,995 — 50 KB under the gate.

Slope: every workflow's recorded size now lives in
.github/workflows/.size-baseline, and check-workflow-size.sh fails a file
that exceeds its entry by more than 4 KB. Growing a file is still
allowed; the ratchet only insists the growth be visible in review instead
of discovered at the wall. A file well under its baseline warns so the
slack is reclaimed rather than banked.

Two things the migration had to learn, both caught by contract tests
rather than by inspection:

Identical prose shares one af id. A step inlined into several jobs must
stay byte-identical across copies, and minting a separate id per copy
broke that for the git-config sanitize step — same length, different
pointer digits.

A cross-file editing contract is not prose. "This copy and the one in
qwen-triage must be edited together" only does its job where the editor
will see it; moving it to the design record is how a lockstep silently
breaks. Blocks naming a sibling workflow or script, or spelling the
contract out, stay put.

Behaviour is unchanged and checked, not assumed: both YAML documents were
parsed and compared with comment lines dropped from every string, and
they are equal.

* fix(ci): harden the workflow-size ratchet per review

- key the vitest baseline lookups by separator-agnostic file name so the
  merge-queue Windows lane resolves entries (split('/') missed win32 joins)
- fail closed on malformed .size-baseline lines: non-numeric values,
  leading zeros (bash octal), and extra fields no longer disable or
  mis-key the ratchet; keep an unterminated final line
- execute the gate script end-to-end in tests (growth, missing entry,
  missing baseline, slack warning, malformed line) so its decision
  branches are witnessed
- correct af-079/af-123/af-084 attributions in the design record

* fix(ci): pin ratchet boundaries and skip pre-bash-4 test hosts

* fix(ci): end migrated autofix teasers at sentence boundaries per review

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-08-22 16:31:56 +00:00

348 lines
13 KiB
JavaScript

/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { spawnSync } from 'node:child_process';
import {
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join, win32 } from 'node:path';
import { describe, expect, it } from 'vitest';
// GitHub does not start runs for a workflow file over 500 KB (512,000 bytes)
// and reports nothing when it stops — see .github/scripts/check-workflow-size.sh
// and .github/workflows/qwen-autofix.md for the incident this encodes.
const GITHUB_LIMIT_BYTES = 512_000;
const WORKFLOW_DIR = '.github/workflows';
const gateScript = readFileSync(
'.github/scripts/check-workflow-size.sh',
'utf8',
);
const ciWorkflow = readFileSync(join(WORKFLOW_DIR, 'ci.yml'), 'utf8');
const gateBytes = Number(
gateScript.match(/GATE_BYTES="\$\{WORKFLOW_SIZE_GATE_BYTES:-(\d+)\}"/)?.[1],
);
const workflowNames = readdirSync(WORKFLOW_DIR).filter(
(name) => name.endsWith('.yml') || name.endsWith('.yaml'),
);
const workflowFiles = workflowNames.map((name) => join(WORKFLOW_DIR, name));
describe('workflow file size', () => {
it('keeps the gate below GitHub 500 KB start-runs limit', () => {
expect(gateBytes).toBeGreaterThan(0);
expect(gateBytes).toBeLessThan(GITHUB_LIMIT_BYTES);
});
it.each(workflowFiles)('%s stays under the gate', (file) => {
const bytes = Buffer.byteLength(readFileSync(file));
expect(bytes).toBeLessThan(gateBytes);
});
it('runs the gate on every CI profile, not just full', () => {
// A .github-only PR classifies as `github_ci_only`; gating the check on the
// `full` profile would skip it for exactly the changes that can trip it.
const step = ciWorkflow.match(
/- name: 'Check workflow file size'[\s\S]*?run: '(.+?)'/,
);
expect(step?.[1]).toBe('.github/scripts/check-workflow-size.sh');
expect(step?.[0]).toContain(
'if: "${{ needs.classify_pr.outputs.skip_ci != \'true\' }}"',
);
expect(step?.[0]).not.toContain('ci_profile');
});
});
describe('workflow size growth ratchet', () => {
// The absolute gate is a ceiling: it only objects once a file is nearly at
// the wall, so growth accrues unremarked until one PR has to pay for
// everyone. qwen-autofix.yml regained 78 KB when its prose moved out and
// gave 25 KB back in one feature commit two days later. The ratchet turns
// that drift into a reviewed line.
const baselinePath = join(WORKFLOW_DIR, '.size-baseline');
const baselineLines = readFileSync(baselinePath, 'utf8')
.split('\n')
.filter((l) => l.trim() && !l.trimStart().startsWith('#'));
const baseline = new Map(
baselineLines
.map((l) => l.trim().split(/\s+/))
.map(([bytes, name]) => [name, Number(bytes)]),
);
// node:path join emits backslashes on the merge-queue Windows lane, where
// splitting on '/' alone finds no separator and hands back the whole path
// as the key — every baseline lookup must accept both separators.
const workflowName = (file) => file.split(/[\\/]/).pop();
const allowance = Number(
gateScript.match(
/GROWTH_ALLOWANCE="\$\{WORKFLOW_SIZE_GROWTH_ALLOWANCE:-(\d+)\}"/,
)?.[1],
);
it('reads a positive allowance from the gate script', () => {
expect(allowance).toBeGreaterThan(0);
});
it('keys win32-style paths by the file name too (merge-queue Windows lane)', () => {
for (const name of workflowNames) {
expect(workflowName(win32.join(WORKFLOW_DIR, name))).toBe(name);
}
});
it.each(workflowFiles)('%s has a baseline entry', (file) => {
expect(baseline.has(workflowName(file))).toBe(true);
});
it.each(workflowFiles)('%s is within its baseline allowance', (file) => {
const bytes = Buffer.byteLength(readFileSync(file));
const recorded = baseline.get(workflowName(file));
expect(bytes).toBeLessThanOrEqual(recorded + allowance);
});
it('records no file that no longer exists', () => {
const present = new Set(workflowFiles.map((f) => workflowName(f)));
expect([...baseline.keys()].filter((n) => !present.has(n))).toEqual([]);
});
it('keeps every baseline at or under the gate', () => {
// A baseline above the gate would let the ratchet pass a file the ceiling
// rejects, so the two gates can never disagree about what is allowed.
expect([...baseline].filter(([, b]) => b > gateBytes)).toEqual([]);
});
it('keeps every baseline entry in the format the gate parses', () => {
// The gate fails closed on lines that are not exactly '<bytes> <file>'
// with a decimal byte count; this mirror must red on the same lines here
// instead of keying on field 2 while CI keys on the rest of the line.
for (const line of baselineLines) {
const fields = line.trim().split(/\s+/);
expect(fields, line).toHaveLength(2);
expect(fields[0], line).toMatch(/^(0|[1-9][0-9]*)$/);
}
});
});
// The gate script's `declare -A baseline=()` needs bash 4+. The merge-queue
// macOS lane ships bash 3.2, where the assoc-array errors leave the ratchet
// failing open, so probe the capability rather than the platform: that lane
// must skip instead of reporting red on a script it cannot execute.
const bashSupportsAssocArrays =
spawnSync('bash', ['-c', 'declare -A t=()'], { stdio: 'ignore' }).status ===
0;
describe.skipIf(process.platform === 'win32' || !bashSupportsAssocArrays)(
'check-workflow-size.sh execution',
() => {
// The block above re-implements the gate's arithmetic in JS; only running
// the real script pins its decision branches (growth, missing entry,
// missing baseline, slack warning, malformed line).
const gatePath = join(
process.cwd(),
'.github',
'scripts',
'check-workflow-size.sh',
);
const runGate = ({ files, baseline }) => {
const dir = mkdtempSync(join(tmpdir(), 'workflow-size-gate-'));
try {
const fixtureDir = join(dir, WORKFLOW_DIR);
mkdirSync(fixtureDir, { recursive: true });
for (const [name, bytes] of Object.entries(files)) {
writeFileSync(join(fixtureDir, name), 'a'.repeat(bytes));
}
if (baseline !== undefined) {
writeFileSync(join(fixtureDir, '.size-baseline'), baseline);
}
return spawnSync('bash', [gatePath], { cwd: dir, encoding: 'utf8' });
} finally {
rmSync(dir, { recursive: true, force: true });
}
};
it('passes a workflow at its recorded size', () => {
const result = runGate({
files: { 'small.yml': 100 },
baseline: '100 small.yml\n',
});
expect(result.status).toBe(0);
expect(result.stdout).toContain('✅');
});
it('passes a workflow grown within its allowance', () => {
const result = runGate({
files: { 'small.yml': 4000 },
baseline: '100 small.yml\n',
});
expect(result.status).toBe(0);
expect(result.stdout).toContain('✅');
});
it('passes a workflow at exactly baseline plus allowance', () => {
const result = runGate({
files: { 'small.yml': 4196 },
baseline: '100 small.yml\n',
});
expect(result.status).toBe(0);
expect(result.stdout).toContain('✅');
});
it('fails a workflow one byte past baseline plus allowance', () => {
const result = runGate({
files: { 'small.yml': 4197 },
baseline: '100 small.yml\n',
});
expect(result.status).toBe(1);
expect(result.stdout).toContain('grew to 4197 bytes');
});
it('fails a workflow grown past its baseline plus allowance', () => {
const result = runGate({
files: { 'small.yml': 5000 },
baseline: '100 small.yml\n',
});
expect(result.status).toBe(1);
expect(result.stdout).toContain('grew to 5000 bytes');
});
it('fails a workflow with no baseline entry', () => {
const result = runGate({
files: { 'small.yml': 100 },
baseline: '# header only\n',
});
expect(result.status).toBe(1);
expect(result.stdout).toContain('has no entry');
expect(result.stdout).toContain("Add '100 small.yml'");
});
it('fails closed when the baseline file is missing', () => {
const result = runGate({ files: { 'small.yml': 100 } });
expect(result.status).toBe(1);
expect(result.stdout).toContain('missing or unreadable');
});
it('fails closed on a value that is not a decimal byte count', () => {
// Bash evaluates leading zeros as octal and errors on non-numeric
// values at the arithmetic sites; either failure mode used to leave
// the ratchet green.
for (const bad of ['4l9995', '1e3', '09023', '0070142']) {
const result = runGate({
files: { 'small.yml': 100 },
baseline: `${bad} small.yml\n`,
});
expect(result.status, bad).toBe(1);
expect(result.stdout, bad).toContain('is malformed');
}
});
it('fails closed on a line with extra fields', () => {
const result = runGate({
files: { 'small.yml': 100 },
baseline: '70142 small.yml # bumped for the build-cache job\n',
});
expect(result.status).toBe(1);
expect(result.stdout).toContain('is malformed');
});
it('keeps an unterminated final baseline line', () => {
const result = runGate({
files: { 'small.yml': 100 },
baseline: '100 small.yml',
});
expect(result.status).toBe(0);
});
it('warns when a file shrinks far below its baseline', () => {
const result = runGate({
files: { 'small.yml': 100 },
baseline: '30000 small.yml\n',
});
expect(result.status).toBe(0);
expect(result.stdout).toContain('::warning');
expect(result.stdout).toContain('under its recorded 30000');
});
// SLACK_BYTES is 20000 in the gate script; these two fixtures pin the
// boundary itself, not just the warning branch.
it('warns when a file sits more than the slack under its baseline', () => {
const result = runGate({
files: { 'small.yml': 100 },
baseline: '20101 small.yml\n',
});
expect(result.status).toBe(0);
expect(result.stdout).toContain('::warning');
expect(result.stdout).toContain('under its recorded 20101');
});
it('does not warn at exactly the slack under its baseline', () => {
const result = runGate({
files: { 'small.yml': 100 },
baseline: '20100 small.yml\n',
});
expect(result.status).toBe(0);
expect(result.stdout).not.toContain('::warning');
});
it('fails a file past the absolute gate', () => {
const result = runGate({
files: { 'big.yml': 470_001 },
baseline: '470001 big.yml\n',
});
expect(result.status).toBe(1);
expect(result.stdout).toContain("past this repo's");
});
},
);
describe('qwen-autofix.yml design-record pointers', () => {
const workflow = readFileSync(join(WORKFLOW_DIR, 'qwen-autofix.yml'), 'utf8');
const doc = readFileSync(join(WORKFLOW_DIR, 'qwen-autofix.md'), 'utf8');
// Steps whose body outgrew the workflow file live in sibling scripts (the
// file sits near GitHub's 500 KB start-runs limit). Their rationale pointers
// moved with them, so scan those too — otherwise extracting a step orphans
// every section it pointed at and this suite reads it as dead prose.
const pointerSources = [
workflow,
readFileSync('.github/scripts/autofix-push-and-report.sh', 'utf8'),
].join('\n');
const pointers = [
...pointerSources.matchAll(/qwen-autofix\.md#(af-\d+)/g),
].map((m) => m[1]);
const anchors = [...doc.matchAll(/<a id="(af-\d+)"><\/a>/g)].map((m) => m[1]);
it('every pointer resolves to a section', () => {
expect(pointers.length).toBeGreaterThan(0);
expect(
[...new Set(pointers)].filter((id) => !anchors.includes(id)),
).toEqual([]);
});
it('every section is still pointed at from the workflow', () => {
expect(anchors.filter((id) => !pointers.includes(id))).toEqual([]);
});
it('allocates each section id exactly once', () => {
// A double allocation (two blocks minted with the same id, e.g. a branch
// that numbered a new block before a same-numbered block landed on main)
// passes every other check here: pointers resolve, anchors stay pointed
// at, and the contents table mirrors the duplication. Browsers resolve
// the anchor to the FIRST occurrence, so one feature's rationale pointer
// silently shows the other's block.
expect(new Set(anchors).size).toBe(anchors.length);
});
it('lists every section in the contents table', () => {
const listed = [...doc.matchAll(/^- \[\d+\..*?\]\(#(af-\d+)\)$/gm)].map(
(m) => m[1],
);
expect(listed).toEqual(anchors);
});
});