diff --git a/.github/scripts/ci-runner-routing.test.mjs b/.github/scripts/ci-runner-routing.test.mjs new file mode 100644 index 0000000000..ac7db5b35e --- /dev/null +++ b/.github/scripts/ci-runner-routing.test.mjs @@ -0,0 +1,199 @@ +// Runner-routing regression guards for ci.yml and serve-ab.yml. +// +// classify_pr carries the routing logic TWICE — the `runs-on` expression +// (which selects the classify job's own runner) and the `pick_runner` shell +// step (which publishes `ubuntu_runner` for every downstream Linux job). If +// they drift, classify and the Test job land on different pools. These tests +// evaluate BOTH against the same event matrix — including the negative +// associations that must stay hosted — and assert they agree. +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { parse } from 'yaml'; + +const workflowsDir = join( + dirname(fileURLToPath(import.meta.url)), + '..', + 'workflows', +); +const ciDoc = parse(readFileSync(join(workflowsDir, 'ci.yml'), 'utf8')); +const serveAbDoc = parse( + readFileSync(join(workflowsDir, 'serve-ab.yml'), 'utf8'), +); + +const TRUSTED = ['OWNER', 'MEMBER', 'COLLABORATOR']; +const ECS = '["self-hosted", "linux", "x64", "ecs-qwen"]'; +const HOSTED = '["ubuntu-latest"]'; + +const classifyRunsOn = String(ciDoc.jobs.classify_pr['runs-on']); +const pickRunner = ciDoc.jobs.classify_pr.steps.find( + (s) => s.id === 'pick_runner', +); + +// GitHub expression semantics for the classify runs-on, restricted to the +// routing-relevant inputs: contains(list, '') is false, a missing +// pull_request (merge_group / dispatch) yields '' for both head.repo and +// author_association. +function simulateRunsOn({ ecsDisabled, sameRepo, assoc, mergeGroup }) { + const trusted = TRUSTED.includes(assoc); + const ecs = + !ecsDisabled && (sameRepo || trusted || mergeGroup); + return ecs ? ECS : HOSTED; +} + +// Executes the real pick_runner shell with the same inputs and returns the +// selected runner exactly as CI would publish it. +function runPickRunner({ ecsDisabled, sameRepo, assoc, eventName, dispatch }) { + const tmp = mkdtempSync(join(tmpdir(), 'pick-runner-')); + const outputFile = join(tmp, 'github_output'); + const result = spawnSync('bash', ['-c', pickRunner.run], { + env: { + SAME_REPO: sameRepo ? 'true' : 'false', + AUTHOR_ASSOCIATION: assoc, + ECS_DISABLED: ecsDisabled ? 'true' : '', + EVENT_NAME: eventName, + DISPATCH_LINUX_RUNNER: dispatch ?? '', + GITHUB_OUTPUT: outputFile, + }, + encoding: 'utf8', + }); + rmSync(tmp, { recursive: true, force: true }); + assert.equal(result.status, 0, `pick_runner failed: ${result.stderr}`); + const line = result.stdout + .split('\n') + .find((l) => l.startsWith('Selected Linux runner: ')); + assert.ok(line, `no selection in pick_runner output: ${result.stdout}`); + return line.slice('Selected Linux runner: '.length); +} + +describe('ci.yml classify_pr runner routing', () => { + it('the expression and the shell step agree on every association', () => { + const associations = [ + ...TRUSTED, + 'CONTRIBUTOR', + 'FIRST_TIME_CONTRIBUTOR', + 'FIRST_TIMER', + 'NONE', + '', + ]; + for (const sameRepo of [true, false]) { + for (const assoc of associations) { + const expected = simulateRunsOn({ + ecsDisabled: false, + sameRepo, + assoc, + mergeGroup: false, + }); + const actual = runPickRunner({ + ecsDisabled: false, + sameRepo, + assoc, + eventName: 'pull_request', + }); + assert.equal( + actual, + expected, + `drift for sameRepo=${sameRepo} assoc='${assoc}'`, + ); + } + } + }); + + it('only write-access associations leave the hosted pool', () => { + for (const assoc of ['CONTRIBUTOR', 'FIRST_TIME_CONTRIBUTOR', 'NONE', '']) { + assert.equal( + runPickRunner({ + ecsDisabled: false, + sameRepo: false, + assoc, + eventName: 'pull_request', + }), + HOSTED, + `assoc '${assoc}' must stay hosted`, + ); + } + for (const assoc of TRUSTED) { + assert.equal( + runPickRunner({ + ecsDisabled: false, + sameRepo: false, + assoc, + eventName: 'pull_request', + }), + ECS, + `assoc '${assoc}' must route to ECS`, + ); + } + }); + + it('merge queue and explicit dispatch still reach ECS; the kill-switch wins', () => { + assert.equal( + runPickRunner({ + ecsDisabled: false, + sameRepo: false, + assoc: '', + eventName: 'merge_group', + }), + ECS, + ); + assert.equal( + runPickRunner({ + ecsDisabled: false, + sameRepo: false, + assoc: '', + eventName: 'workflow_dispatch', + dispatch: 'self-hosted', + }), + ECS, + ); + assert.equal( + runPickRunner({ + ecsDisabled: true, + sameRepo: true, + assoc: 'OWNER', + eventName: 'pull_request', + }), + HOSTED, + 'kill-switch must revert even trusted runs to hosted', + ); + }); + + it('the runs-on expression keeps the trusted clause and kill-switch', () => { + // Structural pins for the expression half of the drift guard — the + // simulation above re-implements it, so pin the real text too. + assert.match( + classifyRunsOn, + /contains\(fromJSON\('\["OWNER","MEMBER","COLLABORATOR"\]'\), github\.event\.pull_request\.author_association\)/, + ); + assert.match(classifyRunsOn, /vars\.MAINTAINER_ECS_RUNNER_DISABLED != 'true'/); + assert.match(classifyRunsOn, /github\.event_name == 'merge_group'/); + }); +}); + +describe('serve-ab.yml runner routing', () => { + const runsOn = String(serveAbDoc.jobs.ab['runs-on']); + + it('admits same-repo and write-access fork PRs, guarded by the kill-switch', () => { + assert.match(runsOn, /head\.repo\.full_name == github\.repository/); + assert.match( + runsOn, + /contains\(fromJSON\('\["OWNER","MEMBER","COLLABORATOR"\]'\), github\.event\.pull_request\.author_association\)/, + ); + assert.match(runsOn, /vars\.MAINTAINER_ECS_RUNNER_DISABLED != 'true'/); + assert.match(runsOn, /ecs-qwen/); + assert.match(runsOn, /ubuntu-latest/); + }); + + it('wipes the reused workspace before checking out PR code', () => { + const wipe = serveAbDoc.jobs.ab.steps.find( + (s) => s.name === 'Wipe stale workspace before checkout', + ); + assert.ok(wipe, 'self-hosted reuse must not bleed one PR into the next'); + assert.equal(wipe.if, "${{ runner.environment == 'self-hosted' }}"); + assert.match(wipe.run, /find "\$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 1 -exec rm -rf/); + }); +}); diff --git a/.github/scripts/qwen-triage-workflow.test.mjs b/.github/scripts/qwen-triage-workflow.test.mjs index d182b5bb01..03de579f07 100644 --- a/.github/scripts/qwen-triage-workflow.test.mjs +++ b/.github/scripts/qwen-triage-workflow.test.mjs @@ -199,12 +199,49 @@ describe('qwen-triage: agent tool/permission settings', () => { describe('qwen-triage: fork-PR runner routing', () => { const runsOn = String(triageJob['runs-on']); + const authorizeJob = doc.jobs.authorize; + const authorizeRunsOn = String(authorizeJob['runs-on']); - it('gates the persistent ECS pool on same-repo (fork code never persists)', () => { + it('routes the ECS pool on same-repo or a REAL write-permission check', () => { assert.match(runsOn, /head\.repo\.full_name == github\.repository/); + // The boundary is the collaborator-permission lookup authorize computes, + // NOT the coarse author_association: MEMBER admits any org member and + // COLLABORATOR admits read-only invitees — neither implies write access + // on this repo, and this job's agent loads bot PATs and a model key. + assert.match( + runsOn, + /needs\.authorize\.outputs\.author_can_write == 'true'/, + ); + assert.doesNotMatch(runsOn, /author_association/); assert.match(runsOn, /ecs-qwen/); }); + it('keeps the authorize gate itself on the same-repo guard', () => { + // authorize IS the permission check (and loads CI_BOT_PAT); it cannot + // route on its own output and must not widen to association-based trust. + assert.match(authorizeRunsOn, /head\.repo\.full_name == github\.repository/); + assert.doesNotMatch(authorizeRunsOn, /author_association/); + assert.doesNotMatch(authorizeRunsOn, /needs\./); + }); + + it('computes author_can_write from the collaborator-permission API', () => { + assert.equal( + authorizeJob.outputs.author_can_write, + '${{ steps.perm.outputs.author_can_write }}', + ); + const perm = authorizeJob.steps.find((s) => s.id === 'perm'); + assert.ok( + String(perm.env.PR_AUTHOR).includes( + 'github.event.pull_request.user.login', + ), + ); + assert.match(perm.run, /collaborators\/\$\{PR_AUTHOR\}\/permission/); + assert.match( + perm.run, + /admin\|maintain\|write\) echo "author_can_write=true"/, + ); + }); + it('falls back to an ephemeral hosted runner', () => { assert.match(runsOn, /ubuntu-latest/); }); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21bed6507b..7b255468a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,17 +50,20 @@ env: # BOTH the github_ci_only helper step and the full-profile Test step, so a # new helper test can't be added to one path and silently dropped from the # other. - HELPER_TESTS: '.github/scripts/pr-safety-precheck.test.mjs .github/scripts/cap-release-notes.test.mjs .github/scripts/ci/classify-profile.test.mjs .github/scripts/ci/main-failure-signature.test.mjs .github/scripts/classify-release-notes.test.mjs .github/scripts/dsw-swe-verified/make-manifest.test.mjs .github/scripts/resolve-sandbox-image.test.mjs .github/scripts/web-shell-visuals-publish.test.mjs .github/scripts/web-shell-visuals-compose.test.mjs .github/scripts/serve-ab-diff.test.mjs .github/scripts/qwen-triage-workflow.test.mjs .github/scripts/auto-minimize-spam.test.mjs' + HELPER_TESTS: '.github/scripts/pr-safety-precheck.test.mjs .github/scripts/cap-release-notes.test.mjs .github/scripts/ci/classify-profile.test.mjs .github/scripts/ci/main-failure-signature.test.mjs .github/scripts/classify-release-notes.test.mjs .github/scripts/dsw-swe-verified/make-manifest.test.mjs .github/scripts/resolve-sandbox-image.test.mjs .github/scripts/web-shell-visuals-publish.test.mjs .github/scripts/web-shell-visuals-compose.test.mjs .github/scripts/serve-ab-diff.test.mjs .github/scripts/qwen-triage-workflow.test.mjs .github/scripts/auto-minimize-spam.test.mjs .github/scripts/ci-runner-routing.test.mjs' jobs: classify_pr: name: 'Classify PR' if: "${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' || github.event_name == 'workflow_dispatch' }}" - # Gate runs on ECS for in-repo PRs and for the merge queue (which runs in the - # base-repo context), else a busy hosted pool delays it and blocks the - # ECS-bound jobs. The kill-switch is read here, so flipping it reverts - # everything to hosted. - runs-on: '${{ (vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == ''merge_group'')) && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}' + # Gate runs on ECS for in-repo PRs, fork PRs whose author has write access + # (OWNER/MEMBER/COLLABORATOR association — a write-access author is as + # trusted as an in-repo branch), and the merge queue (base-repo context), + # else a busy hosted pool delays it and blocks the ECS-bound jobs. The + # kill-switch is read here, so flipping it reverts everything to hosted. + # This runs-on and the pick_runner step below are the canonical home of + # the association routing; sdk-java.yml and serve-ab.yml mirror it. + runs-on: '${{ (vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && (github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON(''["OWNER","MEMBER","COLLABORATOR"]''), github.event.pull_request.author_association) || github.event_name == ''merge_group'')) && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}' continue-on-error: true outputs: skip_ci: '${{ steps.release_sync.outputs.skip_ci }}' @@ -102,23 +105,29 @@ jobs: echo "skip_ci=${skip_ci}" >> "${GITHUB_OUTPUT}" echo "skip_ci=${skip_ci}" - # In-repo PR (head branch in this repo => author has write access) and the - # merge queue (base-repo context) run the Linux jobs on ECS; fork PRs stay + # In-repo PRs, fork PRs whose author has write access + # (OWNER/MEMBER/COLLABORATOR association), and the merge queue + # (base-repo context) run the Linux jobs on ECS; other fork PRs stay # hosted. Disable via repo var MAINTAINER_ECS_RUNNER_DISABLED=true. - name: 'Select Linux runner' id: 'pick_runner' env: SAME_REPO: '${{ github.event.pull_request.head.repo.full_name == github.repository }}' + AUTHOR_ASSOCIATION: '${{ github.event.pull_request.author_association }}' ECS_DISABLED: '${{ vars.MAINTAINER_ECS_RUNNER_DISABLED }}' EVENT_NAME: '${{ github.event_name }}' DISPATCH_LINUX_RUNNER: '${{ github.event.inputs.linux_runner }}' run: |- ubuntu_runner='["ubuntu-latest"]' + trusted_author=false + case "${AUTHOR_ASSOCIATION}" in + OWNER|MEMBER|COLLABORATOR) trusted_author=true ;; + esac if [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then if [[ "${ECS_DISABLED}" != "true" && "${DISPATCH_LINUX_RUNNER}" == "self-hosted" ]]; then ubuntu_runner='["self-hosted", "linux", "x64", "ecs-qwen"]' fi - elif [[ "${ECS_DISABLED}" != "true" && ( "${SAME_REPO}" == "true" || "${EVENT_NAME}" == "merge_group" ) ]]; then + elif [[ "${ECS_DISABLED}" != "true" && ( "${SAME_REPO}" == "true" || "${trusted_author}" == "true" || "${EVENT_NAME}" == "merge_group" ) ]]; then ubuntu_runner='["self-hosted", "linux", "x64", "ecs-qwen"]' fi echo "ubuntu_runner=${ubuntu_runner}" >> "${GITHUB_OUTPUT}" diff --git a/.github/workflows/comment-attachment-guard.yml b/.github/workflows/comment-attachment-guard.yml index 67c0cc4606..85758a7a7e 100644 --- a/.github/workflows/comment-attachment-guard.yml +++ b/.github/workflows/comment-attachment-guard.yml @@ -57,7 +57,10 @@ jobs: github.event.comment.author_association || github.event.review.author_association ) - runs-on: 'ubuntu-latest' + # Checks out nothing and runs no repository code (comment/review 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"]'') }}' steps: - name: 'Remove suspicious attachment comments' uses: 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3' # v9.0.0 diff --git a/.github/workflows/docs-page-action.yml b/.github/workflows/docs-page-action.yml index 0d2352285f..288b1eac04 100644 --- a/.github/workflows/docs-page-action.yml +++ b/.github/workflows/docs-page-action.yml @@ -42,7 +42,13 @@ jobs: environment: name: 'github-pages' url: '${{ steps.deployment.outputs.page_url }}' - runs-on: 'ubuntu-latest' + # Checks out nothing and runs no repository code (push/dispatch use + # base-repo YAML), so the persistent ECS pool is safe. + # 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"]'') }}' + # One API deploy; a hang must not pin a persistent pool machine for the + # 360-minute default. + timeout-minutes: 5 needs: 'build' steps: - name: 'Deploy to GitHub Pages' diff --git a/.github/workflows/main-ci-failure-issue.yml b/.github/workflows/main-ci-failure-issue.yml index 5e9edb274d..b6c8617b67 100644 --- a/.github/workflows/main-ci-failure-issue.yml +++ b/.github/workflows/main-ci-failure-issue.yml @@ -144,6 +144,9 @@ jobs: file_issue: name: 'Create autofix issue' needs: 'analyze' + # 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 permissions: diff --git a/.github/workflows/pr-force-push-reminder.yml b/.github/workflows/pr-force-push-reminder.yml index 06404ff919..1c98ec76db 100644 --- a/.github/workflows/pr-force-push-reminder.yml +++ b/.github/workflows/pr-force-push-reminder.yml @@ -24,7 +24,10 @@ jobs: timeout-minutes: 5 if: |- ${{ github.repository == 'QwenLM/qwen-code' }} - runs-on: 'ubuntu-latest' + # Checks out nothing and runs no repository code (pull_request_target + # 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"]'') }}' steps: - name: 'Detect force-push and post reminder' uses: 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3' # v9.0.0 diff --git a/.github/workflows/pr-self-report-label.yml b/.github/workflows/pr-self-report-label.yml index 3247649923..66c4968319 100644 --- a/.github/workflows/pr-self-report-label.yml +++ b/.github/workflows/pr-self-report-label.yml @@ -24,7 +24,10 @@ jobs: label: if: |- ${{ github.repository == 'QwenLM/qwen-code' }} - runs-on: 'ubuntu-latest' + # Checks out nothing and runs no repository code (pull_request_target + # 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: 5 steps: - name: 'Label a self-reported PR' diff --git a/.github/workflows/qwen-code-pr-review.yml b/.github/workflows/qwen-code-pr-review.yml index b765a29ea5..4173689426 100644 --- a/.github/workflows/qwen-code-pr-review.yml +++ b/.github/workflows/qwen-code-pr-review.yml @@ -228,7 +228,9 @@ jobs: startsWith(github.event.review.body, '@qwen-code /review'))) # Canonical same-repo guard: this job loads CI_BOT_PAT, so fork-triggered # runs stay on hosted (ephemeral); only in-repo PR events on QwenLM/qwen-code - # use the persistent ECS runner. + # use the persistent ECS runner. The job IS the write-permission check, so + # it cannot route on its own output; downstream jobs already route on the + # repository guard alone. runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && github.event.pull_request && github.event.pull_request.head.repo.full_name == github.repository) && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}' timeout-minutes: 5 permissions: diff --git a/.github/workflows/qwen-fleet-shepherd.yml b/.github/workflows/qwen-fleet-shepherd.yml index 6a3efddf25..e2df10c883 100644 --- a/.github/workflows/qwen-fleet-shepherd.yml +++ b/.github/workflows/qwen-fleet-shepherd.yml @@ -65,6 +65,12 @@ jobs: shepherd: if: |- ${{ github.repository == 'QwenLM/qwen-code' && vars.FLEET_SHEPHERD_DISABLED != 'true' }} + # Deliberately hosted, NOT the ECS pool: the shepherd is the watchdog FOR + # that pool. When the pool is wedged, drained, or offline — exactly when + # the shepherd is needed — a pool-routed shepherd would queue behind the + # failure it exists to fix, and recovery would need the manual kill-switch + # flip the shepherd exists to avoid. One 15-minute hosted job per tick is + # a negligible queue cost for that independence. runs-on: 'ubuntu-latest' timeout-minutes: 15 steps: diff --git a/.github/workflows/qwen-issue-followup-bot.yml b/.github/workflows/qwen-issue-followup-bot.yml index 26170101f8..18bfba6404 100644 --- a/.github/workflows/qwen-issue-followup-bot.yml +++ b/.github/workflows/qwen-issue-followup-bot.yml @@ -51,7 +51,10 @@ jobs: github.repository == 'QwenLM/qwen-code' && (github.event_name == 'workflow_dispatch' || vars.QWEN_ISSUE_FOLLOWUP_BOT_ENABLED == 'true') }} - runs-on: 'ubuntu-latest' + # Checks out nothing and runs no repository code (schedule/dispatch use + # base-repo YAML), so the persistent ECS pool is safe. + # 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"]'') }}' steps: - name: 'Prepare issue follow-up runtime' id: 'runtime' diff --git a/.github/workflows/qwen-triage-finalize.yml b/.github/workflows/qwen-triage-finalize.yml index aaae63b767..e0ba58c07b 100644 --- a/.github/workflows/qwen-triage-finalize.yml +++ b/.github/workflows/qwen-triage-finalize.yml @@ -66,7 +66,10 @@ jobs: if: >- github.event.workflow_run.event == 'pull_request' && github.repository == 'QwenLM/qwen-code' - runs-on: 'ubuntu-latest' + # Checks out nothing and runs no repository code (workflow_run 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: 'Finalize CI evidence and deferred approval' diff --git a/.github/workflows/qwen-triage.yml b/.github/workflows/qwen-triage.yml index 8e392db649..64e7178827 100644 --- a/.github/workflows/qwen-triage.yml +++ b/.github/workflows/qwen-triage.yml @@ -76,13 +76,22 @@ jobs: github.event.inputs.tmux_pr != '')) # Canonical same-repo guard: this job loads CI_BOT_PAT, so fork-triggered # runs stay on hosted (ephemeral); only in-repo PR events on QwenLM/qwen-code - # use the persistent ECS runner. + # use the persistent ECS runner. The job IS the write-permission check, so + # it cannot route on its own output; downstream jobs (triage, tmux, verify) + # route on what it finds instead. runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && github.event.pull_request && github.event.pull_request.head.repo.full_name == github.repository) && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}' timeout-minutes: 5 permissions: contents: 'read' outputs: should_run: '${{ steps.perm.outputs.should_run }}' + # Real write-permission check on the PR AUTHOR (collaborator-permission + # API, not the coarse author_association), computed only on + # pull_request_target events; empty on every other event. Consumed by + # the triage job's runner routing so a maintainer's fork PR uses the + # ECS pool while a read-only collaborator's or org member's fork PR + # stays on ephemeral hosted runners. + author_can_write: '${{ steps.perm.outputs.author_can_write }}' # /verify trust level: 'trusted' when the PR author has write, # 'external' when they do not. Both run on the persistent ECS pool # (maintainer decision, 2026-07-29: the pool is network-isolated and @@ -124,6 +133,7 @@ jobs: # issues; compare against null rather than truthiness so the shell # receives a plain true/false. IS_PR: '${{ github.event.issue.pull_request != null }}' + PR_AUTHOR: '${{ github.event.pull_request.user.login }}' run: |- set -euo pipefail IS_VERIFY=false @@ -141,6 +151,18 @@ jobs: # says the PR is ready to be worth that. echo "Automatic PR triage allowed for PR #${PR_NUMBER} after same-repo/precheck gate." >> "$GITHUB_STEP_SUMMARY" echo "should_run=true" >> "$GITHUB_OUTPUT" + # Runner-routing input for the triage job: the REAL author write + # check (author_association admits org members and read-only + # collaborators). Fail OPEN on purpose — unlike the deny gates + # below, this selects a runner pool, not a decision: an API + # hiccup merely routes the triage to a hosted runner, it must + # never skip or fail the triage itself. + if [ -n "$PR_AUTHOR" ] && + author_perm="$(gh api "repos/${GITHUB_REPOSITORY}/collaborators/${PR_AUTHOR}/permission" --jq '.permission' 2>/dev/null)"; then + case "$author_perm" in + admin|maintain|write) echo "author_can_write=true" >> "$GITHUB_OUTPUT" ;; + esac + fi exit 0 fi case "$EVENT_NAME" in @@ -387,13 +409,19 @@ jobs: # (.qwen/skills/triage/SKILL.md Rules), the `settings` deny rules block the # direct interpreter/build/network invocations, and the routing below keeps # foreign code off the persistent pool. - # Runner routing: the persistent ECS pool is reserved for events whose - # payload carries no foreign code — issue triage and same-repo PR events. - # Fork PRs, and comment/dispatch-triggered reruns (which may target fork - # PRs), go to ephemeral hosted runners so a steered agent cannot persist - # anything across runs. Forks of this repo fall back to ubuntu-latest as - # before. - runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && (github.event_name == ''issues'' || (github.event.pull_request && github.event.pull_request.head.repo.full_name == github.repository))) && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}' + # Runner routing: the persistent ECS pool serves events whose payload + # carries no foreign code — issue triage and same-repo PR events — plus + # fork PRs whose author REALLY has write access, per the collaborator- + # permission lookup the authorize job already performs (the coarse + # author_association would admit org members and read-only collaborators + # to this secret-bearing agent; the pool is network-isolated, but a + # write-access author is the trust bar for reusing it). authorize is + # skipped on the issues/dispatch paths, so the explicit `issues` clause + # must stay; there its output is ''. PRs by other fork authors, and + # comment/dispatch-triggered reruns (which may target fork PRs), go to + # ephemeral hosted runners so a steered agent cannot persist anything + # across runs. Forks of this repo fall back to ubuntu-latest as before. + runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && (github.event_name == ''issues'' || (github.event.pull_request && (github.event.pull_request.head.repo.full_name == github.repository || needs.authorize.outputs.author_can_write == ''true'')))) && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}' # startsWith (not contains) prevents false triggers from comments that # mention the phrase in quoted text or mid-sentence descriptions. # always() so the job still evaluates when the upstream `authorize` job is @@ -1739,10 +1767,24 @@ jobs: # inherit the 360-minute default and a hung gh call could hold a hosted # runner for six hours. Same bound as publish-verify. timeout-minutes: 10 - runs-on: 'ubuntu-latest' + # Checks out nothing and runs no repository code (base-repo YAML events), + # so the persistent ECS pool is safe. + # 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"]'') }}' permissions: pull-requests: 'write' 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; artifact download 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; artifact download may fail on leftover read-only files" + - name: 'Download tmux results' uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v5.0.0 with: @@ -3400,10 +3442,24 @@ jobs: # inherit the 360-minute default and a hung gh call could hold a hosted # runner for six hours. timeout-minutes: 10 - runs-on: 'ubuntu-latest' + # Checks out nothing and runs no repository code (base-repo YAML events), + # so the persistent ECS pool is safe. + # 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"]'') }}' permissions: pull-requests: 'write' 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; artifact download 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; artifact download may fail on leftover read-only files" + - name: 'Download verify results' id: 'download' uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v5.0.0 diff --git a/.github/workflows/repo-hygiene.yml b/.github/workflows/repo-hygiene.yml index 045b25374c..270afb451c 100644 --- a/.github/workflows/repo-hygiene.yml +++ b/.github/workflows/repo-hygiene.yml @@ -37,7 +37,10 @@ jobs: dedup: name: 'Dedup' if: "${{ github.repository == 'QwenLM/qwen-code' }}" - runs-on: 'ubuntu-latest' + # Checks out nothing and runs no repository code (schedule/dispatch use + # base-repo YAML), so the persistent ECS pool is safe. + # 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: 5 permissions: contents: 'read' diff --git a/.github/workflows/sdk-java.yml b/.github/workflows/sdk-java.yml index 61799ee961..7c0727e254 100644 --- a/.github/workflows/sdk-java.yml +++ b/.github/workflows/sdk-java.yml @@ -52,7 +52,7 @@ concurrency: jobs: test: name: '${{ matrix.os }} / Java ${{ matrix.java }}' - runs-on: '${{ (matrix.os == ''ubuntu-latest'' && github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && (github.event_name == ''push'' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || matrix.os }}' + runs-on: '${{ (matrix.os == ''ubuntu-latest'' && github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && (github.event_name == ''push'' || 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"]'') || matrix.os }}' timeout-minutes: 30 strategy: fail-fast: false @@ -129,7 +129,7 @@ jobs: daemon-e2e: name: 'Real daemon E2E / Java 11' - runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && (github.event_name == ''push'' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || ''ubuntu-latest'' }}' + runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && (github.event_name == ''push'' || 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"]'') || ''ubuntu-latest'' }}' timeout-minutes: 30 steps: - name: 'Restore workspace ownership' diff --git a/.github/workflows/serve-ab-publish.yml b/.github/workflows/serve-ab-publish.yml index 59e0e4c356..92d72df1e5 100644 --- a/.github/workflows/serve-ab-publish.yml +++ b/.github/workflows/serve-ab-publish.yml @@ -32,9 +32,23 @@ jobs: github.repository == 'QwenLM/qwen-code' && github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' - runs-on: 'ubuntu-latest' + # Checks out nothing and runs no repository code (workflow_run events use + # base-repo YAML), so the persistent ECS pool is safe. + # 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; artifact download 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; artifact download may fail on leftover read-only files" + - name: 'Download serve-ab artifact' id: 'download' continue-on-error: true diff --git a/.github/workflows/serve-ab.yml b/.github/workflows/serve-ab.yml index ac2def91e6..591b2d8953 100644 --- a/.github/workflows/serve-ab.yml +++ b/.github/workflows/serve-ab.yml @@ -7,12 +7,21 @@ name: 'Serve A/B' # the daemon analog of the web-shell visual before/after preview. # # Security model: this workflow BUILDS AND RUNS untrusted PR code, so it runs on -# the `pull_request` trigger (fork PRs get a read-only token and NO secrets), on -# an ephemeral runner, with `contents: read`. It produces only JSON captures + -# a markdown body as an artifact. The privileged step that needs a write token — -# posting the comment — lives in the separate workflow_run publisher, which -# never checks out PR code. The daemon is driven with dummy OpenAI creds, so no -# model is contacted and the responses are deterministic + safe to diff. +# the `pull_request` trigger (fork PRs get a read-only token and NO secrets) and +# with `contents: read`. It produces only JSON captures + a markdown body as an +# artifact. The privileged step that needs a write token — posting the comment — +# lives in the separate workflow_run publisher, which never checks out PR code. +# The daemon is driven with dummy OpenAI creds, so no model is contacted and the +# responses are deterministic + safe to diff. +# +# Runner routing: in-repo PRs, and fork PRs whose author has write access +# (OWNER/MEMBER/COLLABORATOR association), run on the persistent ECS pool — +# a write-access author could push the same code to this repo directly, so the +# pool grants them nothing new, and the pre-checkout wipe below keeps one PR's +# build from bleeding into the next. Other fork PRs stay on ephemeral hosted +# runners. NOTE: this trigger reads its YAML from the fork, so the association +# clause is routing convenience, not a security boundary — the boundary for +# fork code is the repo's fork-PR workflow-approval setting. on: pull_request: branches: @@ -44,9 +53,35 @@ jobs: ab: name: 'Serve A/B (ubuntu-latest, Node 22.x)' if: "${{ github.repository == 'QwenLM/qwen-code' }}" - runs-on: 'ubuntu-latest' + # Same-repo PRs and fork PRs whose author has write access + # (OWNER/MEMBER/COLLABORATOR association) run on the persistent ECS pool; + # other fork PRs stay on ephemeral hosted runners. Keep in sync with + # ci.yml's classify_pr routing. Kill-switch: MAINTAINER_ECS_RUNNER_DISABLED. + runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && (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: 30 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: 'Wipe stale workspace before checkout' + if: "${{ runner.environment == 'self-hosted' }}" + run: |- + set -uo pipefail + # The two checkouts clone into head/ and base/ subdirectories; + # leftovers there from a previous run on the same reusable runner + # could bleed into the builds and silently change the posted A/B + # diff. Hosted runners are ephemeral and never see this. After the + # ownership-restore step everything is user-owned, so no sudo. + find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 1 -exec rm -rf {} + + - name: 'Checkout PR head' uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index bf3401e657..004f6847bf 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -10,7 +10,13 @@ on: jobs: stale: - runs-on: 'ubuntu-latest' + # Checks out nothing and runs no repository code (schedule/dispatch use + # base-repo YAML), so the persistent ECS pool is safe. + # 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"]'') }}' + # A hung sweep must not pin a persistent pool machine for the 360-minute + # default; the sweep itself finishes in well under this. + timeout-minutes: 10 permissions: issues: 'write' pull-requests: 'write' diff --git a/.github/workflows/web-shell-visuals-cleanup.yml b/.github/workflows/web-shell-visuals-cleanup.yml index 3ec557c19e..b3f83a8305 100644 --- a/.github/workflows/web-shell-visuals-cleanup.yml +++ b/.github/workflows/web-shell-visuals-cleanup.yml @@ -19,7 +19,10 @@ permissions: jobs: delete-asset-branch: if: "${{ github.repository == 'QwenLM/qwen-code' }}" - runs-on: 'ubuntu-latest' + # Checks out nothing and runs no repository code (pull_request_target + # events use base-repo YAML), so the persistent ECS pool is safe. + # 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: 5 steps: - name: 'Delete the PR asset branches' diff --git a/scripts/tests/comment-attachment-guard-workflow.test.js b/scripts/tests/comment-attachment-guard-workflow.test.js index b2f5f79050..833d088f72 100644 --- a/scripts/tests/comment-attachment-guard-workflow.test.js +++ b/scripts/tests/comment-attachment-guard-workflow.test.js @@ -107,9 +107,12 @@ describe('comment attachment guard workflow', () => { // its own checks. So the only unsafe direction is skipping a scan that // should have run, and every ambiguity must resolve toward running. describe('job-level gate', () => { + const startIdx = workflow.indexOf(' remove-suspicious-attachments:'); + // Search from the job start: a file-global indexOf would silently change + // the slice's meaning if a job were ever added above this one. const gate = workflow.slice( - workflow.indexOf(' remove-suspicious-attachments:'), - workflow.indexOf("runs-on: 'ubuntu-latest'"), + startIdx, + workflow.indexOf('runs-on:', startIdx), ); it('gates on association and sender before a runner is allocated', () => { diff --git a/scripts/tests/sdk-java-workflow.test.js b/scripts/tests/sdk-java-workflow.test.js index 71fa309b50..c96a7f1117 100644 --- a/scripts/tests/sdk-java-workflow.test.js +++ b/scripts/tests/sdk-java-workflow.test.js @@ -15,6 +15,10 @@ describe('SDK Java self-hosted workflow guards', () => { "github.repository == ''QwenLM/qwen-code''", 'github.event.pull_request.head.repo.full_name == github.repository', "vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true''", + // Write-access fork authors route to ECS too; the association list is + // the repo's established trusted set. Negative associations (CONTRIBUTOR, + // NONE, '') fail contains() and stay hosted. + 'contains(fromJSON(\'\'["OWNER","MEMBER","COLLABORATOR"]\'\'), github.event.pull_request.author_association)', 'fromJSON(\'\'["self-hosted", "linux", "x64", "ecs-qwen"]\'\')', "format('refs/pull/{0}/head', github.event.pull_request.number)", "EXPECTED_SHA: '${{ github.event.pull_request.head.sha }}'",