Skip to content
Merged
58 changes: 45 additions & 13 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,14 @@ jobs:
- *unpack_release_build

- name: 'Run Workspace Tests'
# GitHub's default for `run:` is `bash -e {0}` — no `pipefail` — so
# `npm … | tee` yields tee's status, the `||` handler never fires and
# every branch below is dead code: a shard with a genuinely failing
# test exits 0 and the release proceeds. Measured on release run
# 33806806226, job 100824085040, which reports
# `shell: /usr/bin/bash -e {0}`. Naming the shell is what makes `$?`
# npm's.
shell: 'bash'
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
env:
# Match the tunable per-process bound used by the main CI gate.
VITEST_MAX_THREADS: "${{ startsWith(runner.name, 'ecs-qwen-') && (vars.QWEN_CI_VITEST_MAX_WORKERS || '4') || '' }}"
Expand All @@ -558,6 +566,14 @@ jobs:
# release lane alone, and an empty variable just falls back to the
# default.
VITEST_RETRY: "${{ vars.QWEN_RELEASE_VITEST_RETRY || '2' }}"
# The guard below reads this log line by line. Vitest colours its
# output from the mere presence of CI, and a coloured summary sits
# escapes between a label and its value
# (`ESC[2m Tests ESC[22m ESC[1mESC[32m394 passed`), which no
# anchored pattern can match — on real release bytes three of the
# four conditions return 0 and the guard never reaches the fourth.
# ci.yml already sets this on three steps for the same reason.
NO_COLOR: 'true'
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
run: |-
retry_arg=()
if [ -n "${VITEST_RETRY}" ] && [ "${VITEST_RETRY}" != 'off' ]; then
Expand All @@ -567,29 +583,45 @@ jobs:
# killed by Vitest's own worker RPC timing out reads identically to
# a real break, and this release lost two attempts to that
# (run 33713579913). The status is re-raised untouched either way;
# `-o pipefail` is the step default, so `$?` is npm's.
# `shell: bash` above supplies `-o pipefail`, so `$?` is npm's.
log="${RUNNER_TEMP:-/tmp}/workspace-tests-${{ matrix.shard }}.log"
npm run test:release:workspaces -- --shard=${{ matrix.shard }}/3 --passWithNoTests "${retry_arg[@]}" 2>&1 | tee "${log}" || {
status=$?
if grep -qE '^[[:space:]]*FAIL ' "${log}"; then
: # A failing test names itself; an annotation adds nothing.
elif grep -q 'Timeout calling' "${log}"; then
# Passed only with proof the run reached its end and nothing
# else broke: a normal exit (a signal death is 128+N), a
# passing tally, no failing tally, and no unhandled error that
# is not the transport. Vitest's worker RPC giving up says
# nothing about the product, and --retry cannot cover it —
# retries re-run failing TESTS while an unhandled error fails
# the run outright. It has now cost this release three
# attempts (run 33713579913).
elif grep -q '\[vitest-worker\]: Timeout calling' "${log}"; then

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not applying this one as written — the premise is confirmed but the suggested widening opens a new false-pass route. Measured, not inferred.

Premise checks out on the pinned vitest 3.2.7: grep -ro '\[vitest-\(pool\|api\|worker\)\]' node_modules/vitest/dist/ → [vitest-pool] 6 sites, [vitest-api] 1, [vitest-worker] 1. And your diagnosis of today's behaviour is right: a pool-side death lands in the else leg, whose annotation asserts "No FAIL line and no transport timeout in the log" while the log carries one.

But running the verbatim-extracted step with only the elif widened to \[vitest-(worker|pool|api)\]: Timeout calling, keeping the count grep [vitest-worker]-only exactly as your comment requires:

log = ' Tests  10614 passed (10614)'
    + 'Error: [vitest-pool]: Timeout calling "executeTests"'
    + 'Error: Failed to load ./vitest.config.ts'    # later workspace, died before any summary
HEAD     EXIT=1  ::error  title=Workspace tests exited 1 with no failing test
WIDENED  EXIT=0  ::warning title=Workspace tests passed through a Vitest transport timeout

Because timeouts never counts pool lines, a log that enters the branch on one and carries no Errors summary gets errors=0 and timeouts=0: the parity check passes vacuously, and with an earlier workspace's passing tally already in the same log, all four legs clear. The release proceeds over a workspace that never ran its tests. That is the R5-1 pre-summary shape, made reachable through a branch entry that today routes it to the ::error leg and re-raises the status — so the widening trades a wrong-words annotation and a lost rescue for a silently green release.

Widening the entry needs a precondition that the branch was earned by something countable — [ "${timeouts}" -gt 0 ] alongside the widened grep, or the summary-coverage leg from R5-1, which closes this case too. Either is part of the detection-strategy decision the two Criticals are held on, so leaving this unresolved rather than landing the one-liner. Happy to take whichever variant the maintainer picks.


Left unresolved on purpose: the measured false-pass above means this cannot be landed as a one-liner, and the precondition it needs is the same detection-strategy decision the two Criticals are held on.

# Vitest's worker RPC giving up says nothing about the product,
# and --retry cannot cover it — retries re-run failing TESTS
# while an unhandled error fails the run outright. It has now
# cost this release three attempts (run 33713579913).
#
# Passed only with proof the run reached its end and that every
# unhandled error WAS the transport. That proof is Vitest's own
# count rather than a reading of the crash: it prints
# `Errors N errors` whenever unhandled errors occurred, so the
# guard compares that count with how many carried the
# transport's own `[vitest-worker]: Timeout calling` message.
# Recognising a crash by its header cannot be made to work — the
# header is producer-chosen (26 of this repo's 293 Error
# subclasses have no Error/Exception suffix, and four assign
# that bare name to err.name), so any pattern over headers is
# incomplete by construction, while the count needs no pattern.
#
# `--workspaces` prints one summary per workspace into one log,
# so both figures are whole-file sums and a passing tally cannot
# cover a later workspace's crash. Reaching this branch means at
# least one transport line, so a log with no `Errors` summary at
# all counts 0 against it and the pass is refused, not granted.
errors=$(awk '/^[[:space:]]*Errors[[:space:]]+[0-9]+ errors?$/ { total += $2 } END { print total + 0 }' "${log}")
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
timeouts=$(grep -cE '\[vitest-worker\]: Timeout calling' "${log}" || true)
Comment thread
yiliang114 marked this conversation as resolved.
Resolved

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Holding for a maintainer design decision rather than another route-by-route patch. R1-1 is fix-induced and re-asserted across rounds 3→5: each fix closes one false-pass-through route and the next round finds another — here the unanchored timeouts grep over the whole log, inflated by Vitest's auto-added github-actions reporter. The robust fix is a design choice (anchor the count precisely / fail closed on any unhandled error regardless of parity / consume Vitest's structured output instead of grepping text), each with different release-safety tradeoffs. See the PR-level note. Leaving unresolved.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No change this round; leaving unresolved as held.

