Files
qwen-code/.github/workflows/main-ci-failure-issue.yml
T
7cb46d9fed feat(hooks): add FileChanged notifications for local sessions (#13969)
* feat(hooks): add FileChanged notifications for local sessions

* fix(ci): preserve shell gate coverage and trim transcript data

* fix(ci): flush debug prompts and isolate git fixtures

* fix(hooks): respect ACP watcher ownership and skill registration

* test: stabilize hosted recovery and failed spawn fixtures

* fix(ci): budget the complete hosted integration fork

* fix(runtime-broker): accept MariaDB CSI guard metadata

* test: align schema baseline and hook dispatch waits

* fix(ci): allow hosted shared fork to finish all classes

* fix(hooks): preserve file watching across errors and cwd changes

* fix(ci): isolate reference archive extraction per run

* test(ci): keep reference archive checks dependency-free

* fix(hooks): constrain recursive watches and repair CI

* test(web-shell): synchronize verified connection return

* fix(ci): correct runtime broker callback indentation

* test(web-shell): match the managed workspace registration label

Keep the remote workspace registration smoke checks in sync with the service label added by unified workspace navigation. Preserve exact text matching and all registration and replay assertions.

* fix(ci): reduce standalone runtime fixture overhead

* fix(hooks): ignore implicit atomic-write temporary files

* test(managed-agent): recover initial node port conflicts

* fix(hooks): observe parents before discovery anchors disappear

* test(managed-agent): await stream cleanup before recovery teardown

* test(managed-agent): align hosted shell fixture capabilities

* test(managed-agent): let OS choose initial harness port

* fix(ci): restore runner-local Java cache permissions

* test(runtime-broker): use native execution profile paths

* test(managed-agent): bind valid workspace upload probes

* test(managed-agent): reuse the upload probe digest fixture

* test(managed-agent): bind hook fault listeners to available ports

* test(managed-agent): align the native profile path fixture

* test(managed-agent): Wait for projection refusal retries

* ci: Prepare daemon Java cache before Node preflight

* test(managed-agent): check channel model calls after delivery

* fix(hooks): observe explicitly registered symlink targets

* test(managed-agent): separate consumption from relay snapshots

* test(managed-agent): target tamper injection by resource key

* test(managed-agent): exhaust attestation EOF retry in fault gates

* fix(ci): bound RocketMQ release downloads

* test(tui): await the restored assistant before capturing history

* fix(ci): use a checksum-pinned RocketMQ mirror

* fix(ci): provision missing browser test fonts

* fix(ci): quote the font probe environment

* fix(ci): prepare browser fonts without elevation

* test(core): compare complete prune reports without ordering

* fix(ci): repair only runner-local Java tool caches

* test(managed-agent): qualify fixture JDBC templates

* fix: isolate reconnect hooks and cancellation test fixtures

Skip chat initialization so MCP reconnect cannot re-enable file hooks, and keep cancellation fixtures isolated from the background worktree scanner.

* fix(ci): tolerate a missing Java cache helper on resumed releases

Resuming an existing release checks out a tag that may predate the helper, which failed all five Java jobs before setup-java ran.

* fix: finish the main merge for harness deadlines and workflow size

Use the renamed settle-timeout accessor in the merged hosted-harness tests,
and record sdk-java.yml's new size after the resumed-release cache guard.

* style(managed-agent): restore import grouping in merged verification tests

* test(managed-agent): require consumed child result mirrors

* test(web-shell): center repository settings capture

---------

Co-authored-by: yiliang114 <[email protected]>
Co-authored-by: yungsen.hys <[email protected]>
Co-authored-by: Shaojin Wen <[email protected]>
2026-11-06 21:43:01 +08:00

1031 lines
57 KiB
YAML

# .github/workflows/main-ci-failure-issue.yml
name: 'Main CI Failure Issue'
on:
workflow_run:
# 'Qwen Code CI' is watched for its SCHEDULED run and its post-merge PUSH
# run (see the event filter below — the push arm is not name-scoped, so it
# covers this workflow too). The nightly is the macOS and Windows lanes'
# only trigger outside a pull request, and a lane nobody is told about is a
# lane nobody looks at — which is how those two came to stop running. The
# push arm covers ci.yml's post-merge lane on `main`, whose whole point is
# to surface a regression as it lands. A run that names its failing tests
# files or reuses a per-signature issue — deduped by the test markers in
# its body — so a new regression is a visible, triageable unit instead of
# a comment nobody sees; only a run with no test identity shares the
# workflow's aggregate issue, one comment per occurrence. A newer push run
# that already owns main's tip supersedes this report; see 'Skip a
# superseded attempt'.
# Daily Java and Desktop checks report main regressions through the same
# routing as the nightly CLI checks.
# The Managed Agent gate's post-merge fast tier rides the push arm.
workflows:
- 'E2E Tests'
- 'Managed Agent gate'
- 'SDK Java'
- 'SDK Python'
- 'Qwen Code CI'
- 'Desktop Checks'
- 'tui-parity'
types: ['completed']
# PR approval runs can finish after merge and still need a failure report.
defaults:
run:
shell: 'bash'
jobs:
# The job holding the bot PAT is split off so it still checks out nothing
# and runs no repository code. This job identifies failing tests from the
# run's logs using read-only scopes and the workflow token, then hands the
# finished failure summary to the privileged job as an output.
analyze:
name: 'Identify the failing tests'
if: >-
${{ github.repository == 'QwenLM/qwen-code' &&
github.event.workflow_run.conclusion == 'failure' &&
((github.event.workflow_run.head_branch == 'main' &&
(github.event.workflow_run.event == 'push' ||
(github.event.workflow_run.event == 'schedule' &&
contains(fromJSON('["Qwen Code CI","SDK Java","Desktop Checks","tui-parity"]'), github.event.workflow.name)))) ||
(github.event.workflow_run.event == 'workflow_dispatch' && github.event.workflow.name == 'SDK Java') ||
(github.event.workflow.name == 'Managed Agent gate' &&
contains(fromJSON('["pull_request","pull_request_review"]'), github.event.workflow_run.event))) }}
runs-on: 'ubuntu-latest'
timeout-minutes: 10
permissions:
# Read the job logs of the triggering run.
actions: 'read'
# Read the failed jobs' annotations: an infrastructure-only failure (the
# runner lost the server, the disk filled) is rerun, not filed.
checks: 'read'
contents: 'read'
pull-requests: 'read'
outputs:
infra_only: '${{ steps.infra.outputs.infra_only }}'
pr_number: '${{ steps.destination.outputs.pr_number }}'
file_issue: '${{ steps.destination.outputs.file_issue }}'
comment_body: '${{ steps.summary.outputs.body }}'
# Empty when the run named no failing test; that emptiness routes the
# 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
with:
persist-credentials: false
# A workflow_run event is delivered once, but this job can sit in the
# queue long enough for the same run to be re-run. Attempt 1 of run
# 39237270547 waited hours here; by the time it ran, attempt 2 was green,
# so the failed-jobs list read from the latest attempt was empty and a
# per-commit issue was filed for an already-green head (#14768). A later
# attempt owns the run: its own completion event reports the result.
- name: 'Skip a superseded attempt'
id: 'live'
env:
GH_TOKEN: '${{ github.token }}'
REPO: '${{ github.repository }}'
WORKFLOW_RUN_ID: '${{ github.event.workflow_run.id }}'
RUN_ATTEMPT: '${{ github.event.workflow_run.run_attempt }}'
WORKFLOW_ID: '${{ github.event.workflow_run.workflow_id }}'
EVENT_NAME: '${{ github.event.workflow_run.event }}'
HEAD_BRANCH: '${{ github.event.workflow_run.head_branch }}'
HEAD_SHA: '${{ github.event.workflow_run.head_sha }}'
run: |-
set -euo pipefail
superseded=false
provisional=false
if live_attempt="$(
gh api "repos/${REPO}/actions/runs/${WORKFLOW_RUN_ID}" \
--jq '.run_attempt'
)"; then
# A missing run_attempt must fail open, not read as attempt 0.
if [[ -n "${RUN_ATTEMPT}" && "${live_attempt}" -gt "${RUN_ATTEMPT}" ]]; then
superseded=true
echo "::warning::Run ${WORKFLOW_RUN_ID} is on attempt ${live_attempt}; this attempt-${RUN_ATTEMPT} event is stale and its own completion event reports the result."
fi
else
echo "::warning::Could not read run ${WORKFLOW_RUN_ID}; assuming this attempt-${RUN_ATTEMPT} event is current and reporting."
fi
# A post-merge push run reports the commit that was main when it
# started. When main has since advanced, a newer run of the same
# workflow at that tip owns the result: filing here would open a
# repair issue for a head main has already replaced, which is how
# #15259 was filed after #15128 fixed its host-lock timeout.
#
# 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 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
# what keeps a lone recovery attempt reporting when main moved while
# it queued.
if [[ "${superseded}" != 'true' &&
"${EVENT_NAME}" == 'push' && "${HEAD_BRANCH}" == 'main' &&
-n "${HEAD_SHA}" ]]; then
if tip="$(gh api "repos/${REPO}/commits/main" --jq '.sha')"; then
if [[ -n "${tip}" && "${tip}" != "${HEAD_SHA}" ]]; then
if successor="$(
gh api "repos/${REPO}/actions/workflows/${WORKFLOW_ID}/runs?event=push&branch=main&head_sha=${tip}&per_page=1" \
--jq '.workflow_runs[0] // {} | if (.status // "") == "" then "none" else "\(.status) \(.conclusion // "")" end'
)"; 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
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
echo "::warning::Run ${WORKFLOW_RUN_ID} tested ${HEAD_SHA} and main is now ${tip}, but its newer push run is ${successor_status}${successor_conclusion:+ ${successor_conclusion}}, which this workflow does not report; reporting this run."
fi
else
echo "::warning::Could not list the newer runs of workflow ${WORKFLOW_ID}; reporting this run as though it had no successor."
fi
fi
else
echo "::warning::Could not read the tip of main; reporting this run as though it were still current."
fi
fi
echo "superseded=${superseded}" >> "${GITHUB_OUTPUT}"
echo "provisional=${provisional}" >> "${GITHUB_OUTPUT}"
- name: 'Select the failure destination'
id: 'destination'
if: "${{ steps.live.outputs.superseded != 'true' }}"
env:
GH_TOKEN: '${{ github.token }}'
REPO: '${{ github.repository }}'
EVENT_NAME: '${{ github.event.workflow_run.event }}'
HEAD_BRANCH: '${{ github.event.workflow_run.head_branch }}'
HEAD_SHA: '${{ github.event.workflow_run.head_sha }}'
RUN_ATTEMPT: '${{ github.event.workflow_run.run_attempt }}'
run: |-
set -euo pipefail
pr_number=''
file_issue=false
# An allow-list, not "main and not a PR event": an event admitted
# to the job later defaults to the PR lookup, never to filing a
# main-regression issue for an unmerged head.
if [[ "${HEAD_BRANCH}" == 'main' && ( "${EVENT_NAME}" == 'push' || "${EVENT_NAME}" == 'schedule' || "${EVENT_NAME}" == 'workflow_dispatch' ) ]]; then
file_issue=true
else
# workflow_run.pull_requests can be empty, including on approval
# runs, and an approval run records the BASE repository as its
# head_repository even when the PR head lives in a fork, so an
# owner:branch head= filter derived from it can never find a
# fork's PR. The exact head SHA qualifies instead: a same-named
# branch on an unrelated fork carries a different one.
if ! pr="$(
gh api --method GET "repos/${REPO}/pulls" \
-f state=all -f per_page=100 --paginate \
| jq -sc --arg sha "${HEAD_SHA}" --arg repo "${REPO}" \
'add // [] | map(select(.head.sha == $sha and .base.repo.full_name == $repo and .base.ref == "main" and (.state != "closed" or .merged_at != null))) | .[0] // {}'
)"; then
# Fail soft like the neighbouring reads: a transient listing
# error must cost this report, not the whole analyze job.
pr='{}'
fi
pr_number="$(jq -r '.number // empty' <<< "${pr}")"
file_issue="$(jq -r '.merged_at != null' <<< "${pr}")"
# A rerun re-executes the PR's pre-merge workflow against a head
# main has already replaced; run 39768204855's attempt 2 filed
# #15239 that way. A dispatch's attempt counts its own manual
# runs, not a rerun of the PR's workflow, so it files at any
# attempt. A missing attempt fails open, like the supersession
# guard above.
if [[ "${file_issue}" == 'true' ]]; then
if [[ -z "${RUN_ATTEMPT}" ]]; then
echo "::warning::No run_attempt in the workflow_run payload for PR #${pr_number}; failing open and filing."
elif [[ "${RUN_ATTEMPT}" != '1' && "${EVENT_NAME}" != 'workflow_dispatch' ]]; then
file_issue=false
echo "::warning::Attempt ${RUN_ATTEMPT} of the PR's pre-merge workflow; not filing a main-regression issue for an already-merged head."
fi
fi
fi
if [[ -z "${pr_number}" && "${file_issue}" != 'true' ]]; then
echo "::warning::The admitted ${EVENT_NAME} run on ${HEAD_BRANCH} resolved to no PR and no issue; nothing will be reported."
fi
echo "pr_number=${pr_number}" >> "${GITHUB_OUTPUT}"
echo "file_issue=${file_issue}" >> "${GITHUB_OUTPUT}"
- name: 'Download failed job logs'
id: 'download'
if: "${{ steps.live.outputs.superseded != 'true' }}"
env:
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
REPO: '${{ github.repository }}'
WORKFLOW_RUN_ID: '${{ github.event.workflow_run.id }}'
RUN_ATTEMPT: '${{ github.event.workflow_run.run_attempt }}'
run: |-
log_dir="${RUNNER_TEMP}/failed-logs"
mkdir -p "${log_dir}"
# The endpoint names the event's attempt, not the run: the
# supersession check above can pass and the run still move on while
# this paginates, and the run-level endpoint resolves to the latest
# attempt.
# One fetch feeds both projections below. `--paginate` prints one
# JSON document per page, and the comment helper reads this file
# with a single JSON.parse, so the pages merge into one document
# here — a run wider than 100 jobs would otherwise render a comment
# naming no job. `gh api` writes a non-2xx body to stdout before
# exiting non-zero, and that body carries no `.jobs`, so a parked
# error normalizes to an empty list, which the comment renderer handles.
# The merged document is staged and promoted only once it accounts
# for every job the pages report: page 1 can arrive and page 2 still
# fail, and jq happily merges what it got. A partial list reads like
# a complete one to every consumer here — including the infra
# classifier, which would rerun a regression parked on the missing
# page instead of filing it. `total_count` is the receipt, and a
# response carrying none cannot be certified either: no receipt is a
# missing receipt, not a licence to publish.
jobs_json="${RUNNER_TEMP}/run-jobs.json"
jobs_tmp="${jobs_json}.tmp"
jobs_complete=false
if gh api "repos/${REPO}/actions/runs/${WORKFLOW_RUN_ID}/attempts/${RUN_ATTEMPT}/jobs?per_page=100" \
--paginate \
| jq -s '{jobs: [ .[].jobs[]? ]} as $doc | ([.[].total_count] | max) as $total | if $total != null and ($doc.jobs | length) == $total then $doc else error("incomplete job list: merged \($doc.jobs | length) of \($total)") end' \
> "${jobs_tmp}"; then
mv "${jobs_tmp}" "${jobs_json}"
jobs_complete=true
else
rm -f "${jobs_tmp}"
printf '{"jobs":[]}\n' > "${jobs_json}"
echo "::warning::Could not read the whole job list of run ${WORKFLOW_RUN_ID}; reporting without it"
fi
# Both bodies read this list; the flag lets them say when it is short.
echo "jobs_complete=${jobs_complete}" >> "${GITHUB_OUTPUT}"
job_ids=()
while IFS= read -r job_id; do
job_ids+=("${job_id}")
done < <(
jq -r '.jobs[] | select(.conclusion == "failure") | .id' \
"${jobs_json}" 2>/dev/null
)
echo "Failed jobs: ${#job_ids[@]}"
# Even without test results, the jobs payload names the failed job
# and step. A missing listing costs detail, never the failure report.
jq -r '.jobs[] | select(.conclusion == "failure") | [.name] + [.steps[] | select(.conclusion == "failure") | .name] | @tsv' \
"${jobs_json}" 2>/dev/null \
> "${RUNNER_TEMP}/failed-jobs.tsv" || true
# gh >= 2.97.0 (GHSA-3m3g-3wcr-px46) refuses to print a response
# carrying terminal escape sequences unless opted in, and the
# colourised vitest/pytest logs always carry them: without the flag
# every download fails. Logs feed analyze, whose tests populate the
# comment renderer's Failing tests section; without them the summary
# degrades to job/step names. The analyzer strips ANSI before matching.
for job_id in ${job_ids[@]+"${job_ids[@]}"}; do
if ! gh api "repos/${REPO}/actions/jobs/${job_id}/logs" \
--allow-escape-sequences > "${log_dir}/${job_id}.log"; then
# A missing log costs test details, not the job/step summary.
echo "::warning::Could not download the log of job ${job_id}"
rm -f "${log_dir}/${job_id}.log"
fi
done
- name: 'Detect infrastructure-only failures'
id: 'infra'
if: "${{ steps.live.outputs.superseded != 'true' }}"
env:
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
REPO: '${{ github.repository }}'
RUN_ATTEMPT: '${{ github.event.workflow_run.run_attempt }}'
# Keep in sync with qwen-autofix.yml's INFRA_FAILURE_SIGNATURES — a
# test pins the equality. There the same annotation class auto-reruns
# a PR's red check; here it suppresses the issue failure record.
# Full rationale → qwen-autofix.md#af-076
INFRA_FAILURE_SIGNATURES: 'lost communication with the server|No space left on device|ENOSPC|received a shutdown signal|The runner has received|Failed to initialize container|runner (was|has been) (lost|terminated)|invalid index-pack output|RPC failed|failed to be acquired|lock not acquired within'
run: |-
jobs_json="${RUNNER_TEMP}/run-jobs.json"
# A failed job whose annotations carry an infrastructure signature
# died with the runner, not with the code, and clears on a re-run.
# Only when EVERY failed job matches — and only on the first
# attempt, so on main a persistent break still files once the rerun
# re-fails — does the run get a rerun instead of an issue. Every
# read fails CLOSED: an unfetchable or unannotated job, an empty
# failure list, and a later main attempt all file exactly as before;
# a merged PR's later attempt is suppressed by the destination
# guard.
infra_only=false
if [[ "${RUN_ATTEMPT}" == '1' ]]; then
total=0
infra=0
# The gate workflow's reporter job is always() and fails with
# only the tier-result error, which carries no infrastructure
# signature; excluding it by its failing step keeps a lane's
# infra death classifiable. Filtering the step rather than the
# job keeps the legitimate rerun: when the reporter's own
# runner dies no step has failed, so the job still counts and
# its GitHub-generated annotation still matches.
while IFS=$'\t' read -r job_id check_run_id; do
total=$((total + 1))
annotations=''
if [[ -n "${check_run_id}" ]]; then
annotations="$(
gh api --paginate \
"repos/${REPO}/check-runs/${check_run_id}/annotations" \
--jq '[.[].message] | join("\n")' 2>/dev/null || true
)"
fi
if grep -qiE "${INFRA_FAILURE_SIGNATURES}" <<< "${annotations}"; then
infra=$((infra + 1))
else
echo "Failed job ${job_id} carries no infrastructure annotation; the run gets an issue."
fi
done < <(
# The gate reporter and the macOS and Linux aggregates mirror lane failures
# without an infrastructure signature of their own; excluding
# them by their failing step keeps a lane's
# infra death classifiable. Filtering the step rather than the
# job keeps the legitimate rerun: when the reporter's own
# runner dies no step has failed, so the job still counts and
# its GitHub-generated annotation still matches.
jq -r '.jobs[] | select(.conclusion == "failure") | select(([(.steps // [])[] | select(.conclusion == "failure") | .name] | map(select(. == "Report the tier result" or . == "Require every macOS test group to pass" or . == "Require the build and every Linux test shard to pass")) | length) == 0) | [(.id | tostring), ((.check_run_url // "") | split("/")[-1])] | @tsv' \
"${jobs_json}" 2>/dev/null
)
if [[ "${total}" -gt 0 && "${infra}" -eq "${total}" ]]; then
infra_only=true
fi
fi
echo "infra_only=${infra_only}"
echo "infra_only=${infra_only}" >> "${GITHUB_OUTPUT}"
- name: 'Build the PR failure summary'
id: 'summary'
if: "${{ steps.destination.outputs.pr_number != '' || steps.destination.outputs.file_issue == 'true' }}"
env:
WORKFLOW_NAME: '${{ github.event.workflow.name }}'
WORKFLOW_RUN_ID: '${{ github.event.workflow_run.id }}'
WORKFLOW_RUN_URL: '${{ github.event.workflow_run.html_url }}'
HEAD_SHA: '${{ github.event.workflow_run.head_sha }}'
INFRA_ONLY: '${{ steps.infra.outputs.infra_only }}'
JOBS_COMPLETE: '${{ steps.download.outputs.jobs_complete }}'
run: |-
helper='.github/scripts/ci/main-failure-signature.mjs'
shopt -s nullglob
logs=("${RUNNER_TEMP}/failed-logs/"*.log)
node "${helper}" analyze --workflow "${WORKFLOW_NAME}" \
--jobs "${RUNNER_TEMP}/failed-jobs.tsv" "${logs[@]}" > "${RUNNER_TEMP}/analysis.json"
node "${helper}" comment --analysis "${RUNNER_TEMP}/analysis.json" \
--jobs-json "${RUNNER_TEMP}/run-jobs.json" --sha "${HEAD_SHA}" \
--run-id "${WORKFLOW_RUN_ID}" --run-url "${WORKFLOW_RUN_URL}" > "${RUNNER_TEMP}/pr-comment.md"
delim="QWEN_CI_COMMENT_$(openssl rand -hex 16)"
{
echo "body<<${delim}"
cat "${RUNNER_TEMP}/pr-comment.md"
if [[ "${INFRA_ONLY}" == 'true' ]]; then
echo 'GitHub runner infrastructure failed; this result does not identify a Java test regression.'
fi
if [[ "${JOBS_COMPLETE}" != 'true' ]]; then
echo 'The job list could not be read in full, so this report may name fewer failing jobs than the run had.'
fi
echo "${delim}"
} >> "${GITHUB_OUTPUT}"
# The writer routes on these two: the per-signature issue title (the
# helper leaves it empty when no test was identified) and the dedupe
# markers it embeds in the bodies it files. The markers are
# helper-generated hex strings, but the title is one line at most —
# cleanLine collapses whitespace before a test id is ever collected.
marker_delim="QWEN_TEST_MARKERS_$(openssl rand -hex 16)"
{
echo "signature_title=$(jq -r '.title // ""' "${RUNNER_TEMP}/analysis.json")"
echo "test_markers<<${marker_delim}"
jq -r '.searchMarkers[]?' "${RUNNER_TEMP}/analysis.json"
echo "${marker_delim}"
} >> "${GITHUB_OUTPUT}"
# An all-infrastructure failure has no code signal to file: the runner died,
# not the code, and the run's green legs already exonerate the tree. Rerun
# its failed jobs once (analyze gates this to attempt 1) and file nothing;
# if the break is real or the pool stays sick, the rerun's own completion
# re-enters this workflow at attempt 2 and, for main, the issue is filed
# then; a merged PR's rerun is suppressed as a stale pre-merge head.
# The rerun holds actions: write while infra_only is classified from
# check-run annotation text — text a fork's own steps print — so it runs
# 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' && needs.analyze.outputs.provisional != 'true' }}"
runs-on: 'ubuntu-latest'
timeout-minutes: 5
permissions:
actions: 'write'
steps:
- name: 'Rerun the failed jobs of the triggering run'
env:
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
REPO: '${{ github.repository }}'
WORKFLOW_RUN_ID: '${{ github.event.workflow_run.id }}'
run: |-
gh api -X POST "repos/${REPO}/actions/runs/${WORKFLOW_RUN_ID}/rerun-failed-jobs"
echo "Every failed job of run ${WORKFLOW_RUN_ID} died on infrastructure; reran them and filed no issue."
# Every ISSUE write happens here, as the reporting bot — the one other write
# is rerun_infra's rerun, which holds no PAT. This job deliberately checks
# out nothing and runs no repository code: it consumes the failure summary
# the job above produced.
file_issue:
name: 'Record the workflow failure'
needs: ['analyze', 'rerun_infra']
# always() because rerun_infra is SKIPPED on the ordinary path and a
# skipped need is not a success: file when the run is not
# infrastructure-only, and file when the rerun itself could not be
# dispatched — the issue is then the only record of a red main.
if: "${{ always() && needs.analyze.result == 'success' && needs.analyze.outputs.file_issue == 'true' && (needs.analyze.outputs.infra_only != 'true' || needs.rerun_infra.result != 'success') }}"
# Deliberately hosted, NOT the ECS pool: this job reports that CI broke,
# and the pool being the REASON CI broke would queue the report behind
# the very failure it documents.
runs-on: 'ubuntu-latest'
timeout-minutes: 5
concurrency:
group: 'main-ci-failure-issue-${{ github.event.workflow.id }}'
cancel-in-progress: false
queue: 'max'
permissions:
issues: 'write'
outputs:
issue_url: '${{ steps.issue.outputs.issue_url }}'
steps:
- name: 'Create or comment on the workflow issue'
id: 'issue'
env:
GH_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}'
REPO: '${{ github.repository }}'
WORKFLOW_NAME: '${{ github.event.workflow.name }}'
WORKFLOW_ID: '${{ github.event.workflow.id }}'
BOT_LOGIN: "${{ vars.AUTOFIX_BOT_LOGIN || 'qwen-code-dev-bot' }}"
ISSUE_BODY: '${{ needs.analyze.outputs.comment_body }}'
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
# issue this step comments on): the test markers carry no workflow
# component, and the writer's concurrency group serializes only
# within one workflow — this key is what scopes a marker match back
# to the workflow that owns it.
scope_key="main-ci-failure-scope:${WORKFLOW_ID}"
# Writes get the same transient-failure tolerance the lookups have
# had all along; a one-shot write that dies loses the occurrence.
# Retrying create/comment after a lost response can mint one
# duplicate when the first attempt succeeded server-side — accepted:
# same-marker duplicates converge on the oldest next run, which
# beats the recorded failure vanishing with no trace.
gh_write() {
local attempt
for attempt in 1 2 3; do
if gh "$@"; then
return 0
fi
sleep "${attempt}"
done
return 1
}
# Look up inside the serialized writer. renderIssueTitle emitted the
# legacy " on <sha12>" and " — <shortened test>" title suffixes, and
# the aggregate's dated retitle below rides the " — " arm too — the
# body-marker check on a legacy-shaped match is what keeps a retitled
# aggregate from being misread as an adopted single-failure issue.
# A truncated newest-first list can hide the oldest match; request
# 3000 lightweight records. Avoid --search's asynchronously indexed results.
title_filter='
map(select(.author.login == $bot) | select(
.title == $title or
(.title | startswith($title + " on ")) or
(.title | startswith($title + " — "))
)) | sort_by(.number) | .[] |
[.number, (if .title == $title then "exact" else "legacy" end)] | @tsv'
open_issues="${RUNNER_TEMP}/open-issues.json"
lookup_ok=false
for attempt in 1 2 3; do
if gh issue list --repo "${REPO}" --state open \
--json number,title,author --limit 3000 > "${open_issues}"; then
lookup_ok=true
break
fi
sleep "${attempt}"
done
[[ "${lookup_ok}" == true ]] || exit 1
family_tsv="${RUNNER_TEMP}/family.tsv"
if ! jq -r --arg bot "${BOT_LOGIN}" --arg title "${title}" \
"${title_filter}" "${open_issues}" > "${family_tsv}"; then
# An unreadable listing must not read as an empty family: the
# signature route would certify a false miss and file a duplicate.
echo '::error::Could not parse the open-issues listing; not writing.'
exit 1
fi
family=()
while IFS= read -r family_line; do
[[ -z "${family_line}" ]] || family+=("${family_line}")
done < "${family_tsv}"
markers=()
while IFS= read -r marker_line; do
[[ -z "${marker_line}" ]] || markers+=("${marker_line}")
done <<< "${TEST_MARKERS}"
if [[ -n "${SIGNATURE_TITLE}" && "${#markers[@]}" -gt 0 ]]; then
# Per-signature route: the run named its failing tests. Filings
# embed each test's dedupe marker in the issue body, so a marker
# hit means this signature is already tracked and the run is a
# recurrence comment there; a certified miss means the regression
# is new and gets its own triage issue — a comment on the shared
# aggregate is where new regressions went unnoticed.
existing_issue=''
matched_body=''
signature_unresolved=false
for family_line in ${family[@]+"${family[@]}"}; do
candidate="${family_line%%$'\t'*}"
candidate_body="${RUNNER_TEMP}/candidate-${candidate}.md"
if ! gh issue view "${candidate}" --repo "${REPO}" \
--json body --jq '.body // ""' > "${candidate_body}"; then
# The truncated file the redirect just made must not linger:
# the sibling corpus reuses it with -f and would read the
# unreadable candidate as an owned-marker-free empty body.
rm -f "${candidate_body}"
echo "::warning::Could not read issue #${candidate}; the signature route cannot certify a miss."
signature_unresolved=true
continue
fi
for test_marker in "${markers[@]}"; do
if grep -qF -- "${test_marker}" "${candidate_body}"; then
existing_issue="${candidate}"
matched_body="${candidate_body}"
break
fi
done
[[ -z "${existing_issue}" ]] || break
done
if [[ -z "${existing_issue}" ]]; then
# A human retitle drops a per-signature issue out of the title
# family; its body still carries the test markers, so scan
# those before certifying a miss and filing a duplicate. The
# scope key restricts the scan to this workflow's filings —
# legacy per-signature bodies predate it and stay findable only
# through their titles.
retitled_pages="${RUNNER_TEMP}/open-bot-issues.pages"
markers_json="$(printf '%s\n' "${markers[@]}" | jq -R . | jq -sc .)"
lookup_ok=false
for attempt in 1 2 3; do
if gh api --paginate "repos/${REPO}/issues?state=open&per_page=100" \
> "${retitled_pages}"; then
lookup_ok=true
break
fi
sleep "${attempt}"
done
if [[ "${lookup_ok}" != true ]]; then
echo '::warning::Could not scan retitled issues for the test markers; the signature route cannot certify a miss.'
signature_unresolved=true
elif ! existing_issue="$(jq -sr --arg bot "${BOT_LOGIN}" --arg scope "${scope_key}" --argjson ms "${markers_json}" '
add | map(select(.pull_request == null and .user.login == $bot)
| select((.body // "") | contains($scope))
| select((.body // "") as $b | any($ms[]; . as $m | $b | contains($m))))
| sort_by(.number) | .[0].number // empty' "${retitled_pages}")"; then
echo '::error::Could not parse the retitled-issues listing; not writing.'
exit 1
elif [[ -n "${existing_issue}" ]]; then
matched_body="${RUNNER_TEMP}/candidate-${existing_issue}.md"
if ! jq -sr --argjson n "${existing_issue}" \
'add | map(select(.number == $n)) | .[0].body // ""' \
"${retitled_pages}" > "${matched_body}"; then
echo '::error::Could not re-read the matched issue body; not writing.'
exit 1
fi
fi
fi
if [[ -z "${existing_issue}" ]]; then
# A resolved per-signature issue regresses into a reopen, not a
# fresh filing — the same lifecycle the aggregate has below.
# Closed titles still name the workflow family, and scoped
# bodies survive human retitles.
closed_pages="${RUNNER_TEMP}/closed-signature.json"
lookup_ok=false
for attempt in 1 2 3; do
if gh issue list --repo "${REPO}" --state closed \
--json number,title,author,body --limit 1000 > "${closed_pages}"; then
lookup_ok=true
break
fi
sleep "${attempt}"
done
if [[ "${lookup_ok}" != true ]]; then
echo '::warning::Could not scan closed issues for the test markers; the signature route cannot certify a miss.'
signature_unresolved=true
elif ! existing_issue="$(jq -r --arg bot "${BOT_LOGIN}" --arg title "${title}" --arg scope "${scope_key}" --argjson ms "${markers_json}" '
map(select(.author.login == $bot)
| select(.title == $title or (.title | startswith($title + " — ")) or ((.body // "") | contains($scope)))
| select((.body // "") as $b | any($ms[]; . as $m | $b | contains($m))))
| sort_by(.number) | .[0].number // empty' "${closed_pages}")"; then
echo '::error::Could not parse the closed-issues listing; not writing.'
exit 1
elif [[ -n "${existing_issue}" ]]; then
matched_body="${RUNNER_TEMP}/candidate-${existing_issue}.md"
if ! jq -r --argjson n "${existing_issue}" \
'map(select(.number == $n)) | .[0].body // ""' \
"${closed_pages}" > "${matched_body}"; then
echo '::error::Could not re-read the closed issue body; not writing.'
exit 1
fi
gh_write issue reopen "${existing_issue}" --repo "${REPO}"
fi
fi
if [[ -n "${existing_issue}" ]]; then
comment_file="${RUNNER_TEMP}/issue-comment.md"
printf '%s\n\nRun attempt: %s\n' "${ISSUE_BODY}" "${RUN_ATTEMPT}" \
> "${comment_file}"
echo "issue_url=https://github.com/${REPO}/issues/${existing_issue}" >> "${GITHUB_OUTPUT}"
gh_write issue comment "${existing_issue}" --repo "${REPO}" --body-file "${comment_file}"
# Tests that join a standing failure set later must still find
# this issue, so accrete their markers into its body; the scope
# key rides along, so a human retitle later never drops the
# issue out of the retitled scan. The matched body can predate
# the comment (the retitled and closed scans read listings), so
# re-read it before the full-body replacement — appending beats
# silently overwriting a human's concurrent edit. Best-effort
# either way: the occurrence above is already recorded.
if gh issue view "${existing_issue}" --repo "${REPO}" \
--json body --jq '.body // ""' > "${matched_body}"; then
missing=()
for test_marker in "${markers[@]}"; do
grep -qF -- "${test_marker}" "${matched_body}" || missing+=("${test_marker}")
done
if [[ "${#missing[@]}" -gt 0 ]]; then
# A marker a sibling tracker already owns belongs to it:
# accreting it here would shadow that issue on later runs.
# The family bodies were read this pass, inside the writer
# lock; candidates past the first hit are read now, only
# when there is anything to accrete.
siblings_ok=true
siblings_corpus="${RUNNER_TEMP}/sibling-bodies.md"
: > "${siblings_corpus}"
for family_line in ${family[@]+"${family[@]}"}; do
sibling="${family_line%%$'\t'*}"
[[ "${sibling}" == "${existing_issue}" ]] && continue
sibling_body="${RUNNER_TEMP}/candidate-${sibling}.md"
if [[ ! -f "${sibling_body}" ]] &&
! gh issue view "${sibling}" --repo "${REPO}" \
--json body --jq '.body // ""' > "${sibling_body}"; then
siblings_ok=false
break
fi
cat "${sibling_body}" >> "${siblings_corpus}"
done
if [[ "${siblings_ok}" == true ]]; then
accrete=()
for test_marker in "${missing[@]}"; do
grep -qF -- "${test_marker}" "${siblings_corpus}" || accrete+=("${test_marker}")
done
else
echo '::warning::Could not read every sibling tracker body; skipping marker accretion this run.'
accrete=()
fi
if [[ "${#accrete[@]}" -gt 0 ]]; then
# A tracker a human retitled out of the family owns its
# markers the same way; only its scoped body settles that.
# The retitled scan's pages are reused when it already ran.
if [[ -z "${retitled_pages:-}" || ! -s "${retitled_pages}" ]]; then
retitled_pages="${RUNNER_TEMP}/open-bot-issues.pages"
lookup_ok=false
for attempt in 1 2 3; do
if gh api --paginate "repos/${REPO}/issues?state=open&per_page=100" \
> "${retitled_pages}"; then
lookup_ok=true
break
fi
sleep "${attempt}"
done
fi
if [[ "${lookup_ok:-false}" != true ]]; then
echo '::warning::Could not scan retitled trackers for marker ownership; skipping marker accretion this run.'
accrete=()
else
markers_json="$(printf '%s\n' "${accrete[@]}" | jq -R . | jq -sc .)"
if ! kept="$(jq -sr --arg bot "${BOT_LOGIN}" --arg scope "${scope_key}" --argjson ms "${markers_json}" '
(add | map(select(.pull_request == null and .user.login == $bot)
| select((.body // "") | contains($scope))
| (.body // ""))) as $bodies
| [$ms[] | . as $m | select(any($bodies[]; contains($m)) | not)]
| join("\n")' "${retitled_pages}")"; then
echo '::error::Could not parse the retitled-issues listing; not writing.'
exit 1
fi
accrete=()
while IFS= read -r kept_marker; do
[[ -z "${kept_marker}" ]] || accrete+=("${kept_marker}")
done <<< "${kept}"
fi
fi
else
accrete=()
fi
grep -qF -- "${scope_key}" "${matched_body}" || accrete+=("${scope_key}")
if [[ "${#accrete[@]}" -gt 0 ]]; then
if ! { printf '<!-- %s -->\n' "${accrete[@]}" >> "${matched_body}" &&
gh issue edit "${existing_issue}" --repo "${REPO}" --body-file "${matched_body}"; }; then
echo '::warning::Could not accrete the new test markers onto the tracking issue.'
fi
fi
else
echo '::warning::Could not re-read the tracking issue body for accretion; its markers lag until the next recurrence.'
fi
exit 0
fi
if [[ "${signature_unresolved}" != true ]]; then
body_file="${RUNNER_TEMP}/issue-body.md"
{
printf '%s\n\nRun attempt: %s\n\n' "${ISSUE_BODY}" "${RUN_ATTEMPT}"
printf '<!-- %s -->\n' "${markers[@]}" "${scope_key}"
} > "${body_file}"
issue_url="$(gh_write issue create --repo "${REPO}" \
--title "${SIGNATURE_TITLE}" --body-file "${body_file}")"
echo "issue_url=${issue_url}" >> "${GITHUB_OUTPUT}"
# Label churn must not cost the failure record. A named
# regression is a bug for a human to triage — it is not routed
# to autofix, and it is not the aggregate's ci-cd bucket.
gh issue edit "${issue_url##*/}" --repo "${REPO}" \
--add-label 'type/bug' --add-label 'status/needs-triage' || true
exit 0
fi
echo '::warning::Falling back to the workflow aggregate: a candidate body could not be read.'
fi
# Aggregate route: the run reported no test identity (or the
# signature route could not certify itself), so the occurrence is
# recorded on the workflow's one shared issue. A legacy-shaped title
# is adoption evidence only with a body to back it: a per-signature
# issue carries qwen-main-ci-failure-test markers and must keep its
# identity (adopting it here would retitle it into the generic
# bucket), and a candidate whose body cannot be read is left for the
# marker fallback rather than adopted blind. An exact title needs no
# body check — per-signature titles always carry the " — " suffix.
existing_issue=''
match_kind=''
via_title_lookup=false
# Exact machine titles win first — a per-signature filing's title
# always carries the " — " suffix — but a title alone is never
# enough: a human can retitle a per-signature issue to the exact
# shape, so every candidate passes the same body screen in order.
family_ordered=()
for family_line in ${family[@]+"${family[@]}"}; do
[[ "${family_line#*$'\t'}" == exact ]] && family_ordered+=("${family_line}")
done
for family_line in ${family[@]+"${family[@]}"}; do
[[ "${family_line#*$'\t'}" == exact ]] || family_ordered+=("${family_line}")
done
for family_line in ${family_ordered[@]+"${family_ordered[@]}"}; do
candidate="${family_line%%$'\t'*}"
candidate_body="${RUNNER_TEMP}/family-${candidate}.md"
if ! gh issue view "${candidate}" --repo "${REPO}" \
--json body --jq '.body // ""' > "${candidate_body}"; then
# An unreadable candidate cannot be certified, and adopting the
# NEXT admissible one on sight could hand the occurrence to a
# per-commit filing; the body-marker fallback finds the real
# aggregate without per-issue reads.
rm -f "${candidate_body}"
echo "::warning::Could not read issue #${candidate}; leaving adoption to the marker fallback."
break
fi
# Both marker classes mean an already-adopted aggregate; test
# markers alone mean a per-signature issue, whatever its title.
if grep -qF 'qwen-main-ci-failure-test:' "${candidate_body}" &&
! grep -qF -- "${marker}" "${candidate_body}"; then
continue
fi
existing_issue="${candidate}"
match_kind="${family_line#*$'\t'}"
via_title_lookup=true
break
done
if [[ -z "${existing_issue}" ]]; then
# A bot-owned aggregate remains recognizable after a human retitles it.
issue_match=''
lookup_ok=false
for attempt in 1 2 3; do
if issue_match="$(gh api --paginate "repos/${REPO}/issues?state=open&per_page=100" \
| jq -sr --arg bot "${BOT_LOGIN}" --arg marker "${marker}" '
add | map(select(.pull_request == null and .user.login == $bot) |
select((.body // "") | contains($marker))) |
sort_by(.number) | .[0] // empty | [.number, "exact"] | @tsv')"; then
lookup_ok=true
break
fi
sleep "${attempt}"
done
[[ "${lookup_ok}" == true ]] || exit 1
if [[ -n "${issue_match}" ]]; then
IFS=$'\t' read -r existing_issue match_kind <<< "${issue_match}"
fi
fi
if [[ -z "${existing_issue}" ]]; then
# Bound closed history to the 1000 most recent issues. Older matches
# or an unavailable closed lookup fall back to a fresh aggregate.
# A human-reshaped title produces a fresh aggregate even when the
# body has the workflow marker, and a closed per-signature issue
# (test markers, no workflow marker) is never reopened as one.
issue_match="$(gh issue list --repo "${REPO}" --state closed \
--json number,title,author,body --limit 1000 \
| jq -r --arg bot "${BOT_LOGIN}" --arg title "${title}" --arg marker "${marker}" '
map(select(.author.login == $bot) | select(
.title == $title or
(.title | startswith($title + " on ")) or
(.title | startswith($title + " — "))
) | select(
(.body // "") as $b |
(($b | contains("qwen-main-ci-failure-test:") | not))
or ($b | contains($marker))
)) | sort_by(.number) | .[] |
[.number, (if .title == $title then "exact" else "legacy" end)] | @tsv')" || issue_match=''
if [[ -n "${issue_match}" ]]; then
# The jq emits one TSV candidate per line, oldest first; read
# stops at the first line, which is the only one wanted.
IFS=$'\t' read -r existing_issue match_kind <<< "${issue_match}"
via_title_lookup=true
gh_write issue reopen "${existing_issue}" --repo "${REPO}"
fi
fi
body_file="${RUNNER_TEMP}/issue-body.md"
printf '%s\n\nRun attempt: %s\n\n%s\n' "${ISSUE_BODY}" "${RUN_ATTEMPT}" "${marker}" > "${body_file}"
if [[ -n "${existing_issue}" ]]; then
# Publish the record before retiring a legacy route: an edit failure
# must not lose the comment or report_pr's tracking-issue link.
echo "issue_url=https://github.com/${REPO}/issues/${existing_issue}" >> "${GITHUB_OUTPUT}"
gh_write issue comment "${existing_issue}" --repo "${REPO}" --body-file "${body_file}"
# Migrate adopted bodies so a later human retitle can still find
# the issue. Decoration is best-effort: the occurrence is already
# recorded above. `had_marker` is read BEFORE the migration: an
# aggregate we already retitled carries the marker and must not be
# re-adopted below.
existing_body_file="${RUNNER_TEMP}/existing-issue-body.md"
had_marker=false
body_read_ok=true
if gh issue view "${existing_issue}" --repo "${REPO}" --json body --jq '.body // ""' > "${existing_body_file}"; then
if grep -Fq -- "${marker}" "${existing_body_file}"; then
had_marker=true
elif ! { printf '\n%s\n' "${marker}" >> "${existing_body_file}" &&
gh issue edit "${existing_issue}" --repo "${REPO}" --body-file "${existing_body_file}"; }; then
echo '::warning::Could not add the workflow marker to the existing issue body.'
fi
else
body_read_ok=false
echo '::warning::Could not read the existing issue body for marker migration; leaving its labels alone.'
fi
if [[ "${match_kind}" == legacy && "${had_marker}" != true && "${body_read_ok}" == true ]]; then
# Retire only the old single-failure route, and only with the
# body in hand: on an unreadable body the issue may already be
# a human-routed aggregate, whose labels are not ours to strip.
gh issue edit "${existing_issue}" --repo "${REPO}" \
--remove-label 'status/ready-for-agent' --remove-label 'autofix/approved'
fi
# Keep the shared issue honest in the issue list: an aggregate's
# title otherwise freezes at filing time and a month-old comment
# stream looks stale. Only machine-shaped titles are refreshed —
# a human retitle (reachable only through the marker fallback) is
# left alone, and a same-day second edit is a no-op.
if [[ "${via_title_lookup}" == true ]]; then
gh issue edit "${existing_issue}" --repo "${REPO}" \
--title "${title} — no test identified (latest: ${RUN_AT:0:10})" || \
echo '::warning::Could not retitle the aggregate issue.'
fi
else
issue_url="$(gh_write issue create --repo "${REPO}" --title "${title}" \
--body-file "${body_file}")"
echo "issue_url=${issue_url}" >> "${GITHUB_OUTPUT}"
# Label churn must not cost the failure record. Unlike the image-build
# reporter, this lookup does not depend on labels. Aggregates need triage.
gh issue edit "${issue_url##*/}" --repo "${REPO}" \
--add-label 'scope/ci-cd' --add-label 'status/needs-triage' || true
fi
report_pr:
name: 'Report Java CI failure on the PR'
needs: ['analyze', 'file_issue', 'rerun_infra']
# A prepared summary survives issue-write errors. Only a successfully
# dispatched infrastructure rerun suppresses the comment.
if: "${{ always() && needs.analyze.outputs.comment_body != '' && needs.analyze.outputs.pr_number != '' && (needs.analyze.outputs.infra_only != 'true' || needs.rerun_infra.result != 'success') }}"
runs-on: 'ubuntu-latest'
timeout-minutes: 5
permissions:
issues: 'write'
pull-requests: 'write'
steps:
- name: 'Post or update the failure summary'
env:
GH_TOKEN: '${{ github.token }}'
REPO: '${{ github.repository }}'
PR_NUMBER: '${{ needs.analyze.outputs.pr_number }}'
WORKFLOW_RUN_ID: '${{ github.event.workflow_run.id }}'
COMMENT_BODY: '${{ needs.analyze.outputs.comment_body }}'
ISSUE_URL: '${{ needs.file_issue.outputs.issue_url }}'
run: |-
set -euo pipefail
body_file="${RUNNER_TEMP}/pr-comment.md"
marker="<!-- qwen-managed-agent-ci-failure:${WORKFLOW_RUN_ID} -->"
# Keep the lookup/publish retry contract of upsert-bot-comment.sh
# without checking out repository code in this write-only job.
for attempt in 1 2 3; do
if existing="$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments?per_page=100" \
| jq -sr --arg marker "${marker}" 'add | map(select(.user.login == "github-actions[bot]" and ((.body // "") | contains($marker)))) | last // {} | {id: (.id // ""), body: (.body // "")}')"; then
comment_id="$(jq -r '.id' <<< "${existing}")"
tracking=''
if [[ -n "${ISSUE_URL}" ]]; then
tracking="[Tracking issue](${ISSUE_URL})"
elif [[ -n "${comment_id}" ]]; then
# A suppressed rerun recovers the link from the earlier
# attempt's comment; a missing or deleted comment loses it.
tracking="$(jq -r '.body' <<< "${existing}" | grep -m1 -E '^\[Tracking issue\]\([^)]*\)$' || true)"
fi
printf '%s\n' "${COMMENT_BODY}" > "${body_file}"
if [[ -n "${tracking}" ]]; then
printf '\n%s\n' "${tracking}" >> "${body_file}"
fi
if [[ -n "${comment_id}" ]]; then
endpoint="repos/${REPO}/issues/comments/${comment_id}"
method=PATCH
else
endpoint="repos/${REPO}/issues/${PR_NUMBER}/comments"
method=POST
fi
if gh api --method "${method}" "${endpoint}" -F "body=@${body_file}"; then
exit 0
fi
fi
sleep "${attempt}"
done
echo '::error::Could not publish the CI failure summary after three attempts.'
exit 1