Files
qwen-code/.github/workflows/web-shell-visuals-publish.yml
T
bb518cee86 ci: host PR evidence outside Git (#9985)
* ci: host PR evidence outside Git

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

* ci: pin node and ossutil paths in privileged OSS uploads

The publish-verify evidence upload resolved `node` through a PATH
prefixed with $RUNNER_TEMP — the one directory PR code can write to
via the verify container's bind mount — so a planted fake `node`
there won interpreter resolution inside the step that holds
CI_BOT_PAT and the OSS config (PATH hijack). Resolve node under the
inherited clean PATH and run ossutil from a fresh job-private copy
of the sha256-verified binary installed above instead of resolving
either through $RUNNER_TEMP. Apply the same shape to
web-shell-visuals-publish.yml so the pattern cannot regress if that
job ever moves to a persistent pool.

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

* ci: make OSS setup best-effort and pair bucket/base-URL defaults

Mirror qwen-triage.yml in web-shell-visuals-publish.yml: mark the
ossutil install and credential-config steps continue-on-error so a
setup failure cannot abort the no-image marker-comment path (the
upload itself stays loud when images are present). In both
publishers, derive the default ALIYUN_OSS_PUBLIC_BASE_URL from
ALIYUN_OSS_BUCKET so overriding only one of the two vars cannot
post comment links that 404 against (or show stale objects from)
the other bucket. Re-record both workflow sizes in the baseline.

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

* ci: enrich evidence-upload warning with local preconditions

The single "Failed to upload evidence images" warning could not
distinguish its causes: the ossutil install and credential-config
steps run with continue-on-error and render green when they fail, so
a missing binary or config looks identical to a transient upload
blip or rotated keys. Probe both preconditions into the warning
(ossutil=ok/MISSING, config=ok/MISSING) so the investigator sees the
gap without trawling three steps of logs.

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

* test: enforce the uploader flag contract and pin the production arm

The evidence-hosting stub shifted --config away unvalidated, and every
harness run sets VERIFY_ASSETS_UPLOADER, so the production
`node "$uploader"` arm and its --config flag were never exercised:
dropping --config from the workflow kept every test green while the
real uploader exits 1 on it, silently degrading every /verify report
to text-only. Make the stub reject a missing/empty --bucket, --config
or --prefix, and pin the production invocation shape (inherited-PATH
node resolution plus all three ossutil flags). Mutation-probed: with
--config removed from the workflow both tests now fail.

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

* test: pin the ossutil credential lifecycle in both publishers

Nothing pinned the credential lifecycle this PR adds — the
sha256-checked ossutil install, the credential-config step, and the
if:always() cleanup — so a future edit dropping any of them (or the
always() condition) would leave every test green while the OSS key
pair persists in $RUNNER_TEMP on the persistent ecs-qwen pool. Add
shape assertions for all three steps in qwen-triage.yml and
web-shell-visuals-publish.yml, mirroring the release/desktop wiring
pins in install-script.test.js and desktop-oss-workflow.test.js.

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

* docs: align assets-repo guidance with the self-targeting guard

The PR-review workflow now blanks a QWEN_REVIEW_ASSETS_REPO that equals
the repository under review, but the user doc, the bundled review skill,
and the parseAssetsRepo unset-error still recommended pointing it at the
repo under review — so a maintainer following the published guidance
landed in the "not set" refusal path, and the refusal message
re-recommended the exact value the guard had just rejected. Update all
three surfaces to the new contract: a dedicated external image-host
repository (or a fork/scratch repo), with unset or self-targeting
values deliberately degrading to prose and local artifact paths.

Co-authored-by: Qwen-Coder <qwen-coder @alibabacloud.com>

* test: execute the visuals hosting block against a stub uploader

The workflow-level assertions covered the OSS upload path with shape
regexes only, so a --prefix that drifts away from RAW_BASE (every
preview URL 404s) or a dropped --config line (the uploader hard-fails
before any comment posts) both left the suite green. Extract the
hosting block and run it against a stub uploader that mirrors the real
flag contract and records the upload destination, in the same pattern
as the verify path in scripts/tests/qwen-triage-workflow.test.js: the
images arm must land the staged files under
$STUB_ROOT/$ALIYUN_OSS_BUCKET/pr-assets/web-shell-visuals/<pr>/<sha>
with the exact --bucket/--config/--prefix wiring, and RAW_BASE must
equal that layout; the no-change arm must never invoke the uploader.

Co-authored-by: Qwen-Coder <qwen-coder @alibabacloud.com>

* fix(ci): gate ossutil consumers on verified install

Prevent the persistent publish runner from executing a stale binary under RUNNER_TEMP when the best-effort install step fails. Pin both the install outcome gate and the hardened PATH invocation in focused workflow tests.

Co-authored-by: Qwen-Coder <qwen-coder @alibabacloud.com>

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

* fix(ci): grant publisher checkout access

Co-authored-by: Qwen-Coder <qwen-coder @alibabacloud.com>

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

* test(ci): pin OSS publisher failure paths

Co-authored-by: Qwen-Coder <qwen-coder @alibabacloud.com>

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

* test(ci): exercise production OSS dispatch

Co-authored-by: Qwen-Coder <qwen-coder @alibabacloud.com>

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

* fix(ci): isolate OSS publisher on hosted runner

* fix(ci): sync size baseline and stale runner comments

Co-authored-by: Qwen-Coder <qwen-coder @alibabacloud.com>

* fix(ci): give each visuals publish a fresh OSS prefix

The web-shell prefix was pr-assets/web-shell-visuals/<pr>/<head-sha>, so a
re-run for the same head wrote back over the object keys the already-posted
comment referenced. GitHub serves comment images through a caching proxy, so
reviewers would keep seeing the previous run's screenshots at a URL whose
bytes had changed. The Git-backed design got cache-busting free from the
per-run commit SHA; add the run id back to restore it (the verify lane
already carries <run-id>-<attempt>).

Also:

- Resolve the destination as ALIYUN_OSS_PR_ASSETS_BUCKET -> ALIYUN_OSS_BUCKET
  -> qwen-code-assets in both publishers, with the public base URL derived
  from whichever wins. Unset, this is byte-for-byte today's behaviour; set,
  it moves untrusted PR-derived evidence off the bucket that also serves
  release, desktop, and live-host downloads without a workflow change.
- Sparse-check out /package.json alongside the uploader: it is ESM in a .js
  file, and package.json declares engines >= 22.0 while neither publisher
  pins a Node, so without the "type": "module" marker the uploader only
  parses on the Node versions that infer module syntax.
- Require the credential file, not just a green install outcome, before
  dispatching the /verify upload. The configure step is continue-on-error,
  so a missing or rotated secret otherwise cost three retry backoffs per
  image before degrading to the same text-only report.
- Drop the stale "runs on the persistent ECS pool" premise from the
  clear-stale-downloads step; publish-verify is on ubuntu-latest now.

* fix(ci): retry the ossutil download in both PR publishers

The release syncs download ossutil with no retry, which is fine there: a
red run is rerun by hand. Here the consequence differs. A CDN blip in the
web-shell publisher leaves no ossutil for the upload, and since the upload
aborts the step under set -e, the PR loses its preview comment entirely
rather than degrading; in the verify publisher it silently costs the report
its evidence images. Three retries with --retry-all-errors covers the
transient class without changing any success path.

Also reword the unset-QWEN_REVIEW_ASSETS_REPO refusal. It ended with a flat
claim about the PR-review workflow blanking a self-targeting designation,
which reads as a non sequitur to the far more common reader: someone running
the CLI locally who simply never set the variable. Keep the diagnostic — a
maintainer whose repository variable IS set needs it — but scope it to CI
and say why self-targeting is discouraged in the first place.

* fix(ci): include run attempt in the web-shell OSS asset prefix

The workflow_run id is stable across re-run attempts, so a maintainer
re-run of the visuals publish overwrote the exact object keys an
already-posted comment references (camo keeps rendering attempt-1
shots). Append run_attempt to the prefix, mirroring the verify lane,
and cover same-runId attempt-1-vs-attempt-2 in the hosting harness.

Co-authored-by: Qwen-Coder <qwen-coder @alibabacloud.com>

* fix(ci): close out PR evidence hosting review findings

- The web-shell visuals prefix gains the run attempt: a re-run keeps
  the run id and only increments the attempt, so the old prefix let a
  same-head re-run write back over the object keys an already-posted
  comment references (camo keeps serving the stale screenshots).
  Mirrors the verify lane <run-id>-<attempt>; the re-run test now
  models same-run attempt 1 vs 2, not just distinct run ids.
- The QWEN_REVIEW_ASSETS_REPO self-targeting guard now trims and
  compares case-insensitively before the CLI reads the value, so
  padded or case-shifted self-references degrade like an unset one.
- Both publisher checkouts pin ref: github.sha (the default-branch
  head on their events) instead of relying on default resolution.
- The ossutil download retry budget fits the 10-minute job cap in
  both publishers (~6.1 min worst case; was ~20 min).
- Upload attempts are bounded via OSS_UPLOAD_ATTEMPT_TIMEOUT_MS so a
  stalled ossutil degrades to the text-only report / re-triggerable
  publish instead of burning the job cap; release syncs stay
  unbounded (the knob is opt-in).
- Fix the serving-model comment (the extension allowlist, not the
  magic bytes, is what pins the served Content-Type) and pin what
  earlier suites left unpinned: sparse-checkout lists, bucket-to-URL
  derivation, curl retry flags, persist-credentials, and the ossutil
  isolation lines. Both uploader stubs now require the --config file
  to exist, so config drift turns the harnesses red.
- runHostingBlock tears down its mkdtemp fixture instead of leaking
  it on every call.

# Conflicts:
#	.github/scripts/web-shell-visuals-publish.test.mjs
#	.github/workflows/web-shell-visuals-publish.yml
#	docs/design/2026-08-25-pr-evidence-oss-hosting.md

* fix(ci): gate visuals-publish Configure step on ossutil install outcome

The Install ossutil step keeps continue-on-error: true so a failed install must not block the no-image marker-comment path, but the unguarded Configure Aliyun OSS credentials step still ran and failed confusingly on the missing binary. Give the install step the install-ossutil id and gate Configure on its success outcome, matching the qwen-triage.yml twins.

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

---------

Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: Qwen-Coder <qwen-coder @alibabacloud.com>
Co-authored-by: Shaojin Wen <[email protected]>
2026-08-29 04:36:57 +00:00

368 lines
20 KiB
YAML

name: 'Web-shell Visuals Publish'
# Privileged companion to `web-shell-visuals.yml`. It runs AFTER that workflow
# via workflow_run, so it executes in the base-repo context with a write token
# but NEVER checks out or runs PR code — it only downloads the image artifact
# (opaque bytes), hosts it on Aliyun OSS, and posts an inline
# comment. This is the GitHub-recommended split for commenting on fork PRs with
# the results of untrusted-code execution.
on:
workflow_run:
workflows:
- 'Web-shell Visuals'
types:
- 'completed'
# GITHUB_TOKEN only needs to read the triggering run's artifact. The PR comment
# is posted with CI_BOT_PAT; OSS uses its own repository secrets.
permissions:
actions: 'read'
# Serialize publishes for the SAME PR (identified by its source repo + branch)
# so an older run cannot replace a newer run's marker comment. Different
# PRs — including forks that happen to share a branch name like `main` — get
# distinct groups and publish in parallel. Never cancel an in-flight publish.
concurrency:
group: >-
web-shell-visuals-publish-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: false
defaults:
run:
shell: 'bash'
jobs:
publish:
name: 'Publish web-shell visuals to the PR'
if: >-
github.repository == 'QwenLM/qwen-code' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'
runs-on: 'ubuntu-latest'
timeout-minutes: 10
steps:
# Trusted base-repo scripts. workflow_run checks out the default branch,
# never PR code (the PR head is github.event.workflow_run.head_sha,
# consumed below as RUN_HEAD_SHA — never as a checkout ref). Sparse —
# the publisher, its helper dependency, and the root package.json that
# marks the uploader's `.js` as ESM (without it the uploader only
# parses on the Node versions that infer module syntax, and this job
# pins no Node).
- name: 'Checkout the publish scripts'
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
# Pin the trusted ref explicitly: on workflow_run events
# github.sha is the default-branch head this workflow runs from,
# so the pin makes "never PR code" a property of this step
# instead of an inference from the event's default resolution.
ref: '${{ github.sha }}'
sparse-checkout: |-
/package.json
.github/scripts/web-shell-visuals-publish.mjs
scripts/upload-aliyun-oss-assets.js
scripts/release-script-utils.js
sparse-checkout-cone-mode: false
persist-credentials: false
- name: 'Install ossutil'
id: 'install-ossutil'
# Evidence hosting is best-effort: a setup failure must not block the
# no-image marker-comment path, which has no external dependency
# (mirrors the qwen-triage.yml twins). The upload below stays loud:
# with images present it still aborts the publish step.
continue-on-error: true
env:
OSSUTIL_URL: "${{ vars.OSSUTIL_URL || 'https://gosspublic.alicdn.com/ossutil/1.7.19/ossutil-v1.7.19-linux-amd64.zip' }}"
OSSUTIL_SHA256: "${{ vars.OSSUTIL_SHA256 || 'dcc512e4a893e16bbee63bc769339d8e56b21744fd83c8212a9d8baf28767343' }}"
run: |-
set -euo pipefail
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
# Retry, unlike the release syncs: a red run there is simply rerun,
# but a CDN blip here costs this PR its preview comment (the
# upload aborts the step) or its evidence images. The worst-case
# budget stays inside the 10-minute job cap: 3 attempts x 120s
# max-time + 2 x 2s delays ≈ 6.1 minutes (an unreachable mirror
# fails the connect-timeout long before that).
curl -fsSL --retry 2 --retry-delay 2 --retry-all-errors \
--connect-timeout 10 --max-time 120 "$OSSUTIL_URL" -o "$tmp_dir/ossutil.zip"
echo "$OSSUTIL_SHA256 $tmp_dir/ossutil.zip" | sha256sum -c -
unzip -q "$tmp_dir/ossutil.zip" -d "$tmp_dir"
ossutil_path="$(find "$tmp_dir" -type f \( -name 'ossutil' -o -name 'ossutil64' \) -print -quit)"
[ -n "$ossutil_path" ] || { echo '::error::ossutil binary not found'; exit 1; }
chmod +x "$ossutil_path"
install -m 0755 "$ossutil_path" "$RUNNER_TEMP/ossutil"
"$RUNNER_TEMP/ossutil" >/dev/null
- name: 'Configure Aliyun OSS credentials'
if: "${{ steps.install-ossutil.outcome == 'success' }}"
continue-on-error: true
env:
ALIYUN_OSS_ACCESS_KEY_ID: '${{ secrets.ALIYUN_OSS_ACCESS_KEY_ID }}'
ALIYUN_OSS_ACCESS_KEY_SECRET: '${{ secrets.ALIYUN_OSS_ACCESS_KEY_SECRET }}'
ALIYUN_OSS_ENDPOINT: "${{ vars.ALIYUN_OSS_ENDPOINT || 'https://oss-cn-hangzhou.aliyuncs.com' }}"
run: |-
set -euo pipefail
if [ -z "$ALIYUN_OSS_ACCESS_KEY_ID" ] || [ -z "$ALIYUN_OSS_ACCESS_KEY_SECRET" ]; then
echo '::error::Missing Aliyun OSS credentials.'
exit 1
fi
"$RUNNER_TEMP/ossutil" config -e "$ALIYUN_OSS_ENDPOINT" -i "$ALIYUN_OSS_ACCESS_KEY_ID" -k "$ALIYUN_OSS_ACCESS_KEY_SECRET" -L EN -c "$RUNNER_TEMP/.ossutilconfig"
- name: 'Download visuals artifact'
id: 'download'
continue-on-error: true
uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v5.0.0
with:
name: 'web-shell-visuals'
run-id: '${{ github.event.workflow_run.id }}'
github-token: '${{ secrets.GITHUB_TOKEN }}'
path: 'visuals'
- name: 'Publish visuals to the PR'
env:
GH_TOKEN: '${{ secrets.CI_BOT_PAT }}'
RUN_ID: '${{ github.event.workflow_run.id }}'
# The run id is stable across re-run attempts — only the attempt
# number increments — so it alone cannot keep a re-run off the
# previous attempt's object keys in the OSS prefix below.
RUN_ATTEMPT: '${{ github.event.workflow_run.run_attempt }}'
RUN_URL: '${{ github.event.workflow_run.html_url }}'
# Authenticated (NOT artifact-sourced) head SHA of the run that
# triggered this publish — used to bind the artifact to its real PR.
RUN_HEAD_SHA: '${{ github.event.workflow_run.head_sha }}'
# Authenticated head repo + branch. A (repo, branch) pair maps to at
# most one open PR, so binding on it rejects a sibling PR that merely
# shares the same head commit SHA.
RUN_HEAD_REPO: '${{ github.event.workflow_run.head_repository.full_name }}'
RUN_HEAD_BRANCH: '${{ github.event.workflow_run.head_branch }}'
# PR evidence is untrusted, PR-derived content, so it gets its own
# bucket knob: set ALIYUN_OSS_PR_ASSETS_BUCKET to move it off the
# bucket that also serves release, desktop, and live-host downloads.
# Unset, it falls back to the shared bucket — today's behaviour.
# The default public URL is derived from the resolved bucket so that
# overriding only one of the two vars cannot post comment links
# that 404 against (or show stale objects from) the other bucket.
# A cross-region ALIYUN_OSS_ENDPOINT override must also override
# ALIYUN_OSS_PUBLIC_BASE_URL because the default host pins Hangzhou.
ALIYUN_OSS_BUCKET: "${{ vars.ALIYUN_OSS_PR_ASSETS_BUCKET || vars.ALIYUN_OSS_BUCKET || 'qwen-code-assets' }}"
ALIYUN_OSS_PUBLIC_BASE_URL: "${{ vars.ALIYUN_OSS_PR_ASSETS_PUBLIC_BASE_URL || (vars.ALIYUN_OSS_PR_ASSETS_BUCKET == '' && vars.ALIYUN_OSS_PUBLIC_BASE_URL) || format('https://{0}.oss-cn-hangzhou.aliyuncs.com', vars.ALIYUN_OSS_PR_ASSETS_BUCKET || vars.ALIYUN_OSS_BUCKET || 'qwen-code-assets') }}"
# A stalled upload must not burn this job's 10-minute cap: bound
# every ossutil attempt so the uploader exhausts its retries (and
# aborts this publish, which stays re-triggerable) instead of
# hanging one black-hole socket until the job dies mid-upload.
# Worst case is one image's 3 attempts x 120s + backoffs ≈ 6.1 min.
OSS_UPLOAD_ATTEMPT_TIMEOUT_MS: '120000'
run: |-
set -euo pipefail
ART='visuals'
if [ ! -d "${ART}" ]; then
echo "::notice::No visuals artifact was downloaded; nothing to publish."
exit 0
fi
# --- Validate + bind the untrusted PR number ----------------------
# pr.txt is written by a job that ran PR code, so it is UNTRUSTED: a
# malicious PR could put a *victim* PR number here. Sanitize it, then
# BIND it to this run — require the PR's current head SHA to equal the
# authenticated head SHA of the run that produced the artifact. A
# victim PR's head won't match, so the misdirection is rejected.
PR="$(tr -dc '0-9' < "${ART}/pr.txt" 2>/dev/null | head -c 12 || true)"
if [ -z "${PR}" ]; then
echo "::warning::Artifact has no valid PR number; aborting."
exit 0
fi
# Authenticated bot identity — the dedup lookup filters on this so a
# participant who posts the marker can't hijack or redirect the update.
BOT_LOGIN="$(gh api user --jq '.login' 2>/dev/null || true)"
if [ -z "${BOT_LOGIN}" ]; then
echo "::warning::Could not resolve the bot identity; skipping to avoid mis-targeting a comment."
exit 0
fi
# Re-runnable gate: PR must be open AND its current head must equal this
# run's authenticated head SHA. This binds the untrusted artifact PR
# number to the real run, and is re-checked right before the comment
# write to close the validate->write TOCTOU window (a push/close during
# asset upload must not let a stale run post).
# Returns 0 = valid, 1 = genuinely invalid (closed / head mismatch),
# 2 = TRANSIENT API failure (empty after retries) — distinguished so a
# blip fails loudly (re-triggerable) instead of silently skipping.
validate_pr() {
local j st hd rr rf attempt
j=''
for attempt in 1 2 3; do
j="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR}" 2>/dev/null || true)"
[ -n "${j}" ] && break
sleep 2
done
[ -n "${j}" ] || { echo "::warning::Could not fetch PR #${PR} after retries."; return 2; }
st="$(jq -r '.state // empty' <<< "${j}")"
hd="$(jq -r '.head.sha // empty' <<< "${j}")"
rr="$(jq -r '.head.repo.full_name // empty' <<< "${j}")"
rf="$(jq -r '.head.ref // empty' <<< "${j}")"
[ "${st}" = "open" ] || { echo "::notice::PR #${PR} not open (state=${st:-unknown})."; return 1; }
{ [ -n "${RUN_HEAD_SHA}" ] && [ "${hd}" = "${RUN_HEAD_SHA}" ]; } || {
echo "::warning::PR #${PR} head (${hd:-none}) != run head (${RUN_HEAD_SHA:-none}); refusing."
return 1
}
# Also bind repo+branch: two open PRs can share a head SHA (same
# commit on different branches) but not the same (repo, branch).
{ [ -n "${RUN_HEAD_REPO}" ] && [ "${rr}" = "${RUN_HEAD_REPO}" ] && [ "${rf}" = "${RUN_HEAD_BRANCH}" ]; } || {
echo "::warning::PR #${PR} head ${rr:-none}#${rf:-none} != run ${RUN_HEAD_REPO:-none}#${RUN_HEAD_BRANCH:-none}; refusing."
return 1
}
return 0
}
# Gate: proceed when valid; a genuine invalid state SKIPS quietly, but a
# transient failure EXITS 1 so the publish surfaces as re-triggerable.
gate() {
local rc
if validate_pr; then rc=0; else rc=$?; fi
[ "${rc}" -eq 0 ] && return 0
if [ "${rc}" -eq 2 ]; then
echo "::error::Transient API failure validating PR #${PR}; failing so the publish can be re-triggered."
exit 1
fi
echo "::notice::PR #${PR} no longer valid for publishing; skipping."
exit 0
}
gate
SHORT_SHA="${RUN_HEAD_SHA:0:7}"
# --- Stage + validate the images ----------------------------------
# Delegated to the unit-tested script: magic-byte validation, filename
# sanitization, and the examined/accepted/size caps over untrusted
# artifact content live in web-shell-visuals-publish.mjs (covered by
# web-shell-visuals-publish.test.mjs). It prints the accepted count.
STAGE="${RUNNER_TEMP}/visuals-stage"
rm -rf "${STAGE}"
count="$(node .github/scripts/web-shell-visuals-publish.mjs stage "${ART}/screenshots" "${ART}/gifs" "${STAGE}")"
# A zero count is a MEANINGFUL "no visual change" state, not "nothing
# to do": still update the marker comment (so a prior preview's stale
# images + SHA don't linger — the comment promises to refresh on every
# push), just without hosting a new asset commit.
if [ -z "${count}" ] || [ "${count}" = "0" ]; then
echo "::notice::No changed screenshots; updating the marker with a no-change note (no asset commit)."
HAS_IMAGES=0
else
echo "Staged ${count} image(s) for PR #${PR}."
HAS_IMAGES=1
fi
# Re-validate right before the upload too: a close/new-head during
# download+staging must not publish a stale snapshot.
gate
# --- Host on Aliyun OSS -------------------------------------------
# Head SHA, run id *and* run attempt, so every publish writes a
# prefix nothing has ever served before. The run id is not
# decoration: GitHub renders comment images through its camo proxy,
# which caches by URL, so re-running a preview for the SAME head
# onto the same object keys would leave reviewers looking at the
# previous run's screenshots. The run id alone is not enough either:
# it is stable across re-run attempts (only run_attempt increments),
# so the attempt segment keeps a maintainer re-run off the exact
# keys the previous attempt's comment already references. The old
# design got this free from the per-run commit SHA in the raw URL;
# the verify lane keeps it via <run-id>-<attempt>.
# Host the images ONLY when there are changed views. A no-change run
# skips the upload entirely; RAW_BASE stays empty and the
# comment below is image-less ("no screenshot changes").
RAW_BASE=''
if [ "${HAS_IMAGES}" = "1" ]; then
ASSET_PREFIX="pr-assets/web-shell-visuals/${PR}/${RUN_HEAD_SHA}/${RUN_ID}/${RUN_ATTEMPT}"
# Same PATH discipline as qwen-triage.yml's publish-verify: never
# resolve node or ossutil through the shared $RUNNER_TEMP. node
# is resolved under the inherited PATH and ossutil runs from a
# fresh job-private copy of the sha256-verified binary installed
# above. Not exploitable on today's ephemeral runners, but the
# shape must not regress if this job ever moves to a persistent
# pool.
node_bin="$(command -v node)"
trusted_bin="$(mktemp -d)"
install -m 0755 "${RUNNER_TEMP}/ossutil" "$trusted_bin/ossutil"
PATH="$trusted_bin" "$node_bin" scripts/upload-aliyun-oss-assets.js \
--bucket "${ALIYUN_OSS_BUCKET}" \
--config "${RUNNER_TEMP}/.ossutilconfig" \
--prefix "${ASSET_PREFIX}" \
"${STAGE}"/*
rm -rf "$trusted_bin"
RAW_BASE="${ALIYUN_OSS_PUBLIC_BASE_URL%/}/${ASSET_PREFIX}"
echo "Web-shell visuals hosted at ${RAW_BASE}."
fi
# --- Changed paths, for triaging an EMPTY preview ------------------
# "No view changed" means either "no visual effect" or "no scenario
# renders this UI"; the script tells those apart by looking at which
# files the PR touched (see selectRenderShapingFiles). Read here rather
# than in the render job because that one runs untrusted PR code.
# Best-effort: on API failure the list stays empty and the comment
# falls back to the plain "no screenshot changes" line.
CHANGED_PATHS_FILE="${RUNNER_TEMP}/visuals-changed-paths.txt"
: > "${CHANGED_PATHS_FILE}"
gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR}/files" \
--paginate --jq '.[].filename' > "${CHANGED_PATHS_FILE}" 2>/dev/null \
|| echo "::warning::Could not list changed files for PR #${PR}; the preview will not flag coverage gaps."
# --- Render status, to keep an empty/partial preview honest --------
# 'failure' when >=1 scenario failed to render on the PR head (the
# render job's continue-on-error keeps the passing shots but the whole
# preview would otherwise read as a clean "no changes"). Absent/empty ⇒
# treat as complete (older runs, or the file was pruned), so this only
# ever ADDS a warning, never suppresses an existing preview.
RENDER_STATUS_FILE="${ART}/render-status.txt"
[ -f "${RENDER_STATUS_FILE}" ] || RENDER_STATUS_FILE=''
# --- Build the comment body ---------------------------------------
# Delegated to the same unit-tested script (light/dark pairing, flow
# labels, and HTML escaping live in web-shell-visuals-publish.mjs).
BODY_FILE="${RUNNER_TEMP}/visuals-comment.md"
node .github/scripts/web-shell-visuals-publish.mjs comment \
"${STAGE}" \
"${RAW_BASE}" \
"${SHORT_SHA}" "${RUN_URL}" "${BODY_FILE}" \
"${CHANGED_PATHS_FILE}" \
"${RENDER_STATUS_FILE}"
# --- Post or update the PR comment --------------------------------
# Dedup only against OUR OWN prior comment (bot author + marker), and
# only after a SUCCESSFUL listing — a failed/partial list must not read
# as "no existing comment" (that would POST a duplicate on every failure).
MARKER='<!-- qwen:web-shell-visuals -->'
EXISTING=''
listed=0
for attempt in 1 2 3; do
if resp="$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" --method GET --paginate -F per_page=100 2>/dev/null)"; then
EXISTING="$(jq -sr --arg m "${MARKER}" --arg u "${BOT_LOGIN}" \
'[.[][] | select((.user.login == $u) and (.body | contains($m)))] | last | .id // empty' <<< "${resp}")"
listed=1
break
fi
echo "::notice::comment lookup attempt ${attempt} failed; retrying."
sleep 2
done
if [ "${listed}" -ne 1 ]; then
echo "::error::Could not list PR comments after retries; not posting (avoids duplicates)."
exit 1
fi
# Final TOCTOU gate: re-validate (open + head SHA + repo/branch) AFTER
# the paginated/retried lookup, immediately before the write — a
# close or new head during validation/lookup must not post a stale one.
gate
if [ -n "${EXISTING}" ]; then
gh api -X PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${EXISTING}" -F body=@"${BODY_FILE}" >/dev/null
echo "Updated existing web-shell visuals comment on PR #${PR}."
else
gh api "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" -F body=@"${BODY_FILE}" >/dev/null
echo "Posted web-shell visuals comment on PR #${PR}."
fi
- name: 'Cleanup Aliyun OSS credentials'
if: '${{ always() }}'
run: 'rm -f "$RUNNER_TEMP/.ossutilconfig"'