This thread already carries the author reply holding it for a maintainer design decision, and nothing in this round's commits touches the question it raises. What landed instead is orthogonal and test-side only: pins on shell: 'bash' and NO_COLOR: 'true', a discriminating row for the ^[[:space:]]*/$ anchors on the Errors sum, a row for the count-mismatch refusal, and that refusal's annotation now naming the fifth cause and printing both figures it compared. timeouts=$(grep -cE '\[vitest-worker\]: Timeout calling' …) is byte-identical to what this comment reviewed.

One thing worth recording against the options listed here: the ^Error: anchor is confirmed insufficient (your own execution, P4 still exits 0), and the provenance-keyed state machine you suggest is the same class of change as "consume Vitest's structured output" — a redesign of the guard's detection strategy, not a patch to it. That is the decision being held, and it should be made once rather than per route.


Held unresolved. This round pushed 73ff43f7 (refusal annotation names the fifth cause and prints both compared counts) and dc4e64ca (test-side pins only); neither changes the timeouts detection strategy this Critical is about, so it stays open for the maintainer decision.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still held for the maintainer design decision named in the PR-level note — no autonomous patch this round. The ^[[:space:]]*Error: \[vitest-worker\]: Timeout calling anchor is measured and would close the fabricated-line route, but this thread's own record says it is a narrowing, not a close: round 5 executed the ^Error: anchor and a test printing Error: [vitest-worker]: Timeout calling … at column 0 still bought errors == timeouts parity (P4 → exit 0). Landing it would leave this finding standing and re-mint it on the sibling route next round — the route-by-route loop the hold exists to stop. The pending decision (anchor precisely / fail closed on any unhandled error regardless of parity / consume Vitest's structured output) is unchanged, and your note that the full close is provenance keying is carried into it. Your two probe rows are the right witnesses and are recorded for whichever option lands. Thread left unresolved on purpose.

