mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
fix(ci): file main CI failures whose successor has not settled (#15471)
* 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.
* fix(ci): keep a provisional head off the infra rerun path
Four review findings on d98bc2d1ca:
- rerun_infra holds actions: write but was gated only on infra_only and
file_issue. An unsettled successor now yields provisional=true instead
of superseded=true, so destination runs and file_issue becomes true: an
infra-only failure at a head main already replaced would dispatch
rerun-failed-jobs onto the contended pool this PR cites (#15470), and a
successful dispatch would then satisfy file_issue's
`rerun_infra.result != 'success'` operand, so nothing was filed and no
step summary was written either. Add the provisional operand; skipping
the rerun is what makes file_issue record it instead.
- Only a settled successor can own the result, so invert the case arm:
anything but `completed` (and `none`) is provisional. The enumeration
covered queued and in_progress only, leaving waiting, requested and
pending to file without the caveat and to warn falsely that the
successor "does not report".
- The caveat heads the comment routes as well as the create routes, so
"this issue can be closed" was advice to close a tracker belonging to a
different occurrence. Reword to "this report can be dismissed".
- Pin both YAML hops of the provisional wire; retyping either left
PROVISIONAL empty and the suite green.
Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmvmwqg38tp
* test(ci): cover the provisional caveat on its two untested write sites
The caveat is prepended once ahead of the route split, so it reaches three
write sites: the per-signature certified-miss create, the per-signature
recurrence comment, and the aggregate route. Only the first was exercised
with provisional=true, so narrowing the prepend to a single branch would
drop the caveat from the other two unnoticed.
Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmvn35xgdtw
---------
Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
1 parent
1a98854b76
commit
cf3ba47dfd
5 files changed
+205
-56
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
#
|
||||
# An unsettled 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
|
||||
@@ -139,20 +154,26 @@ jobs:
|
||||
)"; then
|
||||
read -r successor_status successor_conclusion <<< "${successor}"
|
||||
successor_owns=false
|
||||
# Complement, not an enumeration: only a settled successor
|
||||
# owns the result, and not-yet-final statuses grow.
|
||||
case "${successor_status}" in
|
||||
queued | in_progress)
|
||||
successor_owns=true
|
||||
;;
|
||||
completed)
|
||||
case "${successor_conclusion}" in
|
||||
success | failure) successor_owns=true ;;
|
||||
esac
|
||||
;;
|
||||
none)
|
||||
;;
|
||||
*)
|
||||
provisional=true
|
||||
;;
|
||||
esac
|
||||
if [[ "${successor_owns}" == 'true' ]]; then
|
||||
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 +188,7 @@ jobs:
|
||||
fi
|
||||
fi
|
||||
echo "superseded=${superseded}" >> "${GITHUB_OUTPUT}"
|
||||
echo "provisional=${provisional}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: 'Select the failure destination'
|
||||
id: 'destination'
|
||||
@@ -401,10 +423,12 @@ jobs:
|
||||
# only for destinations that file (main runs and merged PRs). An open PR's
|
||||
# infra death is reported on the PR without dispatching a rerun, and the
|
||||
# classifier never grants an untrusted run actions: write.
|
||||
# Not for a provisional report: that head is already replaced, and its rerun
|
||||
# could not re-enter here — skipping lets file_issue record it instead.
|
||||
rerun_infra:
|
||||
name: 'Rerun infrastructure-only failures'
|
||||
needs: 'analyze'
|
||||
if: "${{ needs.analyze.outputs.infra_only == 'true' && needs.analyze.outputs.file_issue == 'true' }}"
|
||||
if: "${{ needs.analyze.outputs.infra_only == 'true' && needs.analyze.outputs.file_issue == 'true' && needs.analyze.outputs.provisional != 'true' }}"
|
||||
runs-on: 'ubuntu-latest'
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
@@ -457,9 +481,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
|
||||
# Heads every route's body — both create paths and both
|
||||
# comment-on-existing paths — so it must not assume this issue is
|
||||
# the tracker for this occurrence.
|
||||
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 report can be dismissed.' \
|
||||
"${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
|
||||
|
||||
@@ -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 has not completed 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` 结束的后继永远
|
||||
不会被分析,因此此时仍然上报原始失败。
|
||||
|
||||
尚未 `completed` 的后继什么都证明不了,而本 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,43 @@ 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.
|
||||
// Every not-yet-final status counts, matching the vocabulary
|
||||
// qwen-autofix.yml uses for the same question.
|
||||
for (const successor of [
|
||||
'in_progress',
|
||||
'queued',
|
||||
'waiting',
|
||||
'requested',
|
||||
'pending',
|
||||
]) {
|
||||
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 +554,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 +573,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 +591,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 +605,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 +621,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 +636,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 +655,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 +669,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 +685,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 +693,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 +701,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 +769,7 @@ fi
|
||||
runAt = '2026-10-24T01:02:03Z',
|
||||
markerIssues = [],
|
||||
closedIssues = [],
|
||||
provisional = false,
|
||||
} = opts;
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'ci-issue-reuse-'));
|
||||
try {
|
||||
@@ -848,6 +867,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',
|
||||
@@ -905,6 +925,19 @@ fi
|
||||
},
|
||||
);
|
||||
|
||||
it('heads a provisional aggregate occurrence with the caveat', () => {
|
||||
// The caveat is prepended once, ahead of the route split, so the
|
||||
// aggregate's own body file is a third write site: a prepend narrowed to
|
||||
// the per-signature branch would silently drop it here.
|
||||
const result = run([botIssue(42)], { provisional: true });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.calls).toContain('issue comment 42');
|
||||
expect(result.body).toMatch(
|
||||
/^> \*\*Provisional\.\*\* Main advanced past this head/,
|
||||
);
|
||||
expect(result.body).toContain('Run 123 failed at commit abcdef123456');
|
||||
});
|
||||
|
||||
it('retires the autofix route when adopting a legacy issue without the workflow marker', () => {
|
||||
const result = run([botIssue(42, ' on abc123')], {
|
||||
issueBody:
|
||||
@@ -1386,6 +1419,18 @@ fi
|
||||
'jq -r \'.searchMarkers[]?\' "${RUNNER_TEMP}/analysis.json"',
|
||||
);
|
||||
});
|
||||
|
||||
it('wires the provisional flag from the live step through to the writer', () => {
|
||||
// Same chain shape, same failure mode: a retyped binding leaves
|
||||
// PROVISIONAL empty, so every provisional filing silently loses its
|
||||
// caveat; a dropped one is unbound and kills the writer outright.
|
||||
expect(jobs.analyze.outputs.provisional).toBe(
|
||||
'${{ steps.live.outputs.provisional }}',
|
||||
);
|
||||
expect(writer.env.PROVISIONAL).toBe(
|
||||
'${{ needs.analyze.outputs.provisional }}',
|
||||
);
|
||||
});
|
||||
describe('per-signature routing', () => {
|
||||
const signatureTitle =
|
||||
'Main CI failed: SDK Java — src/Foo.test > Suite … > case bar';
|
||||
@@ -1420,6 +1465,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';
|
||||
@@ -1466,6 +1542,27 @@ fi
|
||||
expect(result.calls).not.toContain('issue edit');
|
||||
});
|
||||
|
||||
it('heads a provisional recurrence comment with the caveat', () => {
|
||||
// A recurrence is written from a separate file than the certified-miss
|
||||
// create, so it is its own write site: scoping the prepend to the
|
||||
// filing paths would leave a provisional recurrence unmarked.
|
||||
const tracked = `<!-- ${markerA} -->\n<!-- main-ci-failure-scope:1234 -->\n`;
|
||||
const result = run([botIssue(7, ' — Foo.test')], {
|
||||
signatureTitle,
|
||||
testMarkers: markerA,
|
||||
issueBodies: { 7: tracked },
|
||||
provisional: true,
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.calls).toContain('issue comment 7');
|
||||
expect(result.commentBody).toMatch(
|
||||
/^> \*\*Provisional\.\*\* Main advanced past this head/,
|
||||
);
|
||||
expect(result.commentBody).toContain(
|
||||
'Run 123 failed at commit abcdef123456',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the recorded recurrence when the accretion edit fails', () => {
|
||||
const result = run([botIssue(7, ' — Foo.test')], {
|
||||
signatureTitle,
|
||||
@@ -2003,6 +2100,11 @@ printf '%s\\n' '{"jobs":[]}'
|
||||
expect(String(rerunJob.if)).toContain(
|
||||
"needs.analyze.outputs.file_issue == 'true'",
|
||||
);
|
||||
// A provisional report is for a head main already replaced: the rerun
|
||||
// would spend pool capacity on it and could not re-enter here.
|
||||
expect(String(rerunJob.if)).toContain(
|
||||
"needs.analyze.outputs.provisional != 'true'",
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the same signature list as the PR-scan rerun of qwen-autofix.yml', () => {
|
||||
|
||||
Reference in new issue
Block a user