Files
Shaojin Wen bd8684cdea fix(ci): run three hosted control-plane jobs on the ECS pool (#14469)
The hosted ubuntu pool is capped at 20 concurrent jobs for the org and sat
at 16-20 all day on 2026-10-17 while the ECS pool idled (10 of 94 ecs-qwen
slots busy). Seconds-long control-plane jobs queued behind the long hosted
lanes for a median of 28 minutes.

Route three of them to the ECS pool with the house repository guard and
kill-switch. None checks out or runs PR code:

- delay-automatic-review gates every automatic PR review. Its comment said
  the environment wait timer would idle an ECS slot, but the job only gets a
  runner after the timer elapses (run 38656917461: created 23:54:53, set up
  23:59:56, done two seconds later). Also correct the stale 10-minute timer
  note; the environment is set to 5.
- assign-issue-owner runs only the trusted base-ref script on PAT-authored
  label events.
- auto-minimize-spam reads the blocklist from the default branch. Its sparse
  entry becomes the .github/scripts directory: cone mode rejects a file entry
  on a reused workspace (#13306), and still brings the files directly under
  .github/, the blocklist among them.

Both checkout jobs gain the self-hosted ownership heal that assign-pr-owner
carries. Pin the routing, the kill-switch, the default-branch-only checkout
and the reused-workspace safety in ci-runner-routing.test.mjs.
2026-10-17 20:01:13 +08:00

258 lines
10 KiB
YAML

name: 'Auto-minimize spam comments'
# Minimize new issue/PR comments from users listed in
# .github/spam-blocklist.txt. Comment events check only the triggering
# comment; blocklist updates and the hourly scan clean up older comments
# and cover events that run with a read-only token.
#
# The blocklist is a plain-text file in the repo — one username per
# line, case-insensitive, # for comments. No special API scopes
# needed beyond issues:write + pull-requests:write.
#
# Note: the lookback window (LOOKBACK_HOURS) filters issues and inline
# review comments. The GraphQL pullRequests connection has no `since`
# filter, so PRs are always scoped to the 100 most recently updated.
#
# Coverage limits: review bodies and Discussions are not scanned.
# comments(last: 30) means a >30-comment flood on a single thread is
# only partially cleaned per run.
on:
schedule:
- cron: '30 * * * *' # Every hour at :30
issue_comment:
types: ['created']
pull_request_review_comment:
types: ['created']
push:
branches: ['main']
paths: ['.github/spam-blocklist.txt']
workflow_dispatch:
inputs:
hours:
description: 'Look back this many hours (default 2)'
required: false
default: 2
type: 'number'
permissions:
contents: 'read'
issues: 'write'
pull-requests: 'write'
concurrency:
group: "auto-minimize-spam-${{ github.event.comment.node_id || 'scan' }}"
cancel-in-progress: false
jobs:
minimize:
if: >-
${{
github.repository == 'QwenLM/qwen-code' &&
(
github.event_name == 'schedule' ||
github.event_name == 'workflow_dispatch' ||
github.event_name == 'push' ||
(
github.event.comment.user.type != 'Bot' &&
!contains(
fromJSON('["OWNER","MEMBER","COLLABORATOR"]'),
github.event.comment.author_association
) &&
(
github.event_name != 'pull_request_review_comment' ||
github.event.pull_request.head.repo.full_name == github.repository
)
)
)
}}
# Reads the blocklist from the default branch and runs no repository code
# (comment events use base-repo YAML), so the persistent ECS pool is safe
# and skips the saturated hosted queue.
# Kill-switch: MAINTAINER_ECS_RUNNER_DISABLED.
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}'
timeout-minutes: 10
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"
- name: 'Checkout blocklist'
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
# A directory, not the file itself; see assign-pr-owner.yml. Cone
# mode adds the files directly under .github/, the blocklist among
# them.
sparse-checkout: '.github/scripts'
ref: '${{ github.event.repository.default_branch }}'
persist-credentials: false
- name: 'Minimize comments from blocklisted users'
env:
GH_TOKEN: '${{ github.token }}'
LOOKBACK_HOURS: "${{ inputs.hours || (github.event_name == 'push' && '72') || '2' }}"
EVENT_COMMENT_LOGIN: '${{ github.event.comment.user.login }}'
EVENT_COMMENT_NODE_ID: '${{ github.event.comment.node_id }}'
run: |-
set -euo pipefail
REPO="$GITHUB_REPOSITORY"
SINCE="$(date -u -d "${LOOKBACK_HOURS} hours ago" +%Y-%m-%dT%H:%M:%SZ)"
BLOCKLIST=".github/spam-blocklist.txt"
write_summary() {
{
echo "## Summary"
if [ -n "$EVENT_COMMENT_NODE_ID" ]; then
echo "- Scanned comment: ${EVENT_COMMENT_NODE_ID}"
else
echo "- Scanned comments since: ${SINCE}"
fi
echo "- Blocklisted users: ${BLOCKED_COUNT}"
echo "- Comments minimized: $1"
} >> "$GITHUB_STEP_SUMMARY"
}
echo "Scanning comments since $SINCE in $REPO"
# ── 1. Parse blocklist ────────────────────────────────────────
if [ ! -f "$BLOCKLIST" ]; then
echo "::notice::No blocklist file found at $BLOCKLIST; nothing to do."
exit 0
fi
# Strip comments, blank lines, whitespace; lowercase for matching.
# `|| true` keeps an all-comment/blank blocklist from tripping
# `set -e` (grep exits 1 when nothing passes the filter).
BLOCKED_USERS="$(
grep -v '^\s*#' "$BLOCKLIST" \
| grep -v '^\s*$' \
| sed 's/^[[:space:]]*//;s/[[:space:]]*$//' \
| tr '[:upper:]' '[:lower:]' \
| sort -u \
|| true
)"
BLOCKED_COUNT="$(printf '%s\n' "$BLOCKED_USERS" | grep -c . || true)"
echo "Blocklisted users: ${BLOCKED_COUNT}"
if [ "$BLOCKED_COUNT" -eq 0 ]; then
echo "Blocklist is empty; nothing to do."
exit 0
fi
# ── 2. Select the triggering comment or scan recent comments ────
if [ -n "$EVENT_COMMENT_NODE_ID" ]; then
ALL_CANDIDATES="${EVENT_COMMENT_LOGIN}"$'\t'"${EVENT_COMMENT_NODE_ID}"
else
ALL_UNMINIMIZED="$(
gh api graphql -f query="
query {
repository(owner: \"${REPO%%/*}\", name: \"${REPO##*/}\") {
issues(first: 100, orderBy: {field: UPDATED_AT, direction: DESC}, filterBy: {since: \"${SINCE}\"}) {
nodes {
number
comments(last: 30) {
nodes { id author { login } isMinimized }
}
}
}
pullRequests(first: 100, orderBy: {field: UPDATED_AT, direction: DESC}) {
nodes {
number
comments(last: 30) {
nodes { id author { login } isMinimized }
}
}
}
}
}
" --jq '
[
.data.repository.issues.nodes[].comments.nodes[],
.data.repository.pullRequests.nodes[].comments.nodes[]
]
| map(select(.isMinimized == false and .author != null))
| .[] | "\(.author.login)\t\(.id)"
'
)"
REVIEW_CANDIDATES="$(
gh api --method GET --paginate \
"repos/${REPO}/pulls/comments" \
-f since="$SINCE" \
-F per_page=100 \
--jq '
.[]
| select(.user != null and .node_id != null)
| "\(.user.login)\t\(.node_id)"
'
)"
ALL_CANDIDATES="${ALL_UNMINIMIZED}"$'\n'"${REVIEW_CANDIDATES}"
fi
MATCHED_IDS=""
MATCHED_COUNT=0
while IFS=$'\t' read -r login node_id; do
[ -z "$login" ] && continue
login_lc="$(printf '%s' "$login" | tr '[:upper:]' '[:lower:]')"
if printf '%s\n' "$BLOCKED_USERS" | grep -qxF "$login_lc"; then
if ! is_minimized="$(
gh api graphql -F id="$node_id" -f query="
query(\$id: ID!) {
node(id: \$id) {
... on Minimizable { isMinimized }
}
}
" --jq 'if .data.node == null then "missing" else .data.node.isMinimized end'
)"; then
is_minimized="missing"
fi
[ "$is_minimized" = "missing" ] && continue
[ "$is_minimized" = "true" ] && continue
MATCHED_IDS="${MATCHED_IDS}${node_id}"$'\n'
MATCHED_COUNT=$((MATCHED_COUNT + 1))
echo " matched: @${login} → ${node_id}"
fi
done <<< "$ALL_CANDIDATES"
echo "Unminimized comments from blocklisted users: ${MATCHED_COUNT}"
if [ "$MATCHED_COUNT" -eq 0 ]; then
echo "Nothing to minimize."
write_summary 0
exit 0
fi
# ── 3. Minimize each matched comment ──────────────────────────
SUCCESS=0
FAIL=0
while IFS= read -r node_id; do
[ -z "$node_id" ] && continue
result="$(
gh api graphql -f query="
mutation {
minimizeComment(input: {subjectId: \"${node_id}\", classifier: OFF_TOPIC}) {
minimizedComment { isMinimized }
}
}
" --jq '.data.minimizeComment.minimizedComment.isMinimized' 2>&1
)" || true
if [ "$result" = "true" ]; then
SUCCESS=$((SUCCESS + 1))
else
FAIL=$((FAIL + 1))
echo "::warning::Failed to minimize ${node_id}: ${result}"
fi
done <<< "$MATCHED_IDS"
echo "Minimized ${SUCCESS} comments, ${FAIL} failed."
write_summary "$SUCCESS"
if [ "$FAIL" -gt 0 ]; then
echo "- Failures: ${FAIL}" >> "$GITHUB_STEP_SUMMARY"
exit 1
fi