中文说明

仍然挂起,等待 PR 级说明中所述的维护者设计决策 —— 本轮不做自动化补丁。^[[:space:]]*Error: \[vitest-worker\]: Timeout calling 这个锚定经过实测,确实能关闭伪造行路线;但本讨论串自己的记录表明它只是收窄而非关闭:第 5 轮实际执行过 ^Error: 锚定,测试在第 0 列打印 Error: [vitest-worker]: Timeout calling … 时仍能买到 errors == timeouts 平价(P4 → exit 0)。落地它只会让本发现继续存在,并在下一轮沿姊妹路线被重申 —— 这正是挂起所要终止的逐路线循环。待决的选项不变(精确锚定计数 / 对任何未处理错误一律 fail closed 而不论计数是否相等 / 消费 Vitest 的结构化输出),你关于完整关闭应按出处键控的注记已纳入该决策。你给出的两个探针行是合适的见证,已为最终选定的方案记录在案。本讨论串刻意保持未解决。

if [ "${status}" -lt 128 ] \
&& grep -qE '^[[:space:]]*Tests[[:space:]]+[0-9]+ passed' "${log}" \
&& ! grep -qE '^[[:space:]]*(Tests|Test Files)[[:space:]]+[0-9]+ failed' "${log}" \
&& ! grep -E '^[[:space:]]*Error:' "${log}" | grep -qv 'Timeout calling'; then
echo "::warning title=Workspace tests passed through a Vitest transport timeout::Every test passed and no other error was reported; Vitest's own worker RPC timed out. Treated as a pass."
&& [ "${errors}" -eq "${timeouts}" ]; then

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same non-converging class as R1-1: the guard cannot see a workspace that died before printing any Vitest summary, so the pass-through fires over a real pre-summary crash. Closing this route-by-route keeps spawning siblings (R1-1 recurred rounds 3→5). The converging fix is a design decision about the guard's whole detection strategy (anchor / fail-closed-on-any-unhandled-error / structured output), which needs maintainer judgment given the release-safety blast radius. See the PR-level note. Leaving unresolved.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No change this round; leaving unresolved as held.

This round's commits are test-side plus one annotation message, so the pass-through condition this comment is about — including the absence of any summary-coverage leg — is unchanged at the new head. The settled premise (npm run test:ci --workspaces --if-present continues past a failing workspace, so one shard log accumulates every workspace's output and a pre-summary crash contributes no Errors count, no FAIL line and no tally) is not disputed here, and it is precisely why this is held for a design decision rather than patched route-by-route.

Noting one interaction for whoever takes the decision: the summary-coverage leg you propose (ran banners vs summaries, && [ "${ran}" -eq "${summaries}" ]) would also close the new false-pass route that the R5-2 suggestion opens — a log entering the transport branch with no summary at all currently satisfies errors=0 == timeouts=0 vacuously. So the two threads want the same fix, and the banner shape needs verifying against a real run before either lands.


