Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d9c2630
ci: render web-shell visuals on the persistent pool for trusted lanes
Aug 26, 2026
5785af5
ci: adapt the visuals capture to the pool's RHEL-family image
Aug 26, 2026
57e4734
ci: clear stale capture dirs and pin the visuals gate wiring
Aug 26, 2026
3363682
ci: self-provision Chromium libraries on the pool before the ldd gate…
qwen-code-dev-bot Aug 26, 2026
e7e28e1
ci: exec-test the pool's Chromium library gate instead of pinning its…
qwen-code-dev-bot Aug 26, 2026
2589f04
Merge branch 'main' into ci/web-shell-visuals-capture-ecs
qwen-code-dev-bot Aug 26, 2026
3d3c836
Merge remote-tracking branch 'origin/main' into ci/web-shell-visuals-…
qwen-code-dev-bot Aug 27, 2026
786e01b
ci: gate the Chromium apt lane on the runner environment, not apt-get…
qwen-code-dev-bot Aug 27, 2026
8eb3e19
Merge branch 'main' into ci/web-shell-visuals-capture-ecs
wenshao Aug 27, 2026
a3c8f6a
Merge branch 'main' into ci/web-shell-visuals-capture-ecs
qwen-code-ci-bot Aug 27, 2026
19a74ef
ci: close the round-4 findings on the visuals pool routing (#10087)
qwen-code-ci-bot Aug 27, 2026
52f64f2
ci: close the round-5 findings on the visuals pool routing (#10087)
qwen-code-ci-bot Aug 27, 2026
f6acc7b
ci: close the round-6 findings on the visuals pool routing (#10087)
qwen-code-ci-bot Aug 28, 2026
6103909
Merge branch 'main' into ci/web-shell-visuals-capture-ecs
wenshao Aug 28, 2026
e774aab
Merge branch 'main' into ci/web-shell-visuals-capture-ecs
qwen-code-dev-bot Aug 28, 2026
1668598
ci: close the round-7 findings on the visuals pool routing (#10087)
qwen-code-ci-bot Aug 28, 2026
aca7b61
ci: close the round-8 findings on the visuals pool routing (#10087)
qwen-code-ci-bot Aug 28, 2026
1d334ee
Merge branch 'main' into ci/web-shell-visuals-capture-ecs
qwen-code-dev-bot Aug 28, 2026
dad52aa
Merge branch 'main' into ci/web-shell-visuals-capture-ecs
wenshao Aug 28, 2026
7782fd0
Merge branch 'main' into ci/web-shell-visuals-capture-ecs
qwen-code-dev-bot Aug 29, 2026
d121bb1
Merge remote-tracking branch 'origin/main' into ci/web-shell-visuals-…
qwen-code-dev-bot Aug 29, 2026
6405566
ci: close the round-11 findings on the visuals pool routing (#10087)
qwen-code-dev-bot Aug 29, 2026
8acd78c
Merge branch 'main' into ci/web-shell-visuals-capture-ecs
qwen-code-dev-bot Aug 29, 2026
49f8bbb
Merge branch 'main' into ci/web-shell-visuals-capture-ecs
qwen-code-dev-bot Aug 29, 2026
10e46be
ci: close the round-12-14 findings on the visuals pool routing (#10087)
qwen-code-dev-bot Aug 30, 2026
fc9eab7
Merge branch 'main' into ci/web-shell-visuals-capture-ecs
wenshao Aug 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions .github/scripts/ci-runner-routing.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ function evalRunsOn(expression, { ecsDisabled, eventName, sameRepo, assoc }) {
/github\.event_name != 'pull_request'/,
String(eventName !== 'pull_request'),
],
[/github\.repository == 'QwenLM\/qwen-code'/, 'true'],
[
/github\.event\.pull_request\.head\.repo\.full_name == github\.repository/,
String(sameRepo),
Expand Down Expand Up @@ -421,3 +422,139 @@ describe('serve-ab.yml runner routing', () => {
);
});
});

describe('web-shell-visuals.yml capture runner routing', () => {
// The capture job builds and renders PR code, so it follows the fleet's
// trust split exactly as serve-ab does: same-repo PRs and write-access
// fork authors reach the persistent pool, every other fork PR keeps the
// ephemeral hosted runner, and the kill-switch forces everything hosted.
const visualsDoc = parse(
readFileSync(join(workflowsDir, 'web-shell-visuals.yml'), 'utf8'),
);
// evalRunsOn unwraps the winning fromJSON label to the array it names.
const ECS_LABELS = ['self-hosted', 'linux', 'x64', 'ecs-qwen'];
const HOSTED_LABELS = ['ubuntu-latest'];
const job = visualsDoc.jobs.capture;
const runsOn = String(job['runs-on']);

it('routes pull requests by fork trust', () => {
assert.deepEqual(
evalRunsOn(runsOn, {
ecsDisabled: false,
eventName: 'pull_request',
sameRepo: true,
assoc: 'NONE',
}),
ECS_LABELS,
'same-repo PRs must render on the pool',
);
assert.deepEqual(
evalRunsOn(runsOn, {
ecsDisabled: false,
eventName: 'pull_request',
sameRepo: false,
assoc: 'MEMBER',
}),
ECS_LABELS,
'write-access fork authors must render on the pool',
);
for (const assoc of ['CONTRIBUTOR', 'FIRST_TIME_CONTRIBUTOR', 'NONE', '']) {
assert.deepEqual(
evalRunsOn(runsOn, {
ecsDisabled: false,
eventName: 'pull_request',
sameRepo: false,
assoc,
}),
HOSTED_LABELS,
`assoc '${assoc}' fork PRs must stay on ephemeral hosted runners`,
);
}
});

it('obeys the kill-switch', () => {
assert.deepEqual(
evalRunsOn(runsOn, {
ecsDisabled: true,
eventName: 'pull_request',
sameRepo: true,
assoc: 'OWNER',
}),
HOSTED_LABELS,
'kill-switch must force the capture back to hosted',
);
});

it('matches serve-ab routing byte for byte', () => {
// One trust split, one edit site: a clause change on either side must
// touch both or fail here.
assert.equal(runsOn, String(serveAbDoc.jobs.ab['runs-on']));
});

it('heals workspace ownership before the first checkout', () => {
const steps = job.steps;
const heal = steps.findIndex(
(s) => s.name === 'Restore workspace ownership',
);
const checkout = steps.findIndex((s) =>
String(s.uses || '').startsWith('actions/checkout'),
);
assert.ok(heal !== -1, 'the ownership heal must exist');
assert.equal(steps[heal].if, "${{ runner.environment == 'self-hosted' }}");
assert.match(steps[heal].run, /chown -R .* "\$GITHUB_WORKSPACE"/);
assert.ok(heal < checkout, 'the heal must precede the checkout');
});

it('keeps the capture job free of ambient secrets', () => {
// The security-model header promises the render job holds no secrets of
// its own; the merge-base resolve step's read-only GITHUB_TOKEN is the
// one permitted exception.
for (const step of job.steps) {
for (const value of Object.values(step.env ?? {})) {
const refs = String(value).match(/secrets\.[A-Za-z_]+/g) ?? [];
for (const ref of refs) {
assert.equal(
ref,
'secrets.GITHUB_TOKEN',
`capture step '${step.name}' references ${ref}; the render side must stay secret-free`,
);
}
}
}
});

it('keeps setup-node off the pool', () => {
// The action's post step uploads the npm cache to GitHub; on the
// pool's slow egress that save ran 14+ minutes and timed out
// security-checks' first pool run (2026-08-26). Hosted keeps the
// action; the pool reuses the machine's Node and its persistent npm
// cache, exactly as ci.yml does.
const setup = job.steps.find((s) =>
String(s.uses || '').startsWith('actions/setup-node'),
);
assert.ok(setup, 'the hosted setup-node step must exist');
assert.equal(setup.if, "${{ runner.environment == 'github-hosted' }}");
const preflight = job.steps.find(
(s) => s.uses === './.github/actions/self-hosted-node',
);
assert.ok(preflight, 'the pool lane must use the pre-installed Node');
assert.equal(preflight.if, "${{ runner.environment == 'self-hosted' }}");
});

it('gates the pool render on Chromium libraries actually resolving', () => {
// --with-deps drives apt-get, which the RHEL-family pool machines do
// not have (first pool run failed there, 2026-08-26). The non-apt
// branch must download the browser AND hard-verify its shared
// libraries with ldd, failing with the missing list instead of
// crashing mid-render.
const install = job.steps.find(
(s) => s.name === 'Install Playwright Chromium',
);
assert.ok(install, 'the Playwright install step must exist');
assert.match(install.run, /command -v apt-get/);
assert.match(install.run, /playwright install --with-deps chromium/);
assert.match(install.run, /ldd/);
assert.match(install.run, /not found/);
assert.match(install.run, /exit 1/);
});
});
2 changes: 1 addition & 1 deletion .github/workflows/.size-baseline
Original file line number Diff line number Diff line change
Expand Up @@ -61,5 +61,5 @@
3303 update-ecs-runner-qwen.yml
2307 web-shell-visuals-cleanup.yml
15380 web-shell-visuals-publish.yml
16384 web-shell-visuals.yml
21284 web-shell-visuals.yml
4712 windows-runner-smoke.yml
88 changes: 80 additions & 8 deletions .github/workflows/web-shell-visuals.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,14 @@ name: 'Web-shell Visuals'
#
# Security model: this workflow BUILDS AND RENDERS untrusted PR code, so it runs
# on the `pull_request` trigger (fork PRs get a read-only token and NO secrets),
# on an ephemeral hosted runner, with `contents: read` and no secrets of its
# own. It produces only image/video bytes as an artifact. The privileged step
# that needs a write token — pushing the images and commenting on the PR — lives
# in the separate workflow_run workflow that never checks out PR code.
# with `contents: read` and no secrets of its own. It produces only image/video
# bytes as an artifact. Runner choice follows the fleet's trust split (ci.yml
# classify_pr, serve-ab): same-repo PRs and write-access fork authors render on
# the persistent ECS pool — the same lanes whose code ci.yml already builds and
# tests there — while every other fork PR keeps the ephemeral hosted runner.
# The privileged step that needs a write token — pushing the images and
# commenting on the PR — lives in the separate workflow_run workflow that never
# checks out PR code and deliberately stays on hosted runners.
on:
pull_request:
branches:
Expand Down Expand Up @@ -58,27 +62,64 @@ defaults:

jobs:
capture:
name: 'Capture web-shell visuals (ubuntu-latest, Node 22.x)'
name: 'Capture web-shell visuals (Node 22.x)'
if: "${{ github.repository == 'QwenLM/qwen-code' }}"
runs-on: 'ubuntu-latest'
# Same-repo PRs and write-access fork authors render on the persistent
# ECS pool; other fork PRs stay on ephemeral hosted runners. Keep in
# sync with serve-ab.yml. 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"]'') }}'
# ~2x the original: the job now also builds + renders the merge-base arm.
timeout-minutes: 30
steps:
# Pre-checkout heal, mirroring ci.yml: a previous containerised job on
# this persistent runner may leave root-owned or read-only files in the
# shared workspace, and checkout's clean would die on EACCES. Hosted
# runners are ephemeral and never take this branch.
- 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 PR head'
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
ref: '${{ github.event.pull_request.head.sha }}'
fetch-depth: 1
persist-credentials: false

- name: 'Set up Node.js 22.x'
# Hosted-only, mirroring ci.yml: on the pool, nodejs.org may be
# unreachable through the egress proxy, and the action's post step
# uploads the npm cache to GitHub — a save that ran 14+ minutes over
# the pool's slow egress on security-checks' first pool run
# (2026-08-26). The pool reuses the machine's Node and its persistent
# npm cache instead.
- name: 'Set up Node.js 22.x (hosted)'
if: "${{ runner.environment == 'github-hosted' }}"
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version: '22.x'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
registry-url: 'https://registry.npmjs.org/'

- name: 'Use pre-installed Node.js (self-hosted)'
if: "${{ runner.environment == 'self-hosted' }}"
uses: './.github/actions/self-hosted-node'

- name: 'Configure persistent npm cache (self-hosted)'
if: "${{ runner.environment == 'self-hosted' }}"
run: |-
cache_dir="${HOME}/.cache/qwen-code/npm"
mkdir -p "${cache_dir}"
echo "NPM_CONFIG_CACHE=${cache_dir}" >> "${GITHUB_ENV}"
echo "Using persistent npm cache at ${cache_dir}"

- name: 'Configure npm for rate limiting'
run: |-
npm config set fetch-retry-mintimeout 20000
Expand All @@ -89,8 +130,29 @@ jobs:
- name: 'Install dependencies'
run: 'npm ci --prefer-offline --no-audit --progress=false'

# --with-deps drives apt-get; the pool machines are not Debian-family
# ("apt-get: command not found" on the first pool run, 2026-08-26), so
# there the browser download has to suffice. Verify the shared
# libraries actually resolve and fail with the concrete missing list —
# a clear provisioning signal — instead of a mid-render crash.
- name: 'Install Playwright Chromium'
run: 'npx playwright install --with-deps chromium'
run: |-
set -uo pipefail
if command -v apt-get > /dev/null 2>&1; then
npx playwright install --with-deps chromium
else
npx playwright install chromium
missing=''
while IFS= read -r bin; do
missing="${missing}$(ldd "${bin}" 2>/dev/null | grep 'not found' || true)"
done < <(find "${HOME}/.cache/ms-playwright" -type f \( -name 'headless_shell' -o -name 'chrome' \) 2>/dev/null)
if [ -n "${missing}" ]; then
printf '%s\n' "${missing}" | sort -u
echo "::error::Chromium's shared libraries do not resolve on this runner (missing list above). Provision them in the pool image, or set MAINTAINER_ECS_RUNNER_DISABLED to route the render back to hosted."
exit 1
fi
echo "Chromium shared libraries all resolve on this runner."
fi

- name: 'Choose web-shell Playwright port'
run: |-
Expand Down Expand Up @@ -204,6 +266,16 @@ jobs:
echo "::warning::compose failed; publishing raw after-shots without a before/after."
fi

# Hosted images ship ffmpeg; the pool may not. Best-effort install so
# the GIF conversion below keeps working there — on failure it already
# degrades to uploading the raw .webm with a visible warning. The pool
# machines are RHEL-family (no apt-get), so try dnf/yum after apt.
# sudo -n: never prompt (mirrors the fleet's Install tmux pattern).
- name: 'Install ffmpeg'
if: "${{ runner.environment == 'self-hosted' }}"
run: |-
command -v ffmpeg > /dev/null 2>&1 || sudo -n apt-get install -y ffmpeg > /dev/null 2>&1 || sudo -n dnf install -y ffmpeg > /dev/null 2>&1 || sudo -n yum install -y ffmpeg > /dev/null 2>&1 || echo "::warning::could not install ffmpeg; flow recordings will be uploaded as raw .webm without GIF previews"

- name: 'Convert flow recordings to inline GIFs'
env:
OUT_DIR: '${{ runner.temp }}/web-shell-visuals'
Expand Down
Loading