-
Notifications
You must be signed in to change notification settings - Fork 3k
fix(ci): move undeletable workspace residue aside instead of leaving it #9868
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,6 +11,7 @@ import { | |
| lstatSync, | ||
| mkdirSync, | ||
| mkdtempSync, | ||
| readdirSync, | ||
| readFileSync, | ||
| rmSync, | ||
| symlinkSync, | ||
|
|
@@ -130,6 +131,33 @@ function expectCleanupRecipe(run) { | |
| expectPipedLoopsIsolated(code, 2); | ||
| } | ||
|
|
||
| // Deleting the tree is not always possible: a containerised job on this | ||
| // shared pool can leave residue owned by another uid, and on a pool member | ||
| // without passwordless sudo nothing unprivileged can unlink it. Leaving it | ||
| // in place poisons the checkout of every LATER job scheduled here, so the | ||
| // sweep must move it out of the workspace instead of warning and continuing | ||
| // — renaming needs write permission only on the two parents, and the | ||
| // workspace root is always the runner's own. | ||
| function expectQuarantineFallback(run) { | ||
| const code = stripComments(run); | ||
| expect(code).toContain('_qwen-quarantine'); | ||
| // The move must be the fallback of the removal chain, not an | ||
| // unconditional relocation: a workspace that deletes cleanly keeps its | ||
| // caches. | ||
| expect(code).toMatch( | ||
| /rm -rf "\$GITHUB_WORKSPACE\/\.qwen"[\s\S]*?sudo -n rm -rf[\s\S]*?mv -- "\$GITHUB_WORKSPACE\/\.qwen"/, | ||
| ); | ||
| // Same filesystem by construction — a cross-device `mv` degrades to | ||
| // copy-then-unlink, which fails on exactly the residue this exists for. | ||
| expect(code).toContain( | ||
| '"$(dirname -- "$GITHUB_WORKSPACE")/_qwen-quarantine"', | ||
| ); | ||
| // The quarantined tree still needs a human: the warning must name where | ||
| // it went, and the terminal warning must survive for the case where even | ||
| // the rename fails. | ||
| expect(code).toContain('leaked .qwen; runner needs manual cleanup'); | ||
|
Comment on lines
+157
to
+158
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The rename-failure branch of the new quarantine fallback (the Witness (probe in an isolated tree): the missing fixture — mirroring the undeletable-residue test with the quarantine parent locked (chmod 0o500) so Suggested fix: add that third behavioral fixture — assert 中文说明新增隔离兜底的「重命名失败」分支( 证据(隔离树中的探针):补上缺失的夹具——仿照「不可删除残留」测试、但把隔离目录的父目录锁成 0o500,使 建议修复:补上这第三个行为夹具——断言 — qwen3.8-max via Qwen Code /review (v0.22.0) |
||
| } | ||
|
|
||
| function expectHardenedGit(run) { | ||
| expect(run).toContain( | ||
| 'GIT_SAFE=(git -c core.hooksPath=/dev/null -c core.fsmonitor= -C "$GITHUB_WORKSPACE")', | ||
|
|
@@ -256,6 +284,7 @@ describe('review worktree cleanup steps', () => { | |
| expect(cleanIdx, id).toBeLessThan(checkoutIdx); | ||
| expectCleanupRecipe(run); | ||
| expectHardenedGit(run); | ||
| expectQuarantineFallback(run); | ||
| } | ||
| // The copies are deliberate: a pre-checkout step cannot trust leftover | ||
| // workspace scripts, so the recipe stays inline per job. Pin them | ||
|
|
@@ -457,6 +486,93 @@ describe('review worktree cleanup steps', () => { | |
| }, | ||
| ); | ||
|
|
||
| it.skipIf(!permissionFixturesAvailable)( | ||
| 'the pre-checkout sweep moves residue it cannot delete out of the workspace', | ||
| () => { | ||
| // The incident this exists for: residue whose containing directory | ||
| // denies the unlink, so `rm -rf` fails and actions/checkout dies | ||
| // wiping the workspace (measured, run 32621267802 — two unrelated PRs | ||
| // failed at Checkout on the same runner). Reproduced here with a | ||
| // write-denied parent rather than a foreign uid, which needs root: | ||
| // the failing syscall and the recovery are the same, and the sweep's | ||
| // own chmod is stepped over so it cannot repair the fixture away. | ||
| const root = mkdtempSync(join(tmpdir(), 'ci-quarantine-')); | ||
| const workspace = join(root, 'repo', 'repo'); | ||
| const poison = join( | ||
| workspace, | ||
| `${toPosix(REVIEW_TMP_DIR)}/review-pr-9748-scratch-verify--round-1--x`, | ||
| ); | ||
| const locked = join(poison, 'probe-ws/.qwen/tmp'); | ||
| try { | ||
| mkdirSync(join(locked, 'review-pr-666'), { recursive: true }); | ||
| chmodSync(locked, 0o500); | ||
| const out = spawnSync( | ||
| 'bash', | ||
| [ | ||
| '-c', | ||
| // Neutralise the sweep's own chmod and any sudo: this models the | ||
| // pool member that cannot repair the residue at all. | ||
| `set -euo pipefail\nchmod() { return 1; }\nsudo() { return 1; }\n${ciCleanSteps[0].run}`, | ||
| 'clean-stale-qwen', | ||
| ], | ||
| { | ||
| cwd: workspace, | ||
| env: { ...process.env, GITHUB_WORKSPACE: workspace }, | ||
| encoding: 'utf8', | ||
| }, | ||
| ); | ||
| expect(out.status).toBe(0); | ||
| // The workspace is clear, so the checkout that follows has nothing | ||
| // to trip on … | ||
| expect(existsSync(join(workspace, '.qwen'))).toBe(false); | ||
| // … and the residue was moved, not deleted: it still needs a human, | ||
| // and the warning says where it went. | ||
| const quarantine = join(root, 'repo', '_qwen-quarantine'); | ||
| expect(existsSync(quarantine)).toBe(true); | ||
| expect(readdirSync(quarantine)).toHaveLength(1); | ||
| const warnings = out.stdout | ||
| .split('\n') | ||
| .filter((line) => line.startsWith('::warning::')); | ||
| expect(warnings).toHaveLength(1); | ||
| expect(warnings[0]).toContain('_qwen-quarantine'); | ||
| } finally { | ||
| // The locked directory has usually MOVED by now (that is the point), | ||
| // so repair the whole fixture by path rather than the original one. | ||
| spawnSync('bash', [ | ||
| '-c', | ||
| `chmod -R u+rwX "${root}" 2>/dev/null || true`, | ||
| ]); | ||
| rmSync(root, { recursive: true, force: true }); | ||
| } | ||
| }, | ||
| ); | ||
|
|
||
| it.skipIf(!permissionFixturesAvailable)( | ||
| 'the pre-checkout sweep still deletes residue it can remove', | ||
| () => { | ||
| // The fallback must stay a fallback: a workspace that deletes cleanly | ||
| // keeps its caches instead of accumulating quarantined copies. | ||
| const root = mkdtempSync(join(tmpdir(), 'ci-quarantine-')); | ||
| const workspace = join(root, 'repo', 'repo'); | ||
| try { | ||
| mkdirSync(join(workspace, `${toPosix(REVIEW_TMP_DIR)}/review-pr-77`), { | ||
| recursive: true, | ||
| }); | ||
| const out = spawnSync('bash', ['-c', ciCleanSteps[0].run], { | ||
| cwd: workspace, | ||
| env: { ...process.env, GITHUB_WORKSPACE: workspace }, | ||
| encoding: 'utf8', | ||
| }); | ||
| expect(out.status).toBe(0); | ||
| expect(existsSync(join(workspace, '.qwen'))).toBe(false); | ||
| expect(existsSync(join(root, 'repo', '_qwen-quarantine'))).toBe(false); | ||
| expect(out.stdout).not.toContain('::warning::'); | ||
| } finally { | ||
| rmSync(root, { recursive: true, force: true }); | ||
| } | ||
| }, | ||
| ); | ||
|
|
||
| it.skipIf(!permissionFixturesAvailable)( | ||
| 'remove_review_tree actually removes a plain leftover', | ||
| () => { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Suggestion] This chain-order pin checks the sudo rung's verb only (
sudo -n rm -rf), not its target path — and the one behavioral test that reaches the fallback stubssudo() { return 1; }, so the argument is never observed. Confirmed by probe: the one-line mutationsudo -n rm -rf "$GITHUB_WORKSPACE/.qwen"→sudo -n rm -rf "$GITHUB_WORKSPACE/.qwen.bak"survives this regex and both behavioral tests (all 15 pass), and an argument-recording sudo stub shows the deletion rung then targeting a nonexistent path. On pool members with passwordless sudo, foreign-owned residue would silently never be deleted again — every incident degrades to quarantine-plus-manual-cleanup instead of a clean deletion, and nothing in the suite goes red.中文说明
这条顺序锁定只检查了 sudo 阶梯的动词(
sudo -n rm -rf),没有检查它的目标路径——而唯一会走到兜底分支的行为测试又把sudo桩成sudo() { return 1; },因此该参数从未被观测到。探针已证实:单行变异sudo -n rm -rf "$GITHUB_WORKSPACE/.qwen"→sudo -n rm -rf "$GITHUB_WORKSPACE/.qwen.bak"能通过该正则与两个行为测试(15 项全绿);用记录参数的 sudo 桩可看到删除阶梯随后指向一个不存在的路径。在配备免密 sudo 的池节点上,异属主残留将悄无声息地永远删不掉——每次事故都退化为「隔离 + 人工清理」而非干净删除,而套件中没有任何测试变红。— qwen3.8-max via Qwen Code /review (v0.22.0)