Held unresolved — no summary-coverage leg landed this round (73ff43f7 is annotation text only).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still held for the same maintainer design decision — no autonomous patch this round. The pre-summary-crash premise is undisputed (npm continues past a failing workspace, so one shard log accumulates every workspace's output), and the banner-vs-summary leg is the right shape; but the suggestion carries its own precondition — "verify the banner shape against a real release run before landing it" — which this environment cannot satisfy, and an unverified leg on a release gate is what this PR's history argues against. Your note that this leg and the R5-2 widening want one fix rather than two is recorded against the decision, as is the pre-summary-crash probe row. Thread left unresolved on purpose.

中文说明

同样挂起,等待同一个维护者设计决策 —— 本轮不做自动化补丁。pre-summary 崩溃这一前提没有争议(npm 会越过失败的 workspace 继续执行,因此一个分片日志会累积所有 workspace 的输出),banner 对 summary 的计数检查也是正确的形态;但该建议自带先决条件 —— "落地前先用真实 release 运行验证 banner 形态" —— 本环境无法满足这一点,而在 release 门禁上落地未经验证的检查正是本 PR 的历史所反对的。你关于这条检查与 R5-2 的放宽应当合并为一个修复而非两个的注记,以及 pre-summary 崩溃探针行,都已纳入该决策记录在案。本讨论串刻意保持未解决。

echo "::warning title=Workspace tests passed through a Vitest transport timeout::Every test passed and all ${errors} unhandled error(s) Vitest counted were its own worker RPC timing out. Treated as a pass."
exit 0
fi
echo "::warning title=Workspace tests exited ${status} on a Vitest transport timeout::A transport timeout with no passing tally to back it, so the failure stands. Rerun the job."
echo "::warning title=Workspace tests exited ${status} on a Vitest transport timeout::A transport timeout the run cannot account for — no passing tally, a failing tally, a signal death, an unhandled error that was not the transport, or the two counts disagreeing for a reason this log does not show. The failure stands (status ${status}, ${errors} counted error(s) vs ${timeouts} transport line(s)); rerun the job."
else
echo "::error title=Workspace tests exited ${status} with no failing test::No FAIL line and no transport timeout in the log. Look for a Vitest Unhandled Errors section, or a worker killed before it could report."
fi
Expand Down
185 changes: 174 additions & 11 deletions scripts/tests/release-workflow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,20 @@ describe('release workflow', () => {
expect(testStep.env.VITEST_RETRY).toBe(
"${{ vars.QWEN_RELEASE_VITEST_RETRY || '2' }}",
);
// `shell: 'bash'` is this step's only source of `-o pipefail` — the
// workflow has no `defaults:` block — so it is what makes the guard live
// rather than dead code. Drop it and GitHub falls back to `bash -e {0}`,
// where `npm … | tee` reports tee's status 0, the `||` handler never
// fires, and a shard with genuinely failing tests exits 0 into the
// release. The probe harness below passes `-o pipefail` itself, so no
// behavioural row would notice the line being cleaned up as redundant.
expect(testStep.shell).toBe('bash');
// Vitest colours its summaries from the mere presence of CI, and a
// coloured summary sits escape bytes between a label and its value, so
// every anchored pattern in the guard matches nothing: the pass-through
// is never granted again and each transport timeout reddens the release.
// Quoted in YAML, so it parses to the string rather than a boolean.
expect(testStep.env.NO_COLOR).toBe('true');

const workspacePackages = getTestCiWorkspaces();

Expand Down Expand Up @@ -643,44 +657,193 @@ describe('release workflow', () => {
// A shard that died on Vitest's own worker RPC timing out reads
// identically to a real break, and this release lost two attempts before
// anyone could tell them apart (run 33713579913). The annotation says
// which; the child's status is re-raised untouched either way, so no
// reading of the log can turn a failure green.
// which; the child's status is re-raised untouched, so no reading of the
// log can turn a failure green except the one deliberate pass-through.
//
// That pass-through is granted by Vitest's own count of unhandled errors
// (`Errors N errors`) matching how many carried the transport's message
// — not by recognising a crash from its header. The header is
// producer-chosen, so a pattern over headers is incomplete by
// construction; the rows below carry the shapes that defeat one
// (suffix-less class, no class at all) and the shapes that defeat the
// count (an extra error the timeouts do not account for, a summary the
// run never reached, words a test merely printed).
const testStep = releaseYaml.jobs.workspace_tests.steps.find(
(step) => step.name === 'Run Workspace Tests',
);
const script = testStep.run.replaceAll('${{ matrix.shard }}', '1');
const timeout = 'Error: [vitest-worker]: Timeout calling "x"';
const tally = ' Tests 10614 passed (10614)';
const passedThrough =
'::warning title=Workspace tests passed through a Vitest transport timeout::';
const stands =
'::warning title=Workspace tests exited 1 on a Vitest transport timeout::';

for (const [label, stub, code, annotation, expected] of [
// A failing test names itself; an annotation would only add noise.
['failing test', ' FAIL src/a.test.ts > boom', 1, null],
// Vitest's worker RPC giving up says nothing about the product, and
// --retry cannot cover it. Passed only with proof the run reached its
// end: a normal exit, a passing tally, no failing tally, and no other
// unhandled error. It cost this release three attempts before that.
// --retry cannot cover it — retries re-run failing TESTS while an
// unhandled error fails the run outright. Passed only with proof the
// run reached its end and that the transport accounts for every
// unhandled error Vitest counted. It cost this release three attempts.
[
'transport timeout, run completed',
'Error: [vitest-worker]: Timeout calling "x"\n Tests 10614 passed (10614)',
`${timeout}\n${tally}\n Errors 1 error`,
1,
'::warning title=Workspace tests passed through a Vitest transport timeout::',
passedThrough,
0,
],
[
'transport timeout, killed by a signal',
'Error: [vitest-worker]: Timeout calling "x"\n Tests 10614 passed (10614)',
`${timeout}\n${tally}\n Errors 1 error`,
137,
'::warning title=Workspace tests exited 137 on a Vitest transport timeout::',
],
// One more error than the transport accounts for: the run broke on
// something else as well, whatever its header said.
[
'transport timeout beside a real one',
'Error: [vitest-worker]: Timeout calling "x"\nError: write after end\n Tests 10614 passed (10614)',
`${timeout}\nError: write after end\n${tally}\n Errors 2 errors`,
1,
stands,
],
// The counts can also disagree with no crash in the log at all — the
// same transport message on two lines inflates `timeouts` past what
// Vitest counted. That is a fifth way to reach this refusal, so the
// annotation names it and prints both figures it compared; without
// them the oncall is told one of four things happened when none did.
[
'transport timeout counted twice against one unhandled error',
`${timeout}\n${timeout}\n${tally}\n Errors 1 error`,
1,
'::warning title=Workspace tests exited 1 on a Vitest transport timeout::A transport timeout the run cannot account for — no passing tally, a failing tally, a signal death, an unhandled error that was not the transport, or the two counts disagreeing for a reason this log does not show. The failure stands (status 1, 1 counted error(s) vs 2 transport line(s)); rerun the job.',
],
// The two shapes no header pattern reaches. A class whose name carries
// no Error/Exception suffix is not hypothetical — 26 of this repo's 293
// Error subclasses are named that way, four of them assigning the bare
// name to err.name — and a bare string throw prints under Vitest's own
// `Unknown Error:` heading, which a header matcher misses on the space.
// The count sees both, because it never looks at the header.
[
'transport timeout beside a suffix-less crash header',
`${timeout}\nPoolTimeout: worker pool exhausted\n${tally}\n Errors 2 errors`,
1,
stands,
],
[
'transport timeout beside a bare string throw',
`${timeout}\nUnknown Error: a bare string, no class header\n${tally}\n Errors 2 errors`,
1,
stands,
],
// Ordinary `Error:` lines are test output, not evidence of a break: the
// log of the run this guard was written for carries three of them as
// fixture data. A matcher over headers refuses the pass-through on
// those and reddens a release the guard exists to save; the count is
// unmoved by them.
[
'transport timeout beside Error: lines a test printed',
`${timeout}\nError: boom\nError: Not implemented: navigation\n${tally}\n Errors 1 error`,
1,
passedThrough,
0,
],
// `--workspaces` prints one summary per workspace into one log, so both
// figures are whole-file sums: a passing tally cannot cover a later
// workspace's crash, and two transport deaths in two workspaces still
// pass.
[
'tally, then a later workspace crashing',
`${timeout}\n${tally}\n Tests 8 passed (8)\n Errors 2 errors`,
1,
stands,
],
// The shape of the log this guard was written for: several transport
// deaths in one run, and the plural summary Vitest prints for more than
// one of them (run 33713579913).
[
'four transport deaths, four unhandled errors',
`${timeout}\n${timeout}\n${timeout}\n${timeout}\n${tally}\n Errors 4 errors`,
1,
passedThrough,
0,
],
[
'two workspaces, both lost to the transport',
`${timeout}\n Tests 5 passed (5)\n Errors 1 error\nError: [vitest-worker]: Timeout calling "y"\n Tests 7 passed (7)\n Errors 1 error`,
1,
'::warning title=Workspace tests exited 1 on a Vitest transport timeout::',
passedThrough,
0,
],
// Absent evidence refuses the pass rather than granting it: no summary
// line at all, no passing tally, or a failing tally.
[
'transport timeout, no error summary to count',
`${timeout}\n${tally}`,
1,
stands,
],
[
'transport timeout, no tally to back it',
'Error: [vitest-worker]: Timeout calling "onTaskUpdate"',
1,
'::warning title=Workspace tests exited 1 on a Vitest transport timeout::',
stands,
],
// Vitest's own summary is the only thing that grants the pass, so a
// run that never printed a tally does not get one even when its error
// count is all transport.
[
'error summary with no tally to back it',
`${timeout}\n Errors 1 error`,
1,
stands,
],
// `Timeout calling` in a test's own output is not the transport dying:
// the branch is entered on Vitest's own `[vitest-worker]:` message, so
// a log carrying only the words is unexplained, not passed through.
[
'Timeout calling printed by a test',
`Timeout calling the vendor API\n${tally}`,
1,
'::error title=Workspace tests exited 1 with no failing test::',
],
// ...and the count is anchored on the same message, so those words
// beside a real transport death do not inflate it into a mismatch.
[
'transport timeout, and Timeout calling printed by a test',
`${timeout}\nTimeout calling the vendor API\n${tally}\n Errors 1 error`,
1,
passedThrough,
0,
],
// ...and the summary sum is anchored on the section-line shape for the
// same reason. Vitest echoes a test's console output at column 0, so a
// workspace printing summary-shaped fixture data lands there; an
// unanchored `Errors N errors` match would add it to the sum, inflate
// `errors` past `timeouts`, and refuse a pass-through every test
// earned — the false-red this PR exists to remove, back again.
[
'transport timeout, and a summary-shaped line a test printed',
`${timeout}\nErrors 2 errors occurred in fixture data\n${tally}\n Errors 1 error`,
1,
passedThrough,
0,
],
[
'transport timeout, failing tally',
`${timeout}\n Tests 3 failed | 10611 passed (10614)\n Errors 1 error`,
1,
stands,
],
// One workspace can print a passing tally while a later one fails
// without ever emitting a FAIL line, so the failing tally is checked
// across the whole log rather than trusted to the branch above.
[
'passing tally in one workspace, failing tally in another',
`${timeout}\n Tests 10 passed (10)\n Test Files 1 failed (3)\n Errors 1 error`,
1,
stands,
],
[
'unexplained',
Expand Down
Loading