mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-25 00:26:31 +00:00
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>
This commit is contained in:
parent
6c742ec792
commit
a5d77eb817
6 changed files with 2064 additions and 611 deletions
63
.github/scripts/autofix-push-and-report.sh
vendored
63
.github/scripts/autofix-push-and-report.sh
vendored
|
|
@ -2,10 +2,12 @@
|
|||
# Push the round's commit to the PR head and post the round report.
|
||||
#
|
||||
# The body below is the 'Push and report' step of review-address in
|
||||
# .github/workflows/qwen-autofix.yml, byte-identical to the inline block it
|
||||
# came from: 626 lines, ~41 KB. The file it left is within a few KB of the
|
||||
# repo's 470,000-byte gate, and GitHub stops starting runs past 512,000 without
|
||||
# saying so (.github/scripts/check-workflow-size.sh). No absolute size is
|
||||
# .github/workflows/qwen-autofix.yml — the inline block it came from (626
|
||||
# lines, ~41 KB at the move), its long comments since migrated to
|
||||
# qwen-autofix.md pointers like the rest of the workflow. The file it left
|
||||
# is within a few KB of the repo's 470,000-byte gate, and GitHub stops starting
|
||||
# runs past 512,000 without saying so (.github/scripts/check-workflow-size.sh).
|
||||
# No absolute size is
|
||||
# quoted here on purpose — main moves it every day, and a number that decays is
|
||||
# how this comment earned three review rounds. It is also
|
||||
# the step docs/design/autofix-gate-runner-isolation.md moves into its own
|
||||
|
|
@ -78,15 +80,8 @@ MODEL_DISPLAY="${MODEL:-default}"
|
|||
# Growth-audit trail (+ re-arm on sound): audit rounds record the
|
||||
# verdict under the key the baseline was READ under — same rule as
|
||||
# the growth markers, same dead-key hazard (a supersede-exempt
|
||||
# round can report under a stale WINDOW after a re-arm). The
|
||||
# verdict comes from AUDIT_VERDICT — the verdict the verification
|
||||
# GATE validated and surfaced as a step output — NOT a re-read of
|
||||
# growth-audit.json: the branch's own build/tests run as the runner
|
||||
# user and WORKDIR is a predictable path they can write, so the
|
||||
# file could change after the gate looked. Re-arming is allowed
|
||||
# for completed rounds only ($1 = allow): a sound verdict whose
|
||||
# round then FAILED must not re-anchor the window — the failure
|
||||
# path re-measures under the same window instead.
|
||||
# round can report under a stale WINDOW after a re-arm).
|
||||
# Full rationale → qwen-autofix.md#af-131
|
||||
emit_growth_audit_marker() {
|
||||
local allow_rearm="${1:-false}"
|
||||
[[ "${KISS_AUDIT}" == 'true' ]] || return 0
|
||||
|
|
@ -233,12 +228,7 @@ resolve_and_reply_threads() {
|
|||
fi
|
||||
# The mirror of the resolve above: a finding the agent did NOT
|
||||
# resolve keeps its thread open, and this answers it IN that thread.
|
||||
# Without it the reason sits only in the round summary, so the
|
||||
# reviewer who opens the still-open thread sees silence and cannot
|
||||
# tell their finding was read. Same neutralisation as the summary
|
||||
# body — a reply is model output posted verbatim under the bot
|
||||
# identity, so it could otherwise smuggle a forged control marker.
|
||||
# Best-effort: a reply failure must never fail a good push.
|
||||
# Full rationale → qwen-autofix.md#af-132
|
||||
if [[ -s "${WORKDIR}/comment-replies.json" ]] &&
|
||||
jq -e 'type == "array"' "${WORKDIR}/comment-replies.json" > /dev/null 2>&1; then
|
||||
REPLIED_N=0
|
||||
|
|
@ -271,12 +261,8 @@ resolve_and_reply_threads() {
|
|||
# later round whose agent rewrites an unchanged declination
|
||||
# must not post the same bot reply twice on one thread
|
||||
# (observed 2026-08-16: an identical reply posted three
|
||||
# times, #9296). Skip when the thread already carries a
|
||||
# comment by the bot whose body EQUALS the neutralised body
|
||||
# about to be posted; a changed body — a new reason in a
|
||||
# later round — still posts. Best-effort like the rest: with
|
||||
# a stale or empty threads view this degrades to the old
|
||||
# post-always behavior.
|
||||
# times, #9296).
|
||||
# Full rationale → qwen-autofix.md#af-133
|
||||
if jq -e --argjson id "${root_id}" --arg bot "${AUTOFIX_BOT}" \
|
||||
--arg body "${REPLY_BODY}" '
|
||||
map(select(any(.comments.nodes[]; .databaseId == $id)))
|
||||
|
|
@ -382,13 +368,8 @@ bash "${RUNNER_TEMP}/resanitize-git-config.sh"
|
|||
if [[ "${OUTCOME}" == "fixed" ]]; then
|
||||
NEXT_ROUND="$(( ROUND + 1 ))"
|
||||
# The tree the gate verified is what gets pushed: assert HEAD is
|
||||
# the gate's verified_head before touching credentials. A repo
|
||||
# redirect (a planted .git/commondir/GIT_DIR — the first defused
|
||||
# by resanitize, the second by the env strip) would otherwise let
|
||||
# `git rev-parse HEAD` and the push read an attacker repo whose
|
||||
# HEAD differs; this compares against the value the gate recorded
|
||||
# in GITHUB_OUTPUT (unreachable from a disk write). Empty
|
||||
# verified_head only on a noop, which does not reach this push.
|
||||
# the gate's verified_head before touching credentials.
|
||||
# Full rationale → qwen-autofix.md#af-134
|
||||
HEAD_NOW="$(git rev-parse HEAD)"
|
||||
if [[ -z "${VERIFIED_HEAD}" || "${HEAD_NOW}" != "${VERIFIED_HEAD}" ]]; then
|
||||
echo "::error::HEAD ${HEAD_NOW} is not the gate's verified head ${VERIFIED_HEAD:-<empty>} — refusing to push"
|
||||
|
|
@ -560,13 +541,8 @@ fi
|
|||
|
||||
# Bounded retry on the report post: this one comment carries the
|
||||
# round's ENTIRE persisted state (autofix-eval watermark/round,
|
||||
# redcheck head, growth baseline). The push has already landed, so
|
||||
# a transient API failure here loses the marker while keeping the
|
||||
# growth — the retry scan would re-anchor the baseline at the
|
||||
# post-push size and re-evaluate feedback it already addressed.
|
||||
# Three attempts bound that to genuine outages; the final failure
|
||||
# keeps today's semantics (step fails, no marker, next scan
|
||||
# retries the round).
|
||||
# redcheck head, growth baseline).
|
||||
# Full rationale → qwen-autofix.md#af-135
|
||||
REPORT_POSTED='false'
|
||||
for attempt in 1 2 3; do
|
||||
if gh pr comment "${PR}" --repo "${REPO}" --body-file "${WORKDIR}/report.md"; then
|
||||
|
|
@ -590,13 +566,8 @@ if [[ "${OUTCOME}" == "fixed" && "${MAX_ROUNDS}" == "${TAKEOVER_MAX_ROUNDS}" ]]
|
|||
# Crossing trigger, not an equality test: failure rounds also
|
||||
# advance the round counter, so `push@9, crash@10, push@11`
|
||||
# would skip an exact %10 check forever — and a failure-heavy
|
||||
# PR is the very PR the digest exists for. Post on the first
|
||||
# PUSHED round once 10+ rounds have accumulated since the last
|
||||
# digest in THIS window (or since the window opened). The
|
||||
# window opens at the round SEED, not at zero: a '/takeover
|
||||
# from 60' counter starts at 60, so the no-digest-yet baseline
|
||||
# is the seed — otherwise the seed-inflated counter digests on
|
||||
# the window's first push with a 1-2 round census.
|
||||
# PR is the very PR the digest exists for.
|
||||
# Full rationale → qwen-autofix.md#af-136
|
||||
MS_LAST="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg win "${WINDOW:-none}" --argjson start "${ROUND_START:-0}" '
|
||||
[ .[] | select((.user.login // "") == $ab) | (.body // "")
|
||||
| [ scan("<!-- autofix-milestone round=([0-9]+) win=([^ ]+) -->") ] | .[]
|
||||
|
|
|
|||
51
.github/scripts/check-workflow-size.sh
vendored
51
.github/scripts/check-workflow-size.sh
vendored
|
|
@ -16,7 +16,44 @@ GITHUB_LIMIT_BYTES=512000
|
|||
GATE_BYTES="${WORKFLOW_SIZE_GATE_BYTES:-470000}"
|
||||
WARN_BYTES=$((GATE_BYTES - 25000))
|
||||
|
||||
# The gate above is the ceiling; the baseline below is the RATCHET. The gate
|
||||
# alone only objects once a file is nearly at the wall, so growth accumulates
|
||||
# invisibly until one unlucky PR has to pay for everyone: qwen-autofix.yml
|
||||
# regained 78 KB when its prose moved out (#9517) and gave 25 KB of it back in
|
||||
# a single feature commit two days later, unremarked. Each file's recorded size
|
||||
# lives in .size-baseline; exceeding it by more than the allowance fails until
|
||||
# the number is updated in the same PR, which turns the drift into one line a
|
||||
# reviewer sees.
|
||||
BASELINE_FILE='.github/workflows/.size-baseline'
|
||||
GROWTH_ALLOWANCE="${WORKFLOW_SIZE_GROWTH_ALLOWANCE:-4096}"
|
||||
# Loose enough that ordinary edits do not churn the manifest, tight enough that
|
||||
# a file which shed real weight gets its baseline reclaimed rather than banking
|
||||
# the slack for the next unreviewed 25 KB.
|
||||
SLACK_BYTES=20000
|
||||
|
||||
status=0
|
||||
declare -A baseline=()
|
||||
if [[ -r "${BASELINE_FILE}" ]]; then
|
||||
# The || clause keeps an unterminated final line, which read reports as a
|
||||
# failure and the loop would otherwise silently drop.
|
||||
while read -r recorded name extra || [[ -n "${recorded}" ]]; do
|
||||
[[ -z "${recorded}" || "${recorded}" == \#* ]] && continue
|
||||
# Fail closed on malformed lines: bash evaluates a leading-zero value as
|
||||
# OCTAL at the arithmetic sites below, a non-numeric one errors both
|
||||
# comparisons to false (the ratchet would fail OPEN), and extra fields
|
||||
# key differently in the vitest mirror.
|
||||
if [[ -z "${name}" || -n "${extra}" || ! "${recorded}" =~ ^(0|[1-9][0-9]*)$ ]]; then
|
||||
echo "::error file=${BASELINE_FILE}::${BASELINE_FILE} entry '${recorded}${name:+ ${name}}${extra:+ ${extra}}' is malformed — expected exactly '<bytes> <file>' with a decimal byte count (no leading zeros)"
|
||||
status=1
|
||||
continue
|
||||
fi
|
||||
baseline["${name}"]="${recorded}"
|
||||
done <"${BASELINE_FILE}"
|
||||
else
|
||||
echo "::error::${BASELINE_FILE} is missing or unreadable — the growth ratchet cannot run"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
shopt -s nullglob
|
||||
for file in .github/workflows/*.yml .github/workflows/*.yaml; do
|
||||
if ! size="$(wc -c <"${file}")"; then
|
||||
|
|
@ -24,6 +61,7 @@ for file in .github/workflows/*.yml .github/workflows/*.yaml; do
|
|||
status=1
|
||||
continue
|
||||
fi
|
||||
size="${size// /}"
|
||||
pct=$((size * 100 / GITHUB_LIMIT_BYTES))
|
||||
if ((size > GATE_BYTES)); then
|
||||
echo "::error file=${file}::${file} is ${size} bytes — ${pct}% of GitHub's ${GITHUB_LIMIT_BYTES}-byte start-runs limit, past this repo's ${GATE_BYTES}-byte gate. Move prose into a sibling .md and long steps into .github/scripts/; do not raise the gate."
|
||||
|
|
@ -31,9 +69,20 @@ for file in .github/workflows/*.yml .github/workflows/*.yaml; do
|
|||
elif ((size > WARN_BYTES)); then
|
||||
echo "::warning file=${file}::${file} is ${size} bytes (${pct}% of GitHub's limit) — approaching the ${GATE_BYTES}-byte gate."
|
||||
fi
|
||||
|
||||
base="${baseline[${file##*/}]:-}"
|
||||
if [[ -z "${base}" ]]; then
|
||||
echo "::error file=${file}::${file} has no entry in ${BASELINE_FILE}. Add '${size} ${file##*/}' so its growth is tracked."
|
||||
status=1
|
||||
elif ((size > base + GROWTH_ALLOWANCE)); then
|
||||
echo "::error file=${file}::${file} grew to ${size} bytes, $((size - base)) over its recorded ${base} (allowance ${GROWTH_ALLOWANCE}). Move prose into a sibling .md and long steps into .github/scripts/ — or, if the growth is real, update ${BASELINE_FILE} in this PR and say why."
|
||||
status=1
|
||||
elif ((size + SLACK_BYTES < base)); then
|
||||
echo "::warning file=${file}::${file} is ${size} bytes, $((base - size)) under its recorded ${base} — lower the entry in ${BASELINE_FILE} so the slack is not banked."
|
||||
fi
|
||||
done
|
||||
|
||||
if ((status == 0)); then
|
||||
echo "✅ every workflow file is under the ${GATE_BYTES}-byte gate"
|
||||
echo "✅ every workflow file is under the ${GATE_BYTES}-byte gate and within ${GROWTH_ALLOWANCE} bytes of its recorded baseline"
|
||||
fi
|
||||
exit "${status}"
|
||||
|
|
|
|||
66
.github/workflows/.size-baseline
vendored
Normal file
66
.github/workflows/.size-baseline
vendored
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# Recorded byte size of each workflow file, enforced by
|
||||
# .github/scripts/check-workflow-size.sh.
|
||||
#
|
||||
# The absolute gate in that script is the ceiling; this file is the ratchet.
|
||||
# A workflow may not grow past its recorded size by more than the allowance
|
||||
# without updating the number here IN THE SAME PR — which is the point: it
|
||||
# turns invisible drift into one reviewed line. qwen-autofix.yml regained
|
||||
# 78 KB when its prose moved to qwen-autofix.md (#9517), then gave 25 KB of
|
||||
# it back in a single feature commit two days later, with nothing objecting
|
||||
# until the next PR ran out of ceiling.
|
||||
#
|
||||
# Growing a file is allowed — deliberately. Prefer moving prose into the
|
||||
# sibling .md and long steps into .github/scripts/ first; if the growth is
|
||||
# real, bump the number and say why in the PR.
|
||||
2226 assign-issue-owner.yml
|
||||
3480 audio-capture-prebuilds.yml
|
||||
9023 auto-minimize-spam.yml
|
||||
4638 build-and-publish-image.yml
|
||||
29715 cd-cua-driver.yml
|
||||
2076 cd-mobile-mcp.yml
|
||||
69782 ci.yml
|
||||
1482 codeql.yml
|
||||
9389 comment-attachment-guard.yml
|
||||
31677 desktop-release.yml
|
||||
2038 docs-page-action.yml
|
||||
10005 dsw-swe-verified-release.yml
|
||||
12340 e2e.yml
|
||||
11394 finalize-release.yml
|
||||
15871 live-host-release.yml
|
||||
6384 live-host.yml
|
||||
7642 main-ci-failure-issue.yml
|
||||
1686 npm-cache.yml
|
||||
7299 pr-force-push-reminder.yml
|
||||
6495 pr-self-report-label.yml
|
||||
9646 qwen-autofix-fork-bridge.yml
|
||||
5942 qwen-autofix-fork-signal.yml
|
||||
392111 qwen-autofix.yml
|
||||
7061 qwen-ci-flaky-rerun.yml
|
||||
151937 qwen-code-pr-review.yml
|
||||
79041 qwen-fleet-shepherd.yml
|
||||
20525 qwen-issue-followup-bot.yml
|
||||
5760 qwen-pr-safety-precheck.yml
|
||||
27648 qwen-triage-finalize.yml
|
||||
344548 qwen-triage.yml
|
||||
9657 release-sdk-java.yml
|
||||
22037 release-sdk-python.yml
|
||||
19094 release-sdk.yml
|
||||
14546 release-vscode-companion.yml
|
||||
39291 release.yml
|
||||
43717 repo-hygiene.yml
|
||||
1079 scorecard-monthly.yml
|
||||
10691 sdk-java.yml
|
||||
3886 sdk-python.yml
|
||||
3197 security-checks.yml
|
||||
6777 serve-ab-publish.yml
|
||||
17013 serve-ab.yml
|
||||
2641 stale.yml
|
||||
8653 sync-cua-driver-to-oss.yml
|
||||
10920 sync-desktop-to-oss.yml
|
||||
10018 sync-live-host-to-oss.yml
|
||||
10138 sync-release-to-oss.yml
|
||||
3303 update-ecs-runner-qwen.yml
|
||||
2307 web-shell-visuals-cleanup.yml
|
||||
15380 web-shell-visuals-publish.yml
|
||||
16384 web-shell-visuals.yml
|
||||
4712 windows-runner-smoke.yml
|
||||
1531
.github/workflows/qwen-autofix.md
vendored
1531
.github/workflows/qwen-autofix.md
vendored
File diff suppressed because it is too large
Load diff
695
.github/workflows/qwen-autofix.yml
vendored
695
.github/workflows/qwen-autofix.yml
vendored
File diff suppressed because it is too large
Load diff
|
|
@ -4,8 +4,17 @@
|
|||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
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)
|
||||
|
|
@ -23,9 +32,10 @@ const gateBytes = Number(
|
|||
gateScript.match(/GATE_BYTES="\$\{WORKFLOW_SIZE_GATE_BYTES:-(\d+)\}"/)?.[1],
|
||||
);
|
||||
|
||||
const workflowFiles = readdirSync(WORKFLOW_DIR)
|
||||
.filter((name) => name.endsWith('.yml') || name.endsWith('.yaml'))
|
||||
.map((name) => join(WORKFLOW_DIR, name));
|
||||
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', () => {
|
||||
|
|
@ -52,6 +62,245 @@ describe('workflow file size', () => {
|
|||
});
|
||||
});
|
||||
|
||||
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');
|
||||
|
|
@ -80,6 +329,16 @@ describe('qwen-autofix.yml design-record pointers', () => {
|
|||
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],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue