mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
* 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]>
368 lines
20 KiB
YAML
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"'
|