Files
8749bc465c fix(ci): skip Managed Agent reviews after the PR closes (#14774) (#14777)
* fix(ci): skip Managed Agent reviews after the PR closes (#14774)

Co-authored-by: Qwen-Coder <[email protected]>

* test(ci): pin closed-PR gate wiring and diagnostics (#14774)

* refactor(ci): skip closed-PR reviews at the gate job level (#14774)

#14857 landed the job-level review guard, so a closed-PR approval can skip
before any job starts instead of selecting `none` from inside the tier step.
Move the closed check into the `scope` and `gate` conditions, drop the
`PR_STATE` env wiring and the tier-script branch, and restore the report text
that described it — no job now reports a reason. Tests pin both jobs skipping
for a closed-PR approval and keep open-PR approvals running.

Resolves #14774

* docs(gate): describe an all-skipped review run precisely

This branch already rewrites the lines that describe a skipped review run, so
the correction belongs here rather than in a second PR touching the same
paragraph.

The skipped run still lists one check per job, and the reporting job's name is
shown as its unevaluated expression, so "nothing reported" is wrong on both
counts: three skipped checks appear, none of them named `Managed Agent gate`.
Verified on run 39392964690, a `pull_request_review` event on the #14857 head.

The `scope.if` comment carried the same overclaim ("it never reported the
required name anyway"): on an untouched PR the review run did report
`Managed Agent gate`, and only the same head's pull_request run makes that
check redundant.

---------

Co-authored-by: qwen-code-dev-bot <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: 易良 <[email protected]>
2026-10-20 20:07:49 +08:00

326 lines
18 KiB
YAML

name: 'Managed Agent gate'
# Design: docs/design/2026-10-16-managed-agent-gate.md. The required check is
# the `Managed Agent gate` job below; every other job here is advisory.
on:
pull_request:
branches: ['main']
pull_request_review:
types: ['submitted']
push:
branches: ['main']
merge_group:
permissions:
contents: 'read'
# One group per pull request, so a push that supersedes an in-flight approval
# run cancels it: that run would otherwise keep going for ~125 minutes and then
# report a head branch protection no longer reads. cancel-in-progress is a
# property of the incoming run and stays true only for pull_request, so a second
# approval queues behind the first full run and then finds it through the
# deduplication below instead of starting another. A group keeps one pending
# run and a newcomer cancels it whatever cancel-in-progress says, so a review
# that is not an approval gets a group of its own and cannot cancel an
# approval run waiting there. Main pushes stay keyed per commit.
concurrency:
group: >-
managed-agent-gate-${{ github.event_name == 'pull_request_review' &&
github.event.review.state != 'approved' &&
format('review-{0}', github.event.review.id) ||
github.event.pull_request.number ||
github.event_name == 'push' && github.sha ||
github.ref }}
cancel-in-progress: "${{ github.event_name == 'pull_request' }}"
jobs:
scope:
name: 'Select the Managed Agent tier'
# Only an approval on an open pull request can select a tier above none
# (see `Choose the tier`), so any other review starts no job and costs no
# runner. On a touched PR its only possible check was the informational
# one; on an untouched PR the same head's pull_request run already reports
# `Managed Agent gate`. A review submitted after the pull request closed
# cannot gate anything — its head already merged or was abandoned — and
# the push arm covers the merged tree.
if: "${{ github.event_name != 'pull_request_review' || (github.event.review.state == 'approved' && github.event.pull_request.state == 'open') }}"
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && (github.event_name != ''pull_request'' && github.event_name != ''pull_request_review'' || github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON(''["OWNER","MEMBER","COLLABORATOR"]''), github.event.pull_request.author_association))) && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}'
timeout-minutes: 10
permissions:
actions: 'read'
checks: 'read'
contents: 'read'
outputs:
sha: '${{ steps.paths.outputs.sha }}'
tier: '${{ steps.tier.outputs.tier }}'
touched: '${{ steps.paths.outputs.touched }}'
steps:
- name: 'Restore workspace ownership'
if: "${{ runner.environment == 'self-hosted' }}"
run: |-
set -uo pipefail
RUNNER_UID="$(id -u)"
RUNNER_GID="$(id -g)"
if [ "$RUNNER_UID" != "0" ]; then
chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files"
fi
chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files"
# A leftover workspace owned by a different uid than the step user
# (e.g. a runner-user checkout under root-run steps) makes every git
# call fail with "detected dubious ownership". The pinned
# actions/checkout adds safe.directory only to its own temporary
# global config, so later steps are uncovered; trust the workspace
# here instead. The --get-all guard keeps a persistent runner's
# gitconfig from accumulating a duplicate entry per job.
git config --global --get-all safe.directory 2>/dev/null | grep -qxF "$GITHUB_WORKSPACE" || git config --global --add safe.directory "$GITHUB_WORKSPACE" || echo "::warning::could not mark the workspace as a git safe.directory; git steps may fail on a mismatched-ownership checkout"
# ci.yml's pre-checkout sweep, byte-identical to the ci.yml copies
# (pinned in scripts/tests/sdk-java-workflow.test.js): residue left by
# review/autofix jobs on the shared ECS pool fails actions/checkout's
# clean — the #13506 failure. Hosted legs skip it on fresh VMs.
- name: 'Clean stale .qwen before checkout'
if: "${{ runner.environment == 'self-hosted' }}"
run: |-
set -uo pipefail
for stale_qwen in "$GITHUB_WORKSPACE/.qwen" "$GITHUB_WORKSPACE/.qwen.root-orig" "$GITHUB_WORKSPACE/trusted-ci-classifier"; do
if [ ! -e "$stale_qwen" ] && [ ! -L "$stale_qwen" ]; then
continue
fi
if [ -d "$stale_qwen" ] && [ ! -L "$stale_qwen" ]; then
chmod -R u+w "$stale_qwen" 2>/dev/null || true
fi
# A foreign-owned directory cannot always be renamed to a
# different parent: updating its .. entry can fail even when the
# workspace parent is writable. If that individual move fails,
# quarantine the runner-owned workspace itself, then recreate the
# empty checkout root. Warm contents are lost only on this
# otherwise unrecoverable path.
rm -rf -- "$stale_qwen" 2>/dev/null ||
sudo -n rm -rf -- "$stale_qwen" 2>/dev/null ||
{
quarantine="$(dirname -- "$GITHUB_WORKSPACE")/_qwen-quarantine"
mkdir -p "$quarantine" 2>/dev/null || true
stale_name="$(basename -- "$stale_qwen")"
if mv -- "$stale_qwen" "$quarantine/${stale_name#\.}-$(date -u +%Y%m%dT%H%M%SZ)-$$" 2>/dev/null; then
echo "::warning::could not delete leaked $stale_name; moved it to $quarantine so this checkout can proceed — that directory needs manual cleanup"
else
workspace_quarantine="$quarantine/workspace-$(date -u +%Y%m%dT%H%M%SZ)-$$"
if mv -- "$GITHUB_WORKSPACE" "$workspace_quarantine" 2>/dev/null &&
mkdir -p "$GITHUB_WORKSPACE" 2>/dev/null &&
cd "$GITHUB_WORKSPACE"; then
echo "::warning::could not delete leaked $stale_name; moved the whole workspace to $workspace_quarantine so this checkout can proceed — that directory needs manual cleanup"
break
else
echo "::warning::leaked $stale_name survived every recovery; runner needs manual cleanup"
fi
fi
}
done
# Interrupted reviews leave worktree registrations under .qwen/tmp/
# and qwen-review/* branches behind. prune drops registrations whose
# directories the rm above removed; worktree remove --force then
# clears any still-registered leftover directory (--force tolerates
# dirty contents), since a branch checked out in a live worktree
# cannot be deleted. If removal still fails, the registration
# survives and the branch delete below warns. The sweep deletes all
# review artifacts, not just the current PR's: safe because a runner
# executes one job at a time. Kept inline rather than a shared
# script: this runs pre-checkout on shared runners, where leftover
# workspace files are untrusted.
if [ -e "$GITHUB_WORKSPACE/.git" ]; then
GIT_SAFE=(git -c core.hooksPath=/dev/null -c core.fsmonitor= -C "$GITHUB_WORKSPACE")
"${GIT_SAFE[@]}" worktree prune -v || true
"${GIT_SAFE[@]}" worktree list --porcelain \
| awk '$1 == "worktree" && index($0, "/.qwen/tmp/review-pr-") > 0 { sub(/^worktree /, ""); print }' \
| while read -r worktree; do
[ -n "$worktree" ] || continue
# Registered paths come from leftover git metadata and are
# untrusted: the awk filter above matched by substring, so reject
# `..` traversal and re-anchor to the review prefix before the
# destructive remove.
case "$worktree" in
*/../*|../*|*/..)
echo "::warning::skipping suspicious review worktree path: $worktree"
continue
;;
"$GITHUB_WORKSPACE/.qwen/tmp/review-pr-"*) : ;;
*)
echo "::warning::skipping unexpected review worktree path: $worktree"
continue
;;
esac
"${GIT_SAFE[@]}" worktree remove --force "$worktree" ||
echo "::warning::could not remove review worktree: $worktree"
done || true
"${GIT_SAFE[@]}" worktree prune -v || true
"${GIT_SAFE[@]}" for-each-ref --format='%(refname:short)' 'refs/heads/qwen-review/*' \
| while read -r stale_ref; do
if [ -n "$stale_ref" ]; then
"${GIT_SAFE[@]}" branch -D "$stale_ref" ||
echo "::warning::could not remove review branch: $stale_ref"
fi
done || true
fi
- name: 'Recreate runner Git metadata'
if: "${{ runner.environment == 'self-hosted' }}"
run: |-
rm -rf -- "$GITHUB_WORKSPACE/.git"
- name: 'Checkout'
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
fetch-depth: 2
persist-credentials: false
# Pull request and review events check out the test merge commit, whose
# first parent is the base, so the diff is the PR's own change set.
- name: 'Match the changed paths'
id: 'paths'
env:
EVENT_NAME: '${{ github.event_name }}'
PUSH_BEFORE: '${{ github.event.before }}'
MERGE_GROUP_BASE: '${{ github.event.merge_group.base_sha }}'
run: |-
set -euo pipefail
case "${EVENT_NAME}" in
push) base="${PUSH_BEFORE}" ;;
merge_group) base="${MERGE_GROUP_BASE}" ;;
*) base="$(git rev-parse HEAD^1)" ;;
esac
if [[ "${base}" =~ ^0+$ ]]; then
touched=true
else
git fetch --no-tags --depth=1 origin "${base}"
# quotePath off: git's default octal-quoted rendering of a
# non-ASCII path matches none of the matcher's ^-anchored
# patterns and would fail this classification open.
touched="$(git -c core.quotePath=false diff --name-only --no-renames "${base}" HEAD | node .github/scripts/managed-agent-gate-scope.mjs)"
fi
echo "touched=${touched}" >> "${GITHUB_OUTPUT}"
echo "sha=$(git rev-parse HEAD)" >> "${GITHUB_OUTPUT}"
# The full suite runs once per head commit, on its first approval:
# dismissed stale approvals mean that approval follows the last push.
# A later approval reruns it only if no earlier approval run passed.
- name: 'Choose the tier'
id: 'tier'
env:
EVENT_NAME: '${{ github.event_name }}'
TOUCHED: '${{ steps.paths.outputs.touched }}'
REVIEW_STATE: '${{ github.event.review.state }}'
REVIEW_AUTHOR_ASSOCIATION: '${{ github.event.review.author_association }}'
BASE_REF: '${{ github.event.pull_request.base.ref }}'
HEAD_SHA: '${{ github.event.pull_request.head.sha }}'
PR_NUMBER: '${{ github.event.pull_request.number }}'
GH_TOKEN: '${{ github.token }}'
REPO: '${{ github.repository }}'
RUN_ID: '${{ github.run_id }}'
run: |-
set -euo pipefail
tier=none
if [[ "${TOUCHED}" == 'true' ]]; then
case "${EVENT_NAME}" in
pull_request | push | merge_group) tier=fast ;;
pull_request_review)
# A maintainer's approval only. qwen-triage-finalize.yml watches
# this workflow and posts a deferred APPROVE once a PR's checks
# land green, so an ungated check lets the gate's own fast tier
# buy the most expensive run in the repository with no human
# involved. An empty association fails closed the same way.
if [[ "${REVIEW_STATE}" == 'approved' && "${BASE_REF}" == 'main' &&
' OWNER MEMBER COLLABORATOR ' == *" ${REVIEW_AUTHOR_ASSOCIATION} "* ]]; then
tier=full
urls="$(gh api "repos/${REPO}/commits/${HEAD_SHA}/check-runs?check_name=Managed%20Agent%20gate&per_page=100" \
--jq '.check_runs[] | select(.status != "completed" or .conclusion == "success") | .details_url')"
for run in $({ grep -oE '/runs/[0-9]+/' <<< "${urls}" || true; } | tr -dc '0-9\n' | sort -u); do
[[ "${run}" == "${RUN_ID}" ]] && continue
IFS=$'\t' read -r event prs <<< "$(gh api "repos/${REPO}/actions/runs/${run}" --jq '[.event, ([.pull_requests[].number] | map(tostring) | join(" "))] | @tsv')"
# A passing check of the required name covers this head
# only when it came from THIS pull request's review run
# whose callee actually ran: another PR's review run on
# the shared head sha and a run that reported without a
# suite both ran no Java lane.
[[ "${event}" == 'pull_request_review' ]] || continue
[[ " ${prs} " == *" ${PR_NUMBER} "* ]] || continue
[[ "$(gh api "repos/${REPO}/actions/runs/${run}/jobs?per_page=100" --jq '[.jobs[].name | startswith("SDK Java (full)")] | any')" == 'true' ]] || continue
echo "Run ${run} already covers ${HEAD_SHA}."
tier=none
break
done
fi
;;
esac
fi
echo "tier=${tier}" >> "${GITHUB_OUTPUT}"
suite:
name: 'SDK Java (${{ needs.scope.outputs.tier }})'
needs: 'scope'
if: "${{ needs.scope.outputs.tier != 'none' }}"
permissions:
contents: 'read'
uses: './.github/workflows/sdk-java.yml'
with:
ref: '${{ needs.scope.outputs.sha }}'
tier: '${{ needs.scope.outputs.tier }}'
# Only a full run, an unaffected change, or a push/merge group reports the
# required name. A touched pull request therefore shows the required check
# as expected until its first approval's full run reports it; the most
# recently updated check of a name is the one branch protection reads.
gate:
name: >-
${{ (needs.scope.outputs.tier == 'full' || needs.scope.outputs.touched != 'true' ||
(github.event_name != 'pull_request' && github.event_name != 'pull_request_review')) && 'Managed Agent gate' ||
needs.scope.outputs.tier == 'fast' && 'Managed Agent fast tier' || 'Managed Agent review (no full run)' }}
needs:
- 'scope'
- 'suite'
# A cancelled PR run must release concurrency even if scope was cancelled
# before choosing a tier. Full runs and main/merge-group cancellations
# still report. A review that is not an approval, or one submitted after
# the pull request closed, skips with `scope` above: reporting a tier it
# never selected would only add a green check.
if: "${{ always() && (github.event_name != 'pull_request_review' || (github.event.review.state == 'approved' && github.event.pull_request.state == 'open')) && (github.event_name != 'pull_request' || needs.scope.outputs.tier == 'full' || !cancelled()) }}"
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && (github.event_name != ''pull_request'' && github.event_name != ''pull_request_review'' || github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON(''["OWNER","MEMBER","COLLABORATOR"]''), github.event.pull_request.author_association))) && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}'
timeout-minutes: 5
steps:
- name: 'Report the tier result'
env:
EVENT_NAME: '${{ github.event_name }}'
SCOPE_RESULT: '${{ needs.scope.result }}'
SUITE_RESULT: '${{ needs.suite.result }}'
TIER: '${{ needs.scope.outputs.tier }}'
TOUCHED: '${{ needs.scope.outputs.touched }}'
run: |-
set -euo pipefail
if [[ "${SCOPE_RESULT}" != 'success' ]]; then
echo "::error::Tier selection ${SCOPE_RESULT}."
exit 1
fi
if [[ "${TIER}" == 'none' ]]; then
if [[ "${TOUCHED}" == 'true' ]]; then
echo 'No full run for this review: not an approval, or an earlier approval run already covers this head.'
else
echo 'No Managed Agent paths changed.'
fi
exit 0
fi
if [[ "${SUITE_RESULT}" != 'success' ]]; then
if [[ "${TIER}" == 'fast' && "${EVENT_NAME}" == 'pull_request' && "${SUITE_RESULT}" == 'cancelled' ]]; then
# A superseding push cancels the in-flight fast tier: this check
# then means "replaced by a newer run", not "broken". Only the
# pull request arm cancels in progress, and only the fast tier
# may soften: a cancelled full run stays red so the next
# approval retries the suite instead of deduping against it.
echo "::warning::The fast tier was cancelled, superseded by a newer push."
exit 0
fi
echo "::error::The ${TIER} tier ${SUITE_RESULT}."
exit 1
fi
if [[ "${TIER}" == 'fast' ]]; then
echo 'Fast tier passed. On a pull request the full suite runs on the first approval of this head.'
fi