mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
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]>
This commit is contained in:
15 files changed
+1417
-231
No files matched your search
@@ -5,6 +5,18 @@
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
@@ -22,6 +34,411 @@ import {
|
||||
const PNG = '89504e470d0a1a0a';
|
||||
const GIF89 = '474946383961';
|
||||
const GIF87 = '474946383761';
|
||||
const workflow = readFileSync(
|
||||
'.github/workflows/web-shell-visuals-publish.yml',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
test('workflow hosts visuals on OSS without writing Git refs', () => {
|
||||
assert.match(workflow, /scripts\/upload-aliyun-oss-assets\.js/);
|
||||
// Head SHA, run id AND run attempt: GitHub's camo proxy caches comment
|
||||
// images by URL, so a re-run for the same head must not write back over
|
||||
// the object the previous comment already published — and the run id is
|
||||
// stable across re-run attempts, so the attempt must be in the key too.
|
||||
assert.match(
|
||||
workflow,
|
||||
/pr-assets\/web-shell-visuals\/\$\{PR\}\/\$\{RUN_HEAD_SHA\}\/\$\{RUN_ID\}\/\$\{RUN_ATTEMPT\}/,
|
||||
);
|
||||
assert.match(workflow, /ALIYUN_OSS_PUBLIC_BASE_URL/);
|
||||
assert.match(
|
||||
workflow,
|
||||
/RUN_ATTEMPT: '\$\{\{ github\.event\.workflow_run\.run_attempt \}\}'/,
|
||||
);
|
||||
assert.match(workflow, /PATH="\$trusted_bin" "\$node_bin"/);
|
||||
// ossutil must run from a fresh job-private dir, never $RUNNER_TEMP: a
|
||||
// trusted_bin=$RUNNER_TEMP mutant would re-resolve the binary through the
|
||||
// shared dir on every call and survive the PATH assertion above.
|
||||
assert.match(workflow, /trusted_bin="\$\(mktemp -d\)"/);
|
||||
assert.match(
|
||||
workflow,
|
||||
/install -m 0755 "\$\{RUNNER_TEMP\}\/ossutil" "\$trusted_bin\/ossutil"/,
|
||||
);
|
||||
assert.doesNotMatch(workflow, /PATH="\$RUNNER_TEMP:\$PATH"/);
|
||||
assert.doesNotMatch(workflow, /git push/);
|
||||
assert.doesNotMatch(workflow, /checkout -q --orphan/);
|
||||
});
|
||||
|
||||
test('workflow checks out only trusted base-repo scripts', () => {
|
||||
// The job carries OSS credentials. Its checkout must pin the trusted ref
|
||||
// (on workflow_run events github.sha is the default-branch head, never
|
||||
// the PR head) and take only the publisher files — dropping /package.json
|
||||
// makes the uploader's `.js` stop parsing as ESM on Node versions that
|
||||
// don't infer module syntax, and this job pins no Node.
|
||||
const checkout =
|
||||
workflow.split("'Checkout the publish scripts'")[1]?.split('- name:')[0] ??
|
||||
'';
|
||||
assert.ok(checkout, 'checkout step not found');
|
||||
assert.match(checkout, /ref: '\$\{\{ github\.sha \}\}'/);
|
||||
assert.match(checkout, /persist-credentials: false/);
|
||||
assert.match(checkout, /sparse-checkout-cone-mode: false/);
|
||||
for (const entry of [
|
||||
'/package.json',
|
||||
'.github/scripts/web-shell-visuals-publish.mjs',
|
||||
'scripts/upload-aliyun-oss-assets.js',
|
||||
'scripts/release-script-utils.js',
|
||||
]) {
|
||||
assert.ok(
|
||||
checkout.includes(`\n ${entry}\n`),
|
||||
`sparse-checkout entry missing: ${entry}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('workflow derives the public URL from the resolved bucket', () => {
|
||||
// Overriding only one of the bucket/base-URL vars must not post comment
|
||||
// links that 404 against (or show stale objects from) the other bucket:
|
||||
// the default base URL is derived from whichever bucket wins.
|
||||
const publish =
|
||||
workflow.split("'Publish visuals to the PR'")[1]?.split('run: |-')[0] ?? '';
|
||||
assert.ok(publish, 'publish step env not found');
|
||||
assert.match(
|
||||
publish,
|
||||
/ALIYUN_OSS_BUCKET: "\$\{\{ vars\.ALIYUN_OSS_PR_ASSETS_BUCKET \|\| vars\.ALIYUN_OSS_BUCKET \|\| 'qwen-code-assets' \}\}"/,
|
||||
);
|
||||
assert.match(
|
||||
publish,
|
||||
/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 is bounded per attempt so one black-hole socket cannot
|
||||
// burn the whole 10-minute job cap and strand the preview comment.
|
||||
assert.match(publish, /OSS_UPLOAD_ATTEMPT_TIMEOUT_MS: '120000'/);
|
||||
});
|
||||
|
||||
test('workflow keeps the ossutil download retry budget inside the job cap', () => {
|
||||
// A CDN retry loop whose worst case exceeds the 10-minute job cap turns
|
||||
// the intended "degrade to the no-image path" into "job killed mid-install".
|
||||
const install =
|
||||
workflow.split("'Install ossutil'")[1]?.split('- name:')[0] ?? '';
|
||||
const m = install.match(
|
||||
/curl -fsSL --retry (\d+) --retry-delay (\d+) --retry-all-errors \\\n\s+--connect-timeout (\d+) --max-time (\d+)/,
|
||||
);
|
||||
assert.ok(m, 'curl retry flags not found in the install step');
|
||||
const worstSeconds =
|
||||
(Number(m[1]) + 1) * Number(m[4]) + Number(m[1]) * Number(m[2]);
|
||||
assert.ok(
|
||||
worstSeconds < 10 * 60,
|
||||
`worst-case download budget ${worstSeconds}s exceeds the 10-minute job cap`,
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow pins the ossutil credential lifecycle (sha256 install, config, always() cleanup)', () => {
|
||||
// The sha256-verified install and the credential config it feeds are
|
||||
// best-effort here (a setup failure must not block the no-image path)...
|
||||
const install =
|
||||
workflow.split("'Install ossutil'")[1]?.split('- name:')[0] ?? '';
|
||||
assert.match(install, /continue-on-error: true/);
|
||||
assert.match(install, /sha256sum -c/);
|
||||
const configure =
|
||||
workflow
|
||||
.split("'Configure Aliyun OSS credentials'")[1]
|
||||
?.split('- name:')[0] ?? '';
|
||||
assert.match(configure, /continue-on-error: true/);
|
||||
assert.match(configure, /-c "\$RUNNER_TEMP\/\.ossutilconfig"/);
|
||||
// ...but the cleanup must run even on failure, or the OSS key pair
|
||||
// lingers in $RUNNER_TEMP after the job.
|
||||
const cleanup = workflow.split("'Cleanup Aliyun OSS credentials'")[1] ?? '';
|
||||
assert.match(cleanup, /if: '\$\{\{ always\(\) \}\}'/);
|
||||
assert.match(cleanup, /rm -f "\$RUNNER_TEMP\/\.ossutilconfig"/);
|
||||
});
|
||||
|
||||
// --- Behavioural coverage for the OSS hosting block -----------------------
|
||||
// The shape regexes above pin the hosting contract as text; this harness
|
||||
// EXECUTES the extracted hosting block against a stub uploader that mirrors
|
||||
// scripts/upload-aliyun-oss-assets.js's flag contract (readOptionValue
|
||||
// rejects a valueless --bucket/--config/--prefix; at least one asset is
|
||||
// required) and records where the objects land — the same stub-uploader
|
||||
// pattern as the verify path in scripts/tests/qwen-triage-workflow.test.js.
|
||||
// A --prefix that drifts away from RAW_BASE (every preview URL 404s) or a
|
||||
// dropped --config (the real uploader dies before any comment posts) fails
|
||||
// here, where the shape regexes stay green.
|
||||
|
||||
function hostingBlockSource() {
|
||||
const raw = workflow
|
||||
.split('# --- Host on Aliyun OSS')[1]
|
||||
?.split('\n')
|
||||
.slice(1) // drop the marker line's trailing dashes — not a comment.
|
||||
.join('\n')
|
||||
.split('# --- Changed paths')[0]
|
||||
.replace(/^ {10}/gm, ''); // the step run block's indentation.
|
||||
assert.ok(raw, 'hosting block markers not found in the publish workflow');
|
||||
return raw;
|
||||
}
|
||||
|
||||
function listFiles(root) {
|
||||
if (!existsSync(root)) {
|
||||
return [];
|
||||
}
|
||||
const out = [];
|
||||
const walk = (dir, prefix) => {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
walk(join(dir, entry.name), rel);
|
||||
} else {
|
||||
out.push(rel);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(root, '');
|
||||
return out;
|
||||
}
|
||||
|
||||
function runHostingBlock(
|
||||
hasImages,
|
||||
{
|
||||
publicBaseUrl = 'https://assets.example.test',
|
||||
uploadFails = false,
|
||||
runId = '900001',
|
||||
runAttempt = '1',
|
||||
} = {},
|
||||
) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'visuals-hosting-'));
|
||||
try {
|
||||
return runHostingBlockIn(dir, hasImages, {
|
||||
publicBaseUrl,
|
||||
uploadFails,
|
||||
runId,
|
||||
runAttempt,
|
||||
});
|
||||
} finally {
|
||||
// The fixture used to leak a mkdtemp dir per call; capture everything
|
||||
// the assertions need inside, then tear it down.
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function runHostingBlockIn(
|
||||
dir,
|
||||
hasImages,
|
||||
{ publicBaseUrl, uploadFails, runId, runAttempt },
|
||||
) {
|
||||
const runnerTemp = join(dir, 'runner-temp');
|
||||
const stage = join(dir, 'stage');
|
||||
const work = join(dir, 'work');
|
||||
const stubRoot = join(dir, 'oss');
|
||||
const recordPath = join(dir, 'upload-record.json');
|
||||
mkdirSync(runnerTemp, { recursive: true });
|
||||
mkdirSync(stage, { recursive: true });
|
||||
mkdirSync(join(work, 'scripts'), { recursive: true });
|
||||
// The block installs its job-private ossutil copy from here; the stub
|
||||
// uploader never executes it, but the install must succeed.
|
||||
writeFileSync(join(runnerTemp, 'ossutil'), 'fake ossutil\n');
|
||||
writeFileSync(join(stage, 'home-light.png'), 'png-bytes');
|
||||
writeFileSync(join(stage, 'model-switch.gif'), 'gif-bytes');
|
||||
// Stub uploader: enforces the real flag contract, then copies the objects
|
||||
// under $OSS_STUB_ROOT/<bucket>/<prefix> and records the flags it saw.
|
||||
writeFileSync(
|
||||
join(work, 'scripts', 'upload-aliyun-oss-assets.js'),
|
||||
[
|
||||
"'use strict';",
|
||||
"const { copyFileSync, mkdirSync, writeFileSync } = require('node:fs');",
|
||||
"const { basename, join } = require('node:path');",
|
||||
"if (process.env.OSS_STUB_FAIL === '1') process.exit(1);",
|
||||
"const opts = { bucket: '', config: '', prefix: '' };",
|
||||
'const assets = [];',
|
||||
'const argv = process.argv.slice(2);',
|
||||
'for (let i = 0; i < argv.length; i += 1) {',
|
||||
' const arg = argv[i];',
|
||||
" if (arg === '--bucket' || arg === '--config' || arg === '--prefix') {",
|
||||
' opts[arg.slice(2)] = argv[i + 1] ?? "";',
|
||||
' i += 1;',
|
||||
' } else {',
|
||||
' assets.push(arg);',
|
||||
' }',
|
||||
'}',
|
||||
'for (const [name, value] of Object.entries(opts)) {',
|
||||
' if (!value || value.startsWith("-")) {',
|
||||
' console.error(`upload-assets stub: --${name} requires a value`);',
|
||||
' process.exit(1);',
|
||||
' }',
|
||||
'}',
|
||||
'if (assets.length === 0) {',
|
||||
' console.error("upload-assets stub: at least one ASSET path is required");',
|
||||
' process.exit(1);',
|
||||
'}',
|
||||
'const target = join(process.env.OSS_STUB_ROOT, opts.bucket, opts.prefix);',
|
||||
'mkdirSync(target, { recursive: true });',
|
||||
'for (const file of assets) {',
|
||||
' copyFileSync(file, join(target, basename(file)));',
|
||||
'}',
|
||||
'writeFileSync(process.env.OSS_STUB_RECORD, JSON.stringify(opts));',
|
||||
].join('\n'),
|
||||
);
|
||||
const pr = '4242';
|
||||
const headSha = 'cafe4242cafe4242cafe4242cafe4242cafe4242';
|
||||
const script = [
|
||||
'set -euo pipefail',
|
||||
`HAS_IMAGES=${hasImages ? 1 : 0}`,
|
||||
`PR=${pr}`,
|
||||
`RUN_HEAD_SHA=${headSha}`,
|
||||
`RUN_ID=${runId}`,
|
||||
`RUN_ATTEMPT=${runAttempt}`,
|
||||
`STAGE=${JSON.stringify(stage)}`,
|
||||
'ALIYUN_OSS_BUCKET=assets-bucket',
|
||||
`ALIYUN_OSS_PUBLIC_BASE_URL=${JSON.stringify(publicBaseUrl)}`,
|
||||
`RUNNER_TEMP=${JSON.stringify(runnerTemp)}`,
|
||||
hostingBlockSource(),
|
||||
'printf \'RAW_BASE=%s\\n\' "$RAW_BASE"',
|
||||
].join('\n');
|
||||
const driverPath = join(dir, 'driver.sh');
|
||||
writeFileSync(driverPath, script);
|
||||
const res = spawnSync('bash', [driverPath], {
|
||||
encoding: 'utf8',
|
||||
cwd: work,
|
||||
env: {
|
||||
...process.env,
|
||||
OSS_STUB_ROOT: stubRoot,
|
||||
OSS_STUB_RECORD: recordPath,
|
||||
OSS_STUB_FAIL: uploadFails ? '1' : '0',
|
||||
},
|
||||
});
|
||||
// Capture the outcomes the caller asserts on before the fixture dir is
|
||||
// torn down: the parsed upload record (null when the uploader never ran
|
||||
// or wrote nothing) and the flat listing of everything hosted.
|
||||
const record = existsSync(recordPath)
|
||||
? JSON.parse(readFileSync(recordPath, 'utf8'))
|
||||
: null;
|
||||
const hostedFiles = listFiles(stubRoot).sort();
|
||||
return {
|
||||
res,
|
||||
record,
|
||||
hostedFiles,
|
||||
pr,
|
||||
headSha,
|
||||
runId,
|
||||
runAttempt,
|
||||
runnerTemp,
|
||||
};
|
||||
}
|
||||
|
||||
test('hosting block uploads staged images to the exact prefix RAW_BASE promises', () => {
|
||||
const {
|
||||
res,
|
||||
record,
|
||||
hostedFiles,
|
||||
pr,
|
||||
headSha,
|
||||
runId,
|
||||
runAttempt,
|
||||
runnerTemp,
|
||||
} = runHostingBlock(true);
|
||||
assert.equal(res.status, 0, res.stderr);
|
||||
// Flag wiring: the uploader gets the bucket, the credential file the
|
||||
// configure step writes, and the prefix the comment URLs are built from.
|
||||
const prefix = `pr-assets/web-shell-visuals/${pr}/${headSha}/${runId}/${runAttempt}`;
|
||||
assert.deepEqual(record, {
|
||||
bucket: 'assets-bucket',
|
||||
config: `${runnerTemp}/.ossutilconfig`,
|
||||
prefix,
|
||||
});
|
||||
// Upload-prefix ↔ URL agreement: the objects land exactly where RAW_BASE
|
||||
// (the comment's image base URL) points.
|
||||
assert.deepEqual(hostedFiles, [
|
||||
`assets-bucket/${prefix}/home-light.png`,
|
||||
`assets-bucket/${prefix}/model-switch.gif`,
|
||||
]);
|
||||
assert.match(
|
||||
res.stdout,
|
||||
new RegExp(
|
||||
`^RAW_BASE=https://assets\\.example\\.test/pr-assets/web-shell-visuals/${pr}/${headSha}/${runId}/${runAttempt}$`,
|
||||
'm',
|
||||
),
|
||||
);
|
||||
assert.match(
|
||||
res.stdout,
|
||||
new RegExp(
|
||||
`Web-shell visuals hosted at https://assets\\.example\\.test/pr-assets/web-shell-visuals/${pr}/${headSha}/${runId}/${runAttempt}\\.`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
// The regression this guards: publishing back onto the previous run's object
|
||||
// keys. GitHub serves comment images through a caching proxy, so a re-run for
|
||||
// the same head would keep showing the stale screenshots. A re-run comes in
|
||||
// TWO shapes: a brand-new workflow run (fresh run id) and a re-run of the
|
||||
// SAME run — which keeps the run id and only increments the attempt, and
|
||||
// still re-fires workflow_run `completed`. Both must get fresh prefixes;
|
||||
// dropping the attempt component from ASSET_PREFIX turns this red.
|
||||
test('hosting block gives a re-run of the same head a fresh, non-colliding prefix', () => {
|
||||
const first = runHostingBlock(true, { runId: '900001', runAttempt: '1' });
|
||||
assert.equal(first.res.status, 0, first.res.stderr);
|
||||
const rerunSameRun = runHostingBlock(true, {
|
||||
runId: '900001',
|
||||
runAttempt: '2',
|
||||
});
|
||||
assert.equal(rerunSameRun.res.status, 0, rerunSameRun.res.stderr);
|
||||
const freshRun = runHostingBlock(true, { runId: '900002', runAttempt: '1' });
|
||||
assert.equal(freshRun.res.status, 0, freshRun.res.stderr);
|
||||
const prefixes = [first, rerunSameRun, freshRun].map((r) => r.record.prefix);
|
||||
assert.equal(new Set(prefixes).size, 3);
|
||||
// All still hang off the same immutable per-head path, so the head SHA
|
||||
// stays the thing that binds a URL to the code it depicts.
|
||||
const head = `pr-assets/web-shell-visuals/${first.pr}/${first.headSha}/`;
|
||||
for (const prefix of prefixes) {
|
||||
assert.ok(prefix.startsWith(head), `unexpected prefix: ${prefix}`);
|
||||
}
|
||||
});
|
||||
|
||||
// A maintainer re-run of the SAME workflow run keeps its run id — only
|
||||
// run_attempt increments. Without the attempt segment the re-run would
|
||||
// overwrite the exact object keys the attempt-1 comment already references
|
||||
// (and camo-cached), so attempts of one run must land on distinct prefixes.
|
||||
test('hosting block gives re-run attempts of the SAME run distinct prefixes', () => {
|
||||
const first = runHostingBlock(true, { runId: '900001', runAttempt: '1' });
|
||||
assert.equal(first.res.status, 0, first.res.stderr);
|
||||
const second = runHostingBlock(true, { runId: '900001', runAttempt: '2' });
|
||||
assert.equal(second.res.status, 0, second.res.stderr);
|
||||
const prefixOf = (r) => r.record.prefix;
|
||||
assert.equal(
|
||||
prefixOf(first),
|
||||
`pr-assets/web-shell-visuals/${first.pr}/${first.headSha}/900001/1`,
|
||||
);
|
||||
assert.equal(
|
||||
prefixOf(second),
|
||||
`pr-assets/web-shell-visuals/${first.pr}/${first.headSha}/900001/2`,
|
||||
);
|
||||
assert.notEqual(prefixOf(first), prefixOf(second));
|
||||
});
|
||||
|
||||
test('hosting block skips the uploader entirely on the no-change arm', () => {
|
||||
const { res, record } = runHostingBlock(false);
|
||||
assert.equal(res.status, 0, res.stderr);
|
||||
assert.match(res.stdout, /^RAW_BASE=$/m); // empty -> image-less comment
|
||||
assert.doesNotMatch(res.stdout, /hosted at/);
|
||||
assert.equal(record, null); // the uploader never ran
|
||||
});
|
||||
|
||||
test('hosting block aborts without publishing a URL when upload fails', () => {
|
||||
const { res, record } = runHostingBlock(true, { uploadFails: true });
|
||||
assert.notEqual(res.status, 0);
|
||||
assert.doesNotMatch(res.stdout, /^RAW_BASE=/m);
|
||||
assert.equal(record, null);
|
||||
});
|
||||
|
||||
test('hosting block strips a trailing slash from the public base URL', () => {
|
||||
const { res, pr, headSha, runId, runAttempt } = runHostingBlock(true, {
|
||||
publicBaseUrl: 'https://assets.example.test/',
|
||||
});
|
||||
assert.equal(res.status, 0, res.stderr);
|
||||
assert.match(
|
||||
res.stdout,
|
||||
new RegExp(
|
||||
`^RAW_BASE=https://assets\\.example\\.test/pr-assets/web-shell-visuals/${pr}/${headSha}/${runId}/${runAttempt}$`,
|
||||
'm',
|
||||
),
|
||||
);
|
||||
assert.doesNotMatch(res.stdout, /assets\.example\.test\/\/pr-assets/);
|
||||
});
|
||||
|
||||
test('sanitizeName preserves the extension (regression: a trailing char broke the .png filter)', () => {
|
||||
assert.equal(
|
||||
|
||||
@@ -36,12 +36,12 @@
|
||||
5942 qwen-autofix-fork-signal.yml
|
||||
426924 qwen-autofix.yml
|
||||
7061 qwen-ci-flaky-rerun.yml
|
||||
165876 qwen-code-pr-review.yml
|
||||
168310 qwen-code-pr-review.yml
|
||||
79041 qwen-fleet-shepherd.yml
|
||||
20525 qwen-issue-followup-bot.yml
|
||||
5760 qwen-pr-safety-precheck.yml
|
||||
27648 qwen-triage-finalize.yml
|
||||
344548 qwen-triage.yml
|
||||
350381 qwen-triage.yml
|
||||
9657 release-sdk-java.yml
|
||||
22037 release-sdk-python.yml
|
||||
19094 release-sdk.yml
|
||||
@@ -60,6 +60,6 @@
|
||||
10138 sync-release-to-oss.yml
|
||||
3303 update-ecs-runner-qwen.yml
|
||||
2307 web-shell-visuals-cleanup.yml
|
||||
15380 web-shell-visuals-publish.yml
|
||||
20047 web-shell-visuals-publish.yml
|
||||
16384 web-shell-visuals.yml
|
||||
4712 windows-runner-smoke.yml
|
||||
@@ -924,17 +924,12 @@ jobs:
|
||||
QWEN_CODE_API_TIMEOUT_MS: '600000'
|
||||
QWEN_STREAM_IDLE_TIMEOUT_MS: '600000'
|
||||
QWEN_STREAM_MAX_LIFETIME_MS: '1800000'
|
||||
# Evidence-image destination for `qwen review publish-assets` —
|
||||
# OPT-IN by design: the command refuses to push images anywhere the
|
||||
# user did not designate, so with the repository variable unset the
|
||||
# review simply keeps its evidence as prose and local paths. Set the
|
||||
# variable (e.g. to this repository) to let reviews embed capture
|
||||
# PNGs; images land on commit-pinned pr-assets/<pr>-review branches,
|
||||
# pushed with the same CI_BOT_PAT this step already uses for gh.
|
||||
# Retention: the visuals cleanup workflow deletes those branches
|
||||
# only when the variable points at THIS repository — a fork or
|
||||
# scratch destination manages its own retention.
|
||||
QWEN_REVIEW_ASSETS_REPO: '${{ vars.QWEN_REVIEW_ASSETS_REPO }}'
|
||||
# Evidence-image destination for `qwen review publish-assets`.
|
||||
# It must be a dedicated external host repository: project-local
|
||||
# image branches are fetched by ordinary clones and permanently
|
||||
# inflate this repository. Unset or self-targeting configurations
|
||||
# deliberately degrade to prose and local artifact paths.
|
||||
QWEN_REVIEW_ASSETS_REPO: "${{ vars.QWEN_REVIEW_ASSETS_REPO != github.repository && vars.QWEN_REVIEW_ASSETS_REPO || '' }}"
|
||||
# Per-run agent home so this review's session/memory cannot leak into
|
||||
# the next on the reused self-hosted workspace (reset in "Clean stale
|
||||
# agent state"). Must match the QWEN_HOME computed there.
|
||||
@@ -965,6 +960,22 @@ jobs:
|
||||
}
|
||||
|
||||
REPO="${GITHUB_REPOSITORY}"
|
||||
|
||||
# Normalize the assets-repo designation before the CLI reads it.
|
||||
# The env-level guard compares the RAW variable, so whitespace
|
||||
# variants (" QwenLM/qwen-code ") and case variants slip past it;
|
||||
# trim both ends and re-check self-targeting case-insensitively
|
||||
# (repository names are case-insensitive). A padded or case-shifted
|
||||
# self-reference must degrade to prose exactly like an unset one.
|
||||
QWEN_REVIEW_ASSETS_REPO="${QWEN_REVIEW_ASSETS_REPO#"${QWEN_REVIEW_ASSETS_REPO%%[![:space:]]*}"}"
|
||||
QWEN_REVIEW_ASSETS_REPO="${QWEN_REVIEW_ASSETS_REPO%"${QWEN_REVIEW_ASSETS_REPO##*[![:space:]]}"}"
|
||||
if [ -n "${QWEN_REVIEW_ASSETS_REPO}" ] &&
|
||||
[ "$(printf '%s' "${QWEN_REVIEW_ASSETS_REPO}" | tr '[:upper:]' '[:lower:]')" = \
|
||||
"$(printf '%s' "${REPO}" | tr '[:upper:]' '[:lower:]')" ]; then
|
||||
QWEN_REVIEW_ASSETS_REPO=''
|
||||
fi
|
||||
export QWEN_REVIEW_ASSETS_REPO
|
||||
|
||||
REVIEW_URL="${GITHUB_SERVER_URL}/${REPO}/pull/${PR_NUMBER}"
|
||||
LOG_PATH="${RUNNER_TEMP:-/tmp}/qwen-review-pr-${PR_NUMBER}.jsonl"
|
||||
# Set by configure_qwen_network once the wrapper dir exists.
|
||||
|
||||
@@ -4980,8 +4980,8 @@ jobs:
|
||||
echo "Workspace wiped after external code (deny-by-default cleanup for the next pool job)." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Post the verification report back to the PR. Runs on a clean GitHub-hosted
|
||||
# runner with the write PAT and never checks out PR code, so the write
|
||||
# credential is isolated from the untrusted-code execution in verify above —
|
||||
# runner with the write PAT and only checks out trusted base-repo publisher
|
||||
# scripts, so credentials stay isolated from untrusted execution above —
|
||||
# the same split as publish-tmux. Unlike publish-tmux it also reports the
|
||||
# skip/na cases: /verify is always an explicit request, so silence would
|
||||
# read as a lost run.
|
||||
@@ -5003,31 +5003,82 @@ jobs:
|
||||
# inherit the 360-minute default and a hung gh call could hold a hosted
|
||||
# runner for six hours.
|
||||
timeout-minutes: 10
|
||||
# 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"]'') }}'
|
||||
# This job carries OSS credentials, so it must stay isolated from the
|
||||
# persistent runner pool that executes untrusted PR code.
|
||||
runs-on: 'ubuntu-latest'
|
||||
permissions:
|
||||
contents: 'read'
|
||||
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"
|
||||
# Sparse — the uploader, 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 OSS publisher scripts'
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
with:
|
||||
# Pin the trusted ref explicitly: publish-verify only runs under
|
||||
# issue_comment events (needs: verify), where github.sha is the
|
||||
# default-branch head — never a PR merge ref. 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
|
||||
scripts/upload-aliyun-oss-assets.js
|
||||
scripts/release-script-utils.js
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
|
||||
# The publish job runs on the persistent ECS pool and downloads into
|
||||
# a WORKSPACE-relative directory, which the runner does not clean
|
||||
# between jobs: without this, a previous run's (possibly a different
|
||||
# PR's) flake-gate.log survives and the publisher — which treats the
|
||||
# file's presence as proof THIS run staged it — embeds it as this
|
||||
# run's evidence. The verify side already applies the same rm-first
|
||||
# rule to its own $RUNNER_TEMP tree.
|
||||
- name: 'Install ossutil'
|
||||
id: 'install-ossutil'
|
||||
# Evidence hosting is best-effort; the report still posts as text if
|
||||
# the client cannot be installed.
|
||||
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 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"
|
||||
|
||||
# Belt-and-braces against a stale download directory. The job moved to
|
||||
# an ephemeral hosted runner (and checkout cleans the workspace first),
|
||||
# so nothing should survive between runs — but the publisher treats the
|
||||
# presence of a file like flake-gate.log as proof THIS run staged it,
|
||||
# and that inference must not silently start embedding another PR's
|
||||
# evidence if this job is ever moved back onto the persistent pool.
|
||||
# The verify side applies the same rm-first rule to its $RUNNER_TEMP.
|
||||
- name: 'Clear stale downloaded results'
|
||||
run: 'rm -rf verify-results'
|
||||
|
||||
@@ -5058,6 +5109,25 @@ jobs:
|
||||
VERIFY_RESULT: '${{ needs.verify.result }}'
|
||||
DOWNLOAD_OUTCOME: '${{ steps.download.outcome }}'
|
||||
RUN_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
|
||||
OSSUTIL_INSTALL_OUTCOME: '${{ steps.install-ossutil.outcome }}'
|
||||
# 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 and take
|
||||
# the whole report with it: bound every ossutil attempt so the
|
||||
# uploader exhausts its retries and degrades to a text-only report
|
||||
# instead of hanging one black-hole socket until the job dies.
|
||||
# Worst case is one image's 3 attempts x 120s + backoffs ≈ 6.1 min
|
||||
# (the uploader stops at the first image that fails all attempts).
|
||||
OSS_UPLOAD_ATTEMPT_TIMEOUT_MS: '120000'
|
||||
# shellcheck disable=SC2016
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
@@ -5362,21 +5432,19 @@ jobs:
|
||||
printf '\n'
|
||||
}
|
||||
|
||||
# Host the agent's evidence images (if any) on a per-PR branch
|
||||
# (pr-assets/<N>-verify, matching hand-run convention) and build
|
||||
# a markdown section referencing them. Image bytes come from a run
|
||||
# Host the agent's evidence images (if any) on Aliyun OSS and build a
|
||||
# markdown section referencing them. Image bytes come from a run
|
||||
# that executed PR code: inert but untrusted, so filenames pass a
|
||||
# strict allowlist, count/size are capped (8 files, <2 MB each; the
|
||||
# find predicates enforce both), and any failure degrades to a
|
||||
# text-only comment rather than blocking the report. The
|
||||
# VERIFY_ASSETS_REMOTE override exists as a test seam only.
|
||||
# VERIFY_ASSETS_UPLOADER override exists as a test seam only.
|
||||
EVIDENCE_SECTION=''
|
||||
collect_and_host_evidence() {
|
||||
local imgs=() f base safe seen=' ' hosted=0 total=0 skipped=0
|
||||
local dest_dir="verify/pr${PR_NUMBER}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT:-1}"
|
||||
local assets_branch="pr-assets/${PR_NUMBER}-verify"
|
||||
local clone_dir="${RUNNER_TEMP:-/tmp}/pr-assets"
|
||||
local remote="${VERIFY_ASSETS_REMOTE:-https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git}"
|
||||
local prefix="pr-assets/verify/pr${PR_NUMBER}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT:-1}"
|
||||
local stage_dir="${RUNNER_TEMP:-/tmp}/verify-assets-stage"
|
||||
local uploader="${VERIFY_ASSETS_UPLOADER:-scripts/upload-aliyun-oss-assets.js}"
|
||||
# `|| true` on every find: the artifact download is
|
||||
# continue-on-error, so verify-results may not exist at all, and
|
||||
# a bare non-zero find under `pipefail` would abort the step
|
||||
@@ -5393,19 +5461,8 @@ jobs:
|
||||
EVIDENCE_SECTION="_${total} evidence image(s) were produced but none passed the hosting caps (8 images, ≤2 MB each); see the run artifacts._"$'\n\n'
|
||||
return 0
|
||||
fi
|
||||
rm -rf "$clone_dir"
|
||||
if ! git clone -q --depth 1 --branch "$assets_branch" "$remote" "$clone_dir" 2>/dev/null; then
|
||||
rm -rf "$clone_dir"
|
||||
mkdir -p "$clone_dir"
|
||||
git -C "$clone_dir" init -q
|
||||
git -C "$clone_dir" checkout -q --orphan "$assets_branch"
|
||||
git -C "$clone_dir" remote add origin "$remote"
|
||||
fi
|
||||
# Rebase in the racing-push retry needs a committer identity, so
|
||||
# set it once on the clone instead of per-command -c flags.
|
||||
git -C "$clone_dir" config user.name 'qwen-code-ci-bot'
|
||||
git -C "$clone_dir" config user.email '[email protected]'
|
||||
mkdir -p "$clone_dir/$dest_dir"
|
||||
rm -rf "$stage_dir"
|
||||
mkdir -p "$stage_dir"
|
||||
for f in "${imgs[@]}"; do
|
||||
base="$(basename "$f")"
|
||||
safe="$(printf '%s' "$base" | tr -cd 'a-zA-Z0-9._-' | head -c 80)"
|
||||
@@ -5418,10 +5475,14 @@ jobs:
|
||||
# Two artifact dirs can sanitize to the same name; the second
|
||||
# copy would silently overwrite the first and render twice.
|
||||
case "$seen" in *" $safe "*) continue ;; esac
|
||||
# Extension is attacker-choosable; the magic bytes are what
|
||||
# raw.githubusercontent.com will actually serve. PNG only.
|
||||
# Extension is attacker-choosable, and it is the enforcement
|
||||
# point for what OSS serves: ossutil derives the object's
|
||||
# Content-Type from the key's extension, so the .png allowlist
|
||||
# above is what keeps these hosted as an image type. This
|
||||
# magic-byte check then verifies the bytes actually match the
|
||||
# claimed type. PNG only.
|
||||
[ "$(head -c 8 "$f" | od -An -tx1 | tr -d ' \n')" = '89504e470d0a1a0a' ] || continue
|
||||
cp "$f" "$clone_dir/$dest_dir/$safe" || continue
|
||||
cp "$f" "$stage_dir/$safe" || continue
|
||||
seen="${seen}${safe} "
|
||||
hosted=$((hosted + 1))
|
||||
done
|
||||
@@ -5430,26 +5491,55 @@ jobs:
|
||||
EVIDENCE_SECTION="_${total} evidence image(s) were produced but none passed the hosting checks (PNG magic, unique sanitized name, ≤2 MB, max 8); see the run artifacts._"$'\n\n'
|
||||
return 0
|
||||
fi
|
||||
if ! (
|
||||
cd "$clone_dir" &&
|
||||
git add "$dest_dir" &&
|
||||
git commit -q -m "verify evidence for PR #${PR_NUMBER} (run ${GITHUB_RUN_ID})" &&
|
||||
{
|
||||
git push -q origin "HEAD:$assets_branch" 2>/dev/null ||
|
||||
{
|
||||
# One retry after a racing push from another assets job.
|
||||
git pull -q --rebase origin "$assets_branch" 2>/dev/null &&
|
||||
git push -q origin "HEAD:$assets_branch" 2>/dev/null
|
||||
}
|
||||
}
|
||||
); then
|
||||
echo "::warning::Failed to push evidence images; posting a text-only report." >&2
|
||||
local upload_cmd=()
|
||||
local upload_path="${PATH}"
|
||||
local trusted_bin=''
|
||||
if [ -n "${VERIFY_ASSETS_UPLOADER:-}" ]; then
|
||||
upload_cmd=("$uploader")
|
||||
elif [ "${OSSUTIL_INSTALL_OUTCOME:-}" = 'success' ] &&
|
||||
[ -f "${RUNNER_TEMP:-/tmp}/.ossutilconfig" ]; then
|
||||
# Resolve node under the inherited PATH, never through the
|
||||
# shared temp dir, and run ossutil from a fresh job-private
|
||||
# copy of the sha256-verified binary installed above instead
|
||||
# of resolving it through $RUNNER_TEMP (PATH hijack shape).
|
||||
# Not exploitable on today's ephemeral ubuntu-latest runner,
|
||||
# but the shape must not regress if this job ever moves to a
|
||||
# persistent pool.
|
||||
local node_bin
|
||||
node_bin="$(command -v node || true)"
|
||||
upload_cmd=("$node_bin" "$uploader")
|
||||
trusted_bin="$(mktemp -d || true)"
|
||||
if [ -n "$trusted_bin" ]; then
|
||||
install -m 0755 "${RUNNER_TEMP:-/tmp}/ossutil" "$trusted_bin/ossutil" 2>/dev/null || true
|
||||
upload_path="$trusted_bin"
|
||||
fi
|
||||
else
|
||||
# The install and configure steps are continue-on-error, so a
|
||||
# failure there renders green; say which precondition is missing
|
||||
# instead of burning three retry backoffs per image on an
|
||||
# uploader that cannot possibly authenticate.
|
||||
echo "::warning::OSS publishing unavailable (ossutil=$([ -x "${RUNNER_TEMP:-/tmp}/ossutil" ] && echo ok || echo MISSING), config=$([ -f "${RUNNER_TEMP:-/tmp}/.ossutilconfig" ] && echo ok || echo MISSING)); posting a text-only report." >&2
|
||||
EVIDENCE_SECTION=''
|
||||
return 0
|
||||
fi
|
||||
local raw_base="https://raw.githubusercontent.com/${GITHUB_REPOSITORY}/${assets_branch}/${dest_dir}"
|
||||
if ! PATH="$upload_path" "${upload_cmd[@]}" \
|
||||
--bucket "$ALIYUN_OSS_BUCKET" \
|
||||
--config "${RUNNER_TEMP:-/tmp}/.ossutilconfig" \
|
||||
--prefix "$prefix" \
|
||||
"$stage_dir"/*.png; then
|
||||
if [ -n "$trusted_bin" ]; then rm -rf "$trusted_bin"; fi
|
||||
# Report the local preconditions so a tooling/credential gap
|
||||
# (the install/config steps are continue-on-error and render
|
||||
# green when they fail) is distinguishable from a transient
|
||||
# upload blip without trawling three steps of logs.
|
||||
echo "::warning::Failed to upload evidence images (ossutil=$([ -x "${RUNNER_TEMP:-/tmp}/ossutil" ] && echo ok || echo MISSING), config=$([ -f "${RUNNER_TEMP:-/tmp}/.ossutilconfig" ] && echo ok || echo MISSING)); posting a text-only report." >&2
|
||||
EVIDENCE_SECTION=''
|
||||
return 0
|
||||
fi
|
||||
if [ -n "$trusted_bin" ]; then rm -rf "$trusted_bin"; fi
|
||||
local raw_base="${ALIYUN_OSS_PUBLIC_BASE_URL%/}/${prefix}"
|
||||
EVIDENCE_SECTION=$'### Evidence images\n\n'
|
||||
for f in "$clone_dir/$dest_dir"/*.png; do
|
||||
for f in "$stage_dir"/*.png; do
|
||||
[ -f "$f" ] || continue
|
||||
safe="$(basename "$f")"
|
||||
EVIDENCE_SECTION+=""$'\n\n'
|
||||
@@ -5894,3 +5984,7 @@ jobs:
|
||||
gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" -F body=@"$BODY_FILE" >/dev/null
|
||||
fi
|
||||
echo "Posted verification result to PR #${PR_NUMBER} (verdict=${VERDICT}, agent=${AGENT_VERDICT:-none})." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: 'Cleanup Aliyun OSS credentials'
|
||||
if: '${{ always() }}'
|
||||
run: 'rm -f "$RUNNER_TEMP/.ossutilconfig"'
|
||||
@@ -1,13 +1,10 @@
|
||||
name: 'PR Asset Branch Cleanup'
|
||||
|
||||
# When a PR closes, delete its per-PR asset branches so the `pr-assets/*`
|
||||
# refs (one per PR that ever produced a preview or a verification report)
|
||||
# don't accumulate without bound in the base repository. Runs in the base
|
||||
# context (pull_request_target) but never checks out or runs PR code — it
|
||||
# only deletes refs by name.
|
||||
#
|
||||
# All producers are covered, and every new `pr-assets/*` producer must be
|
||||
# added here: a branch nothing deletes is permanent.
|
||||
# When a PR closes, delete legacy per-PR asset branches left by the former Git
|
||||
# publishers. New previews and verification reports use OSS, and automated
|
||||
# reviews cannot target this repository for assets, but historical refs still
|
||||
# need to drain. Runs in the base context (pull_request_target) and only deletes
|
||||
# refs by name; it never checks out or runs PR code.
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 the `pr-assets` branch, and posts an inline
|
||||
# (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:
|
||||
@@ -13,15 +13,13 @@ on:
|
||||
types:
|
||||
- 'completed'
|
||||
|
||||
# GITHUB_TOKEN only needs to read the triggering run's artifact; the branch push
|
||||
# and PR comment are done with CI_BOT_PAT, which carries its own scope.
|
||||
# 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),
|
||||
# since they force-push to that PR's own `pr-assets/web-shell-visuals-<n>`
|
||||
# branch; serializing means the force-push never has to reconcile a concurrent
|
||||
# same-PR snapshot (a bounded retry below covers transient failures). Different
|
||||
# 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:
|
||||
@@ -43,15 +41,74 @@ jobs:
|
||||
runs-on: 'ubuntu-latest'
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
# Trusted base-repo script (staging + comment builder). workflow_run
|
||||
# checks out the default branch, never PR code. Sparse — just the script.
|
||||
- name: 'Checkout the publish script'
|
||||
# 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:
|
||||
sparse-checkout: '.github/scripts/web-shell-visuals-publish.mjs'
|
||||
# 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
|
||||
@@ -66,6 +123,10 @@ jobs:
|
||||
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.
|
||||
@@ -75,6 +136,23 @@ jobs:
|
||||
# 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
|
||||
|
||||
@@ -174,58 +252,46 @@ jobs:
|
||||
HAS_IMAGES=1
|
||||
fi
|
||||
|
||||
# Re-validate right before the force-push too: a close/new-head during
|
||||
# download+staging must not force-push a stale snapshot (which would
|
||||
# orphan the commit the existing comment still points at).
|
||||
# Re-validate right before the upload too: a close/new-head during
|
||||
# download+staging must not publish a stale snapshot.
|
||||
gate
|
||||
|
||||
# --- Host on the PR's own pr-assets branch (bounded) --------------
|
||||
# Replace the branch with a SINGLE orphan snapshot each run and
|
||||
# force-push, so untrusted PR content can't accumulate unbounded
|
||||
# history in the base repo. The comment always points at the new
|
||||
# immutable commit SHA; the previous snapshot becomes unreachable and
|
||||
# is GC'd. Per-PR branch under the existing pr-assets/<slug> convention
|
||||
# (a bare `pr-assets` branch would D/F-conflict with pr-assets/*), and
|
||||
# the per-PR concurrency group serializes same-PR runs so the
|
||||
# force-push never drops a concurrent snapshot.
|
||||
# --- 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 asset commit entirely; RAW_BASE stays empty and the
|
||||
# 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
|
||||
BRANCH="pr-assets/web-shell-visuals-${PR}"
|
||||
AUTH_URL="https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
|
||||
WORK="${RUNNER_TEMP}/pr-assets"
|
||||
rm -rf "${WORK}"
|
||||
mkdir -p "${WORK}/imgs"
|
||||
cp "${STAGE}"/* "${WORK}/imgs/"
|
||||
cd "${WORK}"
|
||||
git init -q
|
||||
git config user.name 'qwen-code-bot'
|
||||
git config user.email '[email protected]'
|
||||
git checkout -q --orphan snapshot
|
||||
git add imgs
|
||||
git commit -q -m "web-shell visuals: PR #${PR} (run ${RUN_ID})"
|
||||
# Bounded retry for transient push failures (network / brief lock).
|
||||
# Same-PR runs are serialized by concurrency, so this never needs to
|
||||
# reconcile a concurrent snapshot — a plain retry suffices.
|
||||
pushed=0
|
||||
for attempt in 1 2 3; do
|
||||
if git push -q --force "${AUTH_URL}" "HEAD:${BRANCH}"; then
|
||||
pushed=1
|
||||
break
|
||||
fi
|
||||
echo "::notice::force-push attempt ${attempt} failed; retrying."
|
||||
sleep 2
|
||||
done
|
||||
if [ "${pushed}" -ne 1 ]; then
|
||||
echo "::error::Failed to push web-shell visuals to ${BRANCH} after retries."
|
||||
exit 1
|
||||
fi
|
||||
ASSET_SHA="$(git rev-parse HEAD)"
|
||||
cd "${GITHUB_WORKSPACE}"
|
||||
echo "web-shell visuals hosted on ${BRANCH} at ${ASSET_SHA}."
|
||||
RAW_BASE="https://raw.githubusercontent.com/${GITHUB_REPOSITORY}/${ASSET_SHA}/imgs"
|
||||
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 ------------------
|
||||
@@ -295,3 +361,7 @@ jobs:
|
||||
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"'
|
||||
@@ -828,9 +828,8 @@ workflow globs). It must contain:
|
||||
fails, say so under _Not covered_ in one line and ship the text-only report;
|
||||
do not reconstruct the pipeline by hand.
|
||||
|
||||
The publish job hosts what you produce on a per-PR branch
|
||||
(`pr-assets/<N>-verify`) and appends it below the report, capped at
|
||||
**8 images, 2 MB each**; anything
|
||||
The publish job hosts what you produce on Aliyun OSS and appends it below the
|
||||
report, capped at **8 images, 2 MB each**; anything
|
||||
beyond stays in the run artifacts. Name each file as a kebab-case caption
|
||||
that binds image to claim (`01-bundle-ab-base-vs-head.png`,
|
||||
`02-repaint-after-sigcont.png`) — the filename becomes the published
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# PR Evidence Hosting on Aliyun OSS
|
||||
|
||||
## Problem
|
||||
|
||||
Automated PR verification currently publishes screenshots by committing them to
|
||||
`pr-assets/*` branches in the main repository. A normal clone fetches objects
|
||||
reachable from every advertised branch, so long-lived PNG-only branches add
|
||||
hundreds of megabytes to clone traffic and repository storage even though the
|
||||
images are unrelated to the product source tree.
|
||||
|
||||
## Goals
|
||||
|
||||
- Keep inline screenshots in Web Shell visual previews and `/verify` reports.
|
||||
- Stop automated workflows from creating or updating image branches in this
|
||||
repository.
|
||||
- Reuse the existing, tested Aliyun OSS uploader and repository credentials.
|
||||
- Preserve the current validation, size limits, retries, and fail-safe behavior.
|
||||
- Keep PR-derived bytes and OSS credentials separated by a trusted publisher
|
||||
job.
|
||||
|
||||
## Design
|
||||
|
||||
Both trusted publisher jobs configure the existing `ossutil` client and invoke
|
||||
`scripts/upload-aliyun-oss-assets.js`. The uploader keeps its existing
|
||||
three-attempt retry policy and publishes objects with `public-read` ACL.
|
||||
|
||||
Web Shell previews use this immutable prefix:
|
||||
|
||||
```text
|
||||
pr-assets/web-shell-visuals/<pr>/<head-sha>/<run-id>/<run-attempt>/
|
||||
```
|
||||
|
||||
An object key is never written twice. The head SHA binds a URL to the code it
|
||||
depicts; the run id keeps a re-run of that same head from writing back over the
|
||||
objects an already-posted comment references. The run id alone would not be
|
||||
enough: it is stable across re-run attempts (only the attempt number
|
||||
increments), so the run attempt segment keeps a maintainer re-run of the same
|
||||
workflow run off the previous attempt's keys too. That matters because GitHub
|
||||
serves comment images through a caching image proxy: reusing a key would leave
|
||||
reviewers looking at the previous run's screenshots at a URL whose bytes had
|
||||
changed. The Git-backed design got this property free from the per-run commit
|
||||
SHA in the raw URL.
|
||||
|
||||
Sandboxed `/verify` reports use this immutable prefix:
|
||||
|
||||
```text
|
||||
pr-assets/verify/pr<pr>-<run-id>-<run-attempt>/
|
||||
```
|
||||
|
||||
The workflow continues to accept at most eight PNGs of at most 2 MiB each,
|
||||
checks PNG magic bytes, sanitizes names, and degrades to a text-only report if
|
||||
hosting fails.
|
||||
|
||||
The publisher jobs run in the base-repository context and never expose OSS
|
||||
credentials to the jobs that execute PR code. They consume only bounded,
|
||||
validated image artifacts.
|
||||
|
||||
The automated review workflow has a third, separate image path through
|
||||
`qwen review publish-assets`. That CLI feature remains available for an
|
||||
explicitly designated assets repository, but the workflow now rejects a
|
||||
designation that points back to the repository under review. An unset or
|
||||
self-targeting destination degrades to prose and downloadable run artifacts;
|
||||
an external image-host repository remains supported.
|
||||
|
||||
## Bucket selection
|
||||
|
||||
Both publishers resolve the destination as
|
||||
`ALIYUN_OSS_PR_ASSETS_BUCKET` → `ALIYUN_OSS_BUCKET` → `qwen-code-assets`, with
|
||||
the public base URL derived from whichever bucket wins (or set explicitly via
|
||||
`ALIYUN_OSS_PR_ASSETS_PUBLIC_BASE_URL`). Unset, this is exactly today's shared
|
||||
bucket. The knob exists because PR evidence is untrusted, PR-derived content
|
||||
while the shared bucket also serves release, desktop, and live-host downloads;
|
||||
setting one variable separates them without a workflow change.
|
||||
|
||||
It does not narrow the credential: these jobs hold the same OSS key the release
|
||||
sync uses. Scoping a dedicated RAM user to the `pr-assets/` prefix is ops work
|
||||
outside this PR, and is the reason the destination is a variable rather than a
|
||||
constant.
|
||||
|
||||
## Compatibility and cleanup
|
||||
|
||||
Existing PR comments keep their Git-backed URLs. The PR-close cleanup workflow
|
||||
continues deleting historical `pr-assets/*` refs, but no new refs are produced
|
||||
by the migrated workflows.
|
||||
|
||||
OSS object retention is intentionally external to the workflow. The
|
||||
`pr-assets/` prefix can use a bucket lifecycle rule without changing repository
|
||||
history or clone behavior.
|
||||
|
||||
## Verification
|
||||
|
||||
- Execute the workflow-level `/verify` image-hosting harness against a fake OSS
|
||||
uploader, including valid images, rejected images, duplicate names, the exact
|
||||
size boundary, first publication, and upload failure.
|
||||
- Assert both publisher workflows call the shared uploader and contain no Git
|
||||
image push path.
|
||||
- Assert the automated review workflow cannot target this repository for image
|
||||
branches.
|
||||
- Run the shared uploader unit tests and workflow parser tests.
|
||||
@@ -261,9 +261,9 @@ export QWEN_REVIEW_ASSETS_REPO=your-org/your-repo # a repo you can push to
|
||||
/review 123 --comment
|
||||
```
|
||||
|
||||
Maintainers typically point it at the repo under review; anyone else can use a fork or a scratch repo. Images land on the `pr-assets/<pr>-review` branch with content-hashed names, and comments reference them by **commit-pinned** URL — immutable even if the branch later moves, and working unchanged on GitHub Enterprise.
|
||||
Point it at a repository you can push to — a dedicated image-host repository is the recommended destination; a fork or scratch repo works too. Avoid the repository under review itself: image branches pushed there become reachable objects that every clone fetches. Images land on the `pr-assets/<pr>-review` branch with content-hashed names, and comments reference them by **commit-pinned** URL — immutable even if the branch later moves, and working unchanged on GitHub Enterprise.
|
||||
|
||||
For GitHub-triggered reviews (the PR-review workflow), the same variable is wired from a **repository variable** of the same name: with the variable unset the workflow passes an empty value and publishing refuses — nothing changes. A maintainer who sets `QWEN_REVIEW_ASSETS_REPO` in the repository's Actions variables (typically to the repository itself) enables review comments to embed capture PNGs; the branches it writes are cleaned up by the visuals cleanup workflow when the variable points at the same repository, while a fork or scratch destination manages its own retention.
|
||||
For GitHub-triggered reviews (the PR-review workflow), the same variable is wired from a **repository variable** of the same name, and the workflow passes through only a dedicated external host: with the variable unset, or set to the repository under review (compared after trimming and case-normalization, so padded or case-shifted spellings of the same repository count as self-targeting), the workflow passes an empty value and publishing refuses — nothing changes, and the review keeps its evidence as prose and local artifact paths. A maintainer who sets `QWEN_REVIEW_ASSETS_REPO` in the repository's Actions variables to a separate image-host repository enables review comments to embed capture PNGs. An external destination manages its own retention; the visuals cleanup workflow only drains the historical `pr-assets/*` branches that former Git-backed publishers left in this repository.
|
||||
|
||||
The publishing is gated exactly like posting: no designated repo means no publish, and an unauthorized run (no effective `--comment`) is refused the same way `submit` refuses. Only image types are accepted (SVG is excluded deliberately), with size caps, and each file's bytes must match the format its extension claims — mislabeled or unrecognized content is refused. A manifest records every file pushed. Without a designation, findings keep their evidence as local file paths in the terminal and saved report — nothing breaks, comments just stay text-only.
|
||||
|
||||
|
||||
@@ -222,8 +222,12 @@ export function parseAssetsRepo(
|
||||
return {
|
||||
error:
|
||||
'QWEN_REVIEW_ASSETS_REPO is not set. Designate an assets repository ' +
|
||||
'(owner/repo you can push to — the repo under review for maintainers, ' +
|
||||
'a fork or scratch repo otherwise) to publish review evidence images.',
|
||||
'(owner/repo you can push to — a dedicated image-host repository, or ' +
|
||||
'a fork or scratch repo rather than the repository under review, ' +
|
||||
'whose clones would fetch the image branches) to publish review ' +
|
||||
'evidence images. In CI, note that the PR-review workflow blanks a ' +
|
||||
'designation pointing at the repository under review, so a set ' +
|
||||
'repository variable can still arrive here as unset.',
|
||||
};
|
||||
}
|
||||
const parts = v.split('/');
|
||||
|
||||
@@ -182,7 +182,7 @@ Rationale: an inline comment is the only place GitHub renders a ` ```suggestion
|
||||
|
||||
### Evidence images (`publish-assets`) — only for an authorised, posting run
|
||||
|
||||
**When a finding's evidence is an image** — a TUI screenshot, a rendered-output comparison, a browser capture produced during verification — a comment that embeds it is worth more than one that describes it. GitHub's API cannot attach images to review comments (the web UI's drag-and-drop upload has no API equivalent), so image evidence is hosted in a **user-designated assets repository** and referenced by URL. The designation is the `QWEN_REVIEW_ASSETS_REPO` environment variable (`owner/repo` the user can push to — the repo under review for maintainers, a fork or scratch repo otherwise). It is deliberately a **different** variable from `QWEN_REVIEW_SCRATCH_REPO`: the scratch repo's contract forbids PR-derived content, and an evidence screenshot is exactly that.
|
||||
**When a finding's evidence is an image** — a TUI screenshot, a rendered-output comparison, a browser capture produced during verification — a comment that embeds it is worth more than one that describes it. GitHub's API cannot attach images to review comments (the web UI's drag-and-drop upload has no API equivalent), so image evidence is hosted in a **user-designated assets repository** and referenced by URL. The designation is the `QWEN_REVIEW_ASSETS_REPO` environment variable (`owner/repo` the user can push to — a dedicated image-host repository, or a fork or scratch repo rather than the repository under review, whose clones would fetch the image branches; the PR-review workflow refuses a designation that points back at the repository under review). It is deliberately a **different** variable from `QWEN_REVIEW_SCRATCH_REPO`: the scratch repo's contract forbids PR-derived content, and an evidence screenshot is exactly that.
|
||||
|
||||
Findings carry their evidence as local paths in the artifact's `assetFiles` field (Step 6's `qwen review findings` accepts it per finding). Publish them in one call, which weaves the resulting URLs back into the artifact as `assets`:
|
||||
|
||||
|
||||
@@ -1395,15 +1395,64 @@ describe('capture-tools step wiring', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('passes the assets-repo variable into the review step', () => {
|
||||
// The CLI reads QWEN_REVIEW_ASSETS_REPO from the environment; the run:
|
||||
// script never names it, so only this assertion sees a dropped or
|
||||
// misspelled wiring line.
|
||||
it('keeps review evidence branches out of the project repository', () => {
|
||||
// The CLI reads QWEN_REVIEW_ASSETS_REPO from the environment. The workflow
|
||||
// passes through only a dedicated external host, never this project repo.
|
||||
const doc = parse(workflow);
|
||||
expect(
|
||||
doc.jobs['review-pr'].steps.find((s) => s.name === 'Run review').env
|
||||
.QWEN_REVIEW_ASSETS_REPO,
|
||||
).toBe('${{ vars.QWEN_REVIEW_ASSETS_REPO }}');
|
||||
).toBe(
|
||||
"${{ vars.QWEN_REVIEW_ASSETS_REPO != github.repository && vars.QWEN_REVIEW_ASSETS_REPO || '' }}",
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes whitespace and case variants of the assets-repo designation', () => {
|
||||
// The env-level guard compares the RAW variable, so padded or
|
||||
// case-shifted self-references slip past it; the run body trims and
|
||||
// re-checks before the CLI reads the value. Executed, not asserted as
|
||||
// text: a dropped trim or a case-sensitive compare re-enables a
|
||||
// self-targeting designation while every shape assertion stays green.
|
||||
const run = runReviewStep();
|
||||
const marker = '# Normalize the assets-repo designation';
|
||||
const start = run.indexOf(marker);
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const endMarker = 'export QWEN_REVIEW_ASSETS_REPO';
|
||||
const end = run.indexOf(endMarker, start);
|
||||
expect(end).toBeGreaterThan(-1);
|
||||
const fragment = run.slice(start, end + endMarker.length);
|
||||
|
||||
function normalize(value) {
|
||||
return execFileSync(
|
||||
'bash',
|
||||
[
|
||||
'-c',
|
||||
[
|
||||
'set -euo pipefail',
|
||||
'REPO="QwenLM/qwen-code"',
|
||||
'QWEN_REVIEW_ASSETS_REPO="$DESIGNATION"',
|
||||
fragment,
|
||||
'printf "%s" "$QWEN_REVIEW_ASSETS_REPO"',
|
||||
].join('\n'),
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, DESIGNATION: value },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Self-targeting in every disguise degrades to the empty designation.
|
||||
expect(normalize('QwenLM/qwen-code')).toBe('');
|
||||
expect(normalize(' QwenLM/qwen-code ')).toBe('');
|
||||
expect(normalize('qwenlm/QWEN-CODE')).toBe('');
|
||||
expect(normalize('\tQwenLM/qwen-code\n')).toBe('');
|
||||
// An external host survives, trimmed.
|
||||
expect(normalize('other-org/assets')).toBe('other-org/assets');
|
||||
expect(normalize(' other-org/assets ')).toBe('other-org/assets');
|
||||
// Unset-ish values stay empty.
|
||||
expect(normalize('')).toBe('');
|
||||
expect(normalize(' ')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3910,7 +3910,7 @@ describe('qwen-triage verify hardening round 2', () => {
|
||||
});
|
||||
|
||||
// The evidence-hosting path carries the untrusted-image checks; exercise
|
||||
// it end to end against a bare local remote.
|
||||
// it end to end against a local OSS uploader stub.
|
||||
it('hosts only valid, unique, in-limit PNGs and degrades to text', () => {
|
||||
const publishStep = step('Post verification report comment');
|
||||
const script = publishStep
|
||||
@@ -3920,10 +3920,43 @@ describe('qwen-triage verify hardening round 2', () => {
|
||||
const sh = (cmd, opts = {}) =>
|
||||
spawnSync('bash', ['-c', cmd], { encoding: 'utf8', ...opts });
|
||||
try {
|
||||
// A bare remote with a pr-assets branch, plus a gh stub.
|
||||
sh(`git init -q --bare "${dir}/assets.git"`);
|
||||
sh(
|
||||
`mkdir -p "${dir}/seed" && cd "${dir}/seed" && git init -q && git checkout -q -b pr-assets/7999-verify && echo s > s.txt && git add . && git -c user.name=t -c user.email=t@t commit -qm s && git push -q "${dir}/assets.git" pr-assets/7999-verify`,
|
||||
// The uploader stub preserves the object layout while avoiding network
|
||||
// access, and enforces the real uploader's flag contract: the workflow
|
||||
// must pass --bucket, --config and --prefix with values, or the
|
||||
// harness fails exactly where production would silently degrade every
|
||||
// report to text-only. The gh stub captures the rendered report body.
|
||||
writeFileSync(
|
||||
join(dir, 'upload-assets'),
|
||||
[
|
||||
'#!/usr/bin/env bash',
|
||||
'set -euo pipefail',
|
||||
'if [ "${OSS_STUB_FAIL:-}" = 1 ]; then exit 1; fi',
|
||||
'bucket=""; config=""; prefix=""',
|
||||
'while [ "$#" -gt 0 ]; do',
|
||||
' case "$1" in',
|
||||
' --bucket) bucket="$2"; shift 2 ;;',
|
||||
' --config) config="$2"; shift 2 ;;',
|
||||
' --prefix) prefix="$2"; shift 2 ;;',
|
||||
' *) break ;;',
|
||||
' esac',
|
||||
'done',
|
||||
'if [ -z "$bucket" ] || [ -z "$config" ] || [ -z "$prefix" ]; then',
|
||||
' echo "upload-assets stub: --bucket, --config and --prefix are all required" >&2',
|
||||
' exit 1',
|
||||
'fi',
|
||||
// The real uploader hands --config to ossutil, which dies on a
|
||||
// missing file: checking it here means a workflow mutation that
|
||||
// drifts the flag away from the credential file the configure
|
||||
// step writes turns the harness red instead of staying green.
|
||||
'if [ ! -f "$config" ]; then',
|
||||
' echo "upload-assets stub: --config file does not exist: $config" >&2',
|
||||
' exit 1',
|
||||
'fi',
|
||||
'target="$OSS_STUB_ROOT/$bucket/$prefix"',
|
||||
'mkdir -p "$target"',
|
||||
'for file in "$@"; do cp "$file" "$target/$(basename "$file")"; done',
|
||||
].join('\n'),
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
writeFileSync(
|
||||
join(dir, 'gh'),
|
||||
@@ -3960,6 +3993,12 @@ describe('qwen-triage verify hardening round 2', () => {
|
||||
png(join(art, 'evidence', '04-edge.png'), 2 * 1024 * 1024 - 9);
|
||||
writeFileSync(join(art, 'report.md'), '## r\n');
|
||||
|
||||
// The stub arm receives --config "${RUNNER_TEMP}/.ossutilconfig" and
|
||||
// (like the real uploader's ossutil) refuses to run without that
|
||||
// file, so seed the configure step's product for the stub runs. The
|
||||
// production arms below manage the file explicitly instead.
|
||||
writeFileSync(join(dir, '.ossutilconfig'), '[Credentials]\n');
|
||||
|
||||
const out = join(dir, 'comment.md');
|
||||
const res = sh(script, {
|
||||
cwd: work,
|
||||
@@ -3980,29 +4019,27 @@ describe('qwen-triage verify hardening round 2', () => {
|
||||
AGENT_VERDICT: 'findings',
|
||||
SKIP_REASON: '',
|
||||
PREPARE_FAILURE_PHASE: '',
|
||||
VERIFY_ASSETS_REMOTE: `${dir}/assets.git`,
|
||||
ALIYUN_OSS_BUCKET: 'assets-bucket',
|
||||
ALIYUN_OSS_PUBLIC_BASE_URL: 'https://assets.example.test',
|
||||
VERIFY_ASSETS_UPLOADER: join(dir, 'upload-assets'),
|
||||
OSS_STUB_ROOT: join(dir, 'oss'),
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(0);
|
||||
const hosted = sh(
|
||||
`git -C "${dir}/assets.git" ls-tree -r --name-only pr-assets/7999-verify | grep verify/ || true`,
|
||||
)
|
||||
.stdout.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean);
|
||||
const hosted = readdirSync(
|
||||
join(dir, 'oss', 'assets-bucket', 'pr-assets', 'verify', 'pr7999-77-1'),
|
||||
);
|
||||
// Valid + at the exact 2 MiB boundary are hosted; the text file, the
|
||||
// oversize file and the duplicate name are not.
|
||||
expect(hosted.map((p) => p.split('/').pop()).sort()).toEqual([
|
||||
'01-ab.png',
|
||||
'04-edge.png',
|
||||
]);
|
||||
expect(hosted.sort()).toEqual(['01-ab.png', '04-edge.png']);
|
||||
const comment = readFileSync(out, 'utf8');
|
||||
expect(comment).toContain(';
|
||||
expect(comment).toContain(
|
||||
'',
|
||||
);
|
||||
expect(comment).not.toContain('02-fake');
|
||||
expect(comment).toContain('did not pass the hosting checks');
|
||||
|
||||
// Unreachable remote -> text-only, never an aborted report.
|
||||
sh(`rm -rf "${dir}/empty.git" && mkdir -p "${dir}/empty.git"`);
|
||||
// Upload failure -> text-only, never an aborted report.
|
||||
const out2 = join(dir, 'comment2.md');
|
||||
const res2 = sh(script, {
|
||||
cwd: work,
|
||||
@@ -4023,7 +4060,11 @@ describe('qwen-triage verify hardening round 2', () => {
|
||||
AGENT_VERDICT: 'findings',
|
||||
SKIP_REASON: '',
|
||||
PREPARE_FAILURE_PHASE: '',
|
||||
VERIFY_ASSETS_REMOTE: `${dir}/empty.git`,
|
||||
ALIYUN_OSS_BUCKET: 'assets-bucket',
|
||||
ALIYUN_OSS_PUBLIC_BASE_URL: 'https://assets.example.test',
|
||||
VERIFY_ASSETS_UPLOADER: join(dir, 'upload-assets'),
|
||||
OSS_STUB_ROOT: join(dir, 'oss'),
|
||||
OSS_STUB_FAIL: '1',
|
||||
},
|
||||
});
|
||||
expect(res2.status).toBe(0);
|
||||
@@ -4031,19 +4072,8 @@ describe('qwen-triage verify hardening round 2', () => {
|
||||
expect(comment2).toContain('Sandboxed verification');
|
||||
expect(comment2).not.toContain('Evidence images');
|
||||
|
||||
// FIRST RUN on a PR: the remote is valid but the per-PR branch does
|
||||
// not exist yet, so the clone fails and the orphan-init path runs for
|
||||
// real. Both scenarios above take the clone-failed branch too, but
|
||||
// both then fail to push (one seeded the branch, the other has no
|
||||
// remote), so neither proves orphan-init can actually DELIVER. Without
|
||||
// this, a bug in `checkout --orphan` or a dropped `remote add origin`
|
||||
// would silently discard every image on every PR's first run.
|
||||
sh(`git -C "${dir}/assets.git" branch -D pr-assets/7999-verify`);
|
||||
expect(
|
||||
sh(
|
||||
`git -C "${dir}/assets.git" branch --list pr-assets/7999-verify`,
|
||||
).stdout.trim(),
|
||||
).toBe('');
|
||||
// A later run gets an immutable prefix instead of overwriting or
|
||||
// accumulating commits in the same Git ref.
|
||||
const out3 = join(dir, 'comment3.md');
|
||||
const res3 = sh(script, {
|
||||
cwd: work,
|
||||
@@ -4064,33 +4094,311 @@ describe('qwen-triage verify hardening round 2', () => {
|
||||
AGENT_VERDICT: 'findings',
|
||||
SKIP_REASON: '',
|
||||
PREPARE_FAILURE_PHASE: '',
|
||||
VERIFY_ASSETS_REMOTE: `${dir}/assets.git`,
|
||||
ALIYUN_OSS_BUCKET: 'assets-bucket',
|
||||
ALIYUN_OSS_PUBLIC_BASE_URL: 'https://assets.example.test',
|
||||
VERIFY_ASSETS_UPLOADER: join(dir, 'upload-assets'),
|
||||
OSS_STUB_ROOT: join(dir, 'oss'),
|
||||
},
|
||||
});
|
||||
expect(res3.status).toBe(0);
|
||||
// The branch was created by orphan-init and carries this run's images.
|
||||
const hosted3 = sh(
|
||||
`git -C "${dir}/assets.git" ls-tree -r --name-only pr-assets/7999-verify | grep verify/ || true`,
|
||||
)
|
||||
.stdout.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean);
|
||||
expect(hosted3.map((p) => p.split('/').pop()).sort()).toEqual([
|
||||
'01-ab.png',
|
||||
'04-edge.png',
|
||||
]);
|
||||
// Orphan, not a graft onto unrelated history: exactly one commit.
|
||||
expect(
|
||||
sh(
|
||||
`git -C "${dir}/assets.git" rev-list --count pr-assets/7999-verify`,
|
||||
).stdout.trim(),
|
||||
).toBe('1');
|
||||
expect(readFileSync(out3, 'utf8')).toContain(';
|
||||
readdirSync(
|
||||
join(
|
||||
dir,
|
||||
'oss',
|
||||
'assets-bucket',
|
||||
'pr-assets',
|
||||
'verify',
|
||||
'pr7999-79-1',
|
||||
),
|
||||
).sort(),
|
||||
).toEqual(['01-ab.png', '04-edge.png']);
|
||||
expect(readFileSync(out3, 'utf8')).toContain(
|
||||
'https://assets.example.test/pr-assets/verify/pr7999-79-1/01-ab.png',
|
||||
);
|
||||
|
||||
// The production arms below run WITHOUT the seeded credential file
|
||||
// until the success arm writes it back — the missing-file case is
|
||||
// exactly what res4b asserts on.
|
||||
rmSync(join(dir, '.ossutilconfig'));
|
||||
|
||||
// An ossutil install failure is expected to degrade to a text-only
|
||||
// report. This executes the production dispatch arm without touching
|
||||
// the network and pins its successful return under set -e.
|
||||
const out4 = join(dir, 'comment4.md');
|
||||
const res4 = sh(script, {
|
||||
cwd: work,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${dir}:${process.env.PATH}`,
|
||||
GH_STUB_OUT: out4,
|
||||
GH_TOKEN: 'x',
|
||||
GITHUB_REPOSITORY: 'QwenLM/qwen-code',
|
||||
RUNNER_TEMP: dir,
|
||||
GITHUB_STEP_SUMMARY: '/dev/null',
|
||||
GITHUB_RUN_ID: '80',
|
||||
GITHUB_RUN_ATTEMPT: '1',
|
||||
PR_NUMBER: '7999',
|
||||
RUN_URL: 'u',
|
||||
VERIFY_RESULT: 'success',
|
||||
VERDICT: 'pass',
|
||||
AGENT_VERDICT: 'findings',
|
||||
SKIP_REASON: '',
|
||||
PREPARE_FAILURE_PHASE: '',
|
||||
ALIYUN_OSS_BUCKET: 'assets-bucket',
|
||||
ALIYUN_OSS_PUBLIC_BASE_URL: 'https://assets.example.test',
|
||||
OSSUTIL_INSTALL_OUTCOME: 'failure',
|
||||
OSS_STUB_ROOT: join(dir, 'oss'),
|
||||
},
|
||||
});
|
||||
expect(res4.status).toBe(0);
|
||||
const comment4 = readFileSync(out4, 'utf8');
|
||||
expect(comment4).toContain('Sandboxed verification');
|
||||
expect(comment4).not.toContain('Evidence images');
|
||||
|
||||
// Install green but NO credential file: the configure step is
|
||||
// continue-on-error, so this is what a missing/rotated OSS secret
|
||||
// looks like from here. The publisher must degrade to text rather
|
||||
// than hand the uploader a config it cannot read and pay three
|
||||
// retry backoffs per image to learn that.
|
||||
const out4b = join(dir, 'comment4b.md');
|
||||
const res4b = sh(script, {
|
||||
cwd: work,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${dir}:${process.env.PATH}`,
|
||||
GH_STUB_OUT: out4b,
|
||||
GH_TOKEN: 'x',
|
||||
GITHUB_REPOSITORY: 'QwenLM/qwen-code',
|
||||
RUNNER_TEMP: dir,
|
||||
GITHUB_STEP_SUMMARY: '/dev/null',
|
||||
GITHUB_RUN_ID: '80',
|
||||
GITHUB_RUN_ATTEMPT: '2',
|
||||
PR_NUMBER: '7999',
|
||||
RUN_URL: 'u',
|
||||
VERIFY_RESULT: 'success',
|
||||
VERDICT: 'pass',
|
||||
AGENT_VERDICT: 'findings',
|
||||
SKIP_REASON: '',
|
||||
PREPARE_FAILURE_PHASE: '',
|
||||
ALIYUN_OSS_BUCKET: 'assets-bucket',
|
||||
ALIYUN_OSS_PUBLIC_BASE_URL: 'https://assets.example.test',
|
||||
OSSUTIL_INSTALL_OUTCOME: 'success',
|
||||
OSS_STUB_ROOT: join(dir, 'oss'),
|
||||
},
|
||||
});
|
||||
expect(res4b.status).toBe(0);
|
||||
expect(existsSync(join(dir, '.ossutilconfig'))).toBe(false);
|
||||
const comment4b = readFileSync(out4b, 'utf8');
|
||||
expect(comment4b).toContain('Sandboxed verification');
|
||||
expect(comment4b).not.toContain('Evidence images');
|
||||
|
||||
// Exercise the production success dispatch too: copy the verified
|
||||
// binary into a job-private PATH and run the real uploader through it.
|
||||
const realUploader = readFileSync(
|
||||
'scripts/upload-aliyun-oss-assets.js',
|
||||
'utf8',
|
||||
);
|
||||
const realUtils = readFileSync('scripts/release-script-utils.js', 'utf8');
|
||||
mkdirSync(join(work, 'scripts'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(work, 'scripts', 'upload-aliyun-oss-assets.js'),
|
||||
realUploader,
|
||||
);
|
||||
writeFileSync(
|
||||
join(work, 'scripts', 'release-script-utils.js'),
|
||||
realUtils,
|
||||
);
|
||||
writeFileSync(
|
||||
join(dir, 'ossutil'),
|
||||
[
|
||||
'#!/bin/bash',
|
||||
'set -euo pipefail',
|
||||
'[ "$1" = cp ]',
|
||||
'src="$2"; dest="$3"',
|
||||
'target="$OSS_STUB_ROOT/${dest#oss://}"',
|
||||
'/bin/mkdir -p "$(/usr/bin/dirname "$target")"',
|
||||
'/bin/cp "$src" "$target"',
|
||||
].join('\n'),
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
// The configure step's product. The stub ossutil ignores -c, but the
|
||||
// publisher now refuses to dispatch without it — as production does.
|
||||
writeFileSync(join(dir, '.ossutilconfig'), '[Credentials]\n');
|
||||
const out5 = join(dir, 'comment5.md');
|
||||
const res5 = sh(script, {
|
||||
cwd: work,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${dir}:${process.env.PATH}`,
|
||||
GH_STUB_OUT: out5,
|
||||
GH_TOKEN: 'x',
|
||||
GITHUB_REPOSITORY: 'QwenLM/qwen-code',
|
||||
RUNNER_TEMP: dir,
|
||||
GITHUB_STEP_SUMMARY: '/dev/null',
|
||||
GITHUB_RUN_ID: '81',
|
||||
GITHUB_RUN_ATTEMPT: '1',
|
||||
PR_NUMBER: '7999',
|
||||
RUN_URL: 'u',
|
||||
VERIFY_RESULT: 'success',
|
||||
VERDICT: 'pass',
|
||||
AGENT_VERDICT: 'findings',
|
||||
SKIP_REASON: '',
|
||||
PREPARE_FAILURE_PHASE: '',
|
||||
ALIYUN_OSS_BUCKET: 'assets-bucket',
|
||||
ALIYUN_OSS_PUBLIC_BASE_URL: 'https://assets.example.test',
|
||||
OSSUTIL_INSTALL_OUTCOME: 'success',
|
||||
OSS_STUB_ROOT: join(dir, 'oss-production'),
|
||||
},
|
||||
});
|
||||
if (res5.status !== 0) {
|
||||
throw new Error(
|
||||
`production dispatch failed:\n${res5.stdout}\n${res5.stderr}`,
|
||||
);
|
||||
}
|
||||
expect(
|
||||
readdirSync(
|
||||
join(
|
||||
dir,
|
||||
'oss-production',
|
||||
'assets-bucket',
|
||||
'pr-assets',
|
||||
'verify',
|
||||
'pr7999-81-1',
|
||||
),
|
||||
).sort(),
|
||||
).toEqual(['01-ab.png', '04-edge.png']);
|
||||
expect(readFileSync(out5, 'utf8')).toContain(
|
||||
'https://assets.example.test/pr-assets/verify/pr7999-81-1/01-ab.png',
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// The behavioural runs above either stub the uploader via
|
||||
// VERIFY_ASSETS_UPLOADER or (res5) already exercise the production
|
||||
// ossutil arm end-to-end through the real uploader. Pin the full
|
||||
// invocation shape as well: node must resolve under the inherited PATH
|
||||
// (never $RUNNER_TEMP — PATH hijack) and all three ossutil flags must be
|
||||
// present — the real uploader exits 1 on a valueless --config, which in
|
||||
// production silently degrades every /verify report to text-only.
|
||||
it('pins the production uploader invocation and its ossutil flags', () => {
|
||||
const script =
|
||||
stepIn('publish-verify', 'Post verification report comment').match(
|
||||
/run: \|-\n([\s\S]*)$/,
|
||||
)?.[1] ?? '';
|
||||
expect(script).toContain('node_bin="$(command -v node || true)"');
|
||||
expect(script).toContain('upload_cmd=("$node_bin" "$uploader")');
|
||||
expect(script).toContain('upload_path="$trusted_bin"');
|
||||
expect(script).toContain('if ! PATH="$upload_path" "${upload_cmd[@]}"');
|
||||
expect(script).not.toContain('PATH="$RUNNER_TEMP:$PATH"');
|
||||
expect(script).toContain('--bucket "$ALIYUN_OSS_BUCKET"');
|
||||
expect(script).toContain('--config "${RUNNER_TEMP:-/tmp}/.ossutilconfig"');
|
||||
expect(script).toContain('--prefix "$prefix"');
|
||||
});
|
||||
|
||||
// The credential lifecycle is security-relevant: dropping the cleanup
|
||||
// (or its always() condition) leaves the OSS key pair in $RUNNER_TEMP —
|
||||
// not exploitable on today's ephemeral ubuntu-latest runner, but the
|
||||
// shape must not regress if the job ever returns to a persistent pool.
|
||||
// A dropped sha256 check installs whatever the mirror serves. Consumers
|
||||
// must also trust the Actions-computed outcome, never a marker in the
|
||||
// PR-writable temp dir.
|
||||
it('pins the ossutil install/configure/cleanup credential lifecycle', () => {
|
||||
const install = stepIn('publish-verify', 'Install ossutil');
|
||||
expect(install).toContain("id: 'install-ossutil'");
|
||||
expect(install).toContain('continue-on-error: true');
|
||||
expect(install).toContain('sha256sum -c');
|
||||
const configure = stepIn(
|
||||
'publish-verify',
|
||||
'Configure Aliyun OSS credentials',
|
||||
);
|
||||
expect(configure).toContain(
|
||||
'if: "${{ steps.install-ossutil.outcome == \'success\' }}"',
|
||||
);
|
||||
expect(configure).toContain('continue-on-error: true');
|
||||
expect(configure).toContain('-c "$RUNNER_TEMP/.ossutilconfig"');
|
||||
const publish = stepIn(
|
||||
'publish-verify',
|
||||
'Post verification report comment',
|
||||
);
|
||||
expect(publish).toContain(
|
||||
"OSSUTIL_INSTALL_OUTCOME: '${{ steps.install-ossutil.outcome }}'",
|
||||
);
|
||||
// Both preconditions: the Actions-computed install outcome (never a
|
||||
// marker in the PR-writable temp dir) AND the credential file the
|
||||
// continue-on-error configure step is supposed to have written.
|
||||
expect(publish).toContain(
|
||||
'elif [ "${OSSUTIL_INSTALL_OUTCOME:-}" = \'success\' ] &&',
|
||||
);
|
||||
expect(publish).toContain(
|
||||
'[ -f "${RUNNER_TEMP:-/tmp}/.ossutilconfig" ]; then',
|
||||
);
|
||||
const cleanup = stepIn('publish-verify', 'Cleanup Aliyun OSS credentials');
|
||||
expect(cleanup).toContain("if: '${{ always() }}'");
|
||||
expect(cleanup).toContain('rm -f "$RUNNER_TEMP/.ossutilconfig"');
|
||||
});
|
||||
|
||||
// The publisher's checkout is the trust boundary for this job's OSS
|
||||
// credentials: it must take only the trusted base-repo scripts, from the
|
||||
// default-branch head (publish-verify only runs under issue_comment
|
||||
// events, where github.sha is exactly that — never a PR merge ref).
|
||||
// Dropping /package.json stops the uploader's `.js` parsing as ESM on
|
||||
// Node versions that don't infer module syntax, and this job pins no Node.
|
||||
it('checks out only trusted base-repo scripts at a pinned ref', () => {
|
||||
const checkout = stepIn(
|
||||
'publish-verify',
|
||||
'Checkout the OSS publisher scripts',
|
||||
);
|
||||
expect(checkout).toContain("ref: '${{ github.sha }}'");
|
||||
expect(checkout).toContain('persist-credentials: false');
|
||||
expect(checkout).toContain('sparse-checkout-cone-mode: false');
|
||||
for (const entry of [
|
||||
'/package.json',
|
||||
'scripts/upload-aliyun-oss-assets.js',
|
||||
'scripts/release-script-utils.js',
|
||||
]) {
|
||||
expect(checkout).toContain(`\n ${entry}\n`);
|
||||
}
|
||||
});
|
||||
|
||||
// A CDN retry loop whose worst case exceeds the 10-minute job cap turns
|
||||
// the intended "degrade to a text-only report" into "job killed
|
||||
// mid-install"; pin the retry flags and compute the bound.
|
||||
it('keeps the ossutil download retry budget inside the job cap', () => {
|
||||
const install = stepIn('publish-verify', 'Install ossutil');
|
||||
expect(install).toContain('--retry-all-errors');
|
||||
const m = install.match(
|
||||
/curl -fsSL --retry (\d+) --retry-delay (\d+) --retry-all-errors \\\n\s+--connect-timeout (\d+) --max-time (\d+)/,
|
||||
);
|
||||
expect(m).not.toBeNull();
|
||||
const worstSeconds =
|
||||
(Number(m[1]) + 1) * Number(m[4]) + Number(m[1]) * Number(m[2]);
|
||||
const cap = job('publish-verify').match(/timeout-minutes: (\d+)/);
|
||||
expect(cap).not.toBeNull();
|
||||
expect(worstSeconds).toBeLessThan(Number(cap[1]) * 60);
|
||||
});
|
||||
|
||||
// Overriding only one of the bucket/base-URL vars must not post comment
|
||||
// links that 404 against (or show stale objects from) the other bucket:
|
||||
// the default base URL is derived from whichever bucket wins. A stalled
|
||||
// upload is additionally bounded per attempt so one black-hole socket
|
||||
// cannot burn the job cap and lose the whole report.
|
||||
it('derives the public URL from the resolved bucket and bounds uploads', () => {
|
||||
const publish = stepIn(
|
||||
'publish-verify',
|
||||
'Post verification report comment',
|
||||
);
|
||||
expect(publish).toContain(
|
||||
'ALIYUN_OSS_BUCKET: "${{ vars.ALIYUN_OSS_PR_ASSETS_BUCKET || vars.ALIYUN_OSS_BUCKET || \'qwen-code-assets\' }}"',
|
||||
);
|
||||
expect(publish).toContain(
|
||||
"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') }}\"",
|
||||
);
|
||||
expect(publish).toContain("OSS_UPLOAD_ATTEMPT_TIMEOUT_MS: '120000'");
|
||||
});
|
||||
|
||||
// Every publish fixture returned [] for the comments listing, so the
|
||||
// PATCH arm — the one that must reuse a live status comment instead of
|
||||
// stranding it — was never executed by any test.
|
||||
@@ -4158,7 +4466,6 @@ describe('qwen-triage verify hardening round 2', () => {
|
||||
AGENT_VERDICT: 'findings',
|
||||
SKIP_REASON: '',
|
||||
PREPARE_FAILURE_PHASE: '',
|
||||
VERIFY_ASSETS_REMOTE: join(dir, 'none.git'),
|
||||
...env,
|
||||
},
|
||||
});
|
||||
@@ -4518,7 +4825,6 @@ describe('qwen-triage verify publish fidelity', () => {
|
||||
AGENT_VERDICT: '',
|
||||
SKIP_REASON: '',
|
||||
PREPARE_FAILURE_PHASE: '',
|
||||
VERIFY_ASSETS_REMOTE: join(dir, 'nonexistent.git'),
|
||||
...env,
|
||||
},
|
||||
});
|
||||
@@ -5393,24 +5699,22 @@ describe('qwen-triage verify round-3 hardening', () => {
|
||||
},
|
||||
);
|
||||
|
||||
// The publish job must host images on a per-PR branch that can coexist
|
||||
// with the existing pr-assets/* namespace — a bare `pr-assets` leaf
|
||||
// cannot be created while `pr-assets/…` children exist.
|
||||
it('hosts evidence on a per-PR branch, not a bare pr-assets leaf', () => {
|
||||
it('hosts verification evidence on OSS without writing Git refs', () => {
|
||||
const publish = stepIn(
|
||||
'publish-verify',
|
||||
'Post verification report comment',
|
||||
);
|
||||
expect(publish).toContain('pr-assets/${PR_NUMBER}-verify');
|
||||
expect(publish).toContain('checkout -q --orphan');
|
||||
expect(publish).not.toMatch(/--branch pr-assets["\s]/);
|
||||
expect(publish).toContain(
|
||||
'pr-assets/verify/pr${PR_NUMBER}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT:-1}',
|
||||
);
|
||||
expect(publish).toContain('scripts/upload-aliyun-oss-assets.js');
|
||||
expect(publish).not.toContain('checkout -q --orphan');
|
||||
expect(publish).not.toContain('git push');
|
||||
});
|
||||
|
||||
// Every `pr-assets/*` producer needs a deleter, or its branches are
|
||||
// permanent: one single-commit branch per verified PR, forever, slowing
|
||||
// `git ls-remote` and cluttering the branch list for every contributor.
|
||||
// The verify lane became a second producer and was not added.
|
||||
it('deletes both pr-assets producers when a PR closes', () => {
|
||||
// Keep deleting legacy refs until the historical branch population has
|
||||
// drained, even though new evidence is published to OSS.
|
||||
it('deletes both legacy pr-assets refs when a PR closes', () => {
|
||||
const cleanup = readFileSync(
|
||||
'.github/workflows/web-shell-visuals-cleanup.yml',
|
||||
'utf8',
|
||||
@@ -6136,7 +6440,6 @@ describe('qwen-triage verify maintainer-review round', () => {
|
||||
SKIP_REASON: '',
|
||||
PREPARE_FAILURE_PHASE: '',
|
||||
AGENT_VERDICT: '',
|
||||
VERIFY_ASSETS_REMOTE: join(dir, 'none.git'),
|
||||
},
|
||||
});
|
||||
return {
|
||||
@@ -6211,6 +6514,12 @@ describe('qwen-triage verify maintainer-review round', () => {
|
||||
expect(minutes).toBeLessThanOrEqual(30);
|
||||
});
|
||||
|
||||
it('keeps the credential-bearing publisher on a hosted runner', () => {
|
||||
const publish = job('publish-verify');
|
||||
expect(publish).toContain("runs-on: 'ubuntu-latest'");
|
||||
expect(publish).not.toContain('ecs-qwen');
|
||||
});
|
||||
|
||||
// Upstream failure text can name resolved hosts and TLS detail; the agent
|
||||
// only needs to know the call failed.
|
||||
it('does not forward upstream error text to the agent', () => {
|
||||
|
||||
@@ -8,7 +8,11 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseUploadArgs, uploadAssets } from '../upload-aliyun-oss-assets.js';
|
||||
import {
|
||||
parseUploadArgs,
|
||||
resolveAttemptTimeoutMs,
|
||||
uploadAssets,
|
||||
} from '../upload-aliyun-oss-assets.js';
|
||||
|
||||
describe('parseUploadArgs', () => {
|
||||
it('returns help=true and skips later validation when --help is passed', () => {
|
||||
@@ -87,12 +91,16 @@ describe('uploadAssets (integration)', () => {
|
||||
fs.mkdirSync(workDir, { recursive: true });
|
||||
const ossutilPath = path.join(workDir, 'ossutil-shim.cjs');
|
||||
const logPath = path.join(workDir, 'ossutil.log');
|
||||
const exitLine =
|
||||
behavior === 'hang'
|
||||
? 'setTimeout(() => {}, 60000);' // never exits on its own
|
||||
: `process.exit(${behavior === 'fail' ? 1 : 0});`;
|
||||
fs.writeFileSync(
|
||||
ossutilPath,
|
||||
[
|
||||
"const fs = require('node:fs');",
|
||||
`fs.appendFileSync(${JSON.stringify(logPath)}, process.argv.slice(2).join('\\n') + '\\n');`,
|
||||
`process.exit(${behavior === 'fail' ? 1 : 0});`,
|
||||
exitLine,
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
@@ -172,4 +180,96 @@ describe('uploadAssets (integration)', () => {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it('kills a stalled attempt and retries it when attemptTimeoutMs is set', async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-upload-hang-'));
|
||||
try {
|
||||
const { logPath, ossutilCommand, ossutilCommandArgs } = makeOssutilShim(
|
||||
tmp,
|
||||
'hang',
|
||||
);
|
||||
const assetPath = path.join(tmp, 'asset.tar.gz');
|
||||
fs.writeFileSync(assetPath, 'asset');
|
||||
const configPath = path.join(tmp, '.ossutilconfig');
|
||||
fs.writeFileSync(configPath, '[Credentials]\n');
|
||||
|
||||
// Without the bound this test would sit in the shim's 60s hang; the
|
||||
// kill turns the stall into an ordinary failed attempt, so all three
|
||||
// attempts happen and the uploader fails loudly instead of hanging.
|
||||
expect(() =>
|
||||
uploadAssets(
|
||||
{
|
||||
assets: [assetPath],
|
||||
bucket: 'qwen-test-bucket',
|
||||
config: configPath,
|
||||
prefix: 'releases/qwen-code/v0.0.0',
|
||||
},
|
||||
{ ossutilCommand, ossutilCommandArgs, attemptTimeoutMs: 400 },
|
||||
),
|
||||
).toThrow(/ossutil failed after 3 attempts/);
|
||||
const uploadAttempts = fs
|
||||
.readFileSync(logPath, 'utf8')
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line === assetPath);
|
||||
expect(uploadAttempts).toHaveLength(3);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it('succeeds within the attempt bound when the upload is fast', async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-upload-fast-'));
|
||||
try {
|
||||
const { ossutilCommand, ossutilCommandArgs } = makeOssutilShim(tmp);
|
||||
const assetPath = path.join(tmp, 'asset.tar.gz');
|
||||
fs.writeFileSync(assetPath, 'asset');
|
||||
const configPath = path.join(tmp, '.ossutilconfig');
|
||||
fs.writeFileSync(configPath, '[Credentials]\n');
|
||||
|
||||
expect(() =>
|
||||
uploadAssets(
|
||||
{
|
||||
assets: [assetPath],
|
||||
bucket: 'qwen-test-bucket',
|
||||
config: configPath,
|
||||
prefix: 'releases/qwen-code/v0.0.0',
|
||||
},
|
||||
{ ossutilCommand, ossutilCommandArgs, attemptTimeoutMs: 5000 },
|
||||
),
|
||||
).not.toThrow();
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe('resolveAttemptTimeoutMs', () => {
|
||||
const KEY = 'OSS_UPLOAD_ATTEMPT_TIMEOUT_MS';
|
||||
|
||||
it('defaults to unbounded when the env var is unset', () => {
|
||||
delete process.env[KEY];
|
||||
expect(resolveAttemptTimeoutMs()).toBe(0);
|
||||
});
|
||||
|
||||
it('reads a millisecond bound from the environment', () => {
|
||||
process.env[KEY] = '120000';
|
||||
try {
|
||||
expect(resolveAttemptTimeoutMs()).toBe(120000);
|
||||
} finally {
|
||||
delete process.env[KEY];
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a non-integer or negative bound', () => {
|
||||
for (const bad of ['soon', '-5', '1.5']) {
|
||||
process.env[KEY] = bad;
|
||||
try {
|
||||
expect(() => resolveAttemptTimeoutMs()).toThrow(
|
||||
/non-negative integer of milliseconds/,
|
||||
);
|
||||
} finally {
|
||||
delete process.env[KEY];
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import { fail, isMainModule, readOptionValue } from './release-script-utils.js';
|
||||
|
||||
const MAX_UPLOAD_ATTEMPTS = 3;
|
||||
const INITIAL_BACKOFF_MS = 2000;
|
||||
const ATTEMPT_TIMEOUT_ENV = 'OSS_UPLOAD_ATTEMPT_TIMEOUT_MS';
|
||||
|
||||
if (isMainModule(import.meta.url)) {
|
||||
try {
|
||||
@@ -28,13 +29,32 @@ function main(argv) {
|
||||
printUsage();
|
||||
return;
|
||||
}
|
||||
uploadAssets(args);
|
||||
uploadAssets(args, { attemptTimeoutMs: resolveAttemptTimeoutMs() });
|
||||
}
|
||||
|
||||
// A stalled ossutil (a black-hole socket that accepts but never progresses)
|
||||
// must not hang the caller forever: the PR publishers run inside a
|
||||
// 10-minute job cap, and one stuck upload would burn the cap and lose the
|
||||
// whole report/comment. The bound is opt-in via env so the release syncs —
|
||||
// which move much larger files — keep today's unbounded behaviour exactly.
|
||||
function resolveAttemptTimeoutMs() {
|
||||
const raw = process.env[ATTEMPT_TIMEOUT_ENV];
|
||||
if (!raw) {
|
||||
return 0;
|
||||
}
|
||||
const value = Number(raw);
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
fail(
|
||||
`${ATTEMPT_TIMEOUT_ENV} must be a non-negative integer of milliseconds, got ${JSON.stringify(raw)}.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
console.log(`Usage: node scripts/upload-aliyun-oss-assets.js [options] ASSET...
|
||||
|
||||
Uploads local release assets to a public Aliyun OSS prefix via ossutil.
|
||||
Uploads local assets to a public Aliyun OSS prefix via ossutil.
|
||||
|
||||
Options:
|
||||
--bucket NAME OSS bucket name.
|
||||
@@ -101,13 +121,18 @@ function parseUploadArgs(argv) {
|
||||
|
||||
function uploadAssets(
|
||||
{ assets, bucket, config, prefix },
|
||||
{ ossutilCommand = 'ossutil', ossutilCommandArgs = [] } = {},
|
||||
{
|
||||
ossutilCommand = 'ossutil',
|
||||
ossutilCommandArgs = [],
|
||||
attemptTimeoutMs = 0,
|
||||
} = {},
|
||||
) {
|
||||
for (const asset of assets) {
|
||||
const key = `${prefix}/${path.basename(asset)}`;
|
||||
uploadWithRetry(asset, bucket, key, config, {
|
||||
ossutilCommand,
|
||||
ossutilCommandArgs,
|
||||
attemptTimeoutMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -117,7 +142,7 @@ function uploadWithRetry(
|
||||
bucket,
|
||||
key,
|
||||
config,
|
||||
{ ossutilCommand, ossutilCommandArgs },
|
||||
{ ossutilCommand, ossutilCommandArgs, attemptTimeoutMs },
|
||||
) {
|
||||
for (let attempt = 1; attempt <= MAX_UPLOAD_ATTEMPTS; attempt += 1) {
|
||||
const result = spawnSync(
|
||||
@@ -133,10 +158,20 @@ function uploadWithRetry(
|
||||
'--acl',
|
||||
'public-read',
|
||||
],
|
||||
{ stdio: 'inherit' },
|
||||
{
|
||||
stdio: 'inherit',
|
||||
// 0 disables the bound (the release syncs' default); a positive
|
||||
// value SIGKILLs an attempt that stalls past it instead of letting
|
||||
// it hang the caller out to the job cap.
|
||||
...(attemptTimeoutMs > 0
|
||||
? { timeout: attemptTimeoutMs, killSignal: 'SIGKILL' }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
// A timeout surfaces as error.code ETIMEDOUT and a killed child — that
|
||||
// is a retryable attempt failure, not a spawn error to rethrow.
|
||||
if (result.error && result.error.code !== 'ETIMEDOUT') {
|
||||
throw result.error;
|
||||
}
|
||||
if (result.status === 0) {
|
||||
@@ -145,7 +180,9 @@ function uploadWithRetry(
|
||||
if (attempt < MAX_UPLOAD_ATTEMPTS) {
|
||||
const delayMs = INITIAL_BACKOFF_MS * 2 ** (attempt - 1);
|
||||
console.warn(
|
||||
`Upload attempt ${attempt}/${MAX_UPLOAD_ATTEMPTS} failed for ${path.basename(asset)}, retrying in ${delayMs / 1000}s...`,
|
||||
`Upload attempt ${attempt}/${MAX_UPLOAD_ATTEMPTS} failed for ${path.basename(asset)}${
|
||||
result.error ? ' (timed out)' : ''
|
||||
}, retrying in ${delayMs / 1000}s...`,
|
||||
);
|
||||
sleepSync(delayMs);
|
||||
}
|
||||
@@ -161,4 +198,4 @@ function sleepSync(ms) {
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
||||
}
|
||||
|
||||
export { parseUploadArgs, uploadAssets };
|
||||
export { parseUploadArgs, resolveAttemptTimeoutMs, uploadAssets };
|
||||
Reference in new issue
Block a user