fix(ci): file main CI failures whose successor has not settled

The guard added in #15272 treats a queued or in-progress successor at
main's tip as owning the result, so the report is skipped. A successor
that later ends cancelled, timed_out or skipped never re-enters the
workflow — its gate admits only failures — so the failure is never
reported at all.

That is how 10 main-push failures between 14:00Z and 17:30Z on
2026-10-24 produced no issue (#15470), with 65 of the last 100 Qwen
Code CI push runs on main ending cancelled.

The reporter cannot wait for the successor either: it runs on
ubuntu-latest, and that pool queued it for 20-100 minutes in the same
window. Report instead, marked provisional, since the per-signature
route hands new issues to a human rather than to autofix: a stale
provisional report costs a close, a dropped one costs the regression.
This commit is contained in:
yiliang114 committed 2026-10-25 01:35:34 +08:00
1 parent aa6b65a52a
commit d98bc2d1ca
5 files changed
+137 -53

No files matched your search

+1 -1
View File
@@ -31,7 +31,7 @@
16647 live-host-release.yml
5950 live-host.yml
14543 managed-agent-gate.yml
53149 main-ci-failure-issue.yml
55007 main-ci-failure-issue.yml
1343 mobile-shell.yml
2277 pnpm-store.yml
2489 pnpm-worktree-smoke.yml
+37 -8
View File
@@ -70,6 +70,9 @@ jobs:
# record to the workflow's aggregate issue downstream.
signature_title: '${{ steps.summary.outputs.signature_title }}'
test_markers: '${{ steps.summary.outputs.test_markers }}'
# The newer run at main's tip had not settled when this report was
# prepared. The issue is still filed, with a note saying so.
provisional: '${{ steps.live.outputs.provisional }}'
steps:
- name: 'Checkout'
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
@@ -96,6 +99,7 @@ jobs:
run: |-
set -euo pipefail
superseded=false
provisional=false
if live_attempt="$(
gh api "repos/${REPO}/actions/runs/${WORKFLOW_RUN_ID}" \
--jq '.run_attempt'
@@ -114,15 +118,26 @@ jobs:
# repair issue for a head main has already replaced, which is how
# #15259 was filed after #15128 fixed its host-lock timeout.
#
# The successor must also be able to report the result. The job gate
# above admits only `failure` conclusions, so a run that ends
# Only a SETTLED successor can own the result. The job gate above
# admits only `failure` conclusions, so a successor that ends
# cancelled, timed_out, skipped or startup_failure never re-enters
# this workflow; crediting its mere existence would drop the only
# report of a real regression. A failed successor reports itself, and
# a queued or in-progress one is a genuine deferral. A path-filtered
# push arm (SDK Python) or a `[skip ci]` commit leaves no run at the
# new tip at all, and that also reports here. Read failures keep the
# old fail-open reporting.
# report of a real regression. A completed success means the tree is
# green at the tip, and a completed failure reports itself.
#
# A queued or in-progress successor proves nothing yet, and waiting
# for it is not possible here: this job runs on a pool that queued it
# for 20-100 minutes (#15470), so the tip's run is usually still
# young when the report is prepared, and the successor's own re-entry
# only covers the failure conclusion. Reporting is therefore the
# lesser mistake, marked provisional: the per-signature route files
# those for a human to triage and never routes them to autofix
# (#15405), so a stale one costs a close, while a dropped one costs
# the regression.
#
# A path-filtered push arm (SDK Python) or a `[skip ci]` commit
# leaves no run at the new tip at all, and that also reports here.
# Read failures keep the old fail-open reporting.
#
# Every attempt is compared, not just the first: #15259 itself was
# filed by such an attempt-2 recovery run. The successor check is
@@ -141,7 +156,7 @@ jobs:
successor_owns=false
case "${successor_status}" in
queued | in_progress)
successor_owns=true
provisional=true
;;
completed)
case "${successor_conclusion}" in
@@ -153,6 +168,8 @@ jobs:
superseded=true
echo "::warning::Run ${WORKFLOW_RUN_ID} tested ${HEAD_SHA}, but main is now ${tip}; the newer push run owns its result."
echo "Superseded main-CI failure: run ${WORKFLOW_RUN_ID} tested ${HEAD_SHA}, but main is now ${tip}; the newer push run owns the result." >> "${GITHUB_STEP_SUMMARY}"
elif [[ "${provisional}" == 'true' ]]; then
echo "::warning::Run ${WORKFLOW_RUN_ID} tested ${HEAD_SHA} and main is now ${tip}; its newer push run is ${successor_status}, so this report is provisional."
elif [[ "${successor_status}" == 'none' ]]; then
echo "::warning::Run ${WORKFLOW_RUN_ID} tested ${HEAD_SHA} and main is now ${tip}, but workflow ${WORKFLOW_ID} has no newer push run there; reporting this run."
else
@@ -167,6 +184,7 @@ jobs:
fi
fi
echo "superseded=${superseded}" >> "${GITHUB_OUTPUT}"
echo "provisional=${provisional}" >> "${GITHUB_OUTPUT}"
- name: 'Select the failure destination'
id: 'destination'
@@ -457,9 +475,20 @@ jobs:
RUN_ATTEMPT: '${{ github.event.workflow_run.run_attempt }}'
SIGNATURE_TITLE: '${{ needs.analyze.outputs.signature_title }}'
TEST_MARKERS: '${{ needs.analyze.outputs.test_markers }}'
PROVISIONAL: '${{ needs.analyze.outputs.provisional }}'
RUN_AT: '${{ github.event.workflow_run.updated_at }}'
run: |-
set -euo pipefail
if [[ "${PROVISIONAL}" == 'true' ]]; then
# The reader needs the caveat before the finding, and both the
# per-signature and the aggregate route build their body from this
# variable.
ISSUE_BODY="$(
printf '%s\n\n%s' \
'> **Provisional.** Main advanced past this head while the report was being prepared, and the newer push run at the tip had not finished. If that run passes, this failure is already covered and this issue can be closed.' \
"${ISSUE_BODY}"
)"
fi
title="Main CI failed: ${WORKFLOW_NAME}"
marker="<!-- main-ci-failure-aggregate:${WORKFLOW_ID} -->"
# Embedded in every per-signature filing (and accreted onto every
+13 -6
View File
@@ -184,12 +184,19 @@ which has had no `pull_request` trigger since #14205.
A post-merge push failure whose head is no longer main's tip is recorded only
on the analyze run's step summary, not on the workflow issue, when a newer push
run of the same workflow exists at the new tip and can report the result
itself. Queued and in-progress successors count as a deferral, as do
successors that completed `success` or `failure` — a failed successor's own
completion event re-enters this workflow. A successor that ended `cancelled`,
`timed_out`, `skipped`, or `startup_failure` is never analyzed, so the original
failure is reported instead.
run of the same workflow exists at the new tip and has already settled: a
`success` there means the tree is green, and a `failure` reports itself through
its own completion event. A successor that ended `cancelled`, `timed_out`,
`skipped`, or `startup_failure` is never analyzed, so the original failure is
reported instead.
A successor that is still `queued` or `in_progress` settles nothing, and this
job cannot wait for it — the pool the reporter runs on queued it for 20-100
minutes, so the tip's run is usually still young when the report is prepared.
The failure is reported anyway, marked provisional at the top of the body. That
is affordable because the per-signature route hands new issues to a human
rather than to autofix: a stale provisional report costs a close, while a
dropped one costs the regression (#15470).
## Cost
@@ -152,11 +152,16 @@ workflow 共用的汇总 issue 上追加评论,找到匹配的已关闭 issue
起已没有 `pull_request` 触发。
当合入后 push 失败的 head 已不再是 main 的 tip 时,如果新 tip 上存在同一 workflow
的更新 push 运行、且它能自行上报结果,这次失败只记录在 analyze 运行的步骤摘要里,
不会记录到 workflow issue 上。仍在排队或运行中的后继算作延迟上报;以 `success` 或
`failure` 结束的后继同样算数——失败后继自己的完成事件会重新进入本 workflow。以
`cancelled`、`timed_out`、`skipped` 或 `startup_failure` 结束的后继永远不会被分析,
因此此时仍然上报原始失败。
的更新 push 运行、且它**已经定论**,这次失败只记录在 analyze 运行的步骤摘要里,不会
记录到 workflow issue 上:`success` 表示该处代码是绿的,`failure` 会通过自己的完成
事件上报。以 `cancelled`、`timed_out`、`skipped` 或 `startup_failure` 结束的后继永远
不会被分析,因此此时仍然上报原始失败。
仍在 `queued` 或 `in_progress` 的后继什么都证明不了,而本 job 也等不起它——报告所在
的 runner 池把它排了 20-100 分钟,准备好报告时 tip 上的运行通常刚开始不久。此时仍然
上报,并在正文顶部标注为临时性(provisional)。之所以负担得起,是因为按签名路由的新
issue 交给人工分诊而不是 autofix:一条过期的临时报告只需关掉,丢掉一条则丢掉整个回归
(#15470)。
## 成本
@@ -397,7 +397,7 @@ fi
},
{ mainTip: 'new-head' },
);
expect(resolved.output).toBe('superseded=true\n');
expect(resolved.output).toBe('superseded=true\nprovisional=false\n');
expect(resolved.stdout).toContain('::warning::');
expect(resolved.stdout).not.toContain('main is now');
expect(resolved.calls).not.toContain('commits/main');
@@ -428,22 +428,22 @@ fi
);
});
it('reports the attempt that is still the latest', () => {
expect(detect('1').output).toBe('superseded=false\n');
expect(detect('1').output).toBe('superseded=false\nprovisional=false\n');
expect(detect('2', { RUN_ATTEMPT: '2' }).output).toBe(
'superseded=false\n',
'superseded=false\nprovisional=false\n',
);
// Stale-attempt supersession is independent of the tip comparison:
// a bare event never reaches the push arm, and the nightly schedule
// arm is exempt from it. Folding the attempt check inside that arm
// would file an issue for an already-green rerun (#14768's shape).
expect(detect('2').output).toBe('superseded=true\n');
expect(detect('2').output).toBe('superseded=true\nprovisional=false\n');
expect(
detect('2', {
EVENT_NAME: 'schedule',
HEAD_BRANCH: 'main',
HEAD_SHA: 'old-head',
}).output,
).toBe('superseded=true\n');
).toBe('superseded=true\nprovisional=false\n');
});
it('skips a push run when main has advanced past its head', () => {
// Run 39777472391's push run tested 06e59666, main moved on to
@@ -460,7 +460,7 @@ fi
},
{ mainTip: '3d65e23cf62e00da033dccb8f60f9cfdd9c1b76e' },
);
expect(resolved.output).toBe('superseded=true\n');
expect(resolved.output).toBe('superseded=true\nprovisional=false\n');
expect(resolved.stdout).toContain(
'main is now 3d65e23cf62e00da033dccb8f60f9cfdd9c1b76e',
);
@@ -487,7 +487,7 @@ fi
},
{ mainTip: 'new-head', successor: 'none' },
);
expect(resolved.output).toBe('superseded=false\n');
expect(resolved.output).toBe('superseded=false\nprovisional=false\n');
expect(resolved.stdout).toContain('no newer push run');
expect(resolved.calls).toContain(
'actions/workflows/12345/runs?event=push&branch=main&head_sha=new-head&per_page=1',
@@ -506,25 +506,35 @@ fi
},
{ mainTip: 'new-head', successor: 'completed cancelled' },
);
expect(resolved.output).toBe('superseded=false\n');
expect(resolved.output).toBe('superseded=false\nprovisional=false\n');
expect(resolved.stdout).toContain('completed cancelled');
expect(resolved.stdout).toContain('does not report');
expect(resolved.summary).toBe('');
});
it('skips a push run while its successor is still queued', () => {
// A queued or in-progress successor is a genuine deferral: its own
// completion event reports the result if it fails.
const resolved = detect(
'1',
{
EVENT_NAME: 'push',
HEAD_BRANCH: 'main',
HEAD_SHA: 'old-head',
},
{ mainTip: 'new-head', successor: 'in_progress' },
);
expect(resolved.output).toBe('superseded=true\n');
expect(resolved.stdout).toContain('the newer push run owns its result');
it('reports a push run whose successor has not settled, flagged provisional', () => {
// A running successor proves nothing yet, and this job cannot wait for
// it: the hosted pool queued the reporter for 20-100 minutes, so the
// tip's run is usually young. The successor's own re-entry covers only
// the failure conclusion, so discarding here loses a real regression
// whenever that run ends cancelled (#15470). The report is filed with
// the caveat instead, and the per-signature route hands it to a human
// rather than autofix, so a stale one costs a close.
for (const successor of ['in_progress', 'queued']) {
const resolved = detect(
'1',
{
EVENT_NAME: 'push',
HEAD_BRANCH: 'main',
HEAD_SHA: 'old-head',
},
{ mainTip: 'new-head', successor },
);
expect(resolved.output).toBe('superseded=false\nprovisional=true\n');
expect(resolved.stdout).toContain(
`${successor}, so this report is provisional`,
);
expect(resolved.summary).toBe('');
}
});
it('fails open when the newer-run lookup cannot be read', () => {
const resolved = detect(
@@ -536,7 +546,7 @@ fi
},
{ mainTip: 'new-head', failCall: 'newer-runs' },
);
expect(resolved.output).toBe('superseded=false\n');
expect(resolved.output).toBe('superseded=false\nprovisional=false\n');
expect(resolved.stdout).toContain(
'Could not list the newer runs of workflow 12345',
);
@@ -555,7 +565,7 @@ fi
},
{ mainTip: 'new-head', successor: 'completed failure' },
);
expect(resolved.output).toBe('superseded=true\n');
expect(resolved.output).toBe('superseded=true\nprovisional=false\n');
expect(resolved.stdout).toContain('main is now new-head');
expect(resolved.calls).toContain('commits/main');
});
@@ -573,7 +583,7 @@ fi
},
{ mainTip: 'new-head', successor: 'none' },
);
expect(resolved.output).toBe('superseded=false\n');
expect(resolved.output).toBe('superseded=false\nprovisional=false\n');
expect(resolved.stdout).toContain('no newer push run');
expect(resolved.calls).toContain('commits/main');
});
@@ -587,7 +597,7 @@ fi
},
{ mainTip: 'tested-head' },
);
expect(resolved.output).toBe('superseded=false\n');
expect(resolved.output).toBe('superseded=false\nprovisional=false\n');
expect(resolved.stdout).not.toContain('::warning::');
expect(resolved.calls).toContain('commits/main');
});
@@ -603,7 +613,7 @@ fi
},
{ mainTip: '' },
);
expect(resolved.output).toBe('superseded=false\n');
expect(resolved.output).toBe('superseded=false\nprovisional=false\n');
});
it('does not compare a scheduled run with the tip of main', () => {
// A schedule runs at its own head and is the only signal macOS and
@@ -618,7 +628,7 @@ fi
},
{ mainTip: 'new-head' },
);
expect(resolved.output).toBe('superseded=false\n');
expect(resolved.output).toBe('superseded=false\nprovisional=false\n');
expect(resolved.calls).not.toContain('commits/main');
});
it.each([['pull_request'], ['pull_request_review'], ['workflow_dispatch']])(
@@ -637,7 +647,7 @@ fi
},
{ mainTip: 'new-head' },
);
expect(resolved.output).toBe('superseded=false\n');
expect(resolved.output).toBe('superseded=false\nprovisional=false\n');
expect(resolved.calls).not.toContain('commits/main');
},
);
@@ -651,7 +661,7 @@ fi
},
{ mainTip: 'new-head', failCall: 'tip' },
);
expect(resolved.output).toBe('superseded=false\n');
expect(resolved.output).toBe('superseded=false\nprovisional=false\n');
expect(resolved.stdout).toContain('Could not read the tip of main');
expect(resolved.calls).toContain('commits/main');
});
@@ -667,7 +677,7 @@ fi
},
{ mainTip: 'new-head' },
);
expect(resolved.output).toBe('superseded=false\n');
expect(resolved.output).toBe('superseded=false\nprovisional=false\n');
expect(resolved.calls).not.toContain('commits/main');
});
it('fails open when the live run cannot be read', () => {
@@ -675,7 +685,7 @@ fi
// for a real failure beats silently dropping it.
const gh = `#!/usr/bin/env bash\nprintf '%s' '{"message":"Bad credentials"}'\nexit 1\n`;
const resolved = detect('', {}, { ghScript: gh });
expect(resolved.output).toBe('superseded=false\n');
expect(resolved.output).toBe('superseded=false\nprovisional=false\n');
expect(resolved.stdout).toContain('::warning::');
expect(resolved.stdout).toContain('Could not read run 39237270547');
});
@@ -683,7 +693,7 @@ fi
// -gt coerces an empty operand to 0, so a field-less event would read
// as superseded and suppress every report.
expect(detect('2', { RUN_ATTEMPT: '' }).output).toBe(
'superseded=false\n',
'superseded=false\nprovisional=false\n',
);
});
it('gates every failure reader on the supersession check', () => {
@@ -751,6 +761,7 @@ fi
runAt = '2026-10-24T01:02:03Z',
markerIssues = [],
closedIssues = [],
provisional = false,
} = opts;
const tmp = mkdtempSync(join(tmpdir(), 'ci-issue-reuse-'));
try {
@@ -848,6 +859,7 @@ fi
RUN_ATTEMPT: '2',
SIGNATURE_TITLE: signatureTitle,
TEST_MARKERS: testMarkers,
PROVISIONAL: provisional ? 'true' : 'false',
RUN_AT: runAt,
LOOKUP_FAILURES: String(lookupFailures),
FAIL_COMMENT_ONCE: failCommentOnce ? '1' : '0',
@@ -1420,6 +1432,37 @@ fi
expect(result.calls).not.toMatch(/ready-for-agent|autofix|assignee/);
});
it('leads a provisional filing with the caveat and still files it', () => {
// The successor had not settled when the report was prepared, so the
// issue is filed anyway; the caveat is what lets a reader close it
// when the newer run turns out to cover the failure.
const result = run([botIssue(42)], {
signatureTitle,
testMarkers: markerA,
provisional: true,
});
expect(result.status).toBe(0);
expect(result.body).toMatch(
/^> \*\*Provisional\.\*\* Main advanced past this head/,
);
expect(result.body).toContain('Run 123 failed at commit abcdef123456');
expect(result.body).toContain(`<!-- ${markerA} -->`);
// A provisional issue is still a human-triage unit, never autofix
// work — the whole reason reporting it is affordable.
expect(result.calls).toContain(
'issue edit 999 --repo QwenLM/qwen-code --add-label type/bug --add-label status/needs-triage',
);
expect(result.calls).not.toMatch(/ready-for-agent|autofix|assignee/);
});
it('omits the caveat when the tip was settled', () => {
const result = run([botIssue(42)], {
signatureTitle,
testMarkers: markerA,
});
expect(result.body).not.toContain('Provisional.');
});
it('comments on the issue whose body carries the test marker and accretes new ones', () => {
const tracked =
'Earlier report.\n\n<!-- qwen-main-ci-failure-test:aaa111bbb222 -->\n';