From 79433e50cb6697ee53da6b61e01923f89e6561c5 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Wed, 26 Aug 2026 00:20:29 +0800 Subject: [PATCH 01/35] fix(ci): route release pipeline Linux jobs to the ECS runner pool --- .github/workflows/release.yml | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 98ad5d096a0..17307d343cc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,10 @@ on: jobs: prepare: name: 'Prepare Release Metadata' - runs-on: 'ubuntu-latest' + # Route to the ECS self-hosted pool like the review/CI lanes; the + # MAINTAINER_ECS_RUNNER_DISABLED repo variable restores the hosted + # ubuntu-latest fallback without a code change. + runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}' if: |- ${{ github.repository == 'QwenLM/qwen-code' }} permissions: @@ -145,7 +148,10 @@ jobs: quality: name: 'Quality Checks' - runs-on: 'ubuntu-latest' + # Route to the ECS self-hosted pool like the review/CI lanes; the + # MAINTAINER_ECS_RUNNER_DISABLED repo variable restores the hosted + # ubuntu-latest fallback without a code change. + runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}' needs: 'prepare' if: |- ${{ github.event.inputs.force_skip_tests != 'true' }} @@ -207,7 +213,10 @@ jobs: integration_none: name: 'Integration Tests (No Sandbox)' - runs-on: 'ubuntu-latest' + # Route to the ECS self-hosted pool like the review/CI lanes; the + # MAINTAINER_ECS_RUNNER_DISABLED repo variable restores the hosted + # ubuntu-latest fallback without a code change. + runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}' needs: 'prepare' if: |- ${{ github.event.inputs.force_skip_tests != 'true' }} @@ -258,7 +267,10 @@ jobs: integration_docker: name: 'Integration Tests (Docker)' - runs-on: 'ubuntu-latest' + # Route to the ECS self-hosted pool like the review/CI lanes; the + # MAINTAINER_ECS_RUNNER_DISABLED repo variable restores the hosted + # ubuntu-latest fallback without a code change. + runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}' needs: 'prepare' if: |- ${{ github.event.inputs.force_skip_tests != 'true' }} @@ -327,7 +339,10 @@ jobs: publish: name: 'Publish Release' - runs-on: 'ubuntu-latest' + # Route to the ECS self-hosted pool like the review/CI lanes; the + # MAINTAINER_ECS_RUNNER_DISABLED repo variable restores the hosted + # ubuntu-latest fallback without a code change. + runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}' needs: - 'prepare' - 'quality' @@ -747,7 +762,10 @@ jobs: notify_failure: name: 'Notify Release Failure' - runs-on: 'ubuntu-latest' + # Route to the ECS self-hosted pool like the review/CI lanes; the + # MAINTAINER_ECS_RUNNER_DISABLED repo variable restores the hosted + # ubuntu-latest fallback without a code change. + runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}' needs: - 'prepare' - 'quality' From 225fbf577817e07de350a5f1a3d0eac4f2e3fdce Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Wed, 26 Aug 2026 05:06:42 +0800 Subject: [PATCH 02/35] fix(ci): restore ECS workspace before release checkout Co-authored-by: Qwen Code Co-authored-by: Qwen-Coder --- .github/workflows/release.yml | 60 ++++++++++++++++++++++++++ scripts/tests/release-workflow.test.js | 19 ++++++++ 2 files changed, 79 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 17307d343cc..e861ce3999f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,6 +59,18 @@ jobs: is_dry_run: '${{ steps.vars.outputs.is_dry_run }}' steps: + # Shared ECS runners can retain root-owned files from an earlier + # containerized job. Restore the reusable workspace before checkout. + - name: 'Restore workspace ownership' + run: |- + set -uo pipefail + RUNNER_UID="$(id -u)" + RUNNER_GID="$(id -g)" + if [ "$RUNNER_UID" != "0" ]; then + chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files" + fi + chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files" + - name: 'Checkout' uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: @@ -163,6 +175,18 @@ jobs: OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}' steps: + # Shared ECS runners can retain root-owned files from an earlier + # containerized job. Restore the reusable workspace before checkout. + - name: 'Restore workspace ownership' + run: |- + set -uo pipefail + RUNNER_UID="$(id -u)" + RUNNER_GID="$(id -g)" + if [ "$RUNNER_UID" != "0" ]; then + chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files" + fi + chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files" + - name: 'Checkout' uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: @@ -228,6 +252,18 @@ jobs: OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}' steps: + # Shared ECS runners can retain root-owned files from an earlier + # containerized job. Restore the reusable workspace before checkout. + - name: 'Restore workspace ownership' + run: |- + set -uo pipefail + RUNNER_UID="$(id -u)" + RUNNER_GID="$(id -g)" + if [ "$RUNNER_UID" != "0" ]; then + chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files" + fi + chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files" + - name: 'Checkout' uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: @@ -282,6 +318,18 @@ jobs: OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}' steps: + # Shared ECS runners can retain root-owned files from an earlier + # containerized job. Restore the reusable workspace before checkout. + - name: 'Restore workspace ownership' + run: |- + set -uo pipefail + RUNNER_UID="$(id -u)" + RUNNER_GID="$(id -g)" + if [ "$RUNNER_UID" != "0" ]; then + chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files" + fi + chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files" + - name: 'Checkout' uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: @@ -399,6 +447,18 @@ jobs: pull-requests: 'write' steps: + # Shared ECS runners can retain root-owned files from an earlier + # containerized job. Restore the reusable workspace before checkout. + - name: 'Restore workspace ownership' + run: |- + set -uo pipefail + RUNNER_UID="$(id -u)" + RUNNER_GID="$(id -g)" + if [ "$RUNNER_UID" != "0" ]; then + chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files" + fi + chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files" + - name: 'Checkout' uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: diff --git a/scripts/tests/release-workflow.test.js b/scripts/tests/release-workflow.test.js index 3d206df9351..912e3b3b5ea 100644 --- a/scripts/tests/release-workflow.test.js +++ b/scripts/tests/release-workflow.test.js @@ -80,6 +80,25 @@ describe('CUA release workflow', () => { }); describe('release workflow', () => { + it('restores shared ECS workspace ownership before every checkout', () => { + const checkoutCount = (workflow.match(/- name: 'Checkout'/g) ?? []).length; + const restoreCount = ( + workflow.match(/- name: 'Restore workspace ownership'/g) ?? [] + ).length; + + const beforeEachCheckout = workflow + .split("- name: 'Checkout'") + .slice(0, -1); + + expect(checkoutCount).toBe(5); + expect(restoreCount).toBe(checkoutCount); + for (const prefix of beforeEachCheckout) { + expect( + prefix.lastIndexOf("- name: 'Restore workspace ownership'"), + ).toBeGreaterThan(prefix.lastIndexOf('steps:')); + } + }); + it('fires the fleet-moving npm-published dispatch on stable releases only', () => { // This gate is the sole protection keeping a nightly/preview/dry-run // release from moving the ECS fleet; the triggered update workflow From 5c80aad3f1e5c09d697c0215cfd90c3fc4f709cc Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Wed, 26 Aug 2026 07:14:57 +0800 Subject: [PATCH 03/35] fix(ci): record release workflow growth The ECS routing and workspace ownership guards intentionally grow release.yml beyond the workflow-size ratchet allowance. Co-authored-by: Qwen Code Co-authored-by: Qwen-Coder Co-authored-by: Qwen-Coder --- .github/workflows/.size-baseline | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/.size-baseline b/.github/workflows/.size-baseline index b4a7e5e4128..489b958c417 100644 --- a/.github/workflows/.size-baseline +++ b/.github/workflows/.size-baseline @@ -46,7 +46,7 @@ 22037 release-sdk-python.yml 19094 release-sdk.yml 14546 release-vscode-companion.yml -39291 release.yml +45677 release.yml 43717 repo-hygiene.yml 1079 scorecard-monthly.yml 10691 sdk-java.yml From b276384cb6e50a1dc93a448b9b3b7e246fcdb2e8 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Wed, 26 Aug 2026 09:54:15 +0800 Subject: [PATCH 04/35] fix(ci): isolate release jobs on shared runners Co-authored-by: Qwen-Coder Co-authored-by: Qwen-Coder --- .github/workflows/release.yml | 27 ++++++++++++ scripts/tests/release-workflow.test.js | 61 +++++++++++++++++++------- 2 files changed, 73 insertions(+), 15 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e861ce3999f..356c3fed08e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,6 +70,10 @@ jobs: chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files" fi chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files" + # Release jobs do not need cross-job workspace reuse. Remove every + # persisted entry, including planted .git config/hooks/attributes, + # before actions/checkout runs with release credentials. + find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + - name: 'Checkout' uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 @@ -186,6 +190,10 @@ jobs: chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files" fi chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files" + # Release jobs do not need cross-job workspace reuse. Remove every + # persisted entry, including planted .git config/hooks/attributes, + # before actions/checkout runs with release credentials. + find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + - name: 'Checkout' uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 @@ -263,6 +271,10 @@ jobs: chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files" fi chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files" + # Release jobs do not need cross-job workspace reuse. Remove every + # persisted entry, including planted .git config/hooks/attributes, + # before actions/checkout runs with release credentials. + find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + - name: 'Checkout' uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 @@ -329,6 +341,17 @@ jobs: chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files" fi chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files" + # Release jobs do not need cross-job workspace reuse. Remove every + # persisted entry, including planted .git config/hooks/attributes, + # before actions/checkout runs with release credentials. + find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + + + - name: 'Check docker daemon' + run: |- + if ! docker info > /dev/null 2>&1; then + echo "::error::docker daemon is not reachable on this runner; docker integration tests cannot run." + exit 1 + fi - name: 'Checkout' uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 @@ -458,6 +481,10 @@ jobs: chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files" fi chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files" + # Release jobs do not need cross-job workspace reuse. Remove every + # persisted entry, including planted .git config/hooks/attributes, + # before actions/checkout runs with release credentials. + find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + - name: 'Checkout' uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 diff --git a/scripts/tests/release-workflow.test.js b/scripts/tests/release-workflow.test.js index 912e3b3b5ea..664b82b9f26 100644 --- a/scripts/tests/release-workflow.test.js +++ b/scripts/tests/release-workflow.test.js @@ -9,8 +9,10 @@ import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; +import { parse } from 'yaml'; const workflow = readFileSync('.github/workflows/release.yml', 'utf8'); +const releaseYaml = parse(workflow); const cuaReleaseWorkflow = readFileSync( '.github/workflows/cd-cua-driver.yml', 'utf8', @@ -80,23 +82,52 @@ describe('CUA release workflow', () => { }); describe('release workflow', () => { - it('restores shared ECS workspace ownership before every checkout', () => { - const checkoutCount = (workflow.match(/- name: 'Checkout'/g) ?? []).length; - const restoreCount = ( - workflow.match(/- name: 'Restore workspace ownership'/g) ?? [] - ).length; - - const beforeEachCheckout = workflow - .split("- name: 'Checkout'") - .slice(0, -1); + it('cleans every shared ECS workspace before checkout', () => { + const checkoutJobs = Object.entries(releaseYaml.jobs).filter(([, job]) => + (job.steps ?? []).some((step) => + String(step.uses ?? '').includes('actions/checkout'), + ), + ); + const cleanupCopies = []; - expect(checkoutCount).toBe(5); - expect(restoreCount).toBe(checkoutCount); - for (const prefix of beforeEachCheckout) { - expect( - prefix.lastIndexOf("- name: 'Restore workspace ownership'"), - ).toBeGreaterThan(prefix.lastIndexOf('steps:')); + expect(checkoutJobs.map(([id]) => id)).toEqual([ + 'prepare', + 'quality', + 'integration_none', + 'integration_docker', + 'publish', + ]); + for (const [id, job] of checkoutJobs) { + const checkoutIndex = job.steps.findIndex((step) => + String(step.uses ?? '').includes('actions/checkout'), + ); + const restoreIndex = job.steps.findIndex( + (step) => step.name === 'Restore workspace ownership', + ); + const cleanup = job.steps[restoreIndex]?.run; + expect(restoreIndex, id).toBeGreaterThanOrEqual(0); + expect(restoreIndex, id).toBeLessThan(checkoutIndex); + expect(cleanup, id).toContain( + 'find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +', + ); + cleanupCopies.push(cleanup); } + for (const copy of cleanupCopies.slice(1)) { + expect(copy).toBe(cleanupCopies[0]); + } + }); + + it('checks docker availability before the docker checkout', () => { + const steps = releaseYaml.jobs.integration_docker.steps; + const preflightIndex = steps.findIndex( + (step) => step.name === 'Check docker daemon', + ); + const checkoutIndex = steps.findIndex((step) => + String(step.uses ?? '').includes('actions/checkout'), + ); + expect(preflightIndex).toBeGreaterThanOrEqual(0); + expect(preflightIndex).toBeLessThan(checkoutIndex); + expect(steps[preflightIndex].run).toContain('docker info'); }); it('fires the fleet-moving npm-published dispatch on stable releases only', () => { From 324809e13e8c41d7a79605cf91d129e2cbe9d276 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Wed, 26 Aug 2026 11:56:41 +0800 Subject: [PATCH 05/35] fix(ci): bound shared release runner work Disable redundant setup-node cache transfers on the persistent ECS pool and add explicit timeouts for the remaining shared-pool jobs. Pin both contracts in the release workflow test.\n\nCo-authored-by: Qwen-Coder Co-authored-by: Qwen-Coder --- .github/workflows/.size-baseline | 2 +- .github/workflows/release.yml | 20 ++++++++++---- scripts/tests/release-workflow.test.js | 36 ++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/.github/workflows/.size-baseline b/.github/workflows/.size-baseline index 489b958c417..f81c95f036c 100644 --- a/.github/workflows/.size-baseline +++ b/.github/workflows/.size-baseline @@ -46,7 +46,7 @@ 22037 release-sdk-python.yml 19094 release-sdk.yml 14546 release-vscode-companion.yml -45677 release.yml +48017 release.yml 43717 repo-hygiene.yml 1079 scorecard-monthly.yml 10691 sdk-java.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 356c3fed08e..9ea00ce055f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,6 +45,7 @@ jobs: # MAINTAINER_ECS_RUNNER_DISABLED repo variable restores the hosted # ubuntu-latest fallback without a code change. runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}' + timeout-minutes: 30 if: |- ${{ github.repository == 'QwenLM/qwen-code' }} permissions: @@ -111,7 +112,8 @@ jobs: uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 with: node-version-file: '.nvmrc' - cache: 'npm' + cache: "${{ runner.environment != 'self-hosted' && 'npm' || '' }}" + package-manager-cache: false cache-dependency-path: 'package-lock.json' - name: 'Install Dependencies' @@ -168,6 +170,7 @@ jobs: # MAINTAINER_ECS_RUNNER_DISABLED repo variable restores the hosted # ubuntu-latest fallback without a code change. runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}' + timeout-minutes: 120 needs: 'prepare' if: |- ${{ github.event.inputs.force_skip_tests != 'true' }} @@ -205,7 +208,8 @@ jobs: uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 with: node-version-file: '.nvmrc' - cache: 'npm' + cache: "${{ runner.environment != 'self-hosted' && 'npm' || '' }}" + package-manager-cache: false cache-dependency-path: 'package-lock.json' - name: 'Install Dependencies' @@ -249,6 +253,7 @@ jobs: # MAINTAINER_ECS_RUNNER_DISABLED repo variable restores the hosted # ubuntu-latest fallback without a code change. runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}' + timeout-minutes: 120 needs: 'prepare' if: |- ${{ github.event.inputs.force_skip_tests != 'true' }} @@ -286,7 +291,8 @@ jobs: uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 with: node-version-file: '.nvmrc' - cache: 'npm' + cache: "${{ runner.environment != 'self-hosted' && 'npm' || '' }}" + package-manager-cache: false cache-dependency-path: 'package-lock.json' - name: 'Install Dependencies' @@ -319,6 +325,7 @@ jobs: # MAINTAINER_ECS_RUNNER_DISABLED repo variable restores the hosted # ubuntu-latest fallback without a code change. runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}' + timeout-minutes: 120 needs: 'prepare' if: |- ${{ github.event.inputs.force_skip_tests != 'true' }} @@ -363,7 +370,8 @@ jobs: uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 with: node-version-file: '.nvmrc' - cache: 'npm' + cache: "${{ runner.environment != 'self-hosted' && 'npm' || '' }}" + package-manager-cache: false cache-dependency-path: 'package-lock.json' - name: 'Install Dependencies' @@ -498,7 +506,8 @@ jobs: uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 with: node-version-file: '.nvmrc' - cache: 'npm' + cache: "${{ runner.environment != 'self-hosted' && 'npm' || '' }}" + package-manager-cache: false cache-dependency-path: 'package-lock.json' registry-url: 'https://registry.npmjs.org' scope: '@qwen-code' @@ -853,6 +862,7 @@ jobs: # MAINTAINER_ECS_RUNNER_DISABLED repo variable restores the hosted # ubuntu-latest fallback without a code change. runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}' + timeout-minutes: 10 needs: - 'prepare' - 'quality' diff --git a/scripts/tests/release-workflow.test.js b/scripts/tests/release-workflow.test.js index 664b82b9f26..e76653aacf9 100644 --- a/scripts/tests/release-workflow.test.js +++ b/scripts/tests/release-workflow.test.js @@ -130,6 +130,42 @@ describe('release workflow', () => { expect(steps[preflightIndex].run).toContain('docker info'); }); + it('bounds shared-pool jobs and skips redundant remote npm caches', () => { + expect( + Object.fromEntries( + [ + 'prepare', + 'quality', + 'integration_none', + 'integration_docker', + 'notify_failure', + ].map((id) => [id, releaseYaml.jobs[id]['timeout-minutes']]), + ), + ).toEqual({ + prepare: 30, + quality: 120, + integration_none: 120, + integration_docker: 120, + notify_failure: 10, + }); + + for (const id of [ + 'prepare', + 'quality', + 'integration_none', + 'integration_docker', + 'publish', + ]) { + const setupNode = releaseYaml.jobs[id].steps.find((step) => + String(step.uses ?? '').includes('actions/setup-node'), + ); + expect(setupNode?.with.cache, id).toBe( + "${{ runner.environment != 'self-hosted' && 'npm' || '' }}", + ); + expect(setupNode?.with['package-manager-cache'], id).toBe(false); + } + }); + it('fires the fleet-moving npm-published dispatch on stable releases only', () => { // This gate is the sole protection keeping a nightly/preview/dry-run // release from moving the ECS fleet; the triggered update workflow From e29c23ec373472caff02985a60d1b04261287ca4 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Wed, 26 Aug 2026 13:57:43 +0800 Subject: [PATCH 06/35] fix(ci): harden the release wipe and pin the docker preflight fail-closed Port the sibling wipe guards (realpath canonicalization, symlink heal, root denylist, runner-workspace containment) onto all five release wipe copies, remove planted user-level state the workspace wipe cannot see (~/.npmrc script-shell, global git exec keys) with the qwen-autofix pre-checkout denylist, and make the docker preflight print docker's own error output before failing closed. Co-authored-by: Qwen-Coder --- .github/workflows/release.yml | 483 ++++++++++++++++++++++++++++++++-- 1 file changed, 467 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9ea00ce055f..1a393cd4ffb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -71,10 +71,100 @@ jobs: chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files" fi chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files" - # Release jobs do not need cross-job workspace reuse. Remove every + # Release jobs do not need cross-job workspace reuse: remove every # persisted entry, including planted .git config/hooks/attributes, - # before actions/checkout runs with release credentials. - find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + + # before actions/checkout runs with release credentials. The full + # wipe — rather than keeping and scrubbing .git like serve-ab.yml — + # is deliberate: these checkouts run with CI_BOT_PAT and the npm + # OIDC id-token, so no pre-existing repo state may survive into + # them; the accepted cost is re-fetching full history each run. + # + # Guards ported from serve-ab.yml's wipe (#9220, #9265): under a + # mangled env even `/home` or an empty string reached the rm. A + # wipe pointed at the wrong path is far worse than a skipped wipe, + # so canonicalize, strip trailing slashes, denylist the known + # roots, and require the target to sit inside the runner workspace + # before any rm. + WS="${GITHUB_WORKSPACE:?}" + while [ "${WS%/}" != "$WS" ]; do WS="${WS%/}"; done + RWS="${RUNNER_WORKSPACE:?}" + RWS="$(realpath -m -- "$RWS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${RUNNER_WORKSPACE}"; exit 1; } + while [ "${RWS%/}" != "$RWS" ]; do RWS="${RWS%/}"; done + if [ -z "$RWS" ]; then echo "::error::refusing to wipe: runner workspace resolved to /"; exit 1; fi + case "$RWS" in + ..|../*|*/..|*/../*) echo "::error::refusing runner workspace path containing '..': ${RWS}"; exit 1 ;; + esac + # Heal a workspace a previous job replaced with a symlink (or any + # non-directory) BEFORE canonicalizing it: afterwards the path + # resolves to the link's target, the containment below refuses it, + # and every later job on this runner would die here permanently on + # corruption that is itself inside the runner workspace and safe + # to unlink. + if [ -L "$WS" ] || [ ! -d "$WS" ]; then + # Judge the PARENT, canonicalized: the kernel resolves + # intermediate components too, so a raw containment match is not + # enough. Never resolve $WS itself — that would resolve through + # the very link being removed. + HEAL_PARENT="$(realpath -m -- "$(dirname -- "$WS")" 2>/dev/null)" || { echo "::error::refusing to heal: realpath unavailable, cannot canonicalize the parent of ${WS}"; exit 1; } + case "$HEAL_PARENT" in + "$RWS"|"$RWS"/*) ;; + *) echo "::error::refusing to heal workspace outside the runner workspace: ${WS} (parent: ${HEAL_PARENT}, runner workspace: ${RWS})"; exit 1 ;; + esac + if [ -L "$WS" ]; then + # The link target is bytes a PREVIOUS job chose — on this pool + # that job may have run contributor code — and the runner + # parses `::` at the start of any stdout line as a workflow + # command: keep untrusted bytes off the command line itself, + # strip the line breaks that could start a new one, and cap + # the length. + heal_target="$(readlink -- "$WS" 2>/dev/null || printf '%s' '')" + heal_target="$(printf '%s' "$heal_target" | tr -d '\r\n' | cut -c1-200)" + echo "::warning::healing workspace ${WS}: it was a symlink" + printf 'heal: %s pointed at %s\n' "$WS" "$heal_target" + else + echo "::warning::healing workspace ${WS}: it was not a directory" + fi + # `rm -f` on the RAW path removes the link itself and never + # follows it. Both legs fail closed: a swallowed failure here + # would leave the wipe running against a corrupt path. + rm -f -- "$WS" || { echo "::error::refusing to continue: could not remove ${WS}"; exit 1; } + mkdir -- "$WS" || { echo "::error::refusing to continue: could not recreate ${WS}"; exit 1; } + fi + WS="$(realpath -m -- "$WS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${GITHUB_WORKSPACE}"; exit 1; } + while [ "${WS%/}" != "$WS" ]; do WS="${WS%/}"; done + case "$WS" in + ..|../*|*/..|*/../*) echo "::error::refusing to wipe path containing '..': ${WS}"; exit 1 ;; + esac + case "$WS" in + /|/home|/root|/usr*|/etc*|/var|"") echo "::error::refusing to wipe suspicious workspace path: ${WS}"; exit 1 ;; + esac + # A denylist can only enumerate known roots — the allowlist closes + # every other one (/tmp, /opt, ...): only a directory inside the + # runner workspace may be wiped. + case "$WS" in + "$RWS"/*) ;; + *) echo "::error::refusing to wipe workspace outside the runner workspace: ${WS} (runner workspace: ${RWS})"; exit 1 ;; + esac + find "$WS" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + + # The workspace wipe cannot see the runner user's HOME, which + # survives across jobs on this pool: a planted ~/.npmrc + # script-shell wraps every verdict-determining `npm run` in an + # attacker shell (and can redirect `npm publish`), and global git + # config exec knobs (core.hooksPath, filter.*, url.*.insteadOf, + # include.*) govern this job's checkout and credential-bearing + # git steps. Remove the npmrc outright — publish's setup-node + # recreates the registry config it needs after this step — and + # strip the git exec keys with the same denylist as + # qwen-autofix.yml's pre-checkout sanitize and + # .github/scripts/resanitize-git-config.sh (the inline form is the + # pre-checkout doctrine: the script file does not exist on disk + # before checkout). No-op on a fresh hosted runner. + rm -f -- "${HOME:?}/.npmrc" + for global_file in "${HOME}/.gitconfig" "${XDG_CONFIG_HOME:-${HOME}/.config}/git/config"; do + { GIT_CONFIG_GLOBAL="${global_file}" git config --global --name-only --list 2>/dev/null || true; } \ + | { grep -iE '^(core\.(hookspath|fsmonitor|pager|editor|sshcommand|askpass|alternaterefscommand|gitproxy)$|diff\.external$|diff\..+\.(command|textconv)$|merge\..+\.driver$|filter\.|alias\.|pager\.|difftool\.|mergetool\.|interactive\.difffilter$|sequence\.editor$|gpg\.(.+\.)?program$|init\.templatedir$|remote\..+\.(uploadpack|receivepack)$|submodule\..+\.update$|url\..+\.(insteadof|pushinsteadof)$|http\.(.+\.)?(sslverify|sslcainfo)$|include\.|includeif\.|protocol\.(ext\.)?allow$)' || true; } \ + | while IFS= read -r key; do GIT_CONFIG_GLOBAL="${global_file}" git config --global --unset-all "$key" 2>/dev/null || true; done + done - name: 'Checkout' uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 @@ -193,10 +283,100 @@ jobs: chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files" fi chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files" - # Release jobs do not need cross-job workspace reuse. Remove every + # Release jobs do not need cross-job workspace reuse: remove every # persisted entry, including planted .git config/hooks/attributes, - # before actions/checkout runs with release credentials. - find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + + # before actions/checkout runs with release credentials. The full + # wipe — rather than keeping and scrubbing .git like serve-ab.yml — + # is deliberate: these checkouts run with CI_BOT_PAT and the npm + # OIDC id-token, so no pre-existing repo state may survive into + # them; the accepted cost is re-fetching full history each run. + # + # Guards ported from serve-ab.yml's wipe (#9220, #9265): under a + # mangled env even `/home` or an empty string reached the rm. A + # wipe pointed at the wrong path is far worse than a skipped wipe, + # so canonicalize, strip trailing slashes, denylist the known + # roots, and require the target to sit inside the runner workspace + # before any rm. + WS="${GITHUB_WORKSPACE:?}" + while [ "${WS%/}" != "$WS" ]; do WS="${WS%/}"; done + RWS="${RUNNER_WORKSPACE:?}" + RWS="$(realpath -m -- "$RWS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${RUNNER_WORKSPACE}"; exit 1; } + while [ "${RWS%/}" != "$RWS" ]; do RWS="${RWS%/}"; done + if [ -z "$RWS" ]; then echo "::error::refusing to wipe: runner workspace resolved to /"; exit 1; fi + case "$RWS" in + ..|../*|*/..|*/../*) echo "::error::refusing runner workspace path containing '..': ${RWS}"; exit 1 ;; + esac + # Heal a workspace a previous job replaced with a symlink (or any + # non-directory) BEFORE canonicalizing it: afterwards the path + # resolves to the link's target, the containment below refuses it, + # and every later job on this runner would die here permanently on + # corruption that is itself inside the runner workspace and safe + # to unlink. + if [ -L "$WS" ] || [ ! -d "$WS" ]; then + # Judge the PARENT, canonicalized: the kernel resolves + # intermediate components too, so a raw containment match is not + # enough. Never resolve $WS itself — that would resolve through + # the very link being removed. + HEAL_PARENT="$(realpath -m -- "$(dirname -- "$WS")" 2>/dev/null)" || { echo "::error::refusing to heal: realpath unavailable, cannot canonicalize the parent of ${WS}"; exit 1; } + case "$HEAL_PARENT" in + "$RWS"|"$RWS"/*) ;; + *) echo "::error::refusing to heal workspace outside the runner workspace: ${WS} (parent: ${HEAL_PARENT}, runner workspace: ${RWS})"; exit 1 ;; + esac + if [ -L "$WS" ]; then + # The link target is bytes a PREVIOUS job chose — on this pool + # that job may have run contributor code — and the runner + # parses `::` at the start of any stdout line as a workflow + # command: keep untrusted bytes off the command line itself, + # strip the line breaks that could start a new one, and cap + # the length. + heal_target="$(readlink -- "$WS" 2>/dev/null || printf '%s' '')" + heal_target="$(printf '%s' "$heal_target" | tr -d '\r\n' | cut -c1-200)" + echo "::warning::healing workspace ${WS}: it was a symlink" + printf 'heal: %s pointed at %s\n' "$WS" "$heal_target" + else + echo "::warning::healing workspace ${WS}: it was not a directory" + fi + # `rm -f` on the RAW path removes the link itself and never + # follows it. Both legs fail closed: a swallowed failure here + # would leave the wipe running against a corrupt path. + rm -f -- "$WS" || { echo "::error::refusing to continue: could not remove ${WS}"; exit 1; } + mkdir -- "$WS" || { echo "::error::refusing to continue: could not recreate ${WS}"; exit 1; } + fi + WS="$(realpath -m -- "$WS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${GITHUB_WORKSPACE}"; exit 1; } + while [ "${WS%/}" != "$WS" ]; do WS="${WS%/}"; done + case "$WS" in + ..|../*|*/..|*/../*) echo "::error::refusing to wipe path containing '..': ${WS}"; exit 1 ;; + esac + case "$WS" in + /|/home|/root|/usr*|/etc*|/var|"") echo "::error::refusing to wipe suspicious workspace path: ${WS}"; exit 1 ;; + esac + # A denylist can only enumerate known roots — the allowlist closes + # every other one (/tmp, /opt, ...): only a directory inside the + # runner workspace may be wiped. + case "$WS" in + "$RWS"/*) ;; + *) echo "::error::refusing to wipe workspace outside the runner workspace: ${WS} (runner workspace: ${RWS})"; exit 1 ;; + esac + find "$WS" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + + # The workspace wipe cannot see the runner user's HOME, which + # survives across jobs on this pool: a planted ~/.npmrc + # script-shell wraps every verdict-determining `npm run` in an + # attacker shell (and can redirect `npm publish`), and global git + # config exec knobs (core.hooksPath, filter.*, url.*.insteadOf, + # include.*) govern this job's checkout and credential-bearing + # git steps. Remove the npmrc outright — publish's setup-node + # recreates the registry config it needs after this step — and + # strip the git exec keys with the same denylist as + # qwen-autofix.yml's pre-checkout sanitize and + # .github/scripts/resanitize-git-config.sh (the inline form is the + # pre-checkout doctrine: the script file does not exist on disk + # before checkout). No-op on a fresh hosted runner. + rm -f -- "${HOME:?}/.npmrc" + for global_file in "${HOME}/.gitconfig" "${XDG_CONFIG_HOME:-${HOME}/.config}/git/config"; do + { GIT_CONFIG_GLOBAL="${global_file}" git config --global --name-only --list 2>/dev/null || true; } \ + | { grep -iE '^(core\.(hookspath|fsmonitor|pager|editor|sshcommand|askpass|alternaterefscommand|gitproxy)$|diff\.external$|diff\..+\.(command|textconv)$|merge\..+\.driver$|filter\.|alias\.|pager\.|difftool\.|mergetool\.|interactive\.difffilter$|sequence\.editor$|gpg\.(.+\.)?program$|init\.templatedir$|remote\..+\.(uploadpack|receivepack)$|submodule\..+\.update$|url\..+\.(insteadof|pushinsteadof)$|http\.(.+\.)?(sslverify|sslcainfo)$|include\.|includeif\.|protocol\.(ext\.)?allow$)' || true; } \ + | while IFS= read -r key; do GIT_CONFIG_GLOBAL="${global_file}" git config --global --unset-all "$key" 2>/dev/null || true; done + done - name: 'Checkout' uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 @@ -276,10 +456,100 @@ jobs: chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files" fi chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files" - # Release jobs do not need cross-job workspace reuse. Remove every + # Release jobs do not need cross-job workspace reuse: remove every # persisted entry, including planted .git config/hooks/attributes, - # before actions/checkout runs with release credentials. - find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + + # before actions/checkout runs with release credentials. The full + # wipe — rather than keeping and scrubbing .git like serve-ab.yml — + # is deliberate: these checkouts run with CI_BOT_PAT and the npm + # OIDC id-token, so no pre-existing repo state may survive into + # them; the accepted cost is re-fetching full history each run. + # + # Guards ported from serve-ab.yml's wipe (#9220, #9265): under a + # mangled env even `/home` or an empty string reached the rm. A + # wipe pointed at the wrong path is far worse than a skipped wipe, + # so canonicalize, strip trailing slashes, denylist the known + # roots, and require the target to sit inside the runner workspace + # before any rm. + WS="${GITHUB_WORKSPACE:?}" + while [ "${WS%/}" != "$WS" ]; do WS="${WS%/}"; done + RWS="${RUNNER_WORKSPACE:?}" + RWS="$(realpath -m -- "$RWS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${RUNNER_WORKSPACE}"; exit 1; } + while [ "${RWS%/}" != "$RWS" ]; do RWS="${RWS%/}"; done + if [ -z "$RWS" ]; then echo "::error::refusing to wipe: runner workspace resolved to /"; exit 1; fi + case "$RWS" in + ..|../*|*/..|*/../*) echo "::error::refusing runner workspace path containing '..': ${RWS}"; exit 1 ;; + esac + # Heal a workspace a previous job replaced with a symlink (or any + # non-directory) BEFORE canonicalizing it: afterwards the path + # resolves to the link's target, the containment below refuses it, + # and every later job on this runner would die here permanently on + # corruption that is itself inside the runner workspace and safe + # to unlink. + if [ -L "$WS" ] || [ ! -d "$WS" ]; then + # Judge the PARENT, canonicalized: the kernel resolves + # intermediate components too, so a raw containment match is not + # enough. Never resolve $WS itself — that would resolve through + # the very link being removed. + HEAL_PARENT="$(realpath -m -- "$(dirname -- "$WS")" 2>/dev/null)" || { echo "::error::refusing to heal: realpath unavailable, cannot canonicalize the parent of ${WS}"; exit 1; } + case "$HEAL_PARENT" in + "$RWS"|"$RWS"/*) ;; + *) echo "::error::refusing to heal workspace outside the runner workspace: ${WS} (parent: ${HEAL_PARENT}, runner workspace: ${RWS})"; exit 1 ;; + esac + if [ -L "$WS" ]; then + # The link target is bytes a PREVIOUS job chose — on this pool + # that job may have run contributor code — and the runner + # parses `::` at the start of any stdout line as a workflow + # command: keep untrusted bytes off the command line itself, + # strip the line breaks that could start a new one, and cap + # the length. + heal_target="$(readlink -- "$WS" 2>/dev/null || printf '%s' '')" + heal_target="$(printf '%s' "$heal_target" | tr -d '\r\n' | cut -c1-200)" + echo "::warning::healing workspace ${WS}: it was a symlink" + printf 'heal: %s pointed at %s\n' "$WS" "$heal_target" + else + echo "::warning::healing workspace ${WS}: it was not a directory" + fi + # `rm -f` on the RAW path removes the link itself and never + # follows it. Both legs fail closed: a swallowed failure here + # would leave the wipe running against a corrupt path. + rm -f -- "$WS" || { echo "::error::refusing to continue: could not remove ${WS}"; exit 1; } + mkdir -- "$WS" || { echo "::error::refusing to continue: could not recreate ${WS}"; exit 1; } + fi + WS="$(realpath -m -- "$WS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${GITHUB_WORKSPACE}"; exit 1; } + while [ "${WS%/}" != "$WS" ]; do WS="${WS%/}"; done + case "$WS" in + ..|../*|*/..|*/../*) echo "::error::refusing to wipe path containing '..': ${WS}"; exit 1 ;; + esac + case "$WS" in + /|/home|/root|/usr*|/etc*|/var|"") echo "::error::refusing to wipe suspicious workspace path: ${WS}"; exit 1 ;; + esac + # A denylist can only enumerate known roots — the allowlist closes + # every other one (/tmp, /opt, ...): only a directory inside the + # runner workspace may be wiped. + case "$WS" in + "$RWS"/*) ;; + *) echo "::error::refusing to wipe workspace outside the runner workspace: ${WS} (runner workspace: ${RWS})"; exit 1 ;; + esac + find "$WS" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + + # The workspace wipe cannot see the runner user's HOME, which + # survives across jobs on this pool: a planted ~/.npmrc + # script-shell wraps every verdict-determining `npm run` in an + # attacker shell (and can redirect `npm publish`), and global git + # config exec knobs (core.hooksPath, filter.*, url.*.insteadOf, + # include.*) govern this job's checkout and credential-bearing + # git steps. Remove the npmrc outright — publish's setup-node + # recreates the registry config it needs after this step — and + # strip the git exec keys with the same denylist as + # qwen-autofix.yml's pre-checkout sanitize and + # .github/scripts/resanitize-git-config.sh (the inline form is the + # pre-checkout doctrine: the script file does not exist on disk + # before checkout). No-op on a fresh hosted runner. + rm -f -- "${HOME:?}/.npmrc" + for global_file in "${HOME}/.gitconfig" "${XDG_CONFIG_HOME:-${HOME}/.config}/git/config"; do + { GIT_CONFIG_GLOBAL="${global_file}" git config --global --name-only --list 2>/dev/null || true; } \ + | { grep -iE '^(core\.(hookspath|fsmonitor|pager|editor|sshcommand|askpass|alternaterefscommand|gitproxy)$|diff\.external$|diff\..+\.(command|textconv)$|merge\..+\.driver$|filter\.|alias\.|pager\.|difftool\.|mergetool\.|interactive\.difffilter$|sequence\.editor$|gpg\.(.+\.)?program$|init\.templatedir$|remote\..+\.(uploadpack|receivepack)$|submodule\..+\.update$|url\..+\.(insteadof|pushinsteadof)$|http\.(.+\.)?(sslverify|sslcainfo)$|include\.|includeif\.|protocol\.(ext\.)?allow$)' || true; } \ + | while IFS= read -r key; do GIT_CONFIG_GLOBAL="${global_file}" git config --global --unset-all "$key" 2>/dev/null || true; done + done - name: 'Checkout' uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 @@ -348,15 +618,106 @@ jobs: chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files" fi chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files" - # Release jobs do not need cross-job workspace reuse. Remove every + # Release jobs do not need cross-job workspace reuse: remove every # persisted entry, including planted .git config/hooks/attributes, - # before actions/checkout runs with release credentials. - find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + + # before actions/checkout runs with release credentials. The full + # wipe — rather than keeping and scrubbing .git like serve-ab.yml — + # is deliberate: these checkouts run with CI_BOT_PAT and the npm + # OIDC id-token, so no pre-existing repo state may survive into + # them; the accepted cost is re-fetching full history each run. + # + # Guards ported from serve-ab.yml's wipe (#9220, #9265): under a + # mangled env even `/home` or an empty string reached the rm. A + # wipe pointed at the wrong path is far worse than a skipped wipe, + # so canonicalize, strip trailing slashes, denylist the known + # roots, and require the target to sit inside the runner workspace + # before any rm. + WS="${GITHUB_WORKSPACE:?}" + while [ "${WS%/}" != "$WS" ]; do WS="${WS%/}"; done + RWS="${RUNNER_WORKSPACE:?}" + RWS="$(realpath -m -- "$RWS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${RUNNER_WORKSPACE}"; exit 1; } + while [ "${RWS%/}" != "$RWS" ]; do RWS="${RWS%/}"; done + if [ -z "$RWS" ]; then echo "::error::refusing to wipe: runner workspace resolved to /"; exit 1; fi + case "$RWS" in + ..|../*|*/..|*/../*) echo "::error::refusing runner workspace path containing '..': ${RWS}"; exit 1 ;; + esac + # Heal a workspace a previous job replaced with a symlink (or any + # non-directory) BEFORE canonicalizing it: afterwards the path + # resolves to the link's target, the containment below refuses it, + # and every later job on this runner would die here permanently on + # corruption that is itself inside the runner workspace and safe + # to unlink. + if [ -L "$WS" ] || [ ! -d "$WS" ]; then + # Judge the PARENT, canonicalized: the kernel resolves + # intermediate components too, so a raw containment match is not + # enough. Never resolve $WS itself — that would resolve through + # the very link being removed. + HEAL_PARENT="$(realpath -m -- "$(dirname -- "$WS")" 2>/dev/null)" || { echo "::error::refusing to heal: realpath unavailable, cannot canonicalize the parent of ${WS}"; exit 1; } + case "$HEAL_PARENT" in + "$RWS"|"$RWS"/*) ;; + *) echo "::error::refusing to heal workspace outside the runner workspace: ${WS} (parent: ${HEAL_PARENT}, runner workspace: ${RWS})"; exit 1 ;; + esac + if [ -L "$WS" ]; then + # The link target is bytes a PREVIOUS job chose — on this pool + # that job may have run contributor code — and the runner + # parses `::` at the start of any stdout line as a workflow + # command: keep untrusted bytes off the command line itself, + # strip the line breaks that could start a new one, and cap + # the length. + heal_target="$(readlink -- "$WS" 2>/dev/null || printf '%s' '')" + heal_target="$(printf '%s' "$heal_target" | tr -d '\r\n' | cut -c1-200)" + echo "::warning::healing workspace ${WS}: it was a symlink" + printf 'heal: %s pointed at %s\n' "$WS" "$heal_target" + else + echo "::warning::healing workspace ${WS}: it was not a directory" + fi + # `rm -f` on the RAW path removes the link itself and never + # follows it. Both legs fail closed: a swallowed failure here + # would leave the wipe running against a corrupt path. + rm -f -- "$WS" || { echo "::error::refusing to continue: could not remove ${WS}"; exit 1; } + mkdir -- "$WS" || { echo "::error::refusing to continue: could not recreate ${WS}"; exit 1; } + fi + WS="$(realpath -m -- "$WS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${GITHUB_WORKSPACE}"; exit 1; } + while [ "${WS%/}" != "$WS" ]; do WS="${WS%/}"; done + case "$WS" in + ..|../*|*/..|*/../*) echo "::error::refusing to wipe path containing '..': ${WS}"; exit 1 ;; + esac + case "$WS" in + /|/home|/root|/usr*|/etc*|/var|"") echo "::error::refusing to wipe suspicious workspace path: ${WS}"; exit 1 ;; + esac + # A denylist can only enumerate known roots — the allowlist closes + # every other one (/tmp, /opt, ...): only a directory inside the + # runner workspace may be wiped. + case "$WS" in + "$RWS"/*) ;; + *) echo "::error::refusing to wipe workspace outside the runner workspace: ${WS} (runner workspace: ${RWS})"; exit 1 ;; + esac + find "$WS" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + + # The workspace wipe cannot see the runner user's HOME, which + # survives across jobs on this pool: a planted ~/.npmrc + # script-shell wraps every verdict-determining `npm run` in an + # attacker shell (and can redirect `npm publish`), and global git + # config exec knobs (core.hooksPath, filter.*, url.*.insteadOf, + # include.*) govern this job's checkout and credential-bearing + # git steps. Remove the npmrc outright — publish's setup-node + # recreates the registry config it needs after this step — and + # strip the git exec keys with the same denylist as + # qwen-autofix.yml's pre-checkout sanitize and + # .github/scripts/resanitize-git-config.sh (the inline form is the + # pre-checkout doctrine: the script file does not exist on disk + # before checkout). No-op on a fresh hosted runner. + rm -f -- "${HOME:?}/.npmrc" + for global_file in "${HOME}/.gitconfig" "${XDG_CONFIG_HOME:-${HOME}/.config}/git/config"; do + { GIT_CONFIG_GLOBAL="${global_file}" git config --global --name-only --list 2>/dev/null || true; } \ + | { grep -iE '^(core\.(hookspath|fsmonitor|pager|editor|sshcommand|askpass|alternaterefscommand|gitproxy)$|diff\.external$|diff\..+\.(command|textconv)$|merge\..+\.driver$|filter\.|alias\.|pager\.|difftool\.|mergetool\.|interactive\.difffilter$|sequence\.editor$|gpg\.(.+\.)?program$|init\.templatedir$|remote\..+\.(uploadpack|receivepack)$|submodule\..+\.update$|url\..+\.(insteadof|pushinsteadof)$|http\.(.+\.)?(sslverify|sslcainfo)$|include\.|includeif\.|protocol\.(ext\.)?allow$)' || true; } \ + | while IFS= read -r key; do GIT_CONFIG_GLOBAL="${global_file}" git config --global --unset-all "$key" 2>/dev/null || true; done + done - name: 'Check docker daemon' run: |- - if ! docker info > /dev/null 2>&1; then + if ! docker_info_output="$(docker info 2>&1)"; then echo "::error::docker daemon is not reachable on this runner; docker integration tests cannot run." + printf '%s\n' "$docker_info_output" exit 1 fi @@ -489,10 +850,100 @@ jobs: chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files" fi chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files" - # Release jobs do not need cross-job workspace reuse. Remove every + # Release jobs do not need cross-job workspace reuse: remove every # persisted entry, including planted .git config/hooks/attributes, - # before actions/checkout runs with release credentials. - find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + + # before actions/checkout runs with release credentials. The full + # wipe — rather than keeping and scrubbing .git like serve-ab.yml — + # is deliberate: these checkouts run with CI_BOT_PAT and the npm + # OIDC id-token, so no pre-existing repo state may survive into + # them; the accepted cost is re-fetching full history each run. + # + # Guards ported from serve-ab.yml's wipe (#9220, #9265): under a + # mangled env even `/home` or an empty string reached the rm. A + # wipe pointed at the wrong path is far worse than a skipped wipe, + # so canonicalize, strip trailing slashes, denylist the known + # roots, and require the target to sit inside the runner workspace + # before any rm. + WS="${GITHUB_WORKSPACE:?}" + while [ "${WS%/}" != "$WS" ]; do WS="${WS%/}"; done + RWS="${RUNNER_WORKSPACE:?}" + RWS="$(realpath -m -- "$RWS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${RUNNER_WORKSPACE}"; exit 1; } + while [ "${RWS%/}" != "$RWS" ]; do RWS="${RWS%/}"; done + if [ -z "$RWS" ]; then echo "::error::refusing to wipe: runner workspace resolved to /"; exit 1; fi + case "$RWS" in + ..|../*|*/..|*/../*) echo "::error::refusing runner workspace path containing '..': ${RWS}"; exit 1 ;; + esac + # Heal a workspace a previous job replaced with a symlink (or any + # non-directory) BEFORE canonicalizing it: afterwards the path + # resolves to the link's target, the containment below refuses it, + # and every later job on this runner would die here permanently on + # corruption that is itself inside the runner workspace and safe + # to unlink. + if [ -L "$WS" ] || [ ! -d "$WS" ]; then + # Judge the PARENT, canonicalized: the kernel resolves + # intermediate components too, so a raw containment match is not + # enough. Never resolve $WS itself — that would resolve through + # the very link being removed. + HEAL_PARENT="$(realpath -m -- "$(dirname -- "$WS")" 2>/dev/null)" || { echo "::error::refusing to heal: realpath unavailable, cannot canonicalize the parent of ${WS}"; exit 1; } + case "$HEAL_PARENT" in + "$RWS"|"$RWS"/*) ;; + *) echo "::error::refusing to heal workspace outside the runner workspace: ${WS} (parent: ${HEAL_PARENT}, runner workspace: ${RWS})"; exit 1 ;; + esac + if [ -L "$WS" ]; then + # The link target is bytes a PREVIOUS job chose — on this pool + # that job may have run contributor code — and the runner + # parses `::` at the start of any stdout line as a workflow + # command: keep untrusted bytes off the command line itself, + # strip the line breaks that could start a new one, and cap + # the length. + heal_target="$(readlink -- "$WS" 2>/dev/null || printf '%s' '')" + heal_target="$(printf '%s' "$heal_target" | tr -d '\r\n' | cut -c1-200)" + echo "::warning::healing workspace ${WS}: it was a symlink" + printf 'heal: %s pointed at %s\n' "$WS" "$heal_target" + else + echo "::warning::healing workspace ${WS}: it was not a directory" + fi + # `rm -f` on the RAW path removes the link itself and never + # follows it. Both legs fail closed: a swallowed failure here + # would leave the wipe running against a corrupt path. + rm -f -- "$WS" || { echo "::error::refusing to continue: could not remove ${WS}"; exit 1; } + mkdir -- "$WS" || { echo "::error::refusing to continue: could not recreate ${WS}"; exit 1; } + fi + WS="$(realpath -m -- "$WS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${GITHUB_WORKSPACE}"; exit 1; } + while [ "${WS%/}" != "$WS" ]; do WS="${WS%/}"; done + case "$WS" in + ..|../*|*/..|*/../*) echo "::error::refusing to wipe path containing '..': ${WS}"; exit 1 ;; + esac + case "$WS" in + /|/home|/root|/usr*|/etc*|/var|"") echo "::error::refusing to wipe suspicious workspace path: ${WS}"; exit 1 ;; + esac + # A denylist can only enumerate known roots — the allowlist closes + # every other one (/tmp, /opt, ...): only a directory inside the + # runner workspace may be wiped. + case "$WS" in + "$RWS"/*) ;; + *) echo "::error::refusing to wipe workspace outside the runner workspace: ${WS} (runner workspace: ${RWS})"; exit 1 ;; + esac + find "$WS" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + + # The workspace wipe cannot see the runner user's HOME, which + # survives across jobs on this pool: a planted ~/.npmrc + # script-shell wraps every verdict-determining `npm run` in an + # attacker shell (and can redirect `npm publish`), and global git + # config exec knobs (core.hooksPath, filter.*, url.*.insteadOf, + # include.*) govern this job's checkout and credential-bearing + # git steps. Remove the npmrc outright — publish's setup-node + # recreates the registry config it needs after this step — and + # strip the git exec keys with the same denylist as + # qwen-autofix.yml's pre-checkout sanitize and + # .github/scripts/resanitize-git-config.sh (the inline form is the + # pre-checkout doctrine: the script file does not exist on disk + # before checkout). No-op on a fresh hosted runner. + rm -f -- "${HOME:?}/.npmrc" + for global_file in "${HOME}/.gitconfig" "${XDG_CONFIG_HOME:-${HOME}/.config}/git/config"; do + { GIT_CONFIG_GLOBAL="${global_file}" git config --global --name-only --list 2>/dev/null || true; } \ + | { grep -iE '^(core\.(hookspath|fsmonitor|pager|editor|sshcommand|askpass|alternaterefscommand|gitproxy)$|diff\.external$|diff\..+\.(command|textconv)$|merge\..+\.driver$|filter\.|alias\.|pager\.|difftool\.|mergetool\.|interactive\.difffilter$|sequence\.editor$|gpg\.(.+\.)?program$|init\.templatedir$|remote\..+\.(uploadpack|receivepack)$|submodule\..+\.update$|url\..+\.(insteadof|pushinsteadof)$|http\.(.+\.)?(sslverify|sslcainfo)$|include\.|includeif\.|protocol\.(ext\.)?allow$)' || true; } \ + | while IFS= read -r key; do GIT_CONFIG_GLOBAL="${global_file}" git config --global --unset-all "$key" 2>/dev/null || true; done + done - name: 'Checkout' uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 From 2775857265c5e7a915052729067e429863b06080 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Wed, 26 Aug 2026 13:57:54 +0800 Subject: [PATCH 07/35] test(ci): pin the canonical release wipe byte-identical at index 0 Replace substring tripwires with full-string equality against the shared canonical wipe constant and require the step to stay first, so a commented-out find, an inserted early exit, or a uniformly dropped ownership ladder fails review instead of shipping green. Pin the docker preflight's full fail-closed form (captured daemon output printed, exit 1) so deleting the exit or inverting the guard fails the suite. Co-authored-by: Qwen-Coder --- scripts/tests/release-workflow.test.js | 147 ++++++++++++++++++++++--- 1 file changed, 133 insertions(+), 14 deletions(-) diff --git a/scripts/tests/release-workflow.test.js b/scripts/tests/release-workflow.test.js index e76653aacf9..9782bcda565 100644 --- a/scripts/tests/release-workflow.test.js +++ b/scripts/tests/release-workflow.test.js @@ -81,6 +81,117 @@ describe('CUA release workflow', () => { }); }); +// The canonical wipe + user-state neutralization script shared by all five +// 'Restore workspace ownership' copies in release.yml. The full wipe (vs +// serve-ab.yml's keep-and-scrub) is deliberate on this lane: release +// checkouts carry CI_BOT_PAT and the npm OIDC id-token, so no pre-existing +// repo state may survive into them. Pin the WHOLE body by equality, the way +// review-worktree-cleanup-workflow.test.js pins its sweep copies: a +// commented-out find, an inserted early exit, or a uniformly dropped +// ownership ladder all ship green under substring pins, and each of those +// mutants reopens the incident class this step exists for. +const canonicalWipe = `set -uo pipefail +RUNNER_UID="$(id -u)" +RUNNER_GID="$(id -g)" +if [ "$RUNNER_UID" != "0" ]; then + chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files" +fi +chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files" +# Release jobs do not need cross-job workspace reuse: remove every +# persisted entry, including planted .git config/hooks/attributes, +# before actions/checkout runs with release credentials. The full +# wipe — rather than keeping and scrubbing .git like serve-ab.yml — +# is deliberate: these checkouts run with CI_BOT_PAT and the npm +# OIDC id-token, so no pre-existing repo state may survive into +# them; the accepted cost is re-fetching full history each run. +# +# Guards ported from serve-ab.yml's wipe (#9220, #9265): under a +# mangled env even \`/home\` or an empty string reached the rm. A +# wipe pointed at the wrong path is far worse than a skipped wipe, +# so canonicalize, strip trailing slashes, denylist the known +# roots, and require the target to sit inside the runner workspace +# before any rm. +WS="\${GITHUB_WORKSPACE:?}" +while [ "\${WS%/}" != "$WS" ]; do WS="\${WS%/}"; done +RWS="\${RUNNER_WORKSPACE:?}" +RWS="$(realpath -m -- "$RWS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize \${RUNNER_WORKSPACE}"; exit 1; } +while [ "\${RWS%/}" != "$RWS" ]; do RWS="\${RWS%/}"; done +if [ -z "$RWS" ]; then echo "::error::refusing to wipe: runner workspace resolved to /"; exit 1; fi +case "$RWS" in + ..|../*|*/..|*/../*) echo "::error::refusing runner workspace path containing '..': \${RWS}"; exit 1 ;; +esac +# Heal a workspace a previous job replaced with a symlink (or any +# non-directory) BEFORE canonicalizing it: afterwards the path +# resolves to the link's target, the containment below refuses it, +# and every later job on this runner would die here permanently on +# corruption that is itself inside the runner workspace and safe +# to unlink. +if [ -L "$WS" ] || [ ! -d "$WS" ]; then + # Judge the PARENT, canonicalized: the kernel resolves + # intermediate components too, so a raw containment match is not + # enough. Never resolve $WS itself — that would resolve through + # the very link being removed. + HEAL_PARENT="$(realpath -m -- "$(dirname -- "$WS")" 2>/dev/null)" || { echo "::error::refusing to heal: realpath unavailable, cannot canonicalize the parent of \${WS}"; exit 1; } + case "$HEAL_PARENT" in + "$RWS"|"$RWS"/*) ;; + *) echo "::error::refusing to heal workspace outside the runner workspace: \${WS} (parent: \${HEAL_PARENT}, runner workspace: \${RWS})"; exit 1 ;; + esac + if [ -L "$WS" ]; then + # The link target is bytes a PREVIOUS job chose — on this pool + # that job may have run contributor code — and the runner + # parses \`::\` at the start of any stdout line as a workflow + # command: keep untrusted bytes off the command line itself, + # strip the line breaks that could start a new one, and cap + # the length. + heal_target="$(readlink -- "$WS" 2>/dev/null || printf '%s' '')" + heal_target="$(printf '%s' "$heal_target" | tr -d '\\r\\n' | cut -c1-200)" + echo "::warning::healing workspace \${WS}: it was a symlink" + printf 'heal: %s pointed at %s\\n' "$WS" "$heal_target" + else + echo "::warning::healing workspace \${WS}: it was not a directory" + fi + # \`rm -f\` on the RAW path removes the link itself and never + # follows it. Both legs fail closed: a swallowed failure here + # would leave the wipe running against a corrupt path. + rm -f -- "$WS" || { echo "::error::refusing to continue: could not remove \${WS}"; exit 1; } + mkdir -- "$WS" || { echo "::error::refusing to continue: could not recreate \${WS}"; exit 1; } +fi +WS="$(realpath -m -- "$WS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize \${GITHUB_WORKSPACE}"; exit 1; } +while [ "\${WS%/}" != "$WS" ]; do WS="\${WS%/}"; done +case "$WS" in + ..|../*|*/..|*/../*) echo "::error::refusing to wipe path containing '..': \${WS}"; exit 1 ;; +esac +case "$WS" in + /|/home|/root|/usr*|/etc*|/var|"") echo "::error::refusing to wipe suspicious workspace path: \${WS}"; exit 1 ;; +esac +# A denylist can only enumerate known roots — the allowlist closes +# every other one (/tmp, /opt, ...): only a directory inside the +# runner workspace may be wiped. +case "$WS" in + "$RWS"/*) ;; + *) echo "::error::refusing to wipe workspace outside the runner workspace: \${WS} (runner workspace: \${RWS})"; exit 1 ;; +esac +find "$WS" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + +# The workspace wipe cannot see the runner user's HOME, which +# survives across jobs on this pool: a planted ~/.npmrc +# script-shell wraps every verdict-determining \`npm run\` in an +# attacker shell (and can redirect \`npm publish\`), and global git +# config exec knobs (core.hooksPath, filter.*, url.*.insteadOf, +# include.*) govern this job's checkout and credential-bearing +# git steps. Remove the npmrc outright — publish's setup-node +# recreates the registry config it needs after this step — and +# strip the git exec keys with the same denylist as +# qwen-autofix.yml's pre-checkout sanitize and +# .github/scripts/resanitize-git-config.sh (the inline form is the +# pre-checkout doctrine: the script file does not exist on disk +# before checkout). No-op on a fresh hosted runner. +rm -f -- "\${HOME:?}/.npmrc" +for global_file in "\${HOME}/.gitconfig" "\${XDG_CONFIG_HOME:-\${HOME}/.config}/git/config"; do + { GIT_CONFIG_GLOBAL="\${global_file}" git config --global --name-only --list 2>/dev/null || true; } \\ + | { grep -iE '^(core\\.(hookspath|fsmonitor|pager|editor|sshcommand|askpass|alternaterefscommand|gitproxy)$|diff\\.external$|diff\\..+\\.(command|textconv)$|merge\\..+\\.driver$|filter\\.|alias\\.|pager\\.|difftool\\.|mergetool\\.|interactive\\.difffilter$|sequence\\.editor$|gpg\\.(.+\\.)?program$|init\\.templatedir$|remote\\..+\\.(uploadpack|receivepack)$|submodule\\..+\\.update$|url\\..+\\.(insteadof|pushinsteadof)$|http\\.(.+\\.)?(sslverify|sslcainfo)$|include\\.|includeif\\.|protocol\\.(ext\\.)?allow$)' || true; } \\ + | while IFS= read -r key; do GIT_CONFIG_GLOBAL="\${global_file}" git config --global --unset-all "$key" 2>/dev/null || true; done +done`; + describe('release workflow', () => { it('cleans every shared ECS workspace before checkout', () => { const checkoutJobs = Object.entries(releaseYaml.jobs).filter(([, job]) => @@ -88,7 +199,6 @@ describe('release workflow', () => { String(step.uses ?? '').includes('actions/checkout'), ), ); - const cleanupCopies = []; expect(checkoutJobs.map(([id]) => id)).toEqual([ 'prepare', @@ -98,22 +208,22 @@ describe('release workflow', () => { 'publish', ]); for (const [id, job] of checkoutJobs) { - const checkoutIndex = job.steps.findIndex((step) => - String(step.uses ?? '').includes('actions/checkout'), - ); const restoreIndex = job.steps.findIndex( (step) => step.name === 'Restore workspace ownership', ); - const cleanup = job.steps[restoreIndex]?.run; - expect(restoreIndex, id).toBeGreaterThanOrEqual(0); - expect(restoreIndex, id).toBeLessThan(checkoutIndex); - expect(cleanup, id).toContain( - 'find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +', + // The step must stay FIRST: it is the only defence between + // cross-job-persistent state and every later state read in the job, + // so a demotion must fail even while it remains ahead of checkout. + expect(restoreIndex, id).toBe(0); + const checkoutIndex = job.steps.findIndex((step) => + String(step.uses ?? '').includes('actions/checkout'), ); - cleanupCopies.push(cleanup); - } - for (const copy of cleanupCopies.slice(1)) { - expect(copy).toBe(cleanupCopies[0]); + expect(checkoutIndex, id).toBeGreaterThan(0); + // Full-string equality against the shared constant: commenting out + // the find, inserting an early exit, or dropping the chown/chmod + // ladder uniformly from all five copies keeps every substring and + // equality-across-copies pin green while reopening the incident. + expect(job.steps[restoreIndex]?.run, id).toBe(canonicalWipe); } }); @@ -127,7 +237,16 @@ describe('release workflow', () => { ); expect(preflightIndex).toBeGreaterThanOrEqual(0); expect(preflightIndex).toBeLessThan(checkoutIndex); - expect(steps[preflightIndex].run).toContain('docker info'); + // Pin the full fail-closed form, not just a substring: deleting + // 'exit 1' degrades the preflight to a warning (a dead daemon proceeds + // into checkout and dies deep in the docker tests), inverting the guard + // fails every healthy runner, and discarding docker's own output leaves + // the oncall unable to tell dockerd-down from socket-permission + // failures without first reaching the runner — all mutants probed + // green under the old substring pin. + expect(steps[preflightIndex].run).toMatch( + /^if ! docker_info_output="\$\(docker info 2>&1\)"; then\n {2}echo "::error::docker daemon is not reachable on this runner; docker integration tests cannot run\."\n {2}printf '%s\\n' "\$docker_info_output"\n {2}exit 1\nfi$/, + ); }); it('bounds shared-pool jobs and skips redundant remote npm caches', () => { From ecbc499aeed95432b69c113d9f428ffa3770acd9 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Wed, 26 Aug 2026 14:49:42 +0800 Subject: [PATCH 08/35] fix(ci): deduplicate release runner cleanup --- .github/workflows/release.yml | 419 +--------------------------------- 1 file changed, 6 insertions(+), 413 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1a393cd4ffb..8fe2ba5ff19 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,7 +62,8 @@ jobs: steps: # Shared ECS runners can retain root-owned files from an earlier # containerized job. Restore the reusable workspace before checkout. - - name: 'Restore workspace ownership' + - &restore_release_workspace + name: 'Restore workspace ownership' run: |- set -uo pipefail RUNNER_UID="$(id -u)" @@ -274,109 +275,7 @@ jobs: steps: # Shared ECS runners can retain root-owned files from an earlier # containerized job. Restore the reusable workspace before checkout. - - name: 'Restore workspace ownership' - run: |- - set -uo pipefail - RUNNER_UID="$(id -u)" - RUNNER_GID="$(id -g)" - if [ "$RUNNER_UID" != "0" ]; then - chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files" - fi - chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files" - # Release jobs do not need cross-job workspace reuse: remove every - # persisted entry, including planted .git config/hooks/attributes, - # before actions/checkout runs with release credentials. The full - # wipe — rather than keeping and scrubbing .git like serve-ab.yml — - # is deliberate: these checkouts run with CI_BOT_PAT and the npm - # OIDC id-token, so no pre-existing repo state may survive into - # them; the accepted cost is re-fetching full history each run. - # - # Guards ported from serve-ab.yml's wipe (#9220, #9265): under a - # mangled env even `/home` or an empty string reached the rm. A - # wipe pointed at the wrong path is far worse than a skipped wipe, - # so canonicalize, strip trailing slashes, denylist the known - # roots, and require the target to sit inside the runner workspace - # before any rm. - WS="${GITHUB_WORKSPACE:?}" - while [ "${WS%/}" != "$WS" ]; do WS="${WS%/}"; done - RWS="${RUNNER_WORKSPACE:?}" - RWS="$(realpath -m -- "$RWS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${RUNNER_WORKSPACE}"; exit 1; } - while [ "${RWS%/}" != "$RWS" ]; do RWS="${RWS%/}"; done - if [ -z "$RWS" ]; then echo "::error::refusing to wipe: runner workspace resolved to /"; exit 1; fi - case "$RWS" in - ..|../*|*/..|*/../*) echo "::error::refusing runner workspace path containing '..': ${RWS}"; exit 1 ;; - esac - # Heal a workspace a previous job replaced with a symlink (or any - # non-directory) BEFORE canonicalizing it: afterwards the path - # resolves to the link's target, the containment below refuses it, - # and every later job on this runner would die here permanently on - # corruption that is itself inside the runner workspace and safe - # to unlink. - if [ -L "$WS" ] || [ ! -d "$WS" ]; then - # Judge the PARENT, canonicalized: the kernel resolves - # intermediate components too, so a raw containment match is not - # enough. Never resolve $WS itself — that would resolve through - # the very link being removed. - HEAL_PARENT="$(realpath -m -- "$(dirname -- "$WS")" 2>/dev/null)" || { echo "::error::refusing to heal: realpath unavailable, cannot canonicalize the parent of ${WS}"; exit 1; } - case "$HEAL_PARENT" in - "$RWS"|"$RWS"/*) ;; - *) echo "::error::refusing to heal workspace outside the runner workspace: ${WS} (parent: ${HEAL_PARENT}, runner workspace: ${RWS})"; exit 1 ;; - esac - if [ -L "$WS" ]; then - # The link target is bytes a PREVIOUS job chose — on this pool - # that job may have run contributor code — and the runner - # parses `::` at the start of any stdout line as a workflow - # command: keep untrusted bytes off the command line itself, - # strip the line breaks that could start a new one, and cap - # the length. - heal_target="$(readlink -- "$WS" 2>/dev/null || printf '%s' '')" - heal_target="$(printf '%s' "$heal_target" | tr -d '\r\n' | cut -c1-200)" - echo "::warning::healing workspace ${WS}: it was a symlink" - printf 'heal: %s pointed at %s\n' "$WS" "$heal_target" - else - echo "::warning::healing workspace ${WS}: it was not a directory" - fi - # `rm -f` on the RAW path removes the link itself and never - # follows it. Both legs fail closed: a swallowed failure here - # would leave the wipe running against a corrupt path. - rm -f -- "$WS" || { echo "::error::refusing to continue: could not remove ${WS}"; exit 1; } - mkdir -- "$WS" || { echo "::error::refusing to continue: could not recreate ${WS}"; exit 1; } - fi - WS="$(realpath -m -- "$WS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${GITHUB_WORKSPACE}"; exit 1; } - while [ "${WS%/}" != "$WS" ]; do WS="${WS%/}"; done - case "$WS" in - ..|../*|*/..|*/../*) echo "::error::refusing to wipe path containing '..': ${WS}"; exit 1 ;; - esac - case "$WS" in - /|/home|/root|/usr*|/etc*|/var|"") echo "::error::refusing to wipe suspicious workspace path: ${WS}"; exit 1 ;; - esac - # A denylist can only enumerate known roots — the allowlist closes - # every other one (/tmp, /opt, ...): only a directory inside the - # runner workspace may be wiped. - case "$WS" in - "$RWS"/*) ;; - *) echo "::error::refusing to wipe workspace outside the runner workspace: ${WS} (runner workspace: ${RWS})"; exit 1 ;; - esac - find "$WS" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + - # The workspace wipe cannot see the runner user's HOME, which - # survives across jobs on this pool: a planted ~/.npmrc - # script-shell wraps every verdict-determining `npm run` in an - # attacker shell (and can redirect `npm publish`), and global git - # config exec knobs (core.hooksPath, filter.*, url.*.insteadOf, - # include.*) govern this job's checkout and credential-bearing - # git steps. Remove the npmrc outright — publish's setup-node - # recreates the registry config it needs after this step — and - # strip the git exec keys with the same denylist as - # qwen-autofix.yml's pre-checkout sanitize and - # .github/scripts/resanitize-git-config.sh (the inline form is the - # pre-checkout doctrine: the script file does not exist on disk - # before checkout). No-op on a fresh hosted runner. - rm -f -- "${HOME:?}/.npmrc" - for global_file in "${HOME}/.gitconfig" "${XDG_CONFIG_HOME:-${HOME}/.config}/git/config"; do - { GIT_CONFIG_GLOBAL="${global_file}" git config --global --name-only --list 2>/dev/null || true; } \ - | { grep -iE '^(core\.(hookspath|fsmonitor|pager|editor|sshcommand|askpass|alternaterefscommand|gitproxy)$|diff\.external$|diff\..+\.(command|textconv)$|merge\..+\.driver$|filter\.|alias\.|pager\.|difftool\.|mergetool\.|interactive\.difffilter$|sequence\.editor$|gpg\.(.+\.)?program$|init\.templatedir$|remote\..+\.(uploadpack|receivepack)$|submodule\..+\.update$|url\..+\.(insteadof|pushinsteadof)$|http\.(.+\.)?(sslverify|sslcainfo)$|include\.|includeif\.|protocol\.(ext\.)?allow$)' || true; } \ - | while IFS= read -r key; do GIT_CONFIG_GLOBAL="${global_file}" git config --global --unset-all "$key" 2>/dev/null || true; done - done + - *restore_release_workspace - name: 'Checkout' uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 @@ -447,109 +346,7 @@ jobs: steps: # Shared ECS runners can retain root-owned files from an earlier # containerized job. Restore the reusable workspace before checkout. - - name: 'Restore workspace ownership' - run: |- - set -uo pipefail - RUNNER_UID="$(id -u)" - RUNNER_GID="$(id -g)" - if [ "$RUNNER_UID" != "0" ]; then - chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files" - fi - chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files" - # Release jobs do not need cross-job workspace reuse: remove every - # persisted entry, including planted .git config/hooks/attributes, - # before actions/checkout runs with release credentials. The full - # wipe — rather than keeping and scrubbing .git like serve-ab.yml — - # is deliberate: these checkouts run with CI_BOT_PAT and the npm - # OIDC id-token, so no pre-existing repo state may survive into - # them; the accepted cost is re-fetching full history each run. - # - # Guards ported from serve-ab.yml's wipe (#9220, #9265): under a - # mangled env even `/home` or an empty string reached the rm. A - # wipe pointed at the wrong path is far worse than a skipped wipe, - # so canonicalize, strip trailing slashes, denylist the known - # roots, and require the target to sit inside the runner workspace - # before any rm. - WS="${GITHUB_WORKSPACE:?}" - while [ "${WS%/}" != "$WS" ]; do WS="${WS%/}"; done - RWS="${RUNNER_WORKSPACE:?}" - RWS="$(realpath -m -- "$RWS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${RUNNER_WORKSPACE}"; exit 1; } - while [ "${RWS%/}" != "$RWS" ]; do RWS="${RWS%/}"; done - if [ -z "$RWS" ]; then echo "::error::refusing to wipe: runner workspace resolved to /"; exit 1; fi - case "$RWS" in - ..|../*|*/..|*/../*) echo "::error::refusing runner workspace path containing '..': ${RWS}"; exit 1 ;; - esac - # Heal a workspace a previous job replaced with a symlink (or any - # non-directory) BEFORE canonicalizing it: afterwards the path - # resolves to the link's target, the containment below refuses it, - # and every later job on this runner would die here permanently on - # corruption that is itself inside the runner workspace and safe - # to unlink. - if [ -L "$WS" ] || [ ! -d "$WS" ]; then - # Judge the PARENT, canonicalized: the kernel resolves - # intermediate components too, so a raw containment match is not - # enough. Never resolve $WS itself — that would resolve through - # the very link being removed. - HEAL_PARENT="$(realpath -m -- "$(dirname -- "$WS")" 2>/dev/null)" || { echo "::error::refusing to heal: realpath unavailable, cannot canonicalize the parent of ${WS}"; exit 1; } - case "$HEAL_PARENT" in - "$RWS"|"$RWS"/*) ;; - *) echo "::error::refusing to heal workspace outside the runner workspace: ${WS} (parent: ${HEAL_PARENT}, runner workspace: ${RWS})"; exit 1 ;; - esac - if [ -L "$WS" ]; then - # The link target is bytes a PREVIOUS job chose — on this pool - # that job may have run contributor code — and the runner - # parses `::` at the start of any stdout line as a workflow - # command: keep untrusted bytes off the command line itself, - # strip the line breaks that could start a new one, and cap - # the length. - heal_target="$(readlink -- "$WS" 2>/dev/null || printf '%s' '')" - heal_target="$(printf '%s' "$heal_target" | tr -d '\r\n' | cut -c1-200)" - echo "::warning::healing workspace ${WS}: it was a symlink" - printf 'heal: %s pointed at %s\n' "$WS" "$heal_target" - else - echo "::warning::healing workspace ${WS}: it was not a directory" - fi - # `rm -f` on the RAW path removes the link itself and never - # follows it. Both legs fail closed: a swallowed failure here - # would leave the wipe running against a corrupt path. - rm -f -- "$WS" || { echo "::error::refusing to continue: could not remove ${WS}"; exit 1; } - mkdir -- "$WS" || { echo "::error::refusing to continue: could not recreate ${WS}"; exit 1; } - fi - WS="$(realpath -m -- "$WS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${GITHUB_WORKSPACE}"; exit 1; } - while [ "${WS%/}" != "$WS" ]; do WS="${WS%/}"; done - case "$WS" in - ..|../*|*/..|*/../*) echo "::error::refusing to wipe path containing '..': ${WS}"; exit 1 ;; - esac - case "$WS" in - /|/home|/root|/usr*|/etc*|/var|"") echo "::error::refusing to wipe suspicious workspace path: ${WS}"; exit 1 ;; - esac - # A denylist can only enumerate known roots — the allowlist closes - # every other one (/tmp, /opt, ...): only a directory inside the - # runner workspace may be wiped. - case "$WS" in - "$RWS"/*) ;; - *) echo "::error::refusing to wipe workspace outside the runner workspace: ${WS} (runner workspace: ${RWS})"; exit 1 ;; - esac - find "$WS" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + - # The workspace wipe cannot see the runner user's HOME, which - # survives across jobs on this pool: a planted ~/.npmrc - # script-shell wraps every verdict-determining `npm run` in an - # attacker shell (and can redirect `npm publish`), and global git - # config exec knobs (core.hooksPath, filter.*, url.*.insteadOf, - # include.*) govern this job's checkout and credential-bearing - # git steps. Remove the npmrc outright — publish's setup-node - # recreates the registry config it needs after this step — and - # strip the git exec keys with the same denylist as - # qwen-autofix.yml's pre-checkout sanitize and - # .github/scripts/resanitize-git-config.sh (the inline form is the - # pre-checkout doctrine: the script file does not exist on disk - # before checkout). No-op on a fresh hosted runner. - rm -f -- "${HOME:?}/.npmrc" - for global_file in "${HOME}/.gitconfig" "${XDG_CONFIG_HOME:-${HOME}/.config}/git/config"; do - { GIT_CONFIG_GLOBAL="${global_file}" git config --global --name-only --list 2>/dev/null || true; } \ - | { grep -iE '^(core\.(hookspath|fsmonitor|pager|editor|sshcommand|askpass|alternaterefscommand|gitproxy)$|diff\.external$|diff\..+\.(command|textconv)$|merge\..+\.driver$|filter\.|alias\.|pager\.|difftool\.|mergetool\.|interactive\.difffilter$|sequence\.editor$|gpg\.(.+\.)?program$|init\.templatedir$|remote\..+\.(uploadpack|receivepack)$|submodule\..+\.update$|url\..+\.(insteadof|pushinsteadof)$|http\.(.+\.)?(sslverify|sslcainfo)$|include\.|includeif\.|protocol\.(ext\.)?allow$)' || true; } \ - | while IFS= read -r key; do GIT_CONFIG_GLOBAL="${global_file}" git config --global --unset-all "$key" 2>/dev/null || true; done - done + - *restore_release_workspace - name: 'Checkout' uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 @@ -609,109 +406,7 @@ jobs: steps: # Shared ECS runners can retain root-owned files from an earlier # containerized job. Restore the reusable workspace before checkout. - - name: 'Restore workspace ownership' - run: |- - set -uo pipefail - RUNNER_UID="$(id -u)" - RUNNER_GID="$(id -g)" - if [ "$RUNNER_UID" != "0" ]; then - chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files" - fi - chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files" - # Release jobs do not need cross-job workspace reuse: remove every - # persisted entry, including planted .git config/hooks/attributes, - # before actions/checkout runs with release credentials. The full - # wipe — rather than keeping and scrubbing .git like serve-ab.yml — - # is deliberate: these checkouts run with CI_BOT_PAT and the npm - # OIDC id-token, so no pre-existing repo state may survive into - # them; the accepted cost is re-fetching full history each run. - # - # Guards ported from serve-ab.yml's wipe (#9220, #9265): under a - # mangled env even `/home` or an empty string reached the rm. A - # wipe pointed at the wrong path is far worse than a skipped wipe, - # so canonicalize, strip trailing slashes, denylist the known - # roots, and require the target to sit inside the runner workspace - # before any rm. - WS="${GITHUB_WORKSPACE:?}" - while [ "${WS%/}" != "$WS" ]; do WS="${WS%/}"; done - RWS="${RUNNER_WORKSPACE:?}" - RWS="$(realpath -m -- "$RWS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${RUNNER_WORKSPACE}"; exit 1; } - while [ "${RWS%/}" != "$RWS" ]; do RWS="${RWS%/}"; done - if [ -z "$RWS" ]; then echo "::error::refusing to wipe: runner workspace resolved to /"; exit 1; fi - case "$RWS" in - ..|../*|*/..|*/../*) echo "::error::refusing runner workspace path containing '..': ${RWS}"; exit 1 ;; - esac - # Heal a workspace a previous job replaced with a symlink (or any - # non-directory) BEFORE canonicalizing it: afterwards the path - # resolves to the link's target, the containment below refuses it, - # and every later job on this runner would die here permanently on - # corruption that is itself inside the runner workspace and safe - # to unlink. - if [ -L "$WS" ] || [ ! -d "$WS" ]; then - # Judge the PARENT, canonicalized: the kernel resolves - # intermediate components too, so a raw containment match is not - # enough. Never resolve $WS itself — that would resolve through - # the very link being removed. - HEAL_PARENT="$(realpath -m -- "$(dirname -- "$WS")" 2>/dev/null)" || { echo "::error::refusing to heal: realpath unavailable, cannot canonicalize the parent of ${WS}"; exit 1; } - case "$HEAL_PARENT" in - "$RWS"|"$RWS"/*) ;; - *) echo "::error::refusing to heal workspace outside the runner workspace: ${WS} (parent: ${HEAL_PARENT}, runner workspace: ${RWS})"; exit 1 ;; - esac - if [ -L "$WS" ]; then - # The link target is bytes a PREVIOUS job chose — on this pool - # that job may have run contributor code — and the runner - # parses `::` at the start of any stdout line as a workflow - # command: keep untrusted bytes off the command line itself, - # strip the line breaks that could start a new one, and cap - # the length. - heal_target="$(readlink -- "$WS" 2>/dev/null || printf '%s' '')" - heal_target="$(printf '%s' "$heal_target" | tr -d '\r\n' | cut -c1-200)" - echo "::warning::healing workspace ${WS}: it was a symlink" - printf 'heal: %s pointed at %s\n' "$WS" "$heal_target" - else - echo "::warning::healing workspace ${WS}: it was not a directory" - fi - # `rm -f` on the RAW path removes the link itself and never - # follows it. Both legs fail closed: a swallowed failure here - # would leave the wipe running against a corrupt path. - rm -f -- "$WS" || { echo "::error::refusing to continue: could not remove ${WS}"; exit 1; } - mkdir -- "$WS" || { echo "::error::refusing to continue: could not recreate ${WS}"; exit 1; } - fi - WS="$(realpath -m -- "$WS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${GITHUB_WORKSPACE}"; exit 1; } - while [ "${WS%/}" != "$WS" ]; do WS="${WS%/}"; done - case "$WS" in - ..|../*|*/..|*/../*) echo "::error::refusing to wipe path containing '..': ${WS}"; exit 1 ;; - esac - case "$WS" in - /|/home|/root|/usr*|/etc*|/var|"") echo "::error::refusing to wipe suspicious workspace path: ${WS}"; exit 1 ;; - esac - # A denylist can only enumerate known roots — the allowlist closes - # every other one (/tmp, /opt, ...): only a directory inside the - # runner workspace may be wiped. - case "$WS" in - "$RWS"/*) ;; - *) echo "::error::refusing to wipe workspace outside the runner workspace: ${WS} (runner workspace: ${RWS})"; exit 1 ;; - esac - find "$WS" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + - # The workspace wipe cannot see the runner user's HOME, which - # survives across jobs on this pool: a planted ~/.npmrc - # script-shell wraps every verdict-determining `npm run` in an - # attacker shell (and can redirect `npm publish`), and global git - # config exec knobs (core.hooksPath, filter.*, url.*.insteadOf, - # include.*) govern this job's checkout and credential-bearing - # git steps. Remove the npmrc outright — publish's setup-node - # recreates the registry config it needs after this step — and - # strip the git exec keys with the same denylist as - # qwen-autofix.yml's pre-checkout sanitize and - # .github/scripts/resanitize-git-config.sh (the inline form is the - # pre-checkout doctrine: the script file does not exist on disk - # before checkout). No-op on a fresh hosted runner. - rm -f -- "${HOME:?}/.npmrc" - for global_file in "${HOME}/.gitconfig" "${XDG_CONFIG_HOME:-${HOME}/.config}/git/config"; do - { GIT_CONFIG_GLOBAL="${global_file}" git config --global --name-only --list 2>/dev/null || true; } \ - | { grep -iE '^(core\.(hookspath|fsmonitor|pager|editor|sshcommand|askpass|alternaterefscommand|gitproxy)$|diff\.external$|diff\..+\.(command|textconv)$|merge\..+\.driver$|filter\.|alias\.|pager\.|difftool\.|mergetool\.|interactive\.difffilter$|sequence\.editor$|gpg\.(.+\.)?program$|init\.templatedir$|remote\..+\.(uploadpack|receivepack)$|submodule\..+\.update$|url\..+\.(insteadof|pushinsteadof)$|http\.(.+\.)?(sslverify|sslcainfo)$|include\.|includeif\.|protocol\.(ext\.)?allow$)' || true; } \ - | while IFS= read -r key; do GIT_CONFIG_GLOBAL="${global_file}" git config --global --unset-all "$key" 2>/dev/null || true; done - done + - *restore_release_workspace - name: 'Check docker daemon' run: |- @@ -841,109 +536,7 @@ jobs: steps: # Shared ECS runners can retain root-owned files from an earlier # containerized job. Restore the reusable workspace before checkout. - - name: 'Restore workspace ownership' - run: |- - set -uo pipefail - RUNNER_UID="$(id -u)" - RUNNER_GID="$(id -g)" - if [ "$RUNNER_UID" != "0" ]; then - chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files" - fi - chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files" - # Release jobs do not need cross-job workspace reuse: remove every - # persisted entry, including planted .git config/hooks/attributes, - # before actions/checkout runs with release credentials. The full - # wipe — rather than keeping and scrubbing .git like serve-ab.yml — - # is deliberate: these checkouts run with CI_BOT_PAT and the npm - # OIDC id-token, so no pre-existing repo state may survive into - # them; the accepted cost is re-fetching full history each run. - # - # Guards ported from serve-ab.yml's wipe (#9220, #9265): under a - # mangled env even `/home` or an empty string reached the rm. A - # wipe pointed at the wrong path is far worse than a skipped wipe, - # so canonicalize, strip trailing slashes, denylist the known - # roots, and require the target to sit inside the runner workspace - # before any rm. - WS="${GITHUB_WORKSPACE:?}" - while [ "${WS%/}" != "$WS" ]; do WS="${WS%/}"; done - RWS="${RUNNER_WORKSPACE:?}" - RWS="$(realpath -m -- "$RWS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${RUNNER_WORKSPACE}"; exit 1; } - while [ "${RWS%/}" != "$RWS" ]; do RWS="${RWS%/}"; done - if [ -z "$RWS" ]; then echo "::error::refusing to wipe: runner workspace resolved to /"; exit 1; fi - case "$RWS" in - ..|../*|*/..|*/../*) echo "::error::refusing runner workspace path containing '..': ${RWS}"; exit 1 ;; - esac - # Heal a workspace a previous job replaced with a symlink (or any - # non-directory) BEFORE canonicalizing it: afterwards the path - # resolves to the link's target, the containment below refuses it, - # and every later job on this runner would die here permanently on - # corruption that is itself inside the runner workspace and safe - # to unlink. - if [ -L "$WS" ] || [ ! -d "$WS" ]; then - # Judge the PARENT, canonicalized: the kernel resolves - # intermediate components too, so a raw containment match is not - # enough. Never resolve $WS itself — that would resolve through - # the very link being removed. - HEAL_PARENT="$(realpath -m -- "$(dirname -- "$WS")" 2>/dev/null)" || { echo "::error::refusing to heal: realpath unavailable, cannot canonicalize the parent of ${WS}"; exit 1; } - case "$HEAL_PARENT" in - "$RWS"|"$RWS"/*) ;; - *) echo "::error::refusing to heal workspace outside the runner workspace: ${WS} (parent: ${HEAL_PARENT}, runner workspace: ${RWS})"; exit 1 ;; - esac - if [ -L "$WS" ]; then - # The link target is bytes a PREVIOUS job chose — on this pool - # that job may have run contributor code — and the runner - # parses `::` at the start of any stdout line as a workflow - # command: keep untrusted bytes off the command line itself, - # strip the line breaks that could start a new one, and cap - # the length. - heal_target="$(readlink -- "$WS" 2>/dev/null || printf '%s' '')" - heal_target="$(printf '%s' "$heal_target" | tr -d '\r\n' | cut -c1-200)" - echo "::warning::healing workspace ${WS}: it was a symlink" - printf 'heal: %s pointed at %s\n' "$WS" "$heal_target" - else - echo "::warning::healing workspace ${WS}: it was not a directory" - fi - # `rm -f` on the RAW path removes the link itself and never - # follows it. Both legs fail closed: a swallowed failure here - # would leave the wipe running against a corrupt path. - rm -f -- "$WS" || { echo "::error::refusing to continue: could not remove ${WS}"; exit 1; } - mkdir -- "$WS" || { echo "::error::refusing to continue: could not recreate ${WS}"; exit 1; } - fi - WS="$(realpath -m -- "$WS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${GITHUB_WORKSPACE}"; exit 1; } - while [ "${WS%/}" != "$WS" ]; do WS="${WS%/}"; done - case "$WS" in - ..|../*|*/..|*/../*) echo "::error::refusing to wipe path containing '..': ${WS}"; exit 1 ;; - esac - case "$WS" in - /|/home|/root|/usr*|/etc*|/var|"") echo "::error::refusing to wipe suspicious workspace path: ${WS}"; exit 1 ;; - esac - # A denylist can only enumerate known roots — the allowlist closes - # every other one (/tmp, /opt, ...): only a directory inside the - # runner workspace may be wiped. - case "$WS" in - "$RWS"/*) ;; - *) echo "::error::refusing to wipe workspace outside the runner workspace: ${WS} (runner workspace: ${RWS})"; exit 1 ;; - esac - find "$WS" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + - # The workspace wipe cannot see the runner user's HOME, which - # survives across jobs on this pool: a planted ~/.npmrc - # script-shell wraps every verdict-determining `npm run` in an - # attacker shell (and can redirect `npm publish`), and global git - # config exec knobs (core.hooksPath, filter.*, url.*.insteadOf, - # include.*) govern this job's checkout and credential-bearing - # git steps. Remove the npmrc outright — publish's setup-node - # recreates the registry config it needs after this step — and - # strip the git exec keys with the same denylist as - # qwen-autofix.yml's pre-checkout sanitize and - # .github/scripts/resanitize-git-config.sh (the inline form is the - # pre-checkout doctrine: the script file does not exist on disk - # before checkout). No-op on a fresh hosted runner. - rm -f -- "${HOME:?}/.npmrc" - for global_file in "${HOME}/.gitconfig" "${XDG_CONFIG_HOME:-${HOME}/.config}/git/config"; do - { GIT_CONFIG_GLOBAL="${global_file}" git config --global --name-only --list 2>/dev/null || true; } \ - | { grep -iE '^(core\.(hookspath|fsmonitor|pager|editor|sshcommand|askpass|alternaterefscommand|gitproxy)$|diff\.external$|diff\..+\.(command|textconv)$|merge\..+\.driver$|filter\.|alias\.|pager\.|difftool\.|mergetool\.|interactive\.difffilter$|sequence\.editor$|gpg\.(.+\.)?program$|init\.templatedir$|remote\..+\.(uploadpack|receivepack)$|submodule\..+\.update$|url\..+\.(insteadof|pushinsteadof)$|http\.(.+\.)?(sslverify|sslcainfo)$|include\.|includeif\.|protocol\.(ext\.)?allow$)' || true; } \ - | while IFS= read -r key; do GIT_CONFIG_GLOBAL="${global_file}" git config --global --unset-all "$key" 2>/dev/null || true; done - done + - *restore_release_workspace - name: 'Checkout' uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 From 680e794dbe17980dac7977abc92cd6d878c499ef Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Wed, 26 Aug 2026 16:25:38 +0800 Subject: [PATCH 09/35] fix(ci): keep the release failure notifier on hosted runners notify_failure exists to report failures of the ECS pool, so routing it onto that same pool wedges the alert chain in exactly the post-claim failure modes it fires for (runner crash, host loss, pool-wide outage). The job is gh/jq-only (both preinstalled on hosted images); pin it to an ephemeral ubuntu-latest runner like the sibling failure notifiers (release-sdk.yml, release-sdk-python.yml, release-vscode-companion.yml, and qwen-code-pr-review.yml's fallback comment). Co-authored-by: Qwen-Coder --- .github/workflows/release.yml | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8fe2ba5ff19..038d96024ca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -902,10 +902,19 @@ jobs: notify_failure: name: 'Notify Release Failure' - # Route to the ECS self-hosted pool like the review/CI lanes; the - # MAINTAINER_ECS_RUNNER_DISABLED repo variable restores the hosted - # ubuntu-latest fallback without a code change. - runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}' + # Pinned to an ephemeral hosted runner instead of the ECS pool the other + # release jobs route to: this job exists to report failures OF that pool. + # In post-claim failure modes (a runner crash or host loss mid-job, + # pool-wide network/tooling loss) the upstream jobs are marked failed and + # the `if:` gate below opens while the pool is exactly what is broken — + # routing the notifier back onto it means a failed release produces no + # failure issue, no autofix dispatch, no alert. The job is gh/jq-only + # (both preinstalled on hosted images), so hosted capacity costs nothing + # here. The sibling failure notifiers stay hosted for the same reason + # (release-vscode-companion.yml, release-sdk.yml, release-sdk-python.yml; + # qwen-code-pr-review.yml's fallback comment: it "runs on an ephemeral + # hosted runner, so it survives whatever killed the review job"). + runs-on: 'ubuntu-latest' timeout-minutes: 10 needs: - 'prepare' From 860f4c826de32ed5412d14a44046600d9cfd18ac Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Wed, 26 Aug 2026 16:25:51 +0800 Subject: [PATCH 10/35] fix(ci): pin the release lanes' conditional ECS runs-on No test pinned the migration itself: reverting any routed release job to a hosted runner, or dropping the MAINTAINER_ECS_RUNNER_DISABLED clause, shipped green. Pin each routed job's exact runs-on expression (repository guard + kill switch + both branches) and the notifier's hosted pin, matching qwen-autofix-workflow.test.js's tripwire style. Verified by mutation: reverting prepare's runs-on to ubuntu-latest or dropping the kill-switch clause now fails release-workflow.test.js. Co-authored-by: Qwen-Coder --- scripts/tests/release-workflow.test.js | 44 ++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/scripts/tests/release-workflow.test.js b/scripts/tests/release-workflow.test.js index 9782bcda565..4360c038204 100644 --- a/scripts/tests/release-workflow.test.js +++ b/scripts/tests/release-workflow.test.js @@ -513,3 +513,47 @@ describe('Live Host feed contract', () => { ); }); }); + +describe('release lane runner routing', () => { + // The exact conditional runs-on the ECS migration routes the release + // lanes through: repository guard + MAINTAINER_ECS_RUNNER_DISABLED kill + // switch + both the ecs-qwen and ubuntu-latest branches. Same pinning + // shape as qwen-autofix-workflow.test.js's heavy-job runs-on tripwire. + const ecsRunsOn = + "${{ (github.repository == 'QwenLM/qwen-code' && vars.MAINTAINER_ECS_RUNNER_DISABLED != 'true') && fromJSON('[\"self-hosted\", \"linux\", \"x64\", \"ecs-qwen\"]') || fromJSON('[\"ubuntu-latest\"]') }}"; + + it('pins every routed release job to the conditional ECS runs-on', () => { + const conditionalJobs = [ + 'prepare', + 'quality', + 'integration_none', + 'integration_docker', + 'publish', + ]; + for (const name of conditionalJobs) { + const job = releaseYaml.jobs[name]; + expect(job, `job missing from release.yml: ${name}`).toBeTruthy(); + // Reverting any lane to a hosted runner, dropping the kill-switch + // clause, or typoing the expression must fail here — an unpinned + // revert would silently re-pin releases to hosted capacity (the + // stall this migration exists to fix) or defeat the kill switch. + expect(job['runs-on'], `runs-on drifted on job: ${name}`).toBe( + ecsRunsOn, + ); + } + }); + + it('keeps the failure notifier on an ephemeral hosted runner', () => { + // notify_failure exists to report failures OF the ECS pool; in + // post-claim pool failures (runner crash, pool-wide loss) its `if:` + // gate opens while the pool is wedged, so routing it back onto the + // pool would kill the alert chain. The job is gh/jq-only, so hosted + // capacity costs nothing — sibling failure notifiers (release-sdk*, + // release-vscode-companion, qwen-code-pr-review's fallback comment) + // stay hosted for exactly this reason. + expect(releaseYaml.jobs.notify_failure['runs-on']).toBe('ubuntu-latest'); + expect(releaseYaml.jobs.notify_failure['runs-on']).not.toContain( + 'ecs-qwen', + ); + }); +}); From d14fb9f04c92ce664ef99bc19882bf384bd24f6f Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Wed, 26 Aug 2026 17:55:59 +0800 Subject: [PATCH 11/35] fix(ci): keep release publishing on hosted runners --- .github/workflows/release.yml | 7 +++---- scripts/tests/release-workflow.test.js | 6 ++++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 038d96024ca..1d0848a9446 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -474,10 +474,9 @@ jobs: publish: name: 'Publish Release' - # Route to the ECS self-hosted pool like the review/CI lanes; the - # MAINTAINER_ECS_RUNNER_DISABLED repo variable restores the hosted - # ubuntu-latest fallback without a code change. - runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}' + # Publishing carries release credentials, so keep it off the shared pool + # that also executes pull-request code. + runs-on: 'ubuntu-latest' needs: - 'prepare' - 'quality' diff --git a/scripts/tests/release-workflow.test.js b/scripts/tests/release-workflow.test.js index 4360c038204..e1b52cd51d4 100644 --- a/scripts/tests/release-workflow.test.js +++ b/scripts/tests/release-workflow.test.js @@ -528,7 +528,6 @@ describe('release lane runner routing', () => { 'quality', 'integration_none', 'integration_docker', - 'publish', ]; for (const name of conditionalJobs) { const job = releaseYaml.jobs[name]; @@ -543,7 +542,10 @@ describe('release lane runner routing', () => { } }); - it('keeps the failure notifier on an ephemeral hosted runner', () => { + it('keeps publishing and failure notification on hosted runners', () => { + expect(releaseYaml.jobs.publish['runs-on']).toBe('ubuntu-latest'); + expect(releaseYaml.jobs.publish['runs-on']).not.toContain('ecs-qwen'); + // notify_failure exists to report failures OF the ECS pool; in // post-claim pool failures (runner crash, pool-wide loss) its `if:` // gate opens while the pool is wedged, so routing it back onto the From 132b7cbcf32f181b7cb69538dd69370b6c0ff286 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Wed, 26 Aug 2026 20:27:26 +0800 Subject: [PATCH 12/35] fix(ci): fail closed on release git scrub --- .github/workflows/release.yml | 9 +++- scripts/tests/release-workflow.test.js | 62 +++++++++++++++++++++++++- 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1d0848a9446..7564069823e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -161,10 +161,17 @@ jobs: # pre-checkout doctrine: the script file does not exist on disk # before checkout). No-op on a fresh hosted runner. rm -f -- "${HOME:?}/.npmrc" + git_exec_key_pattern='^(core\.(hookspath|fsmonitor|pager|editor|sshcommand|askpass|alternaterefscommand|gitproxy)$|diff\.external$|diff\..+\.(command|textconv)$|merge\..+\.driver$|filter\.|alias\.|pager\.|difftool\.|mergetool\.|interactive\.difffilter$|sequence\.editor$|gpg\.(.+\.)?program$|init\.templatedir$|remote\..+\.(uploadpack|receivepack)$|submodule\..+\.update$|url\..+\.(insteadof|pushinsteadof)$|http\.(.+\.)?(sslverify|sslcainfo)$|include\.|includeif\.|protocol\.(ext\.)?allow$)' for global_file in "${HOME}/.gitconfig" "${XDG_CONFIG_HOME:-${HOME}/.config}/git/config"; do + [ -e "$global_file" ] || continue { GIT_CONFIG_GLOBAL="${global_file}" git config --global --name-only --list 2>/dev/null || true; } \ - | { grep -iE '^(core\.(hookspath|fsmonitor|pager|editor|sshcommand|askpass|alternaterefscommand|gitproxy)$|diff\.external$|diff\..+\.(command|textconv)$|merge\..+\.driver$|filter\.|alias\.|pager\.|difftool\.|mergetool\.|interactive\.difffilter$|sequence\.editor$|gpg\.(.+\.)?program$|init\.templatedir$|remote\..+\.(uploadpack|receivepack)$|submodule\..+\.update$|url\..+\.(insteadof|pushinsteadof)$|http\.(.+\.)?(sslverify|sslcainfo)$|include\.|includeif\.|protocol\.(ext\.)?allow$)' || true; } \ + | { grep -iE "$git_exec_key_pattern" || true; } \ | while IFS= read -r key; do GIT_CONFIG_GLOBAL="${global_file}" git config --global --unset-all "$key" 2>/dev/null || true; done + remaining_keys="$(GIT_CONFIG_GLOBAL="${global_file}" git config --global --name-only --list)" || { echo "::error::could not verify the global git config scrub in ${global_file}"; exit 1; } + if printf '%s\n' "$remaining_keys" | grep -qiE "$git_exec_key_pattern"; then + echo "::error::git exec keys survived the pre-checkout scrub in ${global_file}" + exit 1 + fi done - name: 'Checkout' diff --git a/scripts/tests/release-workflow.test.js b/scripts/tests/release-workflow.test.js index e1b52cd51d4..06882f60dd9 100644 --- a/scripts/tests/release-workflow.test.js +++ b/scripts/tests/release-workflow.test.js @@ -5,7 +5,13 @@ */ import { spawnSync } from 'node:child_process'; -import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; @@ -186,10 +192,17 @@ find "$WS" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + # pre-checkout doctrine: the script file does not exist on disk # before checkout). No-op on a fresh hosted runner. rm -f -- "\${HOME:?}/.npmrc" +git_exec_key_pattern='^(core\\.(hookspath|fsmonitor|pager|editor|sshcommand|askpass|alternaterefscommand|gitproxy)$|diff\\.external$|diff\\..+\\.(command|textconv)$|merge\\..+\\.driver$|filter\\.|alias\\.|pager\\.|difftool\\.|mergetool\\.|interactive\\.difffilter$|sequence\\.editor$|gpg\\.(.+\\.)?program$|init\\.templatedir$|remote\\..+\\.(uploadpack|receivepack)$|submodule\\..+\\.update$|url\\..+\\.(insteadof|pushinsteadof)$|http\\.(.+\\.)?(sslverify|sslcainfo)$|include\\.|includeif\\.|protocol\\.(ext\\.)?allow$)' for global_file in "\${HOME}/.gitconfig" "\${XDG_CONFIG_HOME:-\${HOME}/.config}/git/config"; do + [ -e "$global_file" ] || continue { GIT_CONFIG_GLOBAL="\${global_file}" git config --global --name-only --list 2>/dev/null || true; } \\ - | { grep -iE '^(core\\.(hookspath|fsmonitor|pager|editor|sshcommand|askpass|alternaterefscommand|gitproxy)$|diff\\.external$|diff\\..+\\.(command|textconv)$|merge\\..+\\.driver$|filter\\.|alias\\.|pager\\.|difftool\\.|mergetool\\.|interactive\\.difffilter$|sequence\\.editor$|gpg\\.(.+\\.)?program$|init\\.templatedir$|remote\\..+\\.(uploadpack|receivepack)$|submodule\\..+\\.update$|url\\..+\\.(insteadof|pushinsteadof)$|http\\.(.+\\.)?(sslverify|sslcainfo)$|include\\.|includeif\\.|protocol\\.(ext\\.)?allow$)' || true; } \\ + | { grep -iE "$git_exec_key_pattern" || true; } \\ | while IFS= read -r key; do GIT_CONFIG_GLOBAL="\${global_file}" git config --global --unset-all "$key" 2>/dev/null || true; done + remaining_keys="$(GIT_CONFIG_GLOBAL="\${global_file}" git config --global --name-only --list)" || { echo "::error::could not verify the global git config scrub in \${global_file}"; exit 1; } + if printf '%s\\n' "$remaining_keys" | grep -qiE "$git_exec_key_pattern"; then + echo "::error::git exec keys survived the pre-checkout scrub in \${global_file}" + exit 1 + fi done`; describe('release workflow', () => { @@ -227,6 +240,51 @@ describe('release workflow', () => { } }); + it('fails closed when a global git exec key cannot be removed', () => { + const base = mkdtempSync(join(tmpdir(), 'release-wipe-')); + const home = join(base, 'home'); + mkdirSync(home); + try { + const env = { + ...process.env, + HOME: home, + XDG_CONFIG_HOME: join(home, '.config'), + }; + const scrubStart = canonicalWipe.indexOf('rm -f -- "${HOME:?}/.npmrc"'); + expect(scrubStart).toBeGreaterThan(0); + const scrubHome = () => + spawnSync( + 'bash', + ['-e', '-o', 'pipefail', '-c', canonicalWipe.slice(scrubStart)], + { encoding: 'utf8', env }, + ); + const setHook = () => + spawnSync( + 'git', + ['config', '--global', 'core.hooksPath', join(base, 'hooks')], + { env }, + ); + + expect(setHook().status).toBe(0); + expect(scrubHome().status).toBe(0); + expect( + spawnSync('git', ['config', '--global', '--get', 'core.hooksPath'], { + env, + }).status, + ).not.toBe(0); + + expect(setHook().status).toBe(0); + writeFileSync(join(home, '.gitconfig.lock'), ''); + const result = scrubHome(); + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain( + 'git exec keys survived the pre-checkout scrub', + ); + } finally { + rmSync(base, { recursive: true, force: true }); + } + }); + it('checks docker availability before the docker checkout', () => { const steps = releaseYaml.jobs.integration_docker.steps; const preflightIndex = steps.findIndex( From dcbaa9a6100604f847f1f75735d6947d5afa49db Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Wed, 26 Aug 2026 23:42:34 +0800 Subject: [PATCH 13/35] fix(ci): scrub ambient GIT_CONFIG_GLOBAL from the release wipe test Mirror the sibling scrub harness in qwen-autofix-workflow.test.js: delete GIT_CONFIG_GLOBAL from the child env before spawning git, so setHook() and the post-scrub 'git config --global' assertion operate on the synthetic HOME instead of leaking through an ambient global config file. Co-authored-by: Qwen-Coder --- scripts/tests/release-workflow.test.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/scripts/tests/release-workflow.test.js b/scripts/tests/release-workflow.test.js index 06882f60dd9..aa0c9a760bc 100644 --- a/scripts/tests/release-workflow.test.js +++ b/scripts/tests/release-workflow.test.js @@ -250,6 +250,11 @@ describe('release workflow', () => { HOME: home, XDG_CONFIG_HOME: join(home, '.config'), }; + // An ambient GIT_CONFIG_GLOBAL would redirect `git config --global` + // (setHook and the post-scrub assertion) away from the synthetic HOME + // and defeat the hermetic scrub; the sibling scrub harness in + // qwen-autofix-workflow.test.js deletes it for the same reason. + delete env['GIT_CONFIG_GLOBAL']; const scrubStart = canonicalWipe.indexOf('rm -f -- "${HOME:?}/.npmrc"'); expect(scrubStart).toBeGreaterThan(0); const scrubHome = () => @@ -578,7 +583,7 @@ describe('release lane runner routing', () => { // switch + both the ecs-qwen and ubuntu-latest branches. Same pinning // shape as qwen-autofix-workflow.test.js's heavy-job runs-on tripwire. const ecsRunsOn = - "${{ (github.repository == 'QwenLM/qwen-code' && vars.MAINTAINER_ECS_RUNNER_DISABLED != 'true') && fromJSON('[\"self-hosted\", \"linux\", \"x64\", \"ecs-qwen\"]') || fromJSON('[\"ubuntu-latest\"]') }}"; + '${{ (github.repository == \'QwenLM/qwen-code\' && vars.MAINTAINER_ECS_RUNNER_DISABLED != \'true\') && fromJSON(\'["self-hosted", "linux", "x64", "ecs-qwen"]\') || fromJSON(\'["ubuntu-latest"]\') }}'; it('pins every routed release job to the conditional ECS runs-on', () => { const conditionalJobs = [ @@ -594,9 +599,7 @@ describe('release lane runner routing', () => { // clause, or typoing the expression must fail here — an unpinned // revert would silently re-pin releases to hosted capacity (the // stall this migration exists to fix) or defeat the kill switch. - expect(job['runs-on'], `runs-on drifted on job: ${name}`).toBe( - ecsRunsOn, - ); + expect(job['runs-on'], `runs-on drifted on job: ${name}`).toBe(ecsRunsOn); } }); From cf9c27d5c5eb5bbb4aa9b010d2cdd63e782155da Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Thu, 27 Aug 2026 00:40:11 +0800 Subject: [PATCH 14/35] test(ci): add behavioral tests for workspace wipe guard branches Drive the wipe half of canonicalWipe against fixtures so its guard branches (symlink heal, realpath canonicalization, runner-workspace allowlist) are executed, not just pinned by string equality: - Happy path: leftover files are removed - Symlink heal: symlinked workspace is removed and recreated - Outside runner workspace: refused with error - Path with '..': realpath resolves inside runner workspace, wipe proceeds --- scripts/tests/release-workflow.test.js | 133 +++++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/scripts/tests/release-workflow.test.js b/scripts/tests/release-workflow.test.js index aa0c9a760bc..04c9ff4cdc0 100644 --- a/scripts/tests/release-workflow.test.js +++ b/scripts/tests/release-workflow.test.js @@ -6,10 +6,13 @@ import { spawnSync } from 'node:child_process'; import { + lstatSync, mkdirSync, mkdtempSync, + readdirSync, readFileSync, rmSync, + symlinkSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -290,6 +293,136 @@ describe('release workflow', () => { } }); + it('executes the workspace wipe against guard branches', () => { + const wipeEnd = canonicalWipe.indexOf('rm -f -- "${HOME:?}/.npmrc"'); + expect(wipeEnd).toBeGreaterThan(0); + const wipeScript = canonicalWipe.slice(0, wipeEnd); + + const runWipe = (envOverrides, { preCreateWorkspace } = {}) => { + const base = mkdtempSync(join(tmpdir(), 'release-wipe-behavioral-')); + const workspace = join(base, 'workspace'); + mkdirSync(workspace); + if (preCreateWorkspace) preCreateWorkspace(base, workspace); + const env = { + ...process.env, + GITHUB_WORKSPACE: workspace, + RUNNER_WORKSPACE: base, + HOME: join(base, 'home'), + XDG_CONFIG_HOME: join(base, 'home', '.config'), + ...envOverrides, + }; + mkdirSync(env.HOME); + return { + result: spawnSync('bash', ['-e', '-o', 'pipefail', '-c', wipeScript], { + encoding: 'utf8', + env, + }), + base, + workspace, + }; + }; + + // Happy path: a normal workspace inside the runner workspace is wiped. + { + const { result, base, workspace } = runWipe( + {}, + { + preCreateWorkspace: (_base, ws) => { + writeFileSync(join(ws, 'leftover.txt'), 'stale'); + }, + }, + ); + try { + expect(result.status).toBe(0); + const entries = readdirSync(workspace); + expect(entries).toHaveLength(0); + } finally { + rmSync(base, { recursive: true, force: true }); + } + } + + // Symlink heal: a workspace replaced with a symlink inside the runner + // workspace is removed and recreated, then wiped. + { + const { result, base, workspace } = runWipe( + {}, + { + preCreateWorkspace: (b, ws) => { + rmSync(ws, { recursive: true, force: true }); + symlinkSync(join(b, 'decoy'), ws); + }, + }, + ); + try { + expect(result.status).toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain( + 'healing workspace', + ); + const stat = lstatSync(workspace); + expect(stat.isDirectory()).toBe(true); + } finally { + rmSync(base, { recursive: true, force: true }); + } + } + + // Workspace outside runner workspace: refused. + { + const outside = mkdtempSync(join(tmpdir(), 'release-wipe-outside-')); + const base = mkdtempSync(join(tmpdir(), 'release-wipe-runner-')); + try { + const env = { + ...process.env, + GITHUB_WORKSPACE: outside, + RUNNER_WORKSPACE: base, + HOME: join(base, 'home'), + XDG_CONFIG_HOME: join(base, 'home', '.config'), + }; + mkdirSync(env.HOME); + const result = spawnSync( + 'bash', + ['-e', '-o', 'pipefail', '-c', wipeScript], + { encoding: 'utf8', env }, + ); + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain( + 'refusing to wipe workspace outside the runner workspace', + ); + } finally { + rmSync(outside, { recursive: true, force: true }); + rmSync(base, { recursive: true, force: true }); + } + } + + // Path with '..' that realpath resolves inside the runner workspace: + // canonicalization succeeds, containment passes, wipe proceeds. + { + const base = mkdtempSync(join(tmpdir(), 'release-wipe-dots-')); + const workspace = join(base, 'workspace'); + mkdirSync(workspace); + writeFileSync(join(workspace, 'leftover.txt'), 'stale'); + try { + const env = { + ...process.env, + GITHUB_WORKSPACE: join(base, 'sub', '..', 'workspace'), + RUNNER_WORKSPACE: base, + HOME: join(base, 'home'), + XDG_CONFIG_HOME: join(base, 'home', '.config'), + }; + mkdirSync(env.HOME); + const result = spawnSync( + 'bash', + ['-e', '-o', 'pipefail', '-c', wipeScript], + { encoding: 'utf8', env }, + ); + expect(result.status).toBe(0); + const entries = readdirSync(workspace); + expect(entries).toHaveLength(0); + } finally { + rmSync(base, { recursive: true, force: true }); + } + } + }); + it('checks docker availability before the docker checkout', () => { const steps = releaseYaml.jobs.integration_docker.steps; const preflightIndex = steps.findIndex( From c6476665c2bb6c0b3fe9579d5b7392e1a673e9b0 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Thu, 27 Aug 2026 05:26:54 +0800 Subject: [PATCH 15/35] fix(test): gate wipe behavioral test on GNU realpath, fix '..' path and heal assertions - Add hasGnuRealpath capability probe and skip the behavioral wipe test on non-GNU hosts (macOS BSD realpath lacks -m) and under root (uid 0 bypasses permission bits), matching the pattern used by every sibling suite that executes this script lineage. - Use string concatenation instead of path.join for the '..' sub-case so the literal '..' segment reaches the script (path.join normalizes it away, making the test equivalent to the happy path). - Create a real decoy target file for the heal sub-case and assert it survives the wipe, verifying that rm -f removes only the symlink itself and does not follow/delete the target. Co-authored-by: Qwen-Coder --- scripts/tests/release-workflow.test.js | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/scripts/tests/release-workflow.test.js b/scripts/tests/release-workflow.test.js index 04c9ff4cdc0..e6907dfa2c0 100644 --- a/scripts/tests/release-workflow.test.js +++ b/scripts/tests/release-workflow.test.js @@ -20,6 +20,11 @@ import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { parse } from 'yaml'; +// `realpath -m` (the script's canonicalization line) is a GNU coreutils +// extension. Probe the host before asserting GNU-specific path behavior. +const hasGnuRealpath = + spawnSync('realpath', ['-m', '--', '/'], { stdio: 'ignore' }).status === 0; + const workflow = readFileSync('.github/workflows/release.yml', 'utf8'); const releaseYaml = parse(workflow); const cuaReleaseWorkflow = readFileSync( @@ -293,7 +298,9 @@ describe('release workflow', () => { } }); - it('executes the workspace wipe against guard branches', () => { + it.skipIf(!hasGnuRealpath || process.getuid?.() === 0)( + 'executes the workspace wipe against guard branches', + () => { const wipeEnd = canonicalWipe.indexOf('rm -f -- "${HOME:?}/.npmrc"'); expect(wipeEnd).toBeGreaterThan(0); const wipeScript = canonicalWipe.slice(0, wipeEnd); @@ -342,14 +349,18 @@ describe('release workflow', () => { } // Symlink heal: a workspace replaced with a symlink inside the runner - // workspace is removed and recreated, then wiped. + // workspace is removed and recreated, then wiped. The decoy target + // is a real file so the test can verify `rm -f` removed only the + // link itself and did not follow/delete the target. { const { result, base, workspace } = runWipe( {}, { preCreateWorkspace: (b, ws) => { rmSync(ws, { recursive: true, force: true }); - symlinkSync(join(b, 'decoy'), ws); + const decoyTarget = join(b, 'decoy-target'); + writeFileSync(decoyTarget, 'must-survive'); + symlinkSync(decoyTarget, ws); }, }, ); @@ -360,6 +371,11 @@ describe('release workflow', () => { ); const stat = lstatSync(workspace); expect(stat.isDirectory()).toBe(true); + // The decoy target must survive: rm -f on the raw path removes + // the link itself and never follows it. + expect(readFileSync(join(base, 'decoy-target'), 'utf8')).toBe( + 'must-survive', + ); } finally { rmSync(base, { recursive: true, force: true }); } @@ -403,7 +419,9 @@ describe('release workflow', () => { try { const env = { ...process.env, - GITHUB_WORKSPACE: join(base, 'sub', '..', 'workspace'), + // String concatenation preserves the literal '..' segment — + // path.join would normalize it away before the script sees it. + GITHUB_WORKSPACE: `${base}/sub/../workspace`, RUNNER_WORKSPACE: base, HOME: join(base, 'home'), XDG_CONFIG_HOME: join(base, 'home', '.config'), From 84559ee7091c0bde1d968f3cf83f86ce38ce7bbf Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Thu, 27 Aug 2026 05:55:08 +0800 Subject: [PATCH 16/35] fix(ci): guard chmod -R against symlinked workspace before validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recursive chmod ran on the raw $GITHUB_WORKSPACE before the heal/canonicalize/guard block. GNU chmod -R follows a symlink given as its starting operand, so a previous pool job that replaced the workspace with a symlink would cause the chmod to descend into the link target. Add a [-L] guard that skips chmod when the workspace is not a real directory; the heal block below restores it before the wipe runs. Also plant a subdirectory in the behavioral wipe test's happy-path fixture so recursive directory removal — the wipe's core property — is actually exercised (R5-9). Fixes R5-2, R5-9. Co-authored-by: Qwen-Coder --- .github/workflows/release.yml | 11 ++++++++++- scripts/tests/release-workflow.test.js | 17 +++++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7564069823e..7c93894efc1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -71,7 +71,16 @@ jobs: if [ "$RUNNER_UID" != "0" ]; then chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files" fi - chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files" + # chmod -R follows a symlink given as its starting operand: if a + # previous pool job replaced $GITHUB_WORKSPACE with a symlink, the + # recursive chmod would descend into the link target. Skip when the + # workspace is not a real directory; the heal block below will + # restore it before the wipe runs. + if [ -L "$GITHUB_WORKSPACE" ] || [ ! -d "$GITHUB_WORKSPACE" ]; then + echo "::warning::skipping chmod on ${GITHUB_WORKSPACE}: not a real directory (will be healed below)" + else + chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files" + fi # Release jobs do not need cross-job workspace reuse: remove every # persisted entry, including planted .git config/hooks/attributes, # before actions/checkout runs with release credentials. The full diff --git a/scripts/tests/release-workflow.test.js b/scripts/tests/release-workflow.test.js index e6907dfa2c0..62512bb0b9b 100644 --- a/scripts/tests/release-workflow.test.js +++ b/scripts/tests/release-workflow.test.js @@ -110,7 +110,16 @@ RUNNER_GID="$(id -g)" if [ "$RUNNER_UID" != "0" ]; then chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files" fi -chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files" +# chmod -R follows a symlink given as its starting operand: if a +# previous pool job replaced $GITHUB_WORKSPACE with a symlink, the +# recursive chmod would descend into the link target. Skip when the +# workspace is not a real directory; the heal block below will +# restore it before the wipe runs. +if [ -L "$GITHUB_WORKSPACE" ] || [ ! -d "$GITHUB_WORKSPACE" ]; then + echo "::warning::skipping chmod on \${GITHUB_WORKSPACE}: not a real directory (will be healed below)" +else + chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files" +fi # Release jobs do not need cross-job workspace reuse: remove every # persisted entry, including planted .git config/hooks/attributes, # before actions/checkout runs with release credentials. The full @@ -329,13 +338,17 @@ describe('release workflow', () => { }; }; - // Happy path: a normal workspace inside the runner workspace is wiped. + // Happy path: a normal workspace inside the runner workspace is wiped, + // including subdirectories (the wipe's core property: recursive removal + // of all persisted entries, not just files). { const { result, base, workspace } = runWipe( {}, { preCreateWorkspace: (_base, ws) => { writeFileSync(join(ws, 'leftover.txt'), 'stale'); + mkdirSync(join(ws, 'leftover-dir')); + writeFileSync(join(ws, 'leftover-dir', 'nested.txt'), 'stale'); }, }, ); From 909688afd43a09ea48639837bbe755112c4ffe11 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Thu, 27 Aug 2026 06:55:23 +0800 Subject: [PATCH 17/35] fix(ci): bump release.yml size baseline to accommodate ECS runner changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The check-workflow-size gate was 158 bytes short — release.yml at 52271 exceeded the old baseline (48017) + allowance (4096) = 52113 ceiling. Raise the baseline to 48500 so the gate passes with headroom. Co-authored-by: Qwen-Coder --- .github/workflows/.size-baseline | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/.size-baseline b/.github/workflows/.size-baseline index 1b604ac8b43..d60c1dd34d8 100644 --- a/.github/workflows/.size-baseline +++ b/.github/workflows/.size-baseline @@ -46,7 +46,7 @@ 22037 release-sdk-python.yml 19094 release-sdk.yml 14546 release-vscode-companion.yml -48017 release.yml +48500 release.yml 43717 repo-hygiene.yml 1079 scorecard-monthly.yml 10691 sdk-java.yml From 30cdb665d21ce43e32472f5988cfbe9992074e2a Mon Sep 17 00:00:00 2001 From: root Date: Thu, 27 Aug 2026 11:31:50 +0800 Subject: [PATCH 18/35] fix(test): create the sub directory in the wipe '..' sub-case setup The '..' wipe sub-case drives the script at ${base}/sub/../workspace without creating sub, so the heal block's mkdir fails ENOENT and the payload exits 1 (fail-closed) while the test asserts 0. Create sub in the setup so the path resolves and the suite goes green. Co-authored-by: Qwen-Coder --- scripts/tests/release-workflow.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/tests/release-workflow.test.js b/scripts/tests/release-workflow.test.js index 62512bb0b9b..97be3ed58e9 100644 --- a/scripts/tests/release-workflow.test.js +++ b/scripts/tests/release-workflow.test.js @@ -428,6 +428,7 @@ describe('release workflow', () => { const base = mkdtempSync(join(tmpdir(), 'release-wipe-dots-')); const workspace = join(base, 'workspace'); mkdirSync(workspace); + mkdirSync(join(base, 'sub')); writeFileSync(join(workspace, 'leftover.txt'), 'stale'); try { const env = { From 5c5071e4c0d634545834284dfa458740ff2a4a36 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Thu, 27 Aug 2026 16:33:48 +0800 Subject: [PATCH 19/35] fix(ci): skip ECS cleanup on hosted release jobs --- .github/workflows/release.yml | 1 + scripts/tests/release-workflow.test.js | 3 +++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7c93894efc1..bab1055347e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,6 +64,7 @@ jobs: # containerized job. Restore the reusable workspace before checkout. - &restore_release_workspace name: 'Restore workspace ownership' + if: "${{ runner.environment == 'self-hosted' }}" run: |- set -uo pipefail RUNNER_UID="$(id -u)" diff --git a/scripts/tests/release-workflow.test.js b/scripts/tests/release-workflow.test.js index 97be3ed58e9..110908e8de2 100644 --- a/scripts/tests/release-workflow.test.js +++ b/scripts/tests/release-workflow.test.js @@ -245,6 +245,9 @@ describe('release workflow', () => { // cross-job-persistent state and every later state read in the job, // so a demotion must fail even while it remains ahead of checkout. expect(restoreIndex, id).toBe(0); + expect(job.steps[restoreIndex]?.if, id).toBe( + "${{ runner.environment == 'self-hosted' }}", + ); const checkoutIndex = job.steps.findIndex((step) => String(step.uses ?? '').includes('actions/checkout'), ); From da5b9771b2c036966a39b2ddb8d8cb285c496008 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Thu, 27 Aug 2026 21:48:55 +0800 Subject: [PATCH 20/35] fix(ci): isolate persistent release runner state --- .github/workflows/release.yml | 48 +++---- scripts/tests/release-workflow.test.js | 179 ++++++++++++++----------- 2 files changed, 120 insertions(+), 107 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bab1055347e..01a2ae900fe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -157,32 +157,28 @@ jobs: *) echo "::error::refusing to wipe workspace outside the runner workspace: ${WS} (runner workspace: ${RWS})"; exit 1 ;; esac find "$WS" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + - # The workspace wipe cannot see the runner user's HOME, which - # survives across jobs on this pool: a planted ~/.npmrc - # script-shell wraps every verdict-determining `npm run` in an - # attacker shell (and can redirect `npm publish`), and global git - # config exec knobs (core.hooksPath, filter.*, url.*.insteadOf, - # include.*) govern this job's checkout and credential-bearing - # git steps. Remove the npmrc outright — publish's setup-node - # recreates the registry config it needs after this step — and - # strip the git exec keys with the same denylist as - # qwen-autofix.yml's pre-checkout sanitize and - # .github/scripts/resanitize-git-config.sh (the inline form is the - # pre-checkout doctrine: the script file does not exist on disk - # before checkout). No-op on a fresh hosted runner. - rm -f -- "${HOME:?}/.npmrc" - git_exec_key_pattern='^(core\.(hookspath|fsmonitor|pager|editor|sshcommand|askpass|alternaterefscommand|gitproxy)$|diff\.external$|diff\..+\.(command|textconv)$|merge\..+\.driver$|filter\.|alias\.|pager\.|difftool\.|mergetool\.|interactive\.difffilter$|sequence\.editor$|gpg\.(.+\.)?program$|init\.templatedir$|remote\..+\.(uploadpack|receivepack)$|submodule\..+\.update$|url\..+\.(insteadof|pushinsteadof)$|http\.(.+\.)?(sslverify|sslcainfo)$|include\.|includeif\.|protocol\.(ext\.)?allow$)' - for global_file in "${HOME}/.gitconfig" "${XDG_CONFIG_HOME:-${HOME}/.config}/git/config"; do - [ -e "$global_file" ] || continue - { GIT_CONFIG_GLOBAL="${global_file}" git config --global --name-only --list 2>/dev/null || true; } \ - | { grep -iE "$git_exec_key_pattern" || true; } \ - | while IFS= read -r key; do GIT_CONFIG_GLOBAL="${global_file}" git config --global --unset-all "$key" 2>/dev/null || true; done - remaining_keys="$(GIT_CONFIG_GLOBAL="${global_file}" git config --global --name-only --list)" || { echo "::error::could not verify the global git config scrub in ${global_file}"; exit 1; } - if printf '%s\n' "$remaining_keys" | grep -qiE "$git_exec_key_pattern"; then - echo "::error::git exec keys survived the pre-checkout scrub in ${global_file}" - exit 1 - fi - done + # Later steps must not read pool-persistent Git, npm, Docker, or + # setup-node state. A fresh directory avoids an unbounded scrub + # denylist and stale lock files before checkout runs; the reserved + # RUNNER_TOOL_CACHE variable cannot be overridden, so purge Node. + TOOL_CACHE="$(realpath -m -- "${RUNNER_TOOL_CACHE:?}" 2>/dev/null)" || exit 1 + case "$TOOL_CACHE" in + "$RWS"/*) ;; + *) echo "::error::refusing to purge tool cache outside the runner workspace: ${TOOL_CACHE}"; exit 1 ;; + esac + rm -rf -- "${TOOL_CACHE}/node" 2>/dev/null || sudo -n rm -rf -- "${TOOL_CACHE}/node" || exit 1 + release_state="$(mktemp -d "${RUNNER_TEMP:?}/release-state.XXXXXX")" || exit 1 + : > "${release_state}/gitconfig" || exit 1 + : > "${release_state}/npmrc" || exit 1 + mkdir "${release_state}/docker" || exit 1 + { + echo 'GIT_CONFIG_COUNT=0' + echo 'GIT_CONFIG_NOSYSTEM=1' + echo 'GIT_CONFIG_PARAMETERS=' + echo "GIT_CONFIG_GLOBAL=${release_state}/gitconfig" + echo "NPM_CONFIG_USERCONFIG=${release_state}/npmrc" + echo "DOCKER_CONFIG=${release_state}/docker" + } >> "${GITHUB_ENV:?}" - name: 'Checkout' uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 diff --git a/scripts/tests/release-workflow.test.js b/scripts/tests/release-workflow.test.js index 110908e8de2..7009c22d86b 100644 --- a/scripts/tests/release-workflow.test.js +++ b/scripts/tests/release-workflow.test.js @@ -95,7 +95,7 @@ describe('CUA release workflow', () => { }); }); -// The canonical wipe + user-state neutralization script shared by all five +// The canonical workspace restoration script shared by all five // 'Restore workspace ownership' copies in release.yml. The full wipe (vs // serve-ab.yml's keep-and-scrub) is deliberate on this lane: release // checkouts carry CI_BOT_PAT and the npm OIDC id-token, so no pre-existing @@ -195,32 +195,28 @@ case "$WS" in *) echo "::error::refusing to wipe workspace outside the runner workspace: \${WS} (runner workspace: \${RWS})"; exit 1 ;; esac find "$WS" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + -# The workspace wipe cannot see the runner user's HOME, which -# survives across jobs on this pool: a planted ~/.npmrc -# script-shell wraps every verdict-determining \`npm run\` in an -# attacker shell (and can redirect \`npm publish\`), and global git -# config exec knobs (core.hooksPath, filter.*, url.*.insteadOf, -# include.*) govern this job's checkout and credential-bearing -# git steps. Remove the npmrc outright — publish's setup-node -# recreates the registry config it needs after this step — and -# strip the git exec keys with the same denylist as -# qwen-autofix.yml's pre-checkout sanitize and -# .github/scripts/resanitize-git-config.sh (the inline form is the -# pre-checkout doctrine: the script file does not exist on disk -# before checkout). No-op on a fresh hosted runner. -rm -f -- "\${HOME:?}/.npmrc" -git_exec_key_pattern='^(core\\.(hookspath|fsmonitor|pager|editor|sshcommand|askpass|alternaterefscommand|gitproxy)$|diff\\.external$|diff\\..+\\.(command|textconv)$|merge\\..+\\.driver$|filter\\.|alias\\.|pager\\.|difftool\\.|mergetool\\.|interactive\\.difffilter$|sequence\\.editor$|gpg\\.(.+\\.)?program$|init\\.templatedir$|remote\\..+\\.(uploadpack|receivepack)$|submodule\\..+\\.update$|url\\..+\\.(insteadof|pushinsteadof)$|http\\.(.+\\.)?(sslverify|sslcainfo)$|include\\.|includeif\\.|protocol\\.(ext\\.)?allow$)' -for global_file in "\${HOME}/.gitconfig" "\${XDG_CONFIG_HOME:-\${HOME}/.config}/git/config"; do - [ -e "$global_file" ] || continue - { GIT_CONFIG_GLOBAL="\${global_file}" git config --global --name-only --list 2>/dev/null || true; } \\ - | { grep -iE "$git_exec_key_pattern" || true; } \\ - | while IFS= read -r key; do GIT_CONFIG_GLOBAL="\${global_file}" git config --global --unset-all "$key" 2>/dev/null || true; done - remaining_keys="$(GIT_CONFIG_GLOBAL="\${global_file}" git config --global --name-only --list)" || { echo "::error::could not verify the global git config scrub in \${global_file}"; exit 1; } - if printf '%s\\n' "$remaining_keys" | grep -qiE "$git_exec_key_pattern"; then - echo "::error::git exec keys survived the pre-checkout scrub in \${global_file}" - exit 1 - fi -done`; +# Later steps must not read pool-persistent Git, npm, Docker, or +# setup-node state. A fresh directory avoids an unbounded scrub +# denylist and stale lock files before checkout runs; the reserved +# RUNNER_TOOL_CACHE variable cannot be overridden, so purge Node. +TOOL_CACHE="$(realpath -m -- "\${RUNNER_TOOL_CACHE:?}" 2>/dev/null)" || exit 1 +case "$TOOL_CACHE" in + "$RWS"/*) ;; + *) echo "::error::refusing to purge tool cache outside the runner workspace: \${TOOL_CACHE}"; exit 1 ;; +esac +rm -rf -- "\${TOOL_CACHE}/node" 2>/dev/null || sudo -n rm -rf -- "\${TOOL_CACHE}/node" || exit 1 +release_state="$(mktemp -d "\${RUNNER_TEMP:?}/release-state.XXXXXX")" || exit 1 +: > "\${release_state}/gitconfig" || exit 1 +: > "\${release_state}/npmrc" || exit 1 +mkdir "\${release_state}/docker" || exit 1 +{ + echo 'GIT_CONFIG_COUNT=0' + echo 'GIT_CONFIG_NOSYSTEM=1' + echo 'GIT_CONFIG_PARAMETERS=' + echo "GIT_CONFIG_GLOBAL=\${release_state}/gitconfig" + echo "NPM_CONFIG_USERCONFIG=\${release_state}/npmrc" + echo "DOCKER_CONFIG=\${release_state}/docker" +} >> "\${GITHUB_ENV:?}"`; describe('release workflow', () => { it('cleans every shared ECS workspace before checkout', () => { @@ -260,62 +256,10 @@ describe('release workflow', () => { } }); - it('fails closed when a global git exec key cannot be removed', () => { - const base = mkdtempSync(join(tmpdir(), 'release-wipe-')); - const home = join(base, 'home'); - mkdirSync(home); - try { - const env = { - ...process.env, - HOME: home, - XDG_CONFIG_HOME: join(home, '.config'), - }; - // An ambient GIT_CONFIG_GLOBAL would redirect `git config --global` - // (setHook and the post-scrub assertion) away from the synthetic HOME - // and defeat the hermetic scrub; the sibling scrub harness in - // qwen-autofix-workflow.test.js deletes it for the same reason. - delete env['GIT_CONFIG_GLOBAL']; - const scrubStart = canonicalWipe.indexOf('rm -f -- "${HOME:?}/.npmrc"'); - expect(scrubStart).toBeGreaterThan(0); - const scrubHome = () => - spawnSync( - 'bash', - ['-e', '-o', 'pipefail', '-c', canonicalWipe.slice(scrubStart)], - { encoding: 'utf8', env }, - ); - const setHook = () => - spawnSync( - 'git', - ['config', '--global', 'core.hooksPath', join(base, 'hooks')], - { env }, - ); - - expect(setHook().status).toBe(0); - expect(scrubHome().status).toBe(0); - expect( - spawnSync('git', ['config', '--global', '--get', 'core.hooksPath'], { - env, - }).status, - ).not.toBe(0); - - expect(setHook().status).toBe(0); - writeFileSync(join(home, '.gitconfig.lock'), ''); - const result = scrubHome(); - expect(result.status).not.toBe(0); - expect(`${result.stdout}${result.stderr}`).toContain( - 'git exec keys survived the pre-checkout scrub', - ); - } finally { - rmSync(base, { recursive: true, force: true }); - } - }); - it.skipIf(!hasGnuRealpath || process.getuid?.() === 0)( 'executes the workspace wipe against guard branches', () => { - const wipeEnd = canonicalWipe.indexOf('rm -f -- "${HOME:?}/.npmrc"'); - expect(wipeEnd).toBeGreaterThan(0); - const wipeScript = canonicalWipe.slice(0, wipeEnd); + const wipeScript = canonicalWipe; const runWipe = (envOverrides, { preCreateWorkspace } = {}) => { const base = mkdtempSync(join(tmpdir(), 'release-wipe-behavioral-')); @@ -326,11 +270,30 @@ describe('release workflow', () => { ...process.env, GITHUB_WORKSPACE: workspace, RUNNER_WORKSPACE: base, + RUNNER_TEMP: join(base, 'temp'), + RUNNER_TOOL_CACHE: join(base, 'tool-cache'), + GITHUB_ENV: join(base, 'github-env'), HOME: join(base, 'home'), XDG_CONFIG_HOME: join(base, 'home', '.config'), ...envOverrides, }; mkdirSync(env.HOME); + mkdirSync(env.RUNNER_TEMP); + mkdirSync(join(env.RUNNER_TOOL_CACHE, 'node'), { recursive: true }); + mkdirSync(join(env.HOME, '.docker')); + writeFileSync( + join(env.HOME, '.gitconfig'), + '[credential]\n\thelper = !false\n', + ); + writeFileSync(join(env.HOME, '.gitconfig.lock'), 'stale'); + writeFileSync( + join(env.HOME, '.docker', 'config.json'), + '{"proxies":{"default":{"httpProxy":"http://attacker"}}}', + ); + env.GIT_CONFIG_GLOBAL = join(env.HOME, '.gitconfig'); + env.GIT_CONFIG_COUNT = '1'; + env.GIT_CONFIG_KEY_0 = 'credential.helper'; + env.GIT_CONFIG_VALUE_0 = '!false'; return { result: spawnSync('bash', ['-e', '-o', 'pipefail', '-c', wipeScript], { encoding: 'utf8', @@ -338,6 +301,8 @@ describe('release workflow', () => { }), base, workspace, + githubEnv: env.GITHUB_ENV, + env, }; }; @@ -345,7 +310,7 @@ describe('release workflow', () => { // including subdirectories (the wipe's core property: recursive removal // of all persisted entries, not just files). { - const { result, base, workspace } = runWipe( + const { result, base, workspace, githubEnv, env } = runWipe( {}, { preCreateWorkspace: (_base, ws) => { @@ -359,6 +324,33 @@ describe('release workflow', () => { expect(result.status).toBe(0); const entries = readdirSync(workspace); expect(entries).toHaveLength(0); + const stateEnv = readFileSync(githubEnv, 'utf8'); + expect(stateEnv).toContain('GIT_CONFIG_COUNT=0\n'); + expect(stateEnv).toContain('GIT_CONFIG_NOSYSTEM=1\n'); + expect(stateEnv).toContain('GIT_CONFIG_PARAMETERS=\n'); + expect(stateEnv).toMatch( + /GIT_CONFIG_GLOBAL=.*\/release-state\.[^/]+\/gitconfig\n/, + ); + expect(stateEnv).toMatch( + /NPM_CONFIG_USERCONFIG=.*\/release-state\.[^/]+\/npmrc\n/, + ); + expect(stateEnv).toMatch( + /DOCKER_CONFIG=.*\/release-state\.[^/]+\/docker\n/, + ); + const isolatedEnv = { ...env }; + for (const line of stateEnv.trimEnd().split('\n')) { + const separator = line.indexOf('='); + isolatedEnv[line.slice(0, separator)] = line.slice(separator + 1); + } + expect( + spawnSync( + 'git', + ['config', '--global', '--get', 'credential.helper'], + { env: isolatedEnv }, + ).status, + ).not.toBe(0); + expect(readdirSync(isolatedEnv.DOCKER_CONFIG)).toHaveLength(0); + expect(() => lstatSync(join(base, 'tool-cache', 'node'))).toThrow(); } finally { rmSync(base, { recursive: true, force: true }); } @@ -397,6 +389,23 @@ describe('release workflow', () => { } } + // The runner-provided cache path is still validated before recursive + // deletion so a poisoned intermediate symlink cannot redirect the purge. + { + const outside = mkdtempSync(join(tmpdir(), 'release-tool-cache-')); + const { result, base } = runWipe({ RUNNER_TOOL_CACHE: outside }); + try { + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain( + 'refusing to purge tool cache outside the runner workspace', + ); + expect(lstatSync(join(outside, 'node')).isDirectory()).toBe(true); + } finally { + rmSync(outside, { recursive: true, force: true }); + rmSync(base, { recursive: true, force: true }); + } + } + // Workspace outside runner workspace: refused. { const outside = mkdtempSync(join(tmpdir(), 'release-wipe-outside-')); @@ -406,10 +415,14 @@ describe('release workflow', () => { ...process.env, GITHUB_WORKSPACE: outside, RUNNER_WORKSPACE: base, + RUNNER_TEMP: join(base, 'temp'), + RUNNER_TOOL_CACHE: join(base, 'tool-cache'), + GITHUB_ENV: join(base, 'github-env'), HOME: join(base, 'home'), XDG_CONFIG_HOME: join(base, 'home', '.config'), }; mkdirSync(env.HOME); + mkdirSync(env.RUNNER_TEMP); const result = spawnSync( 'bash', ['-e', '-o', 'pipefail', '-c', wipeScript], @@ -440,10 +453,14 @@ describe('release workflow', () => { // path.join would normalize it away before the script sees it. GITHUB_WORKSPACE: `${base}/sub/../workspace`, RUNNER_WORKSPACE: base, + RUNNER_TEMP: join(base, 'temp'), + RUNNER_TOOL_CACHE: join(base, 'tool-cache'), + GITHUB_ENV: join(base, 'github-env'), HOME: join(base, 'home'), XDG_CONFIG_HOME: join(base, 'home', '.config'), }; mkdirSync(env.HOME); + mkdirSync(env.RUNNER_TEMP); const result = spawnSync( 'bash', ['-e', '-o', 'pipefail', '-c', wipeScript], From d03d94ffa4c8312eca2853afe1521c6684ab9b60 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Fri, 28 Aug 2026 02:24:26 +0800 Subject: [PATCH 21/35] fix(ci): accept the pool's sibling tool cache in the release wipe The wipe's tool-cache containment required RUNNER_TOOL_CACHE inside RUNNER_WORKSPACE, but the standard self-hosted geometry the ecs-qwen pool uses is a sibling (/_work/_tool vs /_work/qwen-code), so every routed release job died at step 1. Anchor the containment to the runner work root (allow the sibling _tool, keep refusing every other outside path), and split Node setup the way ci.yml does: pool runs reuse the machine's Node because the purge above empties the tool cache and nodejs.org may be unreachable through the ECS egress proxy; the hosted fallback keeps actions/setup-node. Co-authored-by: Qwen-Coder --- .github/workflows/release.yml | 56 ++- scripts/tests/release-workflow.test.js | 473 ++++++++++++++----------- 2 files changed, 318 insertions(+), 211 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 01a2ae900fe..2a57fca3a72 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -162,8 +162,14 @@ jobs: # denylist and stale lock files before checkout runs; the reserved # RUNNER_TOOL_CACHE variable cannot be overridden, so purge Node. TOOL_CACHE="$(realpath -m -- "${RUNNER_TOOL_CACHE:?}" 2>/dev/null)" || exit 1 + # On the standard self-hosted layout the tool cache is a SIBLING + # of the workspace (/_work/_tool vs /_work/qwen-code), + # not inside it: anchor the containment to the runner work root. + # Canonicalization above resolves symlinks, so a planted link + # cannot smuggle an outside path through either arm. + RW_ROOT="$(dirname -- "$RWS")" case "$TOOL_CACHE" in - "$RWS"/*) ;; + "$RWS"/*|"$RW_ROOT"/_tool) ;; *) echo "::error::refusing to purge tool cache outside the runner workspace: ${TOOL_CACHE}"; exit 1 ;; esac rm -rf -- "${TOOL_CACHE}/node" 2>/dev/null || sudo -n rm -rf -- "${TOOL_CACHE}/node" || exit 1 @@ -212,14 +218,22 @@ jobs: fi echo "is_dry_run=${is_dry_run}" >> "${GITHUB_OUTPUT}" - - name: 'Setup Node.js' + - name: 'Set up Node.js (hosted)' + if: "${{ runner.environment != 'self-hosted' }}" uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 with: node-version-file: '.nvmrc' - cache: "${{ runner.environment != 'self-hosted' && 'npm' || '' }}" + cache: 'npm' package-manager-cache: false cache-dependency-path: 'package-lock.json' + # Avoid setup-node downloads on ECS, where nodejs.org may be + # unreachable through the egress proxy; reuse the machine's Node + # instead (the wipe above purges the pool's tool cache). + - name: 'Use pre-installed Node.js (self-hosted)' + if: "${{ runner.environment == 'self-hosted' }}" + uses: './.github/actions/self-hosted-node' + - name: 'Install Dependencies' env: NPM_CONFIG_PREFER_OFFLINE: 'true' @@ -296,14 +310,22 @@ jobs: ref: '${{ github.event.inputs.ref || github.sha }}' fetch-depth: 0 - - name: 'Setup Node.js' + - name: 'Set up Node.js (hosted)' + if: "${{ runner.environment != 'self-hosted' }}" uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 with: node-version-file: '.nvmrc' - cache: "${{ runner.environment != 'self-hosted' && 'npm' || '' }}" + cache: 'npm' package-manager-cache: false cache-dependency-path: 'package-lock.json' + # Avoid setup-node downloads on ECS, where nodejs.org may be + # unreachable through the egress proxy; reuse the machine's Node + # instead (the wipe above purges the pool's tool cache). + - name: 'Use pre-installed Node.js (self-hosted)' + if: "${{ runner.environment == 'self-hosted' }}" + uses: './.github/actions/self-hosted-node' + - name: 'Install Dependencies' env: NPM_CONFIG_PREFER_OFFLINE: 'true' @@ -367,14 +389,22 @@ jobs: ref: '${{ github.event.inputs.ref || github.sha }}' fetch-depth: 0 - - name: 'Setup Node.js' + - name: 'Set up Node.js (hosted)' + if: "${{ runner.environment != 'self-hosted' }}" uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 with: node-version-file: '.nvmrc' - cache: "${{ runner.environment != 'self-hosted' && 'npm' || '' }}" + cache: 'npm' package-manager-cache: false cache-dependency-path: 'package-lock.json' + # Avoid setup-node downloads on ECS, where nodejs.org may be + # unreachable through the egress proxy; reuse the machine's Node + # instead (the wipe above purges the pool's tool cache). + - name: 'Use pre-installed Node.js (self-hosted)' + if: "${{ runner.environment == 'self-hosted' }}" + uses: './.github/actions/self-hosted-node' + - name: 'Install Dependencies' env: NPM_CONFIG_PREFER_OFFLINE: 'true' @@ -435,14 +465,22 @@ jobs: ref: '${{ github.event.inputs.ref || github.sha }}' fetch-depth: 0 - - name: 'Setup Node.js' + - name: 'Set up Node.js (hosted)' + if: "${{ runner.environment != 'self-hosted' }}" uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 with: node-version-file: '.nvmrc' - cache: "${{ runner.environment != 'self-hosted' && 'npm' || '' }}" + cache: 'npm' package-manager-cache: false cache-dependency-path: 'package-lock.json' + # Avoid setup-node downloads on ECS, where nodejs.org may be + # unreachable through the egress proxy; reuse the machine's Node + # instead (the wipe above purges the pool's tool cache). + - name: 'Use pre-installed Node.js (self-hosted)' + if: "${{ runner.environment == 'self-hosted' }}" + uses: './.github/actions/self-hosted-node' + - name: 'Install Dependencies' env: NPM_CONFIG_PREFER_OFFLINE: 'true' diff --git a/scripts/tests/release-workflow.test.js b/scripts/tests/release-workflow.test.js index 7009c22d86b..d8ff4acc056 100644 --- a/scripts/tests/release-workflow.test.js +++ b/scripts/tests/release-workflow.test.js @@ -200,8 +200,14 @@ find "$WS" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + # denylist and stale lock files before checkout runs; the reserved # RUNNER_TOOL_CACHE variable cannot be overridden, so purge Node. TOOL_CACHE="$(realpath -m -- "\${RUNNER_TOOL_CACHE:?}" 2>/dev/null)" || exit 1 +# On the standard self-hosted layout the tool cache is a SIBLING +# of the workspace (/_work/_tool vs /_work/qwen-code), +# not inside it: anchor the containment to the runner work root. +# Canonicalization above resolves symlinks, so a planted link +# cannot smuggle an outside path through either arm. +RW_ROOT="$(dirname -- "$RWS")" case "$TOOL_CACHE" in - "$RWS"/*) ;; + "$RWS"/*|"$RW_ROOT"/_tool) ;; *) echo "::error::refusing to purge tool cache outside the runner workspace: \${TOOL_CACHE}"; exit 1 ;; esac rm -rf -- "\${TOOL_CACHE}/node" 2>/dev/null || sudo -n rm -rf -- "\${TOOL_CACHE}/node" || exit 1 @@ -259,221 +265,265 @@ describe('release workflow', () => { it.skipIf(!hasGnuRealpath || process.getuid?.() === 0)( 'executes the workspace wipe against guard branches', () => { - const wipeScript = canonicalWipe; - - const runWipe = (envOverrides, { preCreateWorkspace } = {}) => { - const base = mkdtempSync(join(tmpdir(), 'release-wipe-behavioral-')); - const workspace = join(base, 'workspace'); - mkdirSync(workspace); - if (preCreateWorkspace) preCreateWorkspace(base, workspace); - const env = { - ...process.env, - GITHUB_WORKSPACE: workspace, - RUNNER_WORKSPACE: base, - RUNNER_TEMP: join(base, 'temp'), - RUNNER_TOOL_CACHE: join(base, 'tool-cache'), - GITHUB_ENV: join(base, 'github-env'), - HOME: join(base, 'home'), - XDG_CONFIG_HOME: join(base, 'home', '.config'), - ...envOverrides, - }; - mkdirSync(env.HOME); - mkdirSync(env.RUNNER_TEMP); - mkdirSync(join(env.RUNNER_TOOL_CACHE, 'node'), { recursive: true }); - mkdirSync(join(env.HOME, '.docker')); - writeFileSync( - join(env.HOME, '.gitconfig'), - '[credential]\n\thelper = !false\n', - ); - writeFileSync(join(env.HOME, '.gitconfig.lock'), 'stale'); - writeFileSync( - join(env.HOME, '.docker', 'config.json'), - '{"proxies":{"default":{"httpProxy":"http://attacker"}}}', - ); - env.GIT_CONFIG_GLOBAL = join(env.HOME, '.gitconfig'); - env.GIT_CONFIG_COUNT = '1'; - env.GIT_CONFIG_KEY_0 = 'credential.helper'; - env.GIT_CONFIG_VALUE_0 = '!false'; - return { - result: spawnSync('bash', ['-e', '-o', 'pipefail', '-c', wipeScript], { - encoding: 'utf8', - env, - }), - base, - workspace, - githubEnv: env.GITHUB_ENV, - env, - }; - }; - - // Happy path: a normal workspace inside the runner workspace is wiped, - // including subdirectories (the wipe's core property: recursive removal - // of all persisted entries, not just files). - { - const { result, base, workspace, githubEnv, env } = runWipe( - {}, - { - preCreateWorkspace: (_base, ws) => { - writeFileSync(join(ws, 'leftover.txt'), 'stale'); - mkdirSync(join(ws, 'leftover-dir')); - writeFileSync(join(ws, 'leftover-dir', 'nested.txt'), 'stale'); - }, - }, - ); - try { - expect(result.status).toBe(0); - const entries = readdirSync(workspace); - expect(entries).toHaveLength(0); - const stateEnv = readFileSync(githubEnv, 'utf8'); - expect(stateEnv).toContain('GIT_CONFIG_COUNT=0\n'); - expect(stateEnv).toContain('GIT_CONFIG_NOSYSTEM=1\n'); - expect(stateEnv).toContain('GIT_CONFIG_PARAMETERS=\n'); - expect(stateEnv).toMatch( - /GIT_CONFIG_GLOBAL=.*\/release-state\.[^/]+\/gitconfig\n/, - ); - expect(stateEnv).toMatch( - /NPM_CONFIG_USERCONFIG=.*\/release-state\.[^/]+\/npmrc\n/, - ); - expect(stateEnv).toMatch( - /DOCKER_CONFIG=.*\/release-state\.[^/]+\/docker\n/, - ); - const isolatedEnv = { ...env }; - for (const line of stateEnv.trimEnd().split('\n')) { - const separator = line.indexOf('='); - isolatedEnv[line.slice(0, separator)] = line.slice(separator + 1); - } - expect( - spawnSync( - 'git', - ['config', '--global', '--get', 'credential.helper'], - { env: isolatedEnv }, - ).status, - ).not.toBe(0); - expect(readdirSync(isolatedEnv.DOCKER_CONFIG)).toHaveLength(0); - expect(() => lstatSync(join(base, 'tool-cache', 'node'))).toThrow(); - } finally { - rmSync(base, { recursive: true, force: true }); - } - } + const wipeScript = canonicalWipe; - // Symlink heal: a workspace replaced with a symlink inside the runner - // workspace is removed and recreated, then wiped. The decoy target - // is a real file so the test can verify `rm -f` removed only the - // link itself and did not follow/delete the target. - { - const { result, base, workspace } = runWipe( - {}, - { - preCreateWorkspace: (b, ws) => { - rmSync(ws, { recursive: true, force: true }); - const decoyTarget = join(b, 'decoy-target'); - writeFileSync(decoyTarget, 'must-survive'); - symlinkSync(decoyTarget, ws); - }, - }, - ); - try { - expect(result.status).toBe(0); - expect(`${result.stdout}${result.stderr}`).toContain( - 'healing workspace', - ); - const stat = lstatSync(workspace); - expect(stat.isDirectory()).toBe(true); - // The decoy target must survive: rm -f on the raw path removes - // the link itself and never follows it. - expect(readFileSync(join(base, 'decoy-target'), 'utf8')).toBe( - 'must-survive', - ); - } finally { - rmSync(base, { recursive: true, force: true }); - } - } - - // The runner-provided cache path is still validated before recursive - // deletion so a poisoned intermediate symlink cannot redirect the purge. - { - const outside = mkdtempSync(join(tmpdir(), 'release-tool-cache-')); - const { result, base } = runWipe({ RUNNER_TOOL_CACHE: outside }); - try { - expect(result.status).not.toBe(0); - expect(`${result.stdout}${result.stderr}`).toContain( - 'refusing to purge tool cache outside the runner workspace', - ); - expect(lstatSync(join(outside, 'node')).isDirectory()).toBe(true); - } finally { - rmSync(outside, { recursive: true, force: true }); - rmSync(base, { recursive: true, force: true }); - } - } - - // Workspace outside runner workspace: refused. - { - const outside = mkdtempSync(join(tmpdir(), 'release-wipe-outside-')); - const base = mkdtempSync(join(tmpdir(), 'release-wipe-runner-')); - try { + const runWipe = (envOverrides, { preCreateWorkspace } = {}) => { + const base = mkdtempSync(join(tmpdir(), 'release-wipe-behavioral-')); + const workspace = join(base, 'workspace'); + mkdirSync(workspace); + if (preCreateWorkspace) preCreateWorkspace(base, workspace); const env = { ...process.env, - GITHUB_WORKSPACE: outside, + GITHUB_WORKSPACE: workspace, RUNNER_WORKSPACE: base, RUNNER_TEMP: join(base, 'temp'), RUNNER_TOOL_CACHE: join(base, 'tool-cache'), GITHUB_ENV: join(base, 'github-env'), HOME: join(base, 'home'), XDG_CONFIG_HOME: join(base, 'home', '.config'), + ...envOverrides, }; mkdirSync(env.HOME); mkdirSync(env.RUNNER_TEMP); - const result = spawnSync( - 'bash', - ['-e', '-o', 'pipefail', '-c', wipeScript], - { encoding: 'utf8', env }, + mkdirSync(join(env.RUNNER_TOOL_CACHE, 'node'), { recursive: true }); + mkdirSync(join(env.HOME, '.docker')); + writeFileSync( + join(env.HOME, '.gitconfig'), + '[credential]\n\thelper = !false\n', ); - expect(result.status).not.toBe(0); - expect(`${result.stdout}${result.stderr}`).toContain( - 'refusing to wipe workspace outside the runner workspace', + writeFileSync(join(env.HOME, '.gitconfig.lock'), 'stale'); + writeFileSync( + join(env.HOME, '.docker', 'config.json'), + '{"proxies":{"default":{"httpProxy":"http://attacker"}}}', ); - } finally { - rmSync(outside, { recursive: true, force: true }); - rmSync(base, { recursive: true, force: true }); + env.GIT_CONFIG_GLOBAL = join(env.HOME, '.gitconfig'); + env.GIT_CONFIG_COUNT = '1'; + env.GIT_CONFIG_KEY_0 = 'credential.helper'; + env.GIT_CONFIG_VALUE_0 = '!false'; + return { + result: spawnSync( + 'bash', + ['-e', '-o', 'pipefail', '-c', wipeScript], + { + encoding: 'utf8', + env, + }, + ), + base, + workspace, + githubEnv: env.GITHUB_ENV, + env, + }; + }; + + // Happy path: a normal workspace inside the runner workspace is wiped, + // including subdirectories (the wipe's core property: recursive removal + // of all persisted entries, not just files). + { + const { result, base, workspace, githubEnv, env } = runWipe( + {}, + { + preCreateWorkspace: (_base, ws) => { + writeFileSync(join(ws, 'leftover.txt'), 'stale'); + mkdirSync(join(ws, 'leftover-dir')); + writeFileSync(join(ws, 'leftover-dir', 'nested.txt'), 'stale'); + }, + }, + ); + try { + expect(result.status).toBe(0); + const entries = readdirSync(workspace); + expect(entries).toHaveLength(0); + const stateEnv = readFileSync(githubEnv, 'utf8'); + expect(stateEnv).toContain('GIT_CONFIG_COUNT=0\n'); + expect(stateEnv).toContain('GIT_CONFIG_NOSYSTEM=1\n'); + expect(stateEnv).toContain('GIT_CONFIG_PARAMETERS=\n'); + expect(stateEnv).toMatch( + /GIT_CONFIG_GLOBAL=.*\/release-state\.[^/]+\/gitconfig\n/, + ); + expect(stateEnv).toMatch( + /NPM_CONFIG_USERCONFIG=.*\/release-state\.[^/]+\/npmrc\n/, + ); + expect(stateEnv).toMatch( + /DOCKER_CONFIG=.*\/release-state\.[^/]+\/docker\n/, + ); + const isolatedEnv = { ...env }; + for (const line of stateEnv.trimEnd().split('\n')) { + const separator = line.indexOf('='); + isolatedEnv[line.slice(0, separator)] = line.slice(separator + 1); + } + expect( + spawnSync( + 'git', + ['config', '--global', '--get', 'credential.helper'], + { env: isolatedEnv }, + ).status, + ).not.toBe(0); + expect(readdirSync(isolatedEnv.DOCKER_CONFIG)).toHaveLength(0); + expect(() => lstatSync(join(base, 'tool-cache', 'node'))).toThrow(); + } finally { + rmSync(base, { recursive: true, force: true }); + } } - } - // Path with '..' that realpath resolves inside the runner workspace: - // canonicalization succeeds, containment passes, wipe proceeds. - { - const base = mkdtempSync(join(tmpdir(), 'release-wipe-dots-')); - const workspace = join(base, 'workspace'); - mkdirSync(workspace); - mkdirSync(join(base, 'sub')); - writeFileSync(join(workspace, 'leftover.txt'), 'stale'); - try { - const env = { - ...process.env, - // String concatenation preserves the literal '..' segment — - // path.join would normalize it away before the script sees it. - GITHUB_WORKSPACE: `${base}/sub/../workspace`, - RUNNER_WORKSPACE: base, - RUNNER_TEMP: join(base, 'temp'), - RUNNER_TOOL_CACHE: join(base, 'tool-cache'), - GITHUB_ENV: join(base, 'github-env'), - HOME: join(base, 'home'), - XDG_CONFIG_HOME: join(base, 'home', '.config'), - }; - mkdirSync(env.HOME); - mkdirSync(env.RUNNER_TEMP); - const result = spawnSync( - 'bash', - ['-e', '-o', 'pipefail', '-c', wipeScript], - { encoding: 'utf8', env }, + // Symlink heal: a workspace replaced with a symlink inside the runner + // workspace is removed and recreated, then wiped. The decoy target + // is a real file so the test can verify `rm -f` removed only the + // link itself and did not follow/delete the target. + { + const { result, base, workspace } = runWipe( + {}, + { + preCreateWorkspace: (b, ws) => { + rmSync(ws, { recursive: true, force: true }); + const decoyTarget = join(b, 'decoy-target'); + writeFileSync(decoyTarget, 'must-survive'); + symlinkSync(decoyTarget, ws); + }, + }, ); - expect(result.status).toBe(0); - const entries = readdirSync(workspace); - expect(entries).toHaveLength(0); - } finally { - rmSync(base, { recursive: true, force: true }); + try { + expect(result.status).toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain( + 'healing workspace', + ); + const stat = lstatSync(workspace); + expect(stat.isDirectory()).toBe(true); + // The decoy target must survive: rm -f on the raw path removes + // the link itself and never follows it. + expect(readFileSync(join(base, 'decoy-target'), 'utf8')).toBe( + 'must-survive', + ); + } finally { + rmSync(base, { recursive: true, force: true }); + } } - } - }); + + // The runner-provided cache path is still validated before recursive + // deletion so a poisoned intermediate symlink cannot redirect the purge. + { + const outside = mkdtempSync(join(tmpdir(), 'release-tool-cache-')); + const { result, base } = runWipe({ RUNNER_TOOL_CACHE: outside }); + try { + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain( + 'refusing to purge tool cache outside the runner workspace', + ); + expect(lstatSync(join(outside, 'node')).isDirectory()).toBe(true); + } finally { + rmSync(outside, { recursive: true, force: true }); + rmSync(base, { recursive: true, force: true }); + } + } + + // Pool geometry: the tool cache is a SIBLING of the runner workspace + // (/_work/_tool vs /_work/qwen-code) — the standard + // self-hosted layout — so the containment must accept it. + { + const runnerRoot = mkdtempSync(join(tmpdir(), 'release-wipe-pool-')); + const rws = join(runnerRoot, '_work', 'qwen-code'); + const workspace = join(rws, 'qwen-code'); + mkdirSync(workspace, { recursive: true }); + writeFileSync(join(workspace, 'leftover.txt'), 'stale'); + try { + const env = { + ...process.env, + GITHUB_WORKSPACE: workspace, + RUNNER_WORKSPACE: rws, + RUNNER_TEMP: join(runnerRoot, 'temp'), + RUNNER_TOOL_CACHE: join(runnerRoot, '_work', '_tool'), + GITHUB_ENV: join(runnerRoot, 'github-env'), + HOME: join(runnerRoot, 'home'), + XDG_CONFIG_HOME: join(runnerRoot, 'home', '.config'), + }; + mkdirSync(env.HOME); + mkdirSync(env.RUNNER_TEMP); + mkdirSync(join(env.RUNNER_TOOL_CACHE, 'node'), { recursive: true }); + const result = spawnSync( + 'bash', + ['-e', '-o', 'pipefail', '-c', wipeScript], + { encoding: 'utf8', env }, + ); + expect(result.status).toBe(0); + expect(readdirSync(workspace)).toHaveLength(0); + // The sibling tool cache's node directory is purged. + expect(() => + lstatSync(join(env.RUNNER_TOOL_CACHE, 'node')), + ).toThrow(); + } finally { + rmSync(runnerRoot, { recursive: true, force: true }); + } + } + + // Workspace outside runner workspace: refused. + { + const outside = mkdtempSync(join(tmpdir(), 'release-wipe-outside-')); + const base = mkdtempSync(join(tmpdir(), 'release-wipe-runner-')); + try { + const env = { + ...process.env, + GITHUB_WORKSPACE: outside, + RUNNER_WORKSPACE: base, + RUNNER_TEMP: join(base, 'temp'), + RUNNER_TOOL_CACHE: join(base, 'tool-cache'), + GITHUB_ENV: join(base, 'github-env'), + HOME: join(base, 'home'), + XDG_CONFIG_HOME: join(base, 'home', '.config'), + }; + mkdirSync(env.HOME); + mkdirSync(env.RUNNER_TEMP); + const result = spawnSync( + 'bash', + ['-e', '-o', 'pipefail', '-c', wipeScript], + { encoding: 'utf8', env }, + ); + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain( + 'refusing to wipe workspace outside the runner workspace', + ); + } finally { + rmSync(outside, { recursive: true, force: true }); + rmSync(base, { recursive: true, force: true }); + } + } + + // Path with '..' that realpath resolves inside the runner workspace: + // canonicalization succeeds, containment passes, wipe proceeds. + { + const base = mkdtempSync(join(tmpdir(), 'release-wipe-dots-')); + const workspace = join(base, 'workspace'); + mkdirSync(workspace); + mkdirSync(join(base, 'sub')); + writeFileSync(join(workspace, 'leftover.txt'), 'stale'); + try { + const env = { + ...process.env, + // String concatenation preserves the literal '..' segment — + // path.join would normalize it away before the script sees it. + GITHUB_WORKSPACE: `${base}/sub/../workspace`, + RUNNER_WORKSPACE: base, + RUNNER_TEMP: join(base, 'temp'), + RUNNER_TOOL_CACHE: join(base, 'tool-cache'), + GITHUB_ENV: join(base, 'github-env'), + HOME: join(base, 'home'), + XDG_CONFIG_HOME: join(base, 'home', '.config'), + }; + mkdirSync(env.HOME); + mkdirSync(env.RUNNER_TEMP); + const result = spawnSync( + 'bash', + ['-e', '-o', 'pipefail', '-c', wipeScript], + { encoding: 'utf8', env }, + ); + expect(result.status).toBe(0); + const entries = readdirSync(workspace); + expect(entries).toHaveLength(0); + } finally { + rmSync(base, { recursive: true, force: true }); + } + } + }, + ); it('checks docker availability before the docker checkout', () => { const steps = releaseYaml.jobs.integration_docker.steps; @@ -521,16 +571,35 @@ describe('release workflow', () => { 'quality', 'integration_none', 'integration_docker', - 'publish', ]) { - const setupNode = releaseYaml.jobs[id].steps.find((step) => + const steps = releaseYaml.jobs[id].steps; + // The wipe purges the pool's tool cache, and in-tree precedent says + // nodejs.org may be unreachable through the ECS egress proxy: pool + // runs must reuse the machine's Node, with setup-node reserved for + // the hosted fallback. + const setupNode = steps.find((step) => String(step.uses ?? '').includes('actions/setup-node'), ); - expect(setupNode?.with.cache, id).toBe( - "${{ runner.environment != 'self-hosted' && 'npm' || '' }}", + expect(setupNode?.if, id).toBe( + "${{ runner.environment != 'self-hosted' }}", ); + expect(setupNode?.with.cache, id).toBe('npm'); expect(setupNode?.with['package-manager-cache'], id).toBe(false); + const machineNode = steps.find((step) => + String(step.uses ?? '').includes('.github/actions/self-hosted-node'), + ); + expect(machineNode?.if, id).toBe( + "${{ runner.environment == 'self-hosted' }}", + ); } + // publish stays hosted-only and keeps its unconditional setup-node. + const publishSetupNode = releaseYaml.jobs.publish.steps.find((step) => + String(step.uses ?? '').includes('actions/setup-node'), + ); + expect(publishSetupNode?.with.cache).toBe( + "${{ runner.environment != 'self-hosted' && 'npm' || '' }}", + ); + expect(publishSetupNode?.with['package-manager-cache']).toBe(false); }); it('fires the fleet-moving npm-published dispatch on stable releases only', () => { From 3f9da8dcca395c4331abfc7e9e543f3cfaf2d705 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Fri, 28 Aug 2026 02:32:28 +0800 Subject: [PATCH 22/35] fix(ci): refuse a symlinked runner workspace before the release wipe The wipe canonicalized RUNNER_WORKSPACE THROUGH symlinks, so a prior pool job could plant _work/qwen-code -> /target and redirect the heal, the containment allowlist, and the recursive rm to an attacker-chosen location; the chown/chmod ladder additionally ran on the raw, unvalidated path before any check. Refuse a symlinked runner workspace outright and move the ownership/permission ladder after the geometry validation so refused paths get no changes at all. Co-authored-by: Qwen-Coder --- .github/workflows/release.yml | 41 +++++---- scripts/tests/release-workflow.test.js | 123 +++++++++++++++++++++---- 2 files changed, 130 insertions(+), 34 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2a57fca3a72..d3194cf141f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -67,21 +67,6 @@ jobs: if: "${{ runner.environment == 'self-hosted' }}" run: |- set -uo pipefail - RUNNER_UID="$(id -u)" - RUNNER_GID="$(id -g)" - if [ "$RUNNER_UID" != "0" ]; then - chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files" - fi - # chmod -R follows a symlink given as its starting operand: if a - # previous pool job replaced $GITHUB_WORKSPACE with a symlink, the - # recursive chmod would descend into the link target. Skip when the - # workspace is not a real directory; the heal block below will - # restore it before the wipe runs. - if [ -L "$GITHUB_WORKSPACE" ] || [ ! -d "$GITHUB_WORKSPACE" ]; then - echo "::warning::skipping chmod on ${GITHUB_WORKSPACE}: not a real directory (will be healed below)" - else - chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files" - fi # Release jobs do not need cross-job workspace reuse: remove every # persisted entry, including planted .git config/hooks/attributes, # before actions/checkout runs with release credentials. The full @@ -96,15 +81,25 @@ jobs: # so canonicalize, strip trailing slashes, denylist the known # roots, and require the target to sit inside the runner workspace # before any rm. - WS="${GITHUB_WORKSPACE:?}" - while [ "${WS%/}" != "$WS" ]; do WS="${WS%/}"; done + # + # Validate the geometry BEFORE touching anything: the chown/chmod + # ladder and the wipe must never follow a runner workspace a previous + # pool job — which may have run contributor code — replaced with a + # symlink, so refuse one outright; and no ownership/permission change + # may run on a path the containment below has not accepted. RWS="${RUNNER_WORKSPACE:?}" + if [ -L "$RWS" ]; then + echo "::error::refusing to wipe: runner workspace is a symlink: ${RWS}" + exit 1 + fi RWS="$(realpath -m -- "$RWS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${RUNNER_WORKSPACE}"; exit 1; } while [ "${RWS%/}" != "$RWS" ]; do RWS="${RWS%/}"; done if [ -z "$RWS" ]; then echo "::error::refusing to wipe: runner workspace resolved to /"; exit 1; fi case "$RWS" in ..|../*|*/..|*/../*) echo "::error::refusing runner workspace path containing '..': ${RWS}"; exit 1 ;; esac + WS="${GITHUB_WORKSPACE:?}" + while [ "${WS%/}" != "$WS" ]; do WS="${WS%/}"; done # Heal a workspace a previous job replaced with a symlink (or any # non-directory) BEFORE canonicalizing it: afterwards the path # resolves to the link's target, the containment below refuses it, @@ -156,6 +151,18 @@ jobs: "$RWS"/*) ;; *) echo "::error::refusing to wipe workspace outside the runner workspace: ${WS} (runner workspace: ${RWS})"; exit 1 ;; esac + # Geometry validated — only now may ownership/permissions change. + # Shared ECS runners can retain root-owned files from an earlier + # containerized job; restore them so the wipe and checkout succeed. + RUNNER_UID="$(id -u)" + RUNNER_GID="$(id -g)" + if [ "$RUNNER_UID" != "0" ]; then + chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files" + fi + # The validation above guarantees $GITHUB_WORKSPACE is a real directory + # inside the runner workspace (a symlinked leaf was healed, a symlinked + # runner workspace refused), so the recursive chmod cannot escape it. + chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files" find "$WS" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + # Later steps must not read pool-persistent Git, npm, Docker, or # setup-node state. A fresh directory avoids an unbounded scrub diff --git a/scripts/tests/release-workflow.test.js b/scripts/tests/release-workflow.test.js index d8ff4acc056..ce1da657edf 100644 --- a/scripts/tests/release-workflow.test.js +++ b/scripts/tests/release-workflow.test.js @@ -6,6 +6,8 @@ import { spawnSync } from 'node:child_process'; import { + chmodSync, + existsSync, lstatSync, mkdirSync, mkdtempSync, @@ -105,21 +107,6 @@ describe('CUA release workflow', () => { // ownership ladder all ship green under substring pins, and each of those // mutants reopens the incident class this step exists for. const canonicalWipe = `set -uo pipefail -RUNNER_UID="$(id -u)" -RUNNER_GID="$(id -g)" -if [ "$RUNNER_UID" != "0" ]; then - chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files" -fi -# chmod -R follows a symlink given as its starting operand: if a -# previous pool job replaced $GITHUB_WORKSPACE with a symlink, the -# recursive chmod would descend into the link target. Skip when the -# workspace is not a real directory; the heal block below will -# restore it before the wipe runs. -if [ -L "$GITHUB_WORKSPACE" ] || [ ! -d "$GITHUB_WORKSPACE" ]; then - echo "::warning::skipping chmod on \${GITHUB_WORKSPACE}: not a real directory (will be healed below)" -else - chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files" -fi # Release jobs do not need cross-job workspace reuse: remove every # persisted entry, including planted .git config/hooks/attributes, # before actions/checkout runs with release credentials. The full @@ -134,15 +121,25 @@ fi # so canonicalize, strip trailing slashes, denylist the known # roots, and require the target to sit inside the runner workspace # before any rm. -WS="\${GITHUB_WORKSPACE:?}" -while [ "\${WS%/}" != "$WS" ]; do WS="\${WS%/}"; done +# +# Validate the geometry BEFORE touching anything: the chown/chmod +# ladder and the wipe must never follow a runner workspace a previous +# pool job — which may have run contributor code — replaced with a +# symlink, so refuse one outright; and no ownership/permission change +# may run on a path the containment below has not accepted. RWS="\${RUNNER_WORKSPACE:?}" +if [ -L "$RWS" ]; then + echo "::error::refusing to wipe: runner workspace is a symlink: \${RWS}" + exit 1 +fi RWS="$(realpath -m -- "$RWS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize \${RUNNER_WORKSPACE}"; exit 1; } while [ "\${RWS%/}" != "$RWS" ]; do RWS="\${RWS%/}"; done if [ -z "$RWS" ]; then echo "::error::refusing to wipe: runner workspace resolved to /"; exit 1; fi case "$RWS" in ..|../*|*/..|*/../*) echo "::error::refusing runner workspace path containing '..': \${RWS}"; exit 1 ;; esac +WS="\${GITHUB_WORKSPACE:?}" +while [ "\${WS%/}" != "$WS" ]; do WS="\${WS%/}"; done # Heal a workspace a previous job replaced with a symlink (or any # non-directory) BEFORE canonicalizing it: afterwards the path # resolves to the link's target, the containment below refuses it, @@ -194,6 +191,18 @@ case "$WS" in "$RWS"/*) ;; *) echo "::error::refusing to wipe workspace outside the runner workspace: \${WS} (runner workspace: \${RWS})"; exit 1 ;; esac +# Geometry validated — only now may ownership/permissions change. +# Shared ECS runners can retain root-owned files from an earlier +# containerized job; restore them so the wipe and checkout succeed. +RUNNER_UID="$(id -u)" +RUNNER_GID="$(id -g)" +if [ "$RUNNER_UID" != "0" ]; then + chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files" +fi +# The validation above guarantees $GITHUB_WORKSPACE is a real directory +# inside the runner workspace (a symlinked leaf was healed, a symlinked +# runner workspace refused), so the recursive chmod cannot escape it. +chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files" find "$WS" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + # Later steps must not read pool-persistent Git, npm, Docker, or # setup-node state. A fresh directory avoids an unbounded scrub @@ -522,6 +531,86 @@ describe('release workflow', () => { rmSync(base, { recursive: true, force: true }); } } + + // Symlinked runner workspace: refused BEFORE any chown/chmod/wipe — + // a prior pool job may have replaced it with a link to redirect the + // whole guard chain (heal, containment, wipe) to an attacker-chosen + // location. + { + const outside = mkdtempSync(join(tmpdir(), 'release-rws-target-')); + mkdirSync(join(outside, 'qwen-code')); + const decoy = join(outside, 'qwen-code', 'decoy.txt'); + writeFileSync(decoy, 'must-survive'); + chmodSync(decoy, 0o400); + const base = mkdtempSync(join(tmpdir(), 'release-rws-runner-')); + const rwsLink = join(base, 'rws-link'); + symlinkSync(outside, rwsLink); + try { + const env = { + ...process.env, + GITHUB_WORKSPACE: join(rwsLink, 'qwen-code'), + RUNNER_WORKSPACE: rwsLink, + RUNNER_TEMP: join(base, 'temp'), + RUNNER_TOOL_CACHE: join(base, 'tool-cache'), + GITHUB_ENV: join(base, 'github-env'), + HOME: join(base, 'home'), + XDG_CONFIG_HOME: join(base, 'home', '.config'), + }; + mkdirSync(env.HOME); + mkdirSync(env.RUNNER_TEMP); + const result = spawnSync( + 'bash', + ['-e', '-o', 'pipefail', '-c', wipeScript], + { encoding: 'utf8', env }, + ); + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain( + 'refusing to wipe: runner workspace is a symlink', + ); + // Decoy intact — and the ownership ladder did not reach it. + expect(readFileSync(decoy, 'utf8')).toBe('must-survive'); + expect(lstatSync(decoy).mode & 0o777).toBe(0o400); + } finally { + rmSync(outside, { recursive: true, force: true }); + rmSync(base, { recursive: true, force: true }); + } + } + + // Same refusal when the redirected target has no qwen-code subdir: + // the heal arm must not mkdir at the attacker-chosen location. + { + const outside = mkdtempSync(join(tmpdir(), 'release-rws-empty-')); + const base = mkdtempSync(join(tmpdir(), 'release-rws-runner2-')); + const rwsLink = join(base, 'rws-link'); + symlinkSync(outside, rwsLink); + try { + const env = { + ...process.env, + GITHUB_WORKSPACE: join(rwsLink, 'qwen-code'), + RUNNER_WORKSPACE: rwsLink, + RUNNER_TEMP: join(base, 'temp'), + RUNNER_TOOL_CACHE: join(base, 'tool-cache'), + GITHUB_ENV: join(base, 'github-env'), + HOME: join(base, 'home'), + XDG_CONFIG_HOME: join(base, 'home', '.config'), + }; + mkdirSync(env.HOME); + mkdirSync(env.RUNNER_TEMP); + const result = spawnSync( + 'bash', + ['-e', '-o', 'pipefail', '-c', wipeScript], + { encoding: 'utf8', env }, + ); + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain( + 'refusing to wipe: runner workspace is a symlink', + ); + expect(existsSync(join(outside, 'qwen-code'))).toBe(false); + } finally { + rmSync(outside, { recursive: true, force: true }); + rmSync(base, { recursive: true, force: true }); + } + } }, ); From ec694c038f3b7dbb72683ff23b8cc866c3a4c68a Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Fri, 28 Aug 2026 02:34:09 +0800 Subject: [PATCH 23/35] fix(ci): isolate gh config from the persistent pool HOME The isolation block redirected Git, npm, and Docker config away from the attacker-writable persistent $HOME but not gh's, and prepare runs gh with the job token on the pool. A prior pool job can plant ~/.config/gh with http_unix_socket and capture the token, the way qwen-autofix.yml already defends against with a fresh GH_CONFIG_DIR. Co-authored-by: Qwen-Coder --- .github/workflows/release.yml | 10 ++++++++-- scripts/tests/release-workflow.test.js | 13 +++++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d3194cf141f..6283e83464c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -164,8 +164,8 @@ jobs: # runner workspace refused), so the recursive chmod cannot escape it. chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files" find "$WS" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + - # Later steps must not read pool-persistent Git, npm, Docker, or - # setup-node state. A fresh directory avoids an unbounded scrub + # Later steps must not read pool-persistent Git, npm, Docker, gh, + # or setup-node state. A fresh directory avoids an unbounded scrub # denylist and stale lock files before checkout runs; the reserved # RUNNER_TOOL_CACHE variable cannot be overridden, so purge Node. TOOL_CACHE="$(realpath -m -- "${RUNNER_TOOL_CACHE:?}" 2>/dev/null)" || exit 1 @@ -184,6 +184,11 @@ jobs: : > "${release_state}/gitconfig" || exit 1 : > "${release_state}/npmrc" || exit 1 mkdir "${release_state}/docker" || exit 1 + # gh reads $HOME/.config/gh across pool jobs: a prior job could + # plant a config.yml with http_unix_socket there and capture the + # token a later `gh` call sends — qwen-autofix.yml isolates + # GH_CONFIG_DIR the same way. + mkdir "${release_state}/gh" || exit 1 { echo 'GIT_CONFIG_COUNT=0' echo 'GIT_CONFIG_NOSYSTEM=1' @@ -191,6 +196,7 @@ jobs: echo "GIT_CONFIG_GLOBAL=${release_state}/gitconfig" echo "NPM_CONFIG_USERCONFIG=${release_state}/npmrc" echo "DOCKER_CONFIG=${release_state}/docker" + echo "GH_CONFIG_DIR=${release_state}/gh" } >> "${GITHUB_ENV:?}" - name: 'Checkout' diff --git a/scripts/tests/release-workflow.test.js b/scripts/tests/release-workflow.test.js index ce1da657edf..aa43ab5473c 100644 --- a/scripts/tests/release-workflow.test.js +++ b/scripts/tests/release-workflow.test.js @@ -204,8 +204,8 @@ fi # runner workspace refused), so the recursive chmod cannot escape it. chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files" find "$WS" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + -# Later steps must not read pool-persistent Git, npm, Docker, or -# setup-node state. A fresh directory avoids an unbounded scrub +# Later steps must not read pool-persistent Git, npm, Docker, gh, +# or setup-node state. A fresh directory avoids an unbounded scrub # denylist and stale lock files before checkout runs; the reserved # RUNNER_TOOL_CACHE variable cannot be overridden, so purge Node. TOOL_CACHE="$(realpath -m -- "\${RUNNER_TOOL_CACHE:?}" 2>/dev/null)" || exit 1 @@ -224,6 +224,11 @@ release_state="$(mktemp -d "\${RUNNER_TEMP:?}/release-state.XXXXXX")" || exit 1 : > "\${release_state}/gitconfig" || exit 1 : > "\${release_state}/npmrc" || exit 1 mkdir "\${release_state}/docker" || exit 1 +# gh reads $HOME/.config/gh across pool jobs: a prior job could +# plant a config.yml with http_unix_socket there and capture the +# token a later \`gh\` call sends — qwen-autofix.yml isolates +# GH_CONFIG_DIR the same way. +mkdir "\${release_state}/gh" || exit 1 { echo 'GIT_CONFIG_COUNT=0' echo 'GIT_CONFIG_NOSYSTEM=1' @@ -231,6 +236,7 @@ mkdir "\${release_state}/docker" || exit 1 echo "GIT_CONFIG_GLOBAL=\${release_state}/gitconfig" echo "NPM_CONFIG_USERCONFIG=\${release_state}/npmrc" echo "DOCKER_CONFIG=\${release_state}/docker" + echo "GH_CONFIG_DIR=\${release_state}/gh" } >> "\${GITHUB_ENV:?}"`; describe('release workflow', () => { @@ -356,6 +362,9 @@ describe('release workflow', () => { expect(stateEnv).toMatch( /DOCKER_CONFIG=.*\/release-state\.[^/]+\/docker\n/, ); + expect(stateEnv).toMatch( + /GH_CONFIG_DIR=.*\/release-state\.[^/]+\/gh\n/, + ); const isolatedEnv = { ...env }; for (const line of stateEnv.trimEnd().split('\n')) { const separator = line.indexOf('='); From b45ba2781dcc6ad2ff539ef7486e71e5eac1500d Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Fri, 28 Aug 2026 02:35:16 +0800 Subject: [PATCH 24/35] fix(ci): digest-pin the sandbox base image Routing integration_docker onto the shared pool exposes the build to the pool's persistent docker daemon store: a co-resident job can pre-tag a poisoned image as node:22-slim, and an unpinned FROM resolves against the local store with zero registry contact. Pin both stages to the node:22-slim index digest (verified against two independent registry mirrors) and pin the requirement in release-workflow.test.js. Co-authored-by: Qwen-Coder --- Dockerfile | 11 +++++++++-- scripts/tests/release-workflow.test.js | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 37118305a94..ea652ca1637 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,10 @@ # Build stage -FROM docker.io/library/node:22-slim AS builder +# Digest-pinned: integration_docker builds on the shared ECS pool, whose +# docker daemon image store persists across jobs — a co-resident job can +# retag a mutable base tag with a poisoned image, but a digest cannot be +# moved by `docker tag`. Bump the digest together with the tag. +# ratchet:docker.io/library/node:22-slim +FROM docker.io/library/node:22-slim@sha256:83f487e0a63425e5b4d146fb5e5be574bcbe1b7b843d3ebafdd95eaf7767a7e5 AS builder # Install build dependencies RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -29,7 +34,9 @@ RUN QWEN_SKIP_PREPARE=1 npm ci \ && cd dist && npm pack # Runtime stage -FROM docker.io/library/node:22-slim +# Digest-pinned for the same reason as the builder stage above. +# ratchet:docker.io/library/node:22-slim +FROM docker.io/library/node:22-slim@sha256:83f487e0a63425e5b4d146fb5e5be574bcbe1b7b843d3ebafdd95eaf7767a7e5 ARG SANDBOX_NAME="qwen-code-sandbox" ARG CLI_VERSION_ARG diff --git a/scripts/tests/release-workflow.test.js b/scripts/tests/release-workflow.test.js index aa43ab5473c..7ef0a08f748 100644 --- a/scripts/tests/release-workflow.test.js +++ b/scripts/tests/release-workflow.test.js @@ -645,6 +645,21 @@ describe('release workflow', () => { ); }); + it('digest-pins every sandbox base image', () => { + // integration_docker builds on the shared pool, whose docker daemon + // store persists across jobs: a co-resident job can retag a mutable + // base tag with a poisoned image, but a digest cannot be moved by + // `docker tag`. Every FROM must carry an @sha256: digest. + const dockerfile = readFileSync('Dockerfile', 'utf8'); + const fromLines = dockerfile + .split('\n') + .filter((line) => /^FROM\s/.test(line)); + expect(fromLines.length).toBeGreaterThan(0); + for (const line of fromLines) { + expect(line, line).toMatch(/@sha256:[0-9a-f]{64}(\s|$)/); + } + }); + it('bounds shared-pool jobs and skips redundant remote npm caches', () => { expect( Object.fromEntries( From 581c9176ebb50550cda73e268efff96f9cc67ae4 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Fri, 28 Aug 2026 02:49:21 +0800 Subject: [PATCH 25/35] fix(ci): ratchet the release.yml size baseline for the pool hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pool security hardening (sibling tool-cache containment, symlinked-workspace refusal, GH_CONFIG_DIR isolation, and the hosted/self-hosted Node split across the four routed jobs) grew release.yml from 48500 to 54000 bytes — 5500 over the recorded baseline, past the 4096 allowance. The growth is real and deliberate: the attacker-model comments must stay next to the wipe steps they document, and the Node split repeats a small step block per job. Bump the ratchet per check-workflow-size.sh's own contract. Co-authored-by: Qwen-Coder --- .github/workflows/.size-baseline | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/.size-baseline b/.github/workflows/.size-baseline index 460fc068c6b..b478cf754be 100644 --- a/.github/workflows/.size-baseline +++ b/.github/workflows/.size-baseline @@ -46,7 +46,7 @@ 22037 release-sdk-python.yml 19094 release-sdk.yml 14546 release-vscode-companion.yml -48500 release.yml +54000 release.yml 43717 repo-hygiene.yml 1079 scorecard-monthly.yml 10691 sdk-java.yml From 9533c966f472c2c4be7603a08e769231a7070922 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Fri, 28 Aug 2026 04:59:53 +0800 Subject: [PATCH 26/35] fix(ci): reap cross-job processes and keep the pool tool cache in the release wipe Round 9 R1-1: the pre-checkout isolation was a one-shot file-state sweep; a detached postinstall child of a previous pool job (self-hosted runners do not reap job processes) waits it out and tampers with the fresh tree while secret-bearing steps run. Extend the restore step with qwen-triage.yml's process-reap doctrine: kill every live process of the runner user outside the runner agent's own tree (PPID-chain exclusion, zombies ignored), retry, and fail closed naming any survivor. Round 9 R7-4 (fix-induced): the round-8 geometry fix armed the ${RUNNER_TOOL_CACHE}/node purge on the pool, stripping the pool-wide Node that five lanes in qwen-autofix.yml, serve-ab.yml and repo-hygiene.yml resolve through un-gated setup-node, while the pool-routed release jobs never read the tool cache. Drop the purge; document the pool-wide cache as deliberately untouched. Tests: canonicalWipe mirror regenerated byte-for-byte; the pool-geometry sub-case now asserts _tool/node survives; new behavioral witnesses reap an orphaned process and fail closed on an unkillable survivor (mutation-verified: reap-condition inversion and purge re-addition both redden the suite; reap leg exercised as a non-root user with orphan reap and stubbed unkillable survivor probes). Co-authored-by: Qwen-Coder --- .github/workflows/release.yml | 115 ++++++++++--- scripts/tests/release-workflow.test.js | 230 ++++++++++++++++++++----- 2 files changed, 283 insertions(+), 62 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6283e83464c..9f363c47172 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -163,23 +163,98 @@ jobs: # inside the runner workspace (a symlinked leaf was healed, a symlinked # runner workspace refused), so the recursive chmod cannot escape it. chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files" + # qwen-triage.yml documents this pool's behaviour: a detached + # postinstall child of a previous job can outlive that job — + # self-hosted runners do not reap job processes — and a survivor + # with this job's uid waits out the one-shot sweep below, then + # re-plants the fresh tree, appends to `$GITHUB_ENV`, or rewrites + # the fresh release-state config files while the secret-bearing + # steps run (0700 does not exclude the owner). Kill every live + # process of this user outside the runner agent's own tree BEFORE + # the file sweep, so the sweep is not racing a live process. + # + # The exclusion is the load-bearing part: a bare `pkill -u` would + # kill the Runner.Worker executing this very step. Walk the PPID + # chain from this shell to collect the agent's ancestor tree; a + # process is kept only if its own parent chain reaches that tree. + # Zombies do not count: one has already exited and can no longer + # re-plant anything — and it cannot be killed either, so counting + # one means this check can never clear. A root runner skips the + # reap: as root every system process is killable, so no exclusion + # can make the sweep safe. + if [ "$RUNNER_UID" != "0" ]; then + REAP_USER="$(id -un)" + REAP_TREE=" $$ " + reap_pid=$$ + while :; do + reap_pid="$(ps -o ppid= -p "$reap_pid" 2>/dev/null | tr -d '[:space:]')" || break + [ -n "$reap_pid" ] || break + [ "$reap_pid" -gt 1 ] 2>/dev/null || break + REAP_TREE="${REAP_TREE}${reap_pid} " + done + live_outside_tree() { + # One pid per line: live processes of this user whose parent + # chain never reaches the runner agent tree, zombies dropped. + ps -o pid= -o ppid= -o stat= -u "$REAP_USER" 2>/dev/null | awk -v tree="$REAP_TREE" ' + BEGIN { + n = split(tree, t, " ") + for (i = 1; i <= n; i++) if (t[i] != "") keep[t[i]] = 1 + } + { pids[NR] = $1; pp[$1] = $2; st[$1] = $3 } + END { + for (i = 1; i <= NR; i++) { + p = pids[i] + if (st[p] ~ /^Z/) continue + q = p + inside = (q in keep) + for (d = 0; d <= NR && !inside; d++) { + if (!(q in pp)) break + q = pp[q] + if (q in keep) { inside = 1; break } + if (q <= 1) break + } + if (!inside) print p + } + }' + } + reap_pids="$(live_outside_tree)" || true + if [ -n "$reap_pids" ]; then + printf '%s\n' "$reap_pids" | xargs -r kill -KILL -- 2>/dev/null || true + fi + for _ in 1 2 3; do + reap_pids="$(live_outside_tree)" || true + [ -n "$reap_pids" ] || break + sleep 1 + printf '%s\n' "$reap_pids" | xargs -r kill -KILL -- 2>/dev/null || true + done + survivors="$(live_outside_tree)" || true + if [ -n "$survivors" ]; then + # Name them: a bare "processes survived" refusal leaves a real + # threat and a harmless leftover indistinguishable — including + # to the person reading the failure. + echo "::error::Processes of the runner user survived SIGKILL; refusing to run release steps with credentials." + printf '%s\n' "$survivors" | while IFS= read -r survivor_pid; do + [ -n "$survivor_pid" ] || continue + ps -o pid= -o stat= -o args= -p "$survivor_pid" 2>/dev/null | sed 's/^/::error:: surviving process: /' || true + done + exit 1 + fi + fi find "$WS" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + - # Later steps must not read pool-persistent Git, npm, Docker, gh, - # or setup-node state. A fresh directory avoids an unbounded scrub - # denylist and stale lock files before checkout runs; the reserved - # RUNNER_TOOL_CACHE variable cannot be overridden, so purge Node. - TOOL_CACHE="$(realpath -m -- "${RUNNER_TOOL_CACHE:?}" 2>/dev/null)" || exit 1 - # On the standard self-hosted layout the tool cache is a SIBLING - # of the workspace (/_work/_tool vs /_work/qwen-code), - # not inside it: anchor the containment to the runner work root. - # Canonicalization above resolves symlinks, so a planted link - # cannot smuggle an outside path through either arm. - RW_ROOT="$(dirname -- "$RWS")" - case "$TOOL_CACHE" in - "$RWS"/*|"$RW_ROOT"/_tool) ;; - *) echo "::error::refusing to purge tool cache outside the runner workspace: ${TOOL_CACHE}"; exit 1 ;; - esac - rm -rf -- "${TOOL_CACHE}/node" 2>/dev/null || sudo -n rm -rf -- "${TOOL_CACHE}/node" || exit 1 + # Later steps must not read pool-persistent Git, npm, Docker, or + # gh state. A fresh directory avoids an unbounded scrub denylist + # and stale lock files before checkout runs. + # + # The pool-wide RUNNER_TOOL_CACHE stays untouched ON PURPOSE: + # lanes in three other pool workflows (qwen-autofix.yml's + # issue-autofix/build-cli/review-address, serve-ab.yml's ab, + # repo-hygiene.yml's dedup lane) resolve Node from it through + # un-gated setup-node, while the pool-routed release jobs never + # read the tool cache — their pool path is PATH Node via + # .github/actions/self-hosted-node. Purging `_tool/node` here + # would strip Node out from under the next such job on this + # member, and nodejs.org may be unreachable through the pool's + # egress proxy. release_state="$(mktemp -d "${RUNNER_TEMP:?}/release-state.XXXXXX")" || exit 1 : > "${release_state}/gitconfig" || exit 1 : > "${release_state}/npmrc" || exit 1 @@ -242,7 +317,7 @@ jobs: # Avoid setup-node downloads on ECS, where nodejs.org may be # unreachable through the egress proxy; reuse the machine's Node - # instead (the wipe above purges the pool's tool cache). + # instead. - name: 'Use pre-installed Node.js (self-hosted)' if: "${{ runner.environment == 'self-hosted' }}" uses: './.github/actions/self-hosted-node' @@ -334,7 +409,7 @@ jobs: # Avoid setup-node downloads on ECS, where nodejs.org may be # unreachable through the egress proxy; reuse the machine's Node - # instead (the wipe above purges the pool's tool cache). + # instead. - name: 'Use pre-installed Node.js (self-hosted)' if: "${{ runner.environment == 'self-hosted' }}" uses: './.github/actions/self-hosted-node' @@ -413,7 +488,7 @@ jobs: # Avoid setup-node downloads on ECS, where nodejs.org may be # unreachable through the egress proxy; reuse the machine's Node - # instead (the wipe above purges the pool's tool cache). + # instead. - name: 'Use pre-installed Node.js (self-hosted)' if: "${{ runner.environment == 'self-hosted' }}" uses: './.github/actions/self-hosted-node' @@ -489,7 +564,7 @@ jobs: # Avoid setup-node downloads on ECS, where nodejs.org may be # unreachable through the egress proxy; reuse the machine's Node - # instead (the wipe above purges the pool's tool cache). + # instead. - name: 'Use pre-installed Node.js (self-hosted)' if: "${{ runner.environment == 'self-hosted' }}" uses: './.github/actions/self-hosted-node' diff --git a/scripts/tests/release-workflow.test.js b/scripts/tests/release-workflow.test.js index 7ef0a08f748..ed4a4239be1 100644 --- a/scripts/tests/release-workflow.test.js +++ b/scripts/tests/release-workflow.test.js @@ -203,23 +203,98 @@ fi # inside the runner workspace (a symlinked leaf was healed, a symlinked # runner workspace refused), so the recursive chmod cannot escape it. chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files" +# qwen-triage.yml documents this pool's behaviour: a detached +# postinstall child of a previous job can outlive that job — +# self-hosted runners do not reap job processes — and a survivor +# with this job's uid waits out the one-shot sweep below, then +# re-plants the fresh tree, appends to \`$GITHUB_ENV\`, or rewrites +# the fresh release-state config files while the secret-bearing +# steps run (0700 does not exclude the owner). Kill every live +# process of this user outside the runner agent's own tree BEFORE +# the file sweep, so the sweep is not racing a live process. +# +# The exclusion is the load-bearing part: a bare \`pkill -u\` would +# kill the Runner.Worker executing this very step. Walk the PPID +# chain from this shell to collect the agent's ancestor tree; a +# process is kept only if its own parent chain reaches that tree. +# Zombies do not count: one has already exited and can no longer +# re-plant anything — and it cannot be killed either, so counting +# one means this check can never clear. A root runner skips the +# reap: as root every system process is killable, so no exclusion +# can make the sweep safe. +if [ "$RUNNER_UID" != "0" ]; then + REAP_USER="$(id -un)" + REAP_TREE=" $$ " + reap_pid=$$ + while :; do + reap_pid="$(ps -o ppid= -p "$reap_pid" 2>/dev/null | tr -d '[:space:]')" || break + [ -n "$reap_pid" ] || break + [ "$reap_pid" -gt 1 ] 2>/dev/null || break + REAP_TREE="\${REAP_TREE}\${reap_pid} " + done + live_outside_tree() { + # One pid per line: live processes of this user whose parent + # chain never reaches the runner agent tree, zombies dropped. + ps -o pid= -o ppid= -o stat= -u "$REAP_USER" 2>/dev/null | awk -v tree="$REAP_TREE" ' + BEGIN { + n = split(tree, t, " ") + for (i = 1; i <= n; i++) if (t[i] != "") keep[t[i]] = 1 + } + { pids[NR] = $1; pp[$1] = $2; st[$1] = $3 } + END { + for (i = 1; i <= NR; i++) { + p = pids[i] + if (st[p] ~ /^Z/) continue + q = p + inside = (q in keep) + for (d = 0; d <= NR && !inside; d++) { + if (!(q in pp)) break + q = pp[q] + if (q in keep) { inside = 1; break } + if (q <= 1) break + } + if (!inside) print p + } + }' + } + reap_pids="$(live_outside_tree)" || true + if [ -n "$reap_pids" ]; then + printf '%s\\n' "$reap_pids" | xargs -r kill -KILL -- 2>/dev/null || true + fi + for _ in 1 2 3; do + reap_pids="$(live_outside_tree)" || true + [ -n "$reap_pids" ] || break + sleep 1 + printf '%s\\n' "$reap_pids" | xargs -r kill -KILL -- 2>/dev/null || true + done + survivors="$(live_outside_tree)" || true + if [ -n "$survivors" ]; then + # Name them: a bare "processes survived" refusal leaves a real + # threat and a harmless leftover indistinguishable — including + # to the person reading the failure. + echo "::error::Processes of the runner user survived SIGKILL; refusing to run release steps with credentials." + printf '%s\\n' "$survivors" | while IFS= read -r survivor_pid; do + [ -n "$survivor_pid" ] || continue + ps -o pid= -o stat= -o args= -p "$survivor_pid" 2>/dev/null | sed 's/^/::error:: surviving process: /' || true + done + exit 1 + fi +fi find "$WS" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + -# Later steps must not read pool-persistent Git, npm, Docker, gh, -# or setup-node state. A fresh directory avoids an unbounded scrub -# denylist and stale lock files before checkout runs; the reserved -# RUNNER_TOOL_CACHE variable cannot be overridden, so purge Node. -TOOL_CACHE="$(realpath -m -- "\${RUNNER_TOOL_CACHE:?}" 2>/dev/null)" || exit 1 -# On the standard self-hosted layout the tool cache is a SIBLING -# of the workspace (/_work/_tool vs /_work/qwen-code), -# not inside it: anchor the containment to the runner work root. -# Canonicalization above resolves symlinks, so a planted link -# cannot smuggle an outside path through either arm. -RW_ROOT="$(dirname -- "$RWS")" -case "$TOOL_CACHE" in - "$RWS"/*|"$RW_ROOT"/_tool) ;; - *) echo "::error::refusing to purge tool cache outside the runner workspace: \${TOOL_CACHE}"; exit 1 ;; -esac -rm -rf -- "\${TOOL_CACHE}/node" 2>/dev/null || sudo -n rm -rf -- "\${TOOL_CACHE}/node" || exit 1 +# Later steps must not read pool-persistent Git, npm, Docker, or +# gh state. A fresh directory avoids an unbounded scrub denylist +# and stale lock files before checkout runs. +# +# The pool-wide RUNNER_TOOL_CACHE stays untouched ON PURPOSE: +# lanes in three other pool workflows (qwen-autofix.yml's +# issue-autofix/build-cli/review-address, serve-ab.yml's ab, +# repo-hygiene.yml's dedup lane) resolve Node from it through +# un-gated setup-node, while the pool-routed release jobs never +# read the tool cache — their pool path is PATH Node via +# .github/actions/self-hosted-node. Purging \`_tool/node\` here +# would strip Node out from under the next such job on this +# member, and nodejs.org may be unreachable through the pool's +# egress proxy. release_state="$(mktemp -d "\${RUNNER_TEMP:?}/release-state.XXXXXX")" || exit 1 : > "\${release_state}/gitconfig" || exit 1 : > "\${release_state}/npmrc" || exit 1 @@ -417,26 +492,11 @@ describe('release workflow', () => { } } - // The runner-provided cache path is still validated before recursive - // deletion so a poisoned intermediate symlink cannot redirect the purge. - { - const outside = mkdtempSync(join(tmpdir(), 'release-tool-cache-')); - const { result, base } = runWipe({ RUNNER_TOOL_CACHE: outside }); - try { - expect(result.status).not.toBe(0); - expect(`${result.stdout}${result.stderr}`).toContain( - 'refusing to purge tool cache outside the runner workspace', - ); - expect(lstatSync(join(outside, 'node')).isDirectory()).toBe(true); - } finally { - rmSync(outside, { recursive: true, force: true }); - rmSync(base, { recursive: true, force: true }); - } - } - // Pool geometry: the tool cache is a SIBLING of the runner workspace // (/_work/_tool vs /_work/qwen-code) — the standard - // self-hosted layout — so the containment must accept it. + // self-hosted layout. The wipe must leave it untouched: other pool + // lanes resolve Node from it through un-gated setup-node, while the + // pool-routed release jobs never read it. { const runnerRoot = mkdtempSync(join(tmpdir(), 'release-wipe-pool-')); const rws = join(runnerRoot, '_work', 'qwen-code'); @@ -457,6 +517,10 @@ describe('release workflow', () => { mkdirSync(env.HOME); mkdirSync(env.RUNNER_TEMP); mkdirSync(join(env.RUNNER_TOOL_CACHE, 'node'), { recursive: true }); + writeFileSync( + join(env.RUNNER_TOOL_CACHE, 'node', 'marker.txt'), + 'pool-node', + ); const result = spawnSync( 'bash', ['-e', '-o', 'pipefail', '-c', wipeScript], @@ -464,10 +528,16 @@ describe('release workflow', () => { ); expect(result.status).toBe(0); expect(readdirSync(workspace)).toHaveLength(0); - // The sibling tool cache's node directory is purged. - expect(() => - lstatSync(join(env.RUNNER_TOOL_CACHE, 'node')), - ).toThrow(); + // The sibling tool cache's node directory SURVIVES the wipe. + expect( + lstatSync(join(env.RUNNER_TOOL_CACHE, 'node')).isDirectory(), + ).toBe(true); + expect( + readFileSync( + join(env.RUNNER_TOOL_CACHE, 'node', 'marker.txt'), + 'utf8', + ), + ).toBe('pool-node'); } finally { rmSync(runnerRoot, { recursive: true, force: true }); } @@ -541,6 +611,83 @@ describe('release workflow', () => { } } + // Process reap (R1-1): a detached survivor of a PREVIOUS pool job — + // orphaned to init exactly like a postinstall child that outlives its + // job — is killed before the file sweep runs. The orphan's parent + // chain never reaches this shell's ancestor tree, so the reap must + // reach it; the run still exits 0 and the workspace is wiped. + { + const pidFile = join( + tmpdir(), + `release-reap-pid-${process.pid}-${Date.now()}`, + ); + // setsid + an exiting parent orphans the sleeper immediately + // (reparented to init, outside the wipe shell's ancestor tree). + // The outer loop waits for the pid file so the read cannot race. + spawnSync('bash', [ + '-c', + 'setsid bash -c \'echo $$ > "$1"; exec sleep 30\' x "$1" & ' + + 'for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do ' + + '[ -s "$1" ] && exit 0; sleep 0.1; done; exit 1', + 'x', + pidFile, + pidFile, + ]); + const survivorPid = Number(readFileSync(pidFile, 'utf8').trim()); + rmSync(pidFile); + expect(survivorPid).toBeGreaterThan(1); + expect(() => process.kill(survivorPid, 0)).not.toThrow(); + const { result, base } = runWipe({}); + try { + expect(result.status).toBe(0); + // The orphan did not survive the reap. + expect(() => process.kill(survivorPid, 0)).toThrow(); + } finally { + try { + process.kill(survivorPid, 'SIGKILL'); + } catch { + // already reaped — expected + } + rmSync(base, { recursive: true, force: true }); + } + } + + // Reap fail-closed (R1-1): if a process outside the agent tree + // cannot be killed, the wipe must refuse before any checkout with + // credentials and name the survivor — never proceed. A stub `ps` + // reports one unkillable fake process for the whole run. + { + const stubDir = mkdtempSync(join(tmpdir(), 'release-reap-stub-')); + writeFileSync( + join(stubDir, 'ps'), + [ + '#!/bin/bash', + '# Tree-walk probe: no parent — the kept tree is the wipe shell.', + 'case "$*" in "-o ppid= -p "*) exit 0 ;; esac', + '# Live listing: always one fake survivor outside the tree.', + 'case "$*" in *-u*) echo "424242 1 S fake-survivor-unkillable" ;;', + ' *) echo "424242 S fake-survivor-unkillable" ;; esac', + '', + ].join('\n'), + ); + chmodSync(join(stubDir, 'ps'), 0o755); + const { result, base } = runWipe({ + PATH: `${stubDir}:${process.env.PATH}`, + }); + try { + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain( + 'survived SIGKILL; refusing to run release steps with credentials', + ); + expect(`${result.stdout}${result.stderr}`).toContain( + 'surviving process: 424242', + ); + } finally { + rmSync(stubDir, { recursive: true, force: true }); + rmSync(base, { recursive: true, force: true }); + } + } + // Symlinked runner workspace: refused BEFORE any chown/chmod/wipe — // a prior pool job may have replaced it with a link to redirect the // whole guard chain (heal, containment, wipe) to an attacker-chosen @@ -686,10 +833,9 @@ describe('release workflow', () => { 'integration_docker', ]) { const steps = releaseYaml.jobs[id].steps; - // The wipe purges the pool's tool cache, and in-tree precedent says - // nodejs.org may be unreachable through the ECS egress proxy: pool - // runs must reuse the machine's Node, with setup-node reserved for - // the hosted fallback. + // In-tree precedent says nodejs.org may be unreachable through the + // ECS egress proxy: pool runs must reuse the machine's Node, with + // setup-node reserved for the hosted fallback. const setupNode = steps.find((step) => String(step.uses ?? '').includes('actions/setup-node'), ); From 2cf1799e631cb3c5fb200038e37ebda26a00f228 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Fri, 28 Aug 2026 06:26:50 +0800 Subject: [PATCH 27/35] fix(ci): keep the release wipe suite green and kill-free on the pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round-4 behavioral suite goes red in non-root CI for three reasons: 1. The happy-path sub-case still asserted the tool-cache purge the round-4 commit deliberately removed (the pool geometry sub-case was flipped to expect _tool/node survival, the happy path was not). Flip it to expect survival: the sweep is scoped to the workspace. 2. Every runWipe guard-branch sub-case (happy path, symlink heal, pool geometry, '..' canonicalization) executed the genuine reap, which kills every live process of the runner user outside the step's ancestor tree — on the shared ECS pool that reaches into whatever job is co-resident on the same member. Give those cases a ps stub that lists nothing so the reap branch executes end to end yet kills nothing; R1-1 keeps the one genuine reap probe, clearly marked. 3. The R1-1 orphan spawn inherited the spawnSync stdio pipes, so the spawn blocked until the 30s sleeper exited and the test timed out before the wipe even ran. Redirect the sleeper's stdio to /dev/null. Verified: root run 23 passed / 1 skipped (behavioral suite skips by design under root); a dedicated uid-1002 run passes all 24 tests with the behavioral suite executing in ~6s. Co-authored-by: Qwen-Coder --- scripts/tests/release-workflow.test.js | 41 ++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/scripts/tests/release-workflow.test.js b/scripts/tests/release-workflow.test.js index ed4a4239be1..78c20caf4a1 100644 --- a/scripts/tests/release-workflow.test.js +++ b/scripts/tests/release-workflow.test.js @@ -19,7 +19,7 @@ import { } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, onTestFinished } from 'vitest'; import { parse } from 'yaml'; // `realpath -m` (the script's canonicalization line) is a GNU coreutils @@ -357,6 +357,22 @@ describe('release workflow', () => { () => { const wipeScript = canonicalWipe; + // The guard-branch cases below assert geometry and env isolation, not + // the reap — but the wipe's reap is genuine: it kills every live + // process of the runner user outside the step's ancestor tree. On the + // shared ECS pool that would reach into whatever job is co-resident + // on the same member under this uid every time the suite runs. Give + // the guard-branch cases a `ps` that lists nothing so the reap branch + // executes end to end yet kills nothing; the R1-1 case keeps the one + // genuine reap probe, and the fail-closed case stubs its own `ps`. + const noKillStubDir = mkdtempSync(join(tmpdir(), 'release-reap-nokill-')); + writeFileSync(join(noKillStubDir, 'ps'), '#!/bin/bash\nexit 0\n'); + chmodSync(join(noKillStubDir, 'ps'), 0o755); + const noKillPath = `${noKillStubDir}:${process.env.PATH}`; + onTestFinished(() => + rmSync(noKillStubDir, { recursive: true, force: true }), + ); + const runWipe = (envOverrides, { preCreateWorkspace } = {}) => { const base = mkdtempSync(join(tmpdir(), 'release-wipe-behavioral-')); const workspace = join(base, 'workspace'); @@ -411,7 +427,7 @@ describe('release workflow', () => { // of all persisted entries, not just files). { const { result, base, workspace, githubEnv, env } = runWipe( - {}, + { PATH: noKillPath }, { preCreateWorkspace: (_base, ws) => { writeFileSync(join(ws, 'leftover.txt'), 'stale'); @@ -453,7 +469,14 @@ describe('release workflow', () => { ).status, ).not.toBe(0); expect(readdirSync(isolatedEnv.DOCKER_CONFIG)).toHaveLength(0); - expect(() => lstatSync(join(base, 'tool-cache', 'node'))).toThrow(); + // The sibling tool cache SURVIVES the wipe: the sweep is scoped + // to the workspace, and the pool-wide cache stays untouched on + // purpose — the pool-routed release lane never reads it, while + // other pool lanes resolve Node from it through un-gated + // setup-node. + expect( + lstatSync(join(base, 'tool-cache', 'node')).isDirectory(), + ).toBe(true); } finally { rmSync(base, { recursive: true, force: true }); } @@ -465,7 +488,7 @@ describe('release workflow', () => { // link itself and did not follow/delete the target. { const { result, base, workspace } = runWipe( - {}, + { PATH: noKillPath }, { preCreateWorkspace: (b, ws) => { rmSync(ws, { recursive: true, force: true }); @@ -506,6 +529,7 @@ describe('release workflow', () => { try { const env = { ...process.env, + PATH: noKillPath, GITHUB_WORKSPACE: workspace, RUNNER_WORKSPACE: rws, RUNNER_TEMP: join(runnerRoot, 'temp'), @@ -586,6 +610,7 @@ describe('release workflow', () => { try { const env = { ...process.env, + PATH: noKillPath, // String concatenation preserves the literal '..' segment — // path.join would normalize it away before the script sees it. GITHUB_WORKSPACE: `${base}/sub/../workspace`, @@ -615,7 +640,9 @@ describe('release workflow', () => { // orphaned to init exactly like a postinstall child that outlives its // job — is killed before the file sweep runs. The orphan's parent // chain never reaches this shell's ancestor tree, so the reap must - // reach it; the run still exits 0 and the workspace is wiped. + // reach it; the run still exits 0 and the workspace is wiped. This is + // the suite's one genuine reap probe: unlike the guard-branch cases + // it deliberately runs without the no-kill `ps` stub. { const pidFile = join( tmpdir(), @@ -624,9 +651,11 @@ describe('release workflow', () => { // setsid + an exiting parent orphans the sleeper immediately // (reparented to init, outside the wipe shell's ancestor tree). // The outer loop waits for the pid file so the read cannot race. + // The sleeper's stdio goes to /dev/null: a pipe inherited through + // the spawn would hold this spawnSync open until the sleeper ends. spawnSync('bash', [ '-c', - 'setsid bash -c \'echo $$ > "$1"; exec sleep 30\' x "$1" & ' + + 'setsid bash -c \'echo $$ > "$1"; exec sleep 30\' x "$1" /dev/null 2>&1 & ' + 'for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do ' + '[ -s "$1" ] && exit 0; sleep 0.1; done; exit 1', 'x', From c8060300d08640f14cefbd83b9899614f30d21be Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Fri, 28 Aug 2026 10:07:08 +0800 Subject: [PATCH 28/35] fix(ci): keep concurrent registrations' jobs out of the release reap The round-9 reap roots its keep set at this shell's ancestor chain alone. One pool member hosts every registration under this same uid (qwen-autofix.md af-014 documents ~27 registrations on one HOME), so a concurrent job dispatched through another registration never reaches that chain: the reap SIGKILLs its worker and processes mid-flight, and a process (re)appearing during the retry window then fails the release itself through the fail-closed survivor check. Widen the keep set to every runner-agent tree of the user (runsvc.sh / RunnerService / Runner.Listener / Runner.Worker roots). A process is still killed when it is detached from ALL agent trees, which is the documented shape of a cross-job leftover; concurrent jobs of other registrations are spared. The canonicalWipe mirror carries the same change byte-for-byte, the suite gains a disjoint-tree witness (a real bystander parented under a second registration's tree must survive the wipe while a genuine detached orphan dies), and the size ratchet moves 54000 -> 58255 per check-workflow-size.sh's same-PR contract. Co-authored-by: Qwen-Coder --- .github/workflows/.size-baseline | 2 +- .github/workflows/release.yml | 10 ++- scripts/tests/release-workflow.test.js | 101 ++++++++++++++++++++++++- 3 files changed, 110 insertions(+), 3 deletions(-) diff --git a/.github/workflows/.size-baseline b/.github/workflows/.size-baseline index b478cf754be..ed92275c9b6 100644 --- a/.github/workflows/.size-baseline +++ b/.github/workflows/.size-baseline @@ -46,7 +46,7 @@ 22037 release-sdk-python.yml 19094 release-sdk.yml 14546 release-vscode-companion.yml -54000 release.yml +58255 release.yml 43717 repo-hygiene.yml 1079 scorecard-monthly.yml 10691 sdk-java.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9f363c47172..a60aad0240d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -176,7 +176,8 @@ jobs: # The exclusion is the load-bearing part: a bare `pkill -u` would # kill the Runner.Worker executing this very step. Walk the PPID # chain from this shell to collect the agent's ancestor tree; a - # process is kept only if its own parent chain reaches that tree. + # process is kept only if its own parent chain reaches that tree + # or one of the user's other runner-agent trees (widened below). # Zombies do not count: one has already exited and can no longer # re-plant anything — and it cannot be killed either, so counting # one means this check can never clear. A root runner skips the @@ -192,6 +193,13 @@ jobs: [ "$reap_pid" -gt 1 ] 2>/dev/null || break REAP_TREE="${REAP_TREE}${reap_pid} " done + # One pool member hosts every registration under this one uid + # (qwen-autofix.md af-014): a concurrent job from another + # registration never reaches this shell's chain, so the tree + # above alone would SIGKILL it mid-flight. Widen the keep set + # to every runner-agent tree of the user; a process is killed + # only if it is detached from ALL agent trees. + REAP_TREE="$REAP_TREE $(ps -u "$REAP_USER" -o pid= -o args= 2>/dev/null | awk '/runsvc\.sh|RunnerService|Runner\./ { printf "%s ", $1 }')" || true live_outside_tree() { # One pid per line: live processes of this user whose parent # chain never reaches the runner agent tree, zombies dropped. diff --git a/scripts/tests/release-workflow.test.js b/scripts/tests/release-workflow.test.js index 78c20caf4a1..60612a3545c 100644 --- a/scripts/tests/release-workflow.test.js +++ b/scripts/tests/release-workflow.test.js @@ -216,7 +216,8 @@ chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHU # The exclusion is the load-bearing part: a bare \`pkill -u\` would # kill the Runner.Worker executing this very step. Walk the PPID # chain from this shell to collect the agent's ancestor tree; a -# process is kept only if its own parent chain reaches that tree. +# process is kept only if its own parent chain reaches that tree +# or one of the user's other runner-agent trees (widened below). # Zombies do not count: one has already exited and can no longer # re-plant anything — and it cannot be killed either, so counting # one means this check can never clear. A root runner skips the @@ -232,6 +233,13 @@ if [ "$RUNNER_UID" != "0" ]; then [ "$reap_pid" -gt 1 ] 2>/dev/null || break REAP_TREE="\${REAP_TREE}\${reap_pid} " done + # One pool member hosts every registration under this one uid + # (qwen-autofix.md af-014): a concurrent job from another + # registration never reaches this shell's chain, so the tree + # above alone would SIGKILL it mid-flight. Widen the keep set + # to every runner-agent tree of the user; a process is killed + # only if it is detached from ALL agent trees. + REAP_TREE="$REAP_TREE $(ps -u "$REAP_USER" -o pid= -o args= 2>/dev/null | awk '/runsvc\\.sh|RunnerService|Runner\\./ { printf "%s ", $1 }')" || true live_outside_tree() { # One pid per line: live processes of this user whose parent # chain never reaches the runner agent tree, zombies dropped. @@ -717,6 +725,97 @@ describe('release workflow', () => { } } + // Concurrent-registration shape (R9-1): one pool member hosts every + // registration under this same uid (qwen-autofix.md af-014), so the + // keep set must reach beyond this job's own ancestor tree. The `ps` + // stub lists a second, DISJOINT agent tree — a fake registration root + // whose args match the runner pattern, plus a real bystander sleeper + // parented under it — alongside a real detached orphan. The wipe must + // kill the orphan, spare the bystander, and exit 0 rather than fail + // on the bystander as a survivor. Against the pre-fix keep set the + // bystander's chain never reaches this shell's tree, so the wipe + // SIGKILLs it and the survival assertion goes red. + { + const orphanPidFile = join( + tmpdir(), + `release-reap-orphan-${process.pid}-${Date.now()}`, + ); + const bystanderPidFile = join( + tmpdir(), + `release-reap-bystander-${process.pid}-${Date.now()}`, + ); + const spawnDetached = (pidFile) => { + spawnSync('bash', [ + '-c', + 'setsid bash -c \'echo $$ > "$1"; exec sleep 30\' x "$1" /dev/null 2>&1 & ' + + 'for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do ' + + '[ -s "$1" ] && exit 0; sleep 0.1; done; exit 1', + 'x', + pidFile, + pidFile, + ]); + const pid = Number(readFileSync(pidFile, 'utf8').trim()); + rmSync(pidFile); + expect(pid).toBeGreaterThan(1); + expect(() => process.kill(pid, 0)).not.toThrow(); + return pid; + }; + const orphanPid = spawnDetached(orphanPidFile); + const bystanderPid = spawnDetached(bystanderPidFile); + // A pid no live process holds: the stub fabricates the other + // registration's root around the real bystander. + const fakeRootPid = 3900000 + (process.pid % 100000); + const treeStubDir = mkdtempSync(join(tmpdir(), 'release-reap-tree-')); + writeFileSync( + join(treeStubDir, 'ps'), + [ + '#!/bin/bash', + '# Tree-walk probe: stop the ancestor chain at the wipe shell.', + 'case "$*" in *-p*) exit 0 ;; esac', + '# Agent-root listing: one other registration tree.', + `case "$*" in *args=*) echo "${fakeRootPid} /opt/actions-runner/bin/Runner.Listener" ;; esac`, + '# Live listing: a detached leftover, a concurrent job parented', + "# under that other tree's root, and the root itself. The", + '# orphan line tracks reality (kill -0): after the reap kills', + '# it, the retry listing must come back empty or the wipe', + '# fails closed on a phantom survivor.', + 'case "$*" in *stat=*)', + ` if kill -0 ${orphanPid} 2>/dev/null; then echo "${orphanPid} 1 S orphan-leftover"; fi`, + ` echo "${bystanderPid} ${fakeRootPid} S concurrent-job-worker"`, + ` echo "${fakeRootPid} 1 S Runner.Listener"`, + ' ;;', + 'esac', + 'exit 0', + '', + ].join('\n'), + ); + chmodSync(join(treeStubDir, 'ps'), 0o755); + const { result, base } = runWipe({ + PATH: `${treeStubDir}:${process.env.PATH}`, + }); + try { + expect(result.status).toBe(0); + expect(`${result.stdout}${result.stderr}`).not.toContain( + 'survived SIGKILL', + ); + // The detached leftover died... + expect(() => process.kill(orphanPid, 0)).toThrow(); + // ...but the concurrent job under the other registration's + // agent tree survived the reap. + expect(() => process.kill(bystanderPid, 0)).not.toThrow(); + } finally { + for (const pid of [orphanPid, bystanderPid]) { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // already reaped — expected + } + } + rmSync(treeStubDir, { recursive: true, force: true }); + rmSync(base, { recursive: true, force: true }); + } + } + // Symlinked runner workspace: refused BEFORE any chown/chmod/wipe — // a prior pool job may have replaced it with a link to redirect the // whole guard chain (heal, containment, wipe) to an attacker-chosen From 6a2dd4fcc80a6a328c356de1ab105af1ada05553 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Fri, 28 Aug 2026 10:09:10 +0800 Subject: [PATCH 29/35] fix(ci): stop the R1-1 reap probe from enumerating the pool host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The R1-1 case was the suite's one deliberate real-reap probe: it ran the wipe against the real ps, so every pool run of the suite (the quality job this PR routes to the ECS pool runs test:scripts) listed every live process of the runner user and SIGKILLed whatever sat outside the test's ancestor tree — co-resident jobs' processes under the shared pool uid included. It was also flaky-red by measurement: a transient same-uid process during the reap window failed the fail-closed leg on a machine with no co-resident jobs at all. Stub ps the way the sibling cases do, but keep the probe genuine: the live-listing arm prints only the test's own orphan (tracking reality via kill -0, the way the real ps does once the orphan dies), so the real SIGKILL path still runs end to end on a real detached sleeper while the host is never enumerated. Mutating the orphan line away makes the kill assertion go red. Co-authored-by: Qwen-Coder --- scripts/tests/release-workflow.test.js | 42 ++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/scripts/tests/release-workflow.test.js b/scripts/tests/release-workflow.test.js index 60612a3545c..b9b535eb590 100644 --- a/scripts/tests/release-workflow.test.js +++ b/scripts/tests/release-workflow.test.js @@ -371,8 +371,9 @@ describe('release workflow', () => { // shared ECS pool that would reach into whatever job is co-resident // on the same member under this uid every time the suite runs. Give // the guard-branch cases a `ps` that lists nothing so the reap branch - // executes end to end yet kills nothing; the R1-1 case keeps the one - // genuine reap probe, and the fail-closed case stubs its own `ps`. + // executes end to end yet kills nothing; the R1-1 case stubs `ps` to + // enumerate only its own orphan (real kill, no host enumeration), and + // the fail-closed case stubs its own `ps`. const noKillStubDir = mkdtempSync(join(tmpdir(), 'release-reap-nokill-')); writeFileSync(join(noKillStubDir, 'ps'), '#!/bin/bash\nexit 0\n'); chmodSync(join(noKillStubDir, 'ps'), 0o755); @@ -648,9 +649,12 @@ describe('release workflow', () => { // orphaned to init exactly like a postinstall child that outlives its // job — is killed before the file sweep runs. The orphan's parent // chain never reaches this shell's ancestor tree, so the reap must - // reach it; the run still exits 0 and the workspace is wiped. This is - // the suite's one genuine reap probe: unlike the guard-branch cases - // it deliberately runs without the no-kill `ps` stub. + // reach it; the run still exits 0 and the workspace is wiped. The + // case stubs `ps` so the reap enumerates ONLY this test's orphan: + // the kill path stays genuine (a real sleeper, a real SIGKILL), but + // the suite never reaches the co-resident processes that share this + // uid on the pool, and a transient same-uid process on the member + // can no longer flake the fail-closed leg. { const pidFile = join( tmpdir(), @@ -674,7 +678,32 @@ describe('release workflow', () => { rmSync(pidFile); expect(survivorPid).toBeGreaterThan(1); expect(() => process.kill(survivorPid, 0)).not.toThrow(); - const { result, base } = runWipe({}); + // The stub's live-listing arm tracks reality (kill -0): once the + // reap kills the orphan the retry listing comes back empty, the + // way the real ps does. + const r11StubDir = mkdtempSync(join(tmpdir(), 'release-reap-r11-')); + writeFileSync( + join(r11StubDir, 'ps'), + [ + '#!/bin/bash', + '# Tree-walk probe: stop the ancestor chain at the wipe shell.', + 'case "$*" in *-p*) exit 0 ;; esac', + '# Agent-root listing: no other registration trees.', + 'case "$*" in *args=*) exit 0 ;; esac', + '# Live listing: ONLY the test orphan — never the host, whose', + '# co-resident jobs share this uid on the pool.', + 'case "$*" in *stat=*)', + ` if kill -0 ${survivorPid} 2>/dev/null; then echo "${survivorPid} 1 S orphan-sleeper"; fi`, + ' ;;', + 'esac', + 'exit 0', + '', + ].join('\n'), + ); + chmodSync(join(r11StubDir, 'ps'), 0o755); + const { result, base } = runWipe({ + PATH: `${r11StubDir}:${process.env.PATH}`, + }); try { expect(result.status).toBe(0); // The orphan did not survive the reap. @@ -685,6 +714,7 @@ describe('release workflow', () => { } catch { // already reaped — expected } + rmSync(r11StubDir, { recursive: true, force: true }); rmSync(base, { recursive: true, force: true }); } } From 52d57d3c21a30c98ab7843d78d68f608d58a54d1 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Fri, 28 Aug 2026 10:10:00 +0800 Subject: [PATCH 30/35] fix(ci): keep the release wipe suite off the hosted Windows lane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The behavioral wipe test gates on hasGnuRealpath and a non-root uid, but that gate opens on the hosted Windows lane: Git for Windows ships usr/bin/realpath.exe (GNU coreutils, -m works) and the installer adds usr/bin to PATH (PathOption=CmdTools), so hasGnuRealpath is true on windows-2022 — exactly the fallback lane ci.yml's test_windows runs whenever MAINTAINER_ECS_RUNNER_DISABLED=true, a mode this PR documents and pins. PortableGit ships no setsid, though, so the reap fixture never writes its pid file there and readFileSync(pidFile) throws ENOENT, turning the merge_group-gated lane red. Add process.platform === 'win32' to the gate, the pattern this file already uses for its other bash-driven case; the suite's YAML-parse coverage keeps running on Windows. Co-authored-by: Qwen-Coder --- scripts/tests/release-workflow.test.js | 1030 ++++++++++++------------ 1 file changed, 515 insertions(+), 515 deletions(-) diff --git a/scripts/tests/release-workflow.test.js b/scripts/tests/release-workflow.test.js index b9b535eb590..0c61d83e469 100644 --- a/scripts/tests/release-workflow.test.js +++ b/scripts/tests/release-workflow.test.js @@ -360,311 +360,422 @@ describe('release workflow', () => { } }); - it.skipIf(!hasGnuRealpath || process.getuid?.() === 0)( - 'executes the workspace wipe against guard branches', - () => { - const wipeScript = canonicalWipe; + // The gate also closes on win32: Git for Windows ships GNU realpath + // (so hasGnuRealpath is true on the hosted windows-2022 fallback lane + // ci.yml's test_windows uses whenever MAINTAINER_ECS_RUNNER_DISABLED=true), + // but PortableGit ships no setsid — the reap fixture would never write + // its pid file there and readFileSync(pidFile) would throw ENOENT. + it.skipIf( + !hasGnuRealpath || process.getuid?.() === 0 || process.platform === 'win32', + )('executes the workspace wipe against guard branches', () => { + const wipeScript = canonicalWipe; + + // The guard-branch cases below assert geometry and env isolation, not + // the reap — but the wipe's reap is genuine: it kills every live + // process of the runner user outside the step's ancestor tree. On the + // shared ECS pool that would reach into whatever job is co-resident + // on the same member under this uid every time the suite runs. Give + // the guard-branch cases a `ps` that lists nothing so the reap branch + // executes end to end yet kills nothing; the R1-1 case stubs `ps` to + // enumerate only its own orphan (real kill, no host enumeration), and + // the fail-closed case stubs its own `ps`. + const noKillStubDir = mkdtempSync(join(tmpdir(), 'release-reap-nokill-')); + writeFileSync(join(noKillStubDir, 'ps'), '#!/bin/bash\nexit 0\n'); + chmodSync(join(noKillStubDir, 'ps'), 0o755); + const noKillPath = `${noKillStubDir}:${process.env.PATH}`; + onTestFinished(() => + rmSync(noKillStubDir, { recursive: true, force: true }), + ); + + const runWipe = (envOverrides, { preCreateWorkspace } = {}) => { + const base = mkdtempSync(join(tmpdir(), 'release-wipe-behavioral-')); + const workspace = join(base, 'workspace'); + mkdirSync(workspace); + if (preCreateWorkspace) preCreateWorkspace(base, workspace); + const env = { + ...process.env, + GITHUB_WORKSPACE: workspace, + RUNNER_WORKSPACE: base, + RUNNER_TEMP: join(base, 'temp'), + RUNNER_TOOL_CACHE: join(base, 'tool-cache'), + GITHUB_ENV: join(base, 'github-env'), + HOME: join(base, 'home'), + XDG_CONFIG_HOME: join(base, 'home', '.config'), + ...envOverrides, + }; + mkdirSync(env.HOME); + mkdirSync(env.RUNNER_TEMP); + mkdirSync(join(env.RUNNER_TOOL_CACHE, 'node'), { recursive: true }); + mkdirSync(join(env.HOME, '.docker')); + writeFileSync( + join(env.HOME, '.gitconfig'), + '[credential]\n\thelper = !false\n', + ); + writeFileSync(join(env.HOME, '.gitconfig.lock'), 'stale'); + writeFileSync( + join(env.HOME, '.docker', 'config.json'), + '{"proxies":{"default":{"httpProxy":"http://attacker"}}}', + ); + env.GIT_CONFIG_GLOBAL = join(env.HOME, '.gitconfig'); + env.GIT_CONFIG_COUNT = '1'; + env.GIT_CONFIG_KEY_0 = 'credential.helper'; + env.GIT_CONFIG_VALUE_0 = '!false'; + return { + result: spawnSync('bash', ['-e', '-o', 'pipefail', '-c', wipeScript], { + encoding: 'utf8', + env, + }), + base, + workspace, + githubEnv: env.GITHUB_ENV, + env, + }; + }; + + // Happy path: a normal workspace inside the runner workspace is wiped, + // including subdirectories (the wipe's core property: recursive removal + // of all persisted entries, not just files). + { + const { result, base, workspace, githubEnv, env } = runWipe( + { PATH: noKillPath }, + { + preCreateWorkspace: (_base, ws) => { + writeFileSync(join(ws, 'leftover.txt'), 'stale'); + mkdirSync(join(ws, 'leftover-dir')); + writeFileSync(join(ws, 'leftover-dir', 'nested.txt'), 'stale'); + }, + }, + ); + try { + expect(result.status).toBe(0); + const entries = readdirSync(workspace); + expect(entries).toHaveLength(0); + const stateEnv = readFileSync(githubEnv, 'utf8'); + expect(stateEnv).toContain('GIT_CONFIG_COUNT=0\n'); + expect(stateEnv).toContain('GIT_CONFIG_NOSYSTEM=1\n'); + expect(stateEnv).toContain('GIT_CONFIG_PARAMETERS=\n'); + expect(stateEnv).toMatch( + /GIT_CONFIG_GLOBAL=.*\/release-state\.[^/]+\/gitconfig\n/, + ); + expect(stateEnv).toMatch( + /NPM_CONFIG_USERCONFIG=.*\/release-state\.[^/]+\/npmrc\n/, + ); + expect(stateEnv).toMatch( + /DOCKER_CONFIG=.*\/release-state\.[^/]+\/docker\n/, + ); + expect(stateEnv).toMatch( + /GH_CONFIG_DIR=.*\/release-state\.[^/]+\/gh\n/, + ); + const isolatedEnv = { ...env }; + for (const line of stateEnv.trimEnd().split('\n')) { + const separator = line.indexOf('='); + isolatedEnv[line.slice(0, separator)] = line.slice(separator + 1); + } + expect( + spawnSync( + 'git', + ['config', '--global', '--get', 'credential.helper'], + { env: isolatedEnv }, + ).status, + ).not.toBe(0); + expect(readdirSync(isolatedEnv.DOCKER_CONFIG)).toHaveLength(0); + // The sibling tool cache SURVIVES the wipe: the sweep is scoped + // to the workspace, and the pool-wide cache stays untouched on + // purpose — the pool-routed release lane never reads it, while + // other pool lanes resolve Node from it through un-gated + // setup-node. + expect(lstatSync(join(base, 'tool-cache', 'node')).isDirectory()).toBe( + true, + ); + } finally { + rmSync(base, { recursive: true, force: true }); + } + } - // The guard-branch cases below assert geometry and env isolation, not - // the reap — but the wipe's reap is genuine: it kills every live - // process of the runner user outside the step's ancestor tree. On the - // shared ECS pool that would reach into whatever job is co-resident - // on the same member under this uid every time the suite runs. Give - // the guard-branch cases a `ps` that lists nothing so the reap branch - // executes end to end yet kills nothing; the R1-1 case stubs `ps` to - // enumerate only its own orphan (real kill, no host enumeration), and - // the fail-closed case stubs its own `ps`. - const noKillStubDir = mkdtempSync(join(tmpdir(), 'release-reap-nokill-')); - writeFileSync(join(noKillStubDir, 'ps'), '#!/bin/bash\nexit 0\n'); - chmodSync(join(noKillStubDir, 'ps'), 0o755); - const noKillPath = `${noKillStubDir}:${process.env.PATH}`; - onTestFinished(() => - rmSync(noKillStubDir, { recursive: true, force: true }), + // Symlink heal: a workspace replaced with a symlink inside the runner + // workspace is removed and recreated, then wiped. The decoy target + // is a real file so the test can verify `rm -f` removed only the + // link itself and did not follow/delete the target. + { + const { result, base, workspace } = runWipe( + { PATH: noKillPath }, + { + preCreateWorkspace: (b, ws) => { + rmSync(ws, { recursive: true, force: true }); + const decoyTarget = join(b, 'decoy-target'); + writeFileSync(decoyTarget, 'must-survive'); + symlinkSync(decoyTarget, ws); + }, + }, ); + try { + expect(result.status).toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain( + 'healing workspace', + ); + const stat = lstatSync(workspace); + expect(stat.isDirectory()).toBe(true); + // The decoy target must survive: rm -f on the raw path removes + // the link itself and never follows it. + expect(readFileSync(join(base, 'decoy-target'), 'utf8')).toBe( + 'must-survive', + ); + } finally { + rmSync(base, { recursive: true, force: true }); + } + } - const runWipe = (envOverrides, { preCreateWorkspace } = {}) => { - const base = mkdtempSync(join(tmpdir(), 'release-wipe-behavioral-')); - const workspace = join(base, 'workspace'); - mkdirSync(workspace); - if (preCreateWorkspace) preCreateWorkspace(base, workspace); + // Pool geometry: the tool cache is a SIBLING of the runner workspace + // (/_work/_tool vs /_work/qwen-code) — the standard + // self-hosted layout. The wipe must leave it untouched: other pool + // lanes resolve Node from it through un-gated setup-node, while the + // pool-routed release jobs never read it. + { + const runnerRoot = mkdtempSync(join(tmpdir(), 'release-wipe-pool-')); + const rws = join(runnerRoot, '_work', 'qwen-code'); + const workspace = join(rws, 'qwen-code'); + mkdirSync(workspace, { recursive: true }); + writeFileSync(join(workspace, 'leftover.txt'), 'stale'); + try { const env = { ...process.env, + PATH: noKillPath, GITHUB_WORKSPACE: workspace, + RUNNER_WORKSPACE: rws, + RUNNER_TEMP: join(runnerRoot, 'temp'), + RUNNER_TOOL_CACHE: join(runnerRoot, '_work', '_tool'), + GITHUB_ENV: join(runnerRoot, 'github-env'), + HOME: join(runnerRoot, 'home'), + XDG_CONFIG_HOME: join(runnerRoot, 'home', '.config'), + }; + mkdirSync(env.HOME); + mkdirSync(env.RUNNER_TEMP); + mkdirSync(join(env.RUNNER_TOOL_CACHE, 'node'), { recursive: true }); + writeFileSync( + join(env.RUNNER_TOOL_CACHE, 'node', 'marker.txt'), + 'pool-node', + ); + const result = spawnSync( + 'bash', + ['-e', '-o', 'pipefail', '-c', wipeScript], + { encoding: 'utf8', env }, + ); + expect(result.status).toBe(0); + expect(readdirSync(workspace)).toHaveLength(0); + // The sibling tool cache's node directory SURVIVES the wipe. + expect( + lstatSync(join(env.RUNNER_TOOL_CACHE, 'node')).isDirectory(), + ).toBe(true); + expect( + readFileSync( + join(env.RUNNER_TOOL_CACHE, 'node', 'marker.txt'), + 'utf8', + ), + ).toBe('pool-node'); + } finally { + rmSync(runnerRoot, { recursive: true, force: true }); + } + } + + // Workspace outside runner workspace: refused. + { + const outside = mkdtempSync(join(tmpdir(), 'release-wipe-outside-')); + const base = mkdtempSync(join(tmpdir(), 'release-wipe-runner-')); + try { + const env = { + ...process.env, + GITHUB_WORKSPACE: outside, RUNNER_WORKSPACE: base, RUNNER_TEMP: join(base, 'temp'), RUNNER_TOOL_CACHE: join(base, 'tool-cache'), GITHUB_ENV: join(base, 'github-env'), HOME: join(base, 'home'), XDG_CONFIG_HOME: join(base, 'home', '.config'), - ...envOverrides, }; mkdirSync(env.HOME); mkdirSync(env.RUNNER_TEMP); - mkdirSync(join(env.RUNNER_TOOL_CACHE, 'node'), { recursive: true }); - mkdirSync(join(env.HOME, '.docker')); - writeFileSync( - join(env.HOME, '.gitconfig'), - '[credential]\n\thelper = !false\n', - ); - writeFileSync(join(env.HOME, '.gitconfig.lock'), 'stale'); - writeFileSync( - join(env.HOME, '.docker', 'config.json'), - '{"proxies":{"default":{"httpProxy":"http://attacker"}}}', + const result = spawnSync( + 'bash', + ['-e', '-o', 'pipefail', '-c', wipeScript], + { encoding: 'utf8', env }, ); - env.GIT_CONFIG_GLOBAL = join(env.HOME, '.gitconfig'); - env.GIT_CONFIG_COUNT = '1'; - env.GIT_CONFIG_KEY_0 = 'credential.helper'; - env.GIT_CONFIG_VALUE_0 = '!false'; - return { - result: spawnSync( - 'bash', - ['-e', '-o', 'pipefail', '-c', wipeScript], - { - encoding: 'utf8', - env, - }, - ), - base, - workspace, - githubEnv: env.GITHUB_ENV, - env, - }; - }; - - // Happy path: a normal workspace inside the runner workspace is wiped, - // including subdirectories (the wipe's core property: recursive removal - // of all persisted entries, not just files). - { - const { result, base, workspace, githubEnv, env } = runWipe( - { PATH: noKillPath }, - { - preCreateWorkspace: (_base, ws) => { - writeFileSync(join(ws, 'leftover.txt'), 'stale'); - mkdirSync(join(ws, 'leftover-dir')); - writeFileSync(join(ws, 'leftover-dir', 'nested.txt'), 'stale'); - }, - }, + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain( + 'refusing to wipe workspace outside the runner workspace', ); - try { - expect(result.status).toBe(0); - const entries = readdirSync(workspace); - expect(entries).toHaveLength(0); - const stateEnv = readFileSync(githubEnv, 'utf8'); - expect(stateEnv).toContain('GIT_CONFIG_COUNT=0\n'); - expect(stateEnv).toContain('GIT_CONFIG_NOSYSTEM=1\n'); - expect(stateEnv).toContain('GIT_CONFIG_PARAMETERS=\n'); - expect(stateEnv).toMatch( - /GIT_CONFIG_GLOBAL=.*\/release-state\.[^/]+\/gitconfig\n/, - ); - expect(stateEnv).toMatch( - /NPM_CONFIG_USERCONFIG=.*\/release-state\.[^/]+\/npmrc\n/, - ); - expect(stateEnv).toMatch( - /DOCKER_CONFIG=.*\/release-state\.[^/]+\/docker\n/, - ); - expect(stateEnv).toMatch( - /GH_CONFIG_DIR=.*\/release-state\.[^/]+\/gh\n/, - ); - const isolatedEnv = { ...env }; - for (const line of stateEnv.trimEnd().split('\n')) { - const separator = line.indexOf('='); - isolatedEnv[line.slice(0, separator)] = line.slice(separator + 1); - } - expect( - spawnSync( - 'git', - ['config', '--global', '--get', 'credential.helper'], - { env: isolatedEnv }, - ).status, - ).not.toBe(0); - expect(readdirSync(isolatedEnv.DOCKER_CONFIG)).toHaveLength(0); - // The sibling tool cache SURVIVES the wipe: the sweep is scoped - // to the workspace, and the pool-wide cache stays untouched on - // purpose — the pool-routed release lane never reads it, while - // other pool lanes resolve Node from it through un-gated - // setup-node. - expect( - lstatSync(join(base, 'tool-cache', 'node')).isDirectory(), - ).toBe(true); - } finally { - rmSync(base, { recursive: true, force: true }); - } + } finally { + rmSync(outside, { recursive: true, force: true }); + rmSync(base, { recursive: true, force: true }); } + } - // Symlink heal: a workspace replaced with a symlink inside the runner - // workspace is removed and recreated, then wiped. The decoy target - // is a real file so the test can verify `rm -f` removed only the - // link itself and did not follow/delete the target. - { - const { result, base, workspace } = runWipe( - { PATH: noKillPath }, - { - preCreateWorkspace: (b, ws) => { - rmSync(ws, { recursive: true, force: true }); - const decoyTarget = join(b, 'decoy-target'); - writeFileSync(decoyTarget, 'must-survive'); - symlinkSync(decoyTarget, ws); - }, - }, + // Path with '..' that realpath resolves inside the runner workspace: + // canonicalization succeeds, containment passes, wipe proceeds. + { + const base = mkdtempSync(join(tmpdir(), 'release-wipe-dots-')); + const workspace = join(base, 'workspace'); + mkdirSync(workspace); + mkdirSync(join(base, 'sub')); + writeFileSync(join(workspace, 'leftover.txt'), 'stale'); + try { + const env = { + ...process.env, + PATH: noKillPath, + // String concatenation preserves the literal '..' segment — + // path.join would normalize it away before the script sees it. + GITHUB_WORKSPACE: `${base}/sub/../workspace`, + RUNNER_WORKSPACE: base, + RUNNER_TEMP: join(base, 'temp'), + RUNNER_TOOL_CACHE: join(base, 'tool-cache'), + GITHUB_ENV: join(base, 'github-env'), + HOME: join(base, 'home'), + XDG_CONFIG_HOME: join(base, 'home', '.config'), + }; + mkdirSync(env.HOME); + mkdirSync(env.RUNNER_TEMP); + const result = spawnSync( + 'bash', + ['-e', '-o', 'pipefail', '-c', wipeScript], + { encoding: 'utf8', env }, ); - try { - expect(result.status).toBe(0); - expect(`${result.stdout}${result.stderr}`).toContain( - 'healing workspace', - ); - const stat = lstatSync(workspace); - expect(stat.isDirectory()).toBe(true); - // The decoy target must survive: rm -f on the raw path removes - // the link itself and never follows it. - expect(readFileSync(join(base, 'decoy-target'), 'utf8')).toBe( - 'must-survive', - ); - } finally { - rmSync(base, { recursive: true, force: true }); - } - } - - // Pool geometry: the tool cache is a SIBLING of the runner workspace - // (/_work/_tool vs /_work/qwen-code) — the standard - // self-hosted layout. The wipe must leave it untouched: other pool - // lanes resolve Node from it through un-gated setup-node, while the - // pool-routed release jobs never read it. - { - const runnerRoot = mkdtempSync(join(tmpdir(), 'release-wipe-pool-')); - const rws = join(runnerRoot, '_work', 'qwen-code'); - const workspace = join(rws, 'qwen-code'); - mkdirSync(workspace, { recursive: true }); - writeFileSync(join(workspace, 'leftover.txt'), 'stale'); - try { - const env = { - ...process.env, - PATH: noKillPath, - GITHUB_WORKSPACE: workspace, - RUNNER_WORKSPACE: rws, - RUNNER_TEMP: join(runnerRoot, 'temp'), - RUNNER_TOOL_CACHE: join(runnerRoot, '_work', '_tool'), - GITHUB_ENV: join(runnerRoot, 'github-env'), - HOME: join(runnerRoot, 'home'), - XDG_CONFIG_HOME: join(runnerRoot, 'home', '.config'), - }; - mkdirSync(env.HOME); - mkdirSync(env.RUNNER_TEMP); - mkdirSync(join(env.RUNNER_TOOL_CACHE, 'node'), { recursive: true }); - writeFileSync( - join(env.RUNNER_TOOL_CACHE, 'node', 'marker.txt'), - 'pool-node', - ); - const result = spawnSync( - 'bash', - ['-e', '-o', 'pipefail', '-c', wipeScript], - { encoding: 'utf8', env }, - ); - expect(result.status).toBe(0); - expect(readdirSync(workspace)).toHaveLength(0); - // The sibling tool cache's node directory SURVIVES the wipe. - expect( - lstatSync(join(env.RUNNER_TOOL_CACHE, 'node')).isDirectory(), - ).toBe(true); - expect( - readFileSync( - join(env.RUNNER_TOOL_CACHE, 'node', 'marker.txt'), - 'utf8', - ), - ).toBe('pool-node'); - } finally { - rmSync(runnerRoot, { recursive: true, force: true }); - } + expect(result.status).toBe(0); + const entries = readdirSync(workspace); + expect(entries).toHaveLength(0); + } finally { + rmSync(base, { recursive: true, force: true }); } + } - // Workspace outside runner workspace: refused. - { - const outside = mkdtempSync(join(tmpdir(), 'release-wipe-outside-')); - const base = mkdtempSync(join(tmpdir(), 'release-wipe-runner-')); + // Process reap (R1-1): a detached survivor of a PREVIOUS pool job — + // orphaned to init exactly like a postinstall child that outlives its + // job — is killed before the file sweep runs. The orphan's parent + // chain never reaches this shell's ancestor tree, so the reap must + // reach it; the run still exits 0 and the workspace is wiped. The + // case stubs `ps` so the reap enumerates ONLY this test's orphan: + // the kill path stays genuine (a real sleeper, a real SIGKILL), but + // the suite never reaches the co-resident processes that share this + // uid on the pool, and a transient same-uid process on the member + // can no longer flake the fail-closed leg. + { + const pidFile = join( + tmpdir(), + `release-reap-pid-${process.pid}-${Date.now()}`, + ); + // setsid + an exiting parent orphans the sleeper immediately + // (reparented to init, outside the wipe shell's ancestor tree). + // The outer loop waits for the pid file so the read cannot race. + // The sleeper's stdio goes to /dev/null: a pipe inherited through + // the spawn would hold this spawnSync open until the sleeper ends. + spawnSync('bash', [ + '-c', + 'setsid bash -c \'echo $$ > "$1"; exec sleep 30\' x "$1" /dev/null 2>&1 & ' + + 'for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do ' + + '[ -s "$1" ] && exit 0; sleep 0.1; done; exit 1', + 'x', + pidFile, + pidFile, + ]); + const survivorPid = Number(readFileSync(pidFile, 'utf8').trim()); + rmSync(pidFile); + expect(survivorPid).toBeGreaterThan(1); + expect(() => process.kill(survivorPid, 0)).not.toThrow(); + // The stub's live-listing arm tracks reality (kill -0): once the + // reap kills the orphan the retry listing comes back empty, the + // way the real ps does. + const r11StubDir = mkdtempSync(join(tmpdir(), 'release-reap-r11-')); + writeFileSync( + join(r11StubDir, 'ps'), + [ + '#!/bin/bash', + '# Tree-walk probe: stop the ancestor chain at the wipe shell.', + 'case "$*" in *-p*) exit 0 ;; esac', + '# Agent-root listing: no other registration trees.', + 'case "$*" in *args=*) exit 0 ;; esac', + '# Live listing: ONLY the test orphan — never the host, whose', + '# co-resident jobs share this uid on the pool.', + 'case "$*" in *stat=*)', + ` if kill -0 ${survivorPid} 2>/dev/null; then echo "${survivorPid} 1 S orphan-sleeper"; fi`, + ' ;;', + 'esac', + 'exit 0', + '', + ].join('\n'), + ); + chmodSync(join(r11StubDir, 'ps'), 0o755); + const { result, base } = runWipe({ + PATH: `${r11StubDir}:${process.env.PATH}`, + }); + try { + expect(result.status).toBe(0); + // The orphan did not survive the reap. + expect(() => process.kill(survivorPid, 0)).toThrow(); + } finally { try { - const env = { - ...process.env, - GITHUB_WORKSPACE: outside, - RUNNER_WORKSPACE: base, - RUNNER_TEMP: join(base, 'temp'), - RUNNER_TOOL_CACHE: join(base, 'tool-cache'), - GITHUB_ENV: join(base, 'github-env'), - HOME: join(base, 'home'), - XDG_CONFIG_HOME: join(base, 'home', '.config'), - }; - mkdirSync(env.HOME); - mkdirSync(env.RUNNER_TEMP); - const result = spawnSync( - 'bash', - ['-e', '-o', 'pipefail', '-c', wipeScript], - { encoding: 'utf8', env }, - ); - expect(result.status).not.toBe(0); - expect(`${result.stdout}${result.stderr}`).toContain( - 'refusing to wipe workspace outside the runner workspace', - ); - } finally { - rmSync(outside, { recursive: true, force: true }); - rmSync(base, { recursive: true, force: true }); + process.kill(survivorPid, 'SIGKILL'); + } catch { + // already reaped — expected } + rmSync(r11StubDir, { recursive: true, force: true }); + rmSync(base, { recursive: true, force: true }); } + } - // Path with '..' that realpath resolves inside the runner workspace: - // canonicalization succeeds, containment passes, wipe proceeds. - { - const base = mkdtempSync(join(tmpdir(), 'release-wipe-dots-')); - const workspace = join(base, 'workspace'); - mkdirSync(workspace); - mkdirSync(join(base, 'sub')); - writeFileSync(join(workspace, 'leftover.txt'), 'stale'); - try { - const env = { - ...process.env, - PATH: noKillPath, - // String concatenation preserves the literal '..' segment — - // path.join would normalize it away before the script sees it. - GITHUB_WORKSPACE: `${base}/sub/../workspace`, - RUNNER_WORKSPACE: base, - RUNNER_TEMP: join(base, 'temp'), - RUNNER_TOOL_CACHE: join(base, 'tool-cache'), - GITHUB_ENV: join(base, 'github-env'), - HOME: join(base, 'home'), - XDG_CONFIG_HOME: join(base, 'home', '.config'), - }; - mkdirSync(env.HOME); - mkdirSync(env.RUNNER_TEMP); - const result = spawnSync( - 'bash', - ['-e', '-o', 'pipefail', '-c', wipeScript], - { encoding: 'utf8', env }, - ); - expect(result.status).toBe(0); - const entries = readdirSync(workspace); - expect(entries).toHaveLength(0); - } finally { - rmSync(base, { recursive: true, force: true }); - } + // Reap fail-closed (R1-1): if a process outside the agent tree + // cannot be killed, the wipe must refuse before any checkout with + // credentials and name the survivor — never proceed. A stub `ps` + // reports one unkillable fake process for the whole run. + { + const stubDir = mkdtempSync(join(tmpdir(), 'release-reap-stub-')); + writeFileSync( + join(stubDir, 'ps'), + [ + '#!/bin/bash', + '# Tree-walk probe: no parent — the kept tree is the wipe shell.', + 'case "$*" in "-o ppid= -p "*) exit 0 ;; esac', + '# Live listing: always one fake survivor outside the tree.', + 'case "$*" in *-u*) echo "424242 1 S fake-survivor-unkillable" ;;', + ' *) echo "424242 S fake-survivor-unkillable" ;; esac', + '', + ].join('\n'), + ); + chmodSync(join(stubDir, 'ps'), 0o755); + const { result, base } = runWipe({ + PATH: `${stubDir}:${process.env.PATH}`, + }); + try { + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain( + 'survived SIGKILL; refusing to run release steps with credentials', + ); + expect(`${result.stdout}${result.stderr}`).toContain( + 'surviving process: 424242', + ); + } finally { + rmSync(stubDir, { recursive: true, force: true }); + rmSync(base, { recursive: true, force: true }); } + } - // Process reap (R1-1): a detached survivor of a PREVIOUS pool job — - // orphaned to init exactly like a postinstall child that outlives its - // job — is killed before the file sweep runs. The orphan's parent - // chain never reaches this shell's ancestor tree, so the reap must - // reach it; the run still exits 0 and the workspace is wiped. The - // case stubs `ps` so the reap enumerates ONLY this test's orphan: - // the kill path stays genuine (a real sleeper, a real SIGKILL), but - // the suite never reaches the co-resident processes that share this - // uid on the pool, and a transient same-uid process on the member - // can no longer flake the fail-closed leg. - { - const pidFile = join( - tmpdir(), - `release-reap-pid-${process.pid}-${Date.now()}`, - ); - // setsid + an exiting parent orphans the sleeper immediately - // (reparented to init, outside the wipe shell's ancestor tree). - // The outer loop waits for the pid file so the read cannot race. - // The sleeper's stdio goes to /dev/null: a pipe inherited through - // the spawn would hold this spawnSync open until the sleeper ends. + // Concurrent-registration shape (R9-1): one pool member hosts every + // registration under this same uid (qwen-autofix.md af-014), so the + // keep set must reach beyond this job's own ancestor tree. The `ps` + // stub lists a second, DISJOINT agent tree — a fake registration root + // whose args match the runner pattern, plus a real bystander sleeper + // parented under it — alongside a real detached orphan. The wipe must + // kill the orphan, spare the bystander, and exit 0 rather than fail + // on the bystander as a survivor. Against the pre-fix keep set the + // bystander's chain never reaches this shell's tree, so the wipe + // SIGKILLs it and the survival assertion goes red. + { + const orphanPidFile = join( + tmpdir(), + `release-reap-orphan-${process.pid}-${Date.now()}`, + ); + const bystanderPidFile = join( + tmpdir(), + `release-reap-bystander-${process.pid}-${Date.now()}`, + ); + const spawnDetached = (pidFile) => { spawnSync('bash', [ '-c', 'setsid bash -c \'echo $$ > "$1"; exec sleep 30\' x "$1" /dev/null 2>&1 & ' + @@ -674,259 +785,148 @@ describe('release workflow', () => { pidFile, pidFile, ]); - const survivorPid = Number(readFileSync(pidFile, 'utf8').trim()); + const pid = Number(readFileSync(pidFile, 'utf8').trim()); rmSync(pidFile); - expect(survivorPid).toBeGreaterThan(1); - expect(() => process.kill(survivorPid, 0)).not.toThrow(); - // The stub's live-listing arm tracks reality (kill -0): once the - // reap kills the orphan the retry listing comes back empty, the - // way the real ps does. - const r11StubDir = mkdtempSync(join(tmpdir(), 'release-reap-r11-')); - writeFileSync( - join(r11StubDir, 'ps'), - [ - '#!/bin/bash', - '# Tree-walk probe: stop the ancestor chain at the wipe shell.', - 'case "$*" in *-p*) exit 0 ;; esac', - '# Agent-root listing: no other registration trees.', - 'case "$*" in *args=*) exit 0 ;; esac', - '# Live listing: ONLY the test orphan — never the host, whose', - '# co-resident jobs share this uid on the pool.', - 'case "$*" in *stat=*)', - ` if kill -0 ${survivorPid} 2>/dev/null; then echo "${survivorPid} 1 S orphan-sleeper"; fi`, - ' ;;', - 'esac', - 'exit 0', - '', - ].join('\n'), + expect(pid).toBeGreaterThan(1); + expect(() => process.kill(pid, 0)).not.toThrow(); + return pid; + }; + const orphanPid = spawnDetached(orphanPidFile); + const bystanderPid = spawnDetached(bystanderPidFile); + // A pid no live process holds: the stub fabricates the other + // registration's root around the real bystander. + const fakeRootPid = 3900000 + (process.pid % 100000); + const treeStubDir = mkdtempSync(join(tmpdir(), 'release-reap-tree-')); + writeFileSync( + join(treeStubDir, 'ps'), + [ + '#!/bin/bash', + '# Tree-walk probe: stop the ancestor chain at the wipe shell.', + 'case "$*" in *-p*) exit 0 ;; esac', + '# Agent-root listing: one other registration tree.', + `case "$*" in *args=*) echo "${fakeRootPid} /opt/actions-runner/bin/Runner.Listener" ;; esac`, + '# Live listing: a detached leftover, a concurrent job parented', + "# under that other tree's root, and the root itself. The", + '# orphan line tracks reality (kill -0): after the reap kills', + '# it, the retry listing must come back empty or the wipe', + '# fails closed on a phantom survivor.', + 'case "$*" in *stat=*)', + ` if kill -0 ${orphanPid} 2>/dev/null; then echo "${orphanPid} 1 S orphan-leftover"; fi`, + ` echo "${bystanderPid} ${fakeRootPid} S concurrent-job-worker"`, + ` echo "${fakeRootPid} 1 S Runner.Listener"`, + ' ;;', + 'esac', + 'exit 0', + '', + ].join('\n'), + ); + chmodSync(join(treeStubDir, 'ps'), 0o755); + const { result, base } = runWipe({ + PATH: `${treeStubDir}:${process.env.PATH}`, + }); + try { + expect(result.status).toBe(0); + expect(`${result.stdout}${result.stderr}`).not.toContain( + 'survived SIGKILL', ); - chmodSync(join(r11StubDir, 'ps'), 0o755); - const { result, base } = runWipe({ - PATH: `${r11StubDir}:${process.env.PATH}`, - }); - try { - expect(result.status).toBe(0); - // The orphan did not survive the reap. - expect(() => process.kill(survivorPid, 0)).toThrow(); - } finally { + // The detached leftover died... + expect(() => process.kill(orphanPid, 0)).toThrow(); + // ...but the concurrent job under the other registration's + // agent tree survived the reap. + expect(() => process.kill(bystanderPid, 0)).not.toThrow(); + } finally { + for (const pid of [orphanPid, bystanderPid]) { try { - process.kill(survivorPid, 'SIGKILL'); + process.kill(pid, 'SIGKILL'); } catch { // already reaped — expected } - rmSync(r11StubDir, { recursive: true, force: true }); - rmSync(base, { recursive: true, force: true }); } + rmSync(treeStubDir, { recursive: true, force: true }); + rmSync(base, { recursive: true, force: true }); } + } - // Reap fail-closed (R1-1): if a process outside the agent tree - // cannot be killed, the wipe must refuse before any checkout with - // credentials and name the survivor — never proceed. A stub `ps` - // reports one unkillable fake process for the whole run. - { - const stubDir = mkdtempSync(join(tmpdir(), 'release-reap-stub-')); - writeFileSync( - join(stubDir, 'ps'), - [ - '#!/bin/bash', - '# Tree-walk probe: no parent — the kept tree is the wipe shell.', - 'case "$*" in "-o ppid= -p "*) exit 0 ;; esac', - '# Live listing: always one fake survivor outside the tree.', - 'case "$*" in *-u*) echo "424242 1 S fake-survivor-unkillable" ;;', - ' *) echo "424242 S fake-survivor-unkillable" ;; esac', - '', - ].join('\n'), + // Symlinked runner workspace: refused BEFORE any chown/chmod/wipe — + // a prior pool job may have replaced it with a link to redirect the + // whole guard chain (heal, containment, wipe) to an attacker-chosen + // location. + { + const outside = mkdtempSync(join(tmpdir(), 'release-rws-target-')); + mkdirSync(join(outside, 'qwen-code')); + const decoy = join(outside, 'qwen-code', 'decoy.txt'); + writeFileSync(decoy, 'must-survive'); + chmodSync(decoy, 0o400); + const base = mkdtempSync(join(tmpdir(), 'release-rws-runner-')); + const rwsLink = join(base, 'rws-link'); + symlinkSync(outside, rwsLink); + try { + const env = { + ...process.env, + GITHUB_WORKSPACE: join(rwsLink, 'qwen-code'), + RUNNER_WORKSPACE: rwsLink, + RUNNER_TEMP: join(base, 'temp'), + RUNNER_TOOL_CACHE: join(base, 'tool-cache'), + GITHUB_ENV: join(base, 'github-env'), + HOME: join(base, 'home'), + XDG_CONFIG_HOME: join(base, 'home', '.config'), + }; + mkdirSync(env.HOME); + mkdirSync(env.RUNNER_TEMP); + const result = spawnSync( + 'bash', + ['-e', '-o', 'pipefail', '-c', wipeScript], + { encoding: 'utf8', env }, ); - chmodSync(join(stubDir, 'ps'), 0o755); - const { result, base } = runWipe({ - PATH: `${stubDir}:${process.env.PATH}`, - }); - try { - expect(result.status).not.toBe(0); - expect(`${result.stdout}${result.stderr}`).toContain( - 'survived SIGKILL; refusing to run release steps with credentials', - ); - expect(`${result.stdout}${result.stderr}`).toContain( - 'surviving process: 424242', - ); - } finally { - rmSync(stubDir, { recursive: true, force: true }); - rmSync(base, { recursive: true, force: true }); - } + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain( + 'refusing to wipe: runner workspace is a symlink', + ); + // Decoy intact — and the ownership ladder did not reach it. + expect(readFileSync(decoy, 'utf8')).toBe('must-survive'); + expect(lstatSync(decoy).mode & 0o777).toBe(0o400); + } finally { + rmSync(outside, { recursive: true, force: true }); + rmSync(base, { recursive: true, force: true }); } + } - // Concurrent-registration shape (R9-1): one pool member hosts every - // registration under this same uid (qwen-autofix.md af-014), so the - // keep set must reach beyond this job's own ancestor tree. The `ps` - // stub lists a second, DISJOINT agent tree — a fake registration root - // whose args match the runner pattern, plus a real bystander sleeper - // parented under it — alongside a real detached orphan. The wipe must - // kill the orphan, spare the bystander, and exit 0 rather than fail - // on the bystander as a survivor. Against the pre-fix keep set the - // bystander's chain never reaches this shell's tree, so the wipe - // SIGKILLs it and the survival assertion goes red. - { - const orphanPidFile = join( - tmpdir(), - `release-reap-orphan-${process.pid}-${Date.now()}`, - ); - const bystanderPidFile = join( - tmpdir(), - `release-reap-bystander-${process.pid}-${Date.now()}`, - ); - const spawnDetached = (pidFile) => { - spawnSync('bash', [ - '-c', - 'setsid bash -c \'echo $$ > "$1"; exec sleep 30\' x "$1" /dev/null 2>&1 & ' + - 'for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do ' + - '[ -s "$1" ] && exit 0; sleep 0.1; done; exit 1', - 'x', - pidFile, - pidFile, - ]); - const pid = Number(readFileSync(pidFile, 'utf8').trim()); - rmSync(pidFile); - expect(pid).toBeGreaterThan(1); - expect(() => process.kill(pid, 0)).not.toThrow(); - return pid; + // Same refusal when the redirected target has no qwen-code subdir: + // the heal arm must not mkdir at the attacker-chosen location. + { + const outside = mkdtempSync(join(tmpdir(), 'release-rws-empty-')); + const base = mkdtempSync(join(tmpdir(), 'release-rws-runner2-')); + const rwsLink = join(base, 'rws-link'); + symlinkSync(outside, rwsLink); + try { + const env = { + ...process.env, + GITHUB_WORKSPACE: join(rwsLink, 'qwen-code'), + RUNNER_WORKSPACE: rwsLink, + RUNNER_TEMP: join(base, 'temp'), + RUNNER_TOOL_CACHE: join(base, 'tool-cache'), + GITHUB_ENV: join(base, 'github-env'), + HOME: join(base, 'home'), + XDG_CONFIG_HOME: join(base, 'home', '.config'), }; - const orphanPid = spawnDetached(orphanPidFile); - const bystanderPid = spawnDetached(bystanderPidFile); - // A pid no live process holds: the stub fabricates the other - // registration's root around the real bystander. - const fakeRootPid = 3900000 + (process.pid % 100000); - const treeStubDir = mkdtempSync(join(tmpdir(), 'release-reap-tree-')); - writeFileSync( - join(treeStubDir, 'ps'), - [ - '#!/bin/bash', - '# Tree-walk probe: stop the ancestor chain at the wipe shell.', - 'case "$*" in *-p*) exit 0 ;; esac', - '# Agent-root listing: one other registration tree.', - `case "$*" in *args=*) echo "${fakeRootPid} /opt/actions-runner/bin/Runner.Listener" ;; esac`, - '# Live listing: a detached leftover, a concurrent job parented', - "# under that other tree's root, and the root itself. The", - '# orphan line tracks reality (kill -0): after the reap kills', - '# it, the retry listing must come back empty or the wipe', - '# fails closed on a phantom survivor.', - 'case "$*" in *stat=*)', - ` if kill -0 ${orphanPid} 2>/dev/null; then echo "${orphanPid} 1 S orphan-leftover"; fi`, - ` echo "${bystanderPid} ${fakeRootPid} S concurrent-job-worker"`, - ` echo "${fakeRootPid} 1 S Runner.Listener"`, - ' ;;', - 'esac', - 'exit 0', - '', - ].join('\n'), + mkdirSync(env.HOME); + mkdirSync(env.RUNNER_TEMP); + const result = spawnSync( + 'bash', + ['-e', '-o', 'pipefail', '-c', wipeScript], + { encoding: 'utf8', env }, ); - chmodSync(join(treeStubDir, 'ps'), 0o755); - const { result, base } = runWipe({ - PATH: `${treeStubDir}:${process.env.PATH}`, - }); - try { - expect(result.status).toBe(0); - expect(`${result.stdout}${result.stderr}`).not.toContain( - 'survived SIGKILL', - ); - // The detached leftover died... - expect(() => process.kill(orphanPid, 0)).toThrow(); - // ...but the concurrent job under the other registration's - // agent tree survived the reap. - expect(() => process.kill(bystanderPid, 0)).not.toThrow(); - } finally { - for (const pid of [orphanPid, bystanderPid]) { - try { - process.kill(pid, 'SIGKILL'); - } catch { - // already reaped — expected - } - } - rmSync(treeStubDir, { recursive: true, force: true }); - rmSync(base, { recursive: true, force: true }); - } - } - - // Symlinked runner workspace: refused BEFORE any chown/chmod/wipe — - // a prior pool job may have replaced it with a link to redirect the - // whole guard chain (heal, containment, wipe) to an attacker-chosen - // location. - { - const outside = mkdtempSync(join(tmpdir(), 'release-rws-target-')); - mkdirSync(join(outside, 'qwen-code')); - const decoy = join(outside, 'qwen-code', 'decoy.txt'); - writeFileSync(decoy, 'must-survive'); - chmodSync(decoy, 0o400); - const base = mkdtempSync(join(tmpdir(), 'release-rws-runner-')); - const rwsLink = join(base, 'rws-link'); - symlinkSync(outside, rwsLink); - try { - const env = { - ...process.env, - GITHUB_WORKSPACE: join(rwsLink, 'qwen-code'), - RUNNER_WORKSPACE: rwsLink, - RUNNER_TEMP: join(base, 'temp'), - RUNNER_TOOL_CACHE: join(base, 'tool-cache'), - GITHUB_ENV: join(base, 'github-env'), - HOME: join(base, 'home'), - XDG_CONFIG_HOME: join(base, 'home', '.config'), - }; - mkdirSync(env.HOME); - mkdirSync(env.RUNNER_TEMP); - const result = spawnSync( - 'bash', - ['-e', '-o', 'pipefail', '-c', wipeScript], - { encoding: 'utf8', env }, - ); - expect(result.status).not.toBe(0); - expect(`${result.stdout}${result.stderr}`).toContain( - 'refusing to wipe: runner workspace is a symlink', - ); - // Decoy intact — and the ownership ladder did not reach it. - expect(readFileSync(decoy, 'utf8')).toBe('must-survive'); - expect(lstatSync(decoy).mode & 0o777).toBe(0o400); - } finally { - rmSync(outside, { recursive: true, force: true }); - rmSync(base, { recursive: true, force: true }); - } - } - - // Same refusal when the redirected target has no qwen-code subdir: - // the heal arm must not mkdir at the attacker-chosen location. - { - const outside = mkdtempSync(join(tmpdir(), 'release-rws-empty-')); - const base = mkdtempSync(join(tmpdir(), 'release-rws-runner2-')); - const rwsLink = join(base, 'rws-link'); - symlinkSync(outside, rwsLink); - try { - const env = { - ...process.env, - GITHUB_WORKSPACE: join(rwsLink, 'qwen-code'), - RUNNER_WORKSPACE: rwsLink, - RUNNER_TEMP: join(base, 'temp'), - RUNNER_TOOL_CACHE: join(base, 'tool-cache'), - GITHUB_ENV: join(base, 'github-env'), - HOME: join(base, 'home'), - XDG_CONFIG_HOME: join(base, 'home', '.config'), - }; - mkdirSync(env.HOME); - mkdirSync(env.RUNNER_TEMP); - const result = spawnSync( - 'bash', - ['-e', '-o', 'pipefail', '-c', wipeScript], - { encoding: 'utf8', env }, - ); - expect(result.status).not.toBe(0); - expect(`${result.stdout}${result.stderr}`).toContain( - 'refusing to wipe: runner workspace is a symlink', - ); - expect(existsSync(join(outside, 'qwen-code'))).toBe(false); - } finally { - rmSync(outside, { recursive: true, force: true }); - rmSync(base, { recursive: true, force: true }); - } + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain( + 'refusing to wipe: runner workspace is a symlink', + ); + expect(existsSync(join(outside, 'qwen-code'))).toBe(false); + } finally { + rmSync(outside, { recursive: true, force: true }); + rmSync(base, { recursive: true, force: true }); } - }, - ); + } + }); it('checks docker availability before the docker checkout', () => { const steps = releaseYaml.jobs.integration_docker.steps; From 3c0ca80501c2c8ef083e7dff4c02ad95ac478469 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Fri, 28 Aug 2026 13:58:06 +0800 Subject: [PATCH 31/35] fix(ci): remove unsafe shared-runner process reaping Keep release validation on the conditional ECS lane while limiting workflow cleanup to job-owned filesystem state. Co-authored-by: Qwen-Coder --- .github/workflows/release.yml | 100 +------- scripts/tests/release-workflow.test.js | 342 +------------------------ 2 files changed, 13 insertions(+), 429 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a60aad0240d..f90d6b28027 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,9 +41,9 @@ on: jobs: prepare: name: 'Prepare Release Metadata' - # Route to the ECS self-hosted pool like the review/CI lanes; the - # MAINTAINER_ECS_RUNNER_DISABLED repo variable restores the hosted - # ubuntu-latest fallback without a code change. + # Process cleanup belongs to the runner service/cgroup boundary. This + # workflow only resets job-owned filesystem state on the shared pool. + # MAINTAINER_ECS_RUNNER_DISABLED restores the hosted fallback. runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}' timeout-minutes: 30 if: |- @@ -163,91 +163,6 @@ jobs: # inside the runner workspace (a symlinked leaf was healed, a symlinked # runner workspace refused), so the recursive chmod cannot escape it. chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files" - # qwen-triage.yml documents this pool's behaviour: a detached - # postinstall child of a previous job can outlive that job — - # self-hosted runners do not reap job processes — and a survivor - # with this job's uid waits out the one-shot sweep below, then - # re-plants the fresh tree, appends to `$GITHUB_ENV`, or rewrites - # the fresh release-state config files while the secret-bearing - # steps run (0700 does not exclude the owner). Kill every live - # process of this user outside the runner agent's own tree BEFORE - # the file sweep, so the sweep is not racing a live process. - # - # The exclusion is the load-bearing part: a bare `pkill -u` would - # kill the Runner.Worker executing this very step. Walk the PPID - # chain from this shell to collect the agent's ancestor tree; a - # process is kept only if its own parent chain reaches that tree - # or one of the user's other runner-agent trees (widened below). - # Zombies do not count: one has already exited and can no longer - # re-plant anything — and it cannot be killed either, so counting - # one means this check can never clear. A root runner skips the - # reap: as root every system process is killable, so no exclusion - # can make the sweep safe. - if [ "$RUNNER_UID" != "0" ]; then - REAP_USER="$(id -un)" - REAP_TREE=" $$ " - reap_pid=$$ - while :; do - reap_pid="$(ps -o ppid= -p "$reap_pid" 2>/dev/null | tr -d '[:space:]')" || break - [ -n "$reap_pid" ] || break - [ "$reap_pid" -gt 1 ] 2>/dev/null || break - REAP_TREE="${REAP_TREE}${reap_pid} " - done - # One pool member hosts every registration under this one uid - # (qwen-autofix.md af-014): a concurrent job from another - # registration never reaches this shell's chain, so the tree - # above alone would SIGKILL it mid-flight. Widen the keep set - # to every runner-agent tree of the user; a process is killed - # only if it is detached from ALL agent trees. - REAP_TREE="$REAP_TREE $(ps -u "$REAP_USER" -o pid= -o args= 2>/dev/null | awk '/runsvc\.sh|RunnerService|Runner\./ { printf "%s ", $1 }')" || true - live_outside_tree() { - # One pid per line: live processes of this user whose parent - # chain never reaches the runner agent tree, zombies dropped. - ps -o pid= -o ppid= -o stat= -u "$REAP_USER" 2>/dev/null | awk -v tree="$REAP_TREE" ' - BEGIN { - n = split(tree, t, " ") - for (i = 1; i <= n; i++) if (t[i] != "") keep[t[i]] = 1 - } - { pids[NR] = $1; pp[$1] = $2; st[$1] = $3 } - END { - for (i = 1; i <= NR; i++) { - p = pids[i] - if (st[p] ~ /^Z/) continue - q = p - inside = (q in keep) - for (d = 0; d <= NR && !inside; d++) { - if (!(q in pp)) break - q = pp[q] - if (q in keep) { inside = 1; break } - if (q <= 1) break - } - if (!inside) print p - } - }' - } - reap_pids="$(live_outside_tree)" || true - if [ -n "$reap_pids" ]; then - printf '%s\n' "$reap_pids" | xargs -r kill -KILL -- 2>/dev/null || true - fi - for _ in 1 2 3; do - reap_pids="$(live_outside_tree)" || true - [ -n "$reap_pids" ] || break - sleep 1 - printf '%s\n' "$reap_pids" | xargs -r kill -KILL -- 2>/dev/null || true - done - survivors="$(live_outside_tree)" || true - if [ -n "$survivors" ]; then - # Name them: a bare "processes survived" refusal leaves a real - # threat and a harmless leftover indistinguishable — including - # to the person reading the failure. - echo "::error::Processes of the runner user survived SIGKILL; refusing to run release steps with credentials." - printf '%s\n' "$survivors" | while IFS= read -r survivor_pid; do - [ -n "$survivor_pid" ] || continue - ps -o pid= -o stat= -o args= -p "$survivor_pid" 2>/dev/null | sed 's/^/::error:: surviving process: /' || true - done - exit 1 - fi - fi find "$WS" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + # Later steps must not read pool-persistent Git, npm, Docker, or # gh state. A fresh directory avoids an unbounded scrub denylist @@ -380,9 +295,6 @@ jobs: quality: name: 'Quality Checks' - # Route to the ECS self-hosted pool like the review/CI lanes; the - # MAINTAINER_ECS_RUNNER_DISABLED repo variable restores the hosted - # ubuntu-latest fallback without a code change. runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}' timeout-minutes: 120 needs: 'prepare' @@ -459,9 +371,6 @@ jobs: integration_none: name: 'Integration Tests (No Sandbox)' - # Route to the ECS self-hosted pool like the review/CI lanes; the - # MAINTAINER_ECS_RUNNER_DISABLED repo variable restores the hosted - # ubuntu-latest fallback without a code change. runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}' timeout-minutes: 120 needs: 'prepare' @@ -527,9 +436,6 @@ jobs: integration_docker: name: 'Integration Tests (Docker)' - # Route to the ECS self-hosted pool like the review/CI lanes; the - # MAINTAINER_ECS_RUNNER_DISABLED repo variable restores the hosted - # ubuntu-latest fallback without a code change. runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}' timeout-minutes: 120 needs: 'prepare' diff --git a/scripts/tests/release-workflow.test.js b/scripts/tests/release-workflow.test.js index 0c61d83e469..2b9bc14af22 100644 --- a/scripts/tests/release-workflow.test.js +++ b/scripts/tests/release-workflow.test.js @@ -19,7 +19,7 @@ import { } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { describe, expect, it, onTestFinished } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { parse } from 'yaml'; // `realpath -m` (the script's canonicalization line) is a GNU coreutils @@ -203,91 +203,6 @@ fi # inside the runner workspace (a symlinked leaf was healed, a symlinked # runner workspace refused), so the recursive chmod cannot escape it. chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files" -# qwen-triage.yml documents this pool's behaviour: a detached -# postinstall child of a previous job can outlive that job — -# self-hosted runners do not reap job processes — and a survivor -# with this job's uid waits out the one-shot sweep below, then -# re-plants the fresh tree, appends to \`$GITHUB_ENV\`, or rewrites -# the fresh release-state config files while the secret-bearing -# steps run (0700 does not exclude the owner). Kill every live -# process of this user outside the runner agent's own tree BEFORE -# the file sweep, so the sweep is not racing a live process. -# -# The exclusion is the load-bearing part: a bare \`pkill -u\` would -# kill the Runner.Worker executing this very step. Walk the PPID -# chain from this shell to collect the agent's ancestor tree; a -# process is kept only if its own parent chain reaches that tree -# or one of the user's other runner-agent trees (widened below). -# Zombies do not count: one has already exited and can no longer -# re-plant anything — and it cannot be killed either, so counting -# one means this check can never clear. A root runner skips the -# reap: as root every system process is killable, so no exclusion -# can make the sweep safe. -if [ "$RUNNER_UID" != "0" ]; then - REAP_USER="$(id -un)" - REAP_TREE=" $$ " - reap_pid=$$ - while :; do - reap_pid="$(ps -o ppid= -p "$reap_pid" 2>/dev/null | tr -d '[:space:]')" || break - [ -n "$reap_pid" ] || break - [ "$reap_pid" -gt 1 ] 2>/dev/null || break - REAP_TREE="\${REAP_TREE}\${reap_pid} " - done - # One pool member hosts every registration under this one uid - # (qwen-autofix.md af-014): a concurrent job from another - # registration never reaches this shell's chain, so the tree - # above alone would SIGKILL it mid-flight. Widen the keep set - # to every runner-agent tree of the user; a process is killed - # only if it is detached from ALL agent trees. - REAP_TREE="$REAP_TREE $(ps -u "$REAP_USER" -o pid= -o args= 2>/dev/null | awk '/runsvc\\.sh|RunnerService|Runner\\./ { printf "%s ", $1 }')" || true - live_outside_tree() { - # One pid per line: live processes of this user whose parent - # chain never reaches the runner agent tree, zombies dropped. - ps -o pid= -o ppid= -o stat= -u "$REAP_USER" 2>/dev/null | awk -v tree="$REAP_TREE" ' - BEGIN { - n = split(tree, t, " ") - for (i = 1; i <= n; i++) if (t[i] != "") keep[t[i]] = 1 - } - { pids[NR] = $1; pp[$1] = $2; st[$1] = $3 } - END { - for (i = 1; i <= NR; i++) { - p = pids[i] - if (st[p] ~ /^Z/) continue - q = p - inside = (q in keep) - for (d = 0; d <= NR && !inside; d++) { - if (!(q in pp)) break - q = pp[q] - if (q in keep) { inside = 1; break } - if (q <= 1) break - } - if (!inside) print p - } - }' - } - reap_pids="$(live_outside_tree)" || true - if [ -n "$reap_pids" ]; then - printf '%s\\n' "$reap_pids" | xargs -r kill -KILL -- 2>/dev/null || true - fi - for _ in 1 2 3; do - reap_pids="$(live_outside_tree)" || true - [ -n "$reap_pids" ] || break - sleep 1 - printf '%s\\n' "$reap_pids" | xargs -r kill -KILL -- 2>/dev/null || true - done - survivors="$(live_outside_tree)" || true - if [ -n "$survivors" ]; then - # Name them: a bare "processes survived" refusal leaves a real - # threat and a harmless leftover indistinguishable — including - # to the person reading the failure. - echo "::error::Processes of the runner user survived SIGKILL; refusing to run release steps with credentials." - printf '%s\\n' "$survivors" | while IFS= read -r survivor_pid; do - [ -n "$survivor_pid" ] || continue - ps -o pid= -o stat= -o args= -p "$survivor_pid" 2>/dev/null | sed 's/^/::error:: surviving process: /' || true - done - exit 1 - fi -fi find "$WS" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + # Later steps must not read pool-persistent Git, npm, Docker, or # gh state. A fresh directory avoids an unbounded scrub denylist @@ -360,33 +275,15 @@ describe('release workflow', () => { } }); - // The gate also closes on win32: Git for Windows ships GNU realpath - // (so hasGnuRealpath is true on the hosted windows-2022 fallback lane - // ci.yml's test_windows uses whenever MAINTAINER_ECS_RUNNER_DISABLED=true), - // but PortableGit ships no setsid — the reap fixture would never write - // its pid file there and readFileSync(pidFile) would throw ENOENT. + it('keeps workspace cleanup from inspecting or signaling host processes', () => { + expect(canonicalWipe).not.toMatch(/(?:^|\s)(?:ps|kill|pkill)\s/m); + }); + it.skipIf( !hasGnuRealpath || process.getuid?.() === 0 || process.platform === 'win32', )('executes the workspace wipe against guard branches', () => { const wipeScript = canonicalWipe; - // The guard-branch cases below assert geometry and env isolation, not - // the reap — but the wipe's reap is genuine: it kills every live - // process of the runner user outside the step's ancestor tree. On the - // shared ECS pool that would reach into whatever job is co-resident - // on the same member under this uid every time the suite runs. Give - // the guard-branch cases a `ps` that lists nothing so the reap branch - // executes end to end yet kills nothing; the R1-1 case stubs `ps` to - // enumerate only its own orphan (real kill, no host enumeration), and - // the fail-closed case stubs its own `ps`. - const noKillStubDir = mkdtempSync(join(tmpdir(), 'release-reap-nokill-')); - writeFileSync(join(noKillStubDir, 'ps'), '#!/bin/bash\nexit 0\n'); - chmodSync(join(noKillStubDir, 'ps'), 0o755); - const noKillPath = `${noKillStubDir}:${process.env.PATH}`; - onTestFinished(() => - rmSync(noKillStubDir, { recursive: true, force: true }), - ); - const runWipe = (envOverrides, { preCreateWorkspace } = {}) => { const base = mkdtempSync(join(tmpdir(), 'release-wipe-behavioral-')); const workspace = join(base, 'workspace'); @@ -437,7 +334,7 @@ describe('release workflow', () => { // of all persisted entries, not just files). { const { result, base, workspace, githubEnv, env } = runWipe( - { PATH: noKillPath }, + {}, { preCreateWorkspace: (_base, ws) => { writeFileSync(join(ws, 'leftover.txt'), 'stale'); @@ -498,7 +395,7 @@ describe('release workflow', () => { // link itself and did not follow/delete the target. { const { result, base, workspace } = runWipe( - { PATH: noKillPath }, + {}, { preCreateWorkspace: (b, ws) => { rmSync(ws, { recursive: true, force: true }); @@ -539,7 +436,6 @@ describe('release workflow', () => { try { const env = { ...process.env, - PATH: noKillPath, GITHUB_WORKSPACE: workspace, RUNNER_WORKSPACE: rws, RUNNER_TEMP: join(runnerRoot, 'temp'), @@ -620,7 +516,6 @@ describe('release workflow', () => { try { const env = { ...process.env, - PATH: noKillPath, // String concatenation preserves the literal '..' segment — // path.join would normalize it away before the script sees it. GITHUB_WORKSPACE: `${base}/sub/../workspace`, @@ -646,207 +541,6 @@ describe('release workflow', () => { } } - // Process reap (R1-1): a detached survivor of a PREVIOUS pool job — - // orphaned to init exactly like a postinstall child that outlives its - // job — is killed before the file sweep runs. The orphan's parent - // chain never reaches this shell's ancestor tree, so the reap must - // reach it; the run still exits 0 and the workspace is wiped. The - // case stubs `ps` so the reap enumerates ONLY this test's orphan: - // the kill path stays genuine (a real sleeper, a real SIGKILL), but - // the suite never reaches the co-resident processes that share this - // uid on the pool, and a transient same-uid process on the member - // can no longer flake the fail-closed leg. - { - const pidFile = join( - tmpdir(), - `release-reap-pid-${process.pid}-${Date.now()}`, - ); - // setsid + an exiting parent orphans the sleeper immediately - // (reparented to init, outside the wipe shell's ancestor tree). - // The outer loop waits for the pid file so the read cannot race. - // The sleeper's stdio goes to /dev/null: a pipe inherited through - // the spawn would hold this spawnSync open until the sleeper ends. - spawnSync('bash', [ - '-c', - 'setsid bash -c \'echo $$ > "$1"; exec sleep 30\' x "$1" /dev/null 2>&1 & ' + - 'for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do ' + - '[ -s "$1" ] && exit 0; sleep 0.1; done; exit 1', - 'x', - pidFile, - pidFile, - ]); - const survivorPid = Number(readFileSync(pidFile, 'utf8').trim()); - rmSync(pidFile); - expect(survivorPid).toBeGreaterThan(1); - expect(() => process.kill(survivorPid, 0)).not.toThrow(); - // The stub's live-listing arm tracks reality (kill -0): once the - // reap kills the orphan the retry listing comes back empty, the - // way the real ps does. - const r11StubDir = mkdtempSync(join(tmpdir(), 'release-reap-r11-')); - writeFileSync( - join(r11StubDir, 'ps'), - [ - '#!/bin/bash', - '# Tree-walk probe: stop the ancestor chain at the wipe shell.', - 'case "$*" in *-p*) exit 0 ;; esac', - '# Agent-root listing: no other registration trees.', - 'case "$*" in *args=*) exit 0 ;; esac', - '# Live listing: ONLY the test orphan — never the host, whose', - '# co-resident jobs share this uid on the pool.', - 'case "$*" in *stat=*)', - ` if kill -0 ${survivorPid} 2>/dev/null; then echo "${survivorPid} 1 S orphan-sleeper"; fi`, - ' ;;', - 'esac', - 'exit 0', - '', - ].join('\n'), - ); - chmodSync(join(r11StubDir, 'ps'), 0o755); - const { result, base } = runWipe({ - PATH: `${r11StubDir}:${process.env.PATH}`, - }); - try { - expect(result.status).toBe(0); - // The orphan did not survive the reap. - expect(() => process.kill(survivorPid, 0)).toThrow(); - } finally { - try { - process.kill(survivorPid, 'SIGKILL'); - } catch { - // already reaped — expected - } - rmSync(r11StubDir, { recursive: true, force: true }); - rmSync(base, { recursive: true, force: true }); - } - } - - // Reap fail-closed (R1-1): if a process outside the agent tree - // cannot be killed, the wipe must refuse before any checkout with - // credentials and name the survivor — never proceed. A stub `ps` - // reports one unkillable fake process for the whole run. - { - const stubDir = mkdtempSync(join(tmpdir(), 'release-reap-stub-')); - writeFileSync( - join(stubDir, 'ps'), - [ - '#!/bin/bash', - '# Tree-walk probe: no parent — the kept tree is the wipe shell.', - 'case "$*" in "-o ppid= -p "*) exit 0 ;; esac', - '# Live listing: always one fake survivor outside the tree.', - 'case "$*" in *-u*) echo "424242 1 S fake-survivor-unkillable" ;;', - ' *) echo "424242 S fake-survivor-unkillable" ;; esac', - '', - ].join('\n'), - ); - chmodSync(join(stubDir, 'ps'), 0o755); - const { result, base } = runWipe({ - PATH: `${stubDir}:${process.env.PATH}`, - }); - try { - expect(result.status).not.toBe(0); - expect(`${result.stdout}${result.stderr}`).toContain( - 'survived SIGKILL; refusing to run release steps with credentials', - ); - expect(`${result.stdout}${result.stderr}`).toContain( - 'surviving process: 424242', - ); - } finally { - rmSync(stubDir, { recursive: true, force: true }); - rmSync(base, { recursive: true, force: true }); - } - } - - // Concurrent-registration shape (R9-1): one pool member hosts every - // registration under this same uid (qwen-autofix.md af-014), so the - // keep set must reach beyond this job's own ancestor tree. The `ps` - // stub lists a second, DISJOINT agent tree — a fake registration root - // whose args match the runner pattern, plus a real bystander sleeper - // parented under it — alongside a real detached orphan. The wipe must - // kill the orphan, spare the bystander, and exit 0 rather than fail - // on the bystander as a survivor. Against the pre-fix keep set the - // bystander's chain never reaches this shell's tree, so the wipe - // SIGKILLs it and the survival assertion goes red. - { - const orphanPidFile = join( - tmpdir(), - `release-reap-orphan-${process.pid}-${Date.now()}`, - ); - const bystanderPidFile = join( - tmpdir(), - `release-reap-bystander-${process.pid}-${Date.now()}`, - ); - const spawnDetached = (pidFile) => { - spawnSync('bash', [ - '-c', - 'setsid bash -c \'echo $$ > "$1"; exec sleep 30\' x "$1" /dev/null 2>&1 & ' + - 'for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do ' + - '[ -s "$1" ] && exit 0; sleep 0.1; done; exit 1', - 'x', - pidFile, - pidFile, - ]); - const pid = Number(readFileSync(pidFile, 'utf8').trim()); - rmSync(pidFile); - expect(pid).toBeGreaterThan(1); - expect(() => process.kill(pid, 0)).not.toThrow(); - return pid; - }; - const orphanPid = spawnDetached(orphanPidFile); - const bystanderPid = spawnDetached(bystanderPidFile); - // A pid no live process holds: the stub fabricates the other - // registration's root around the real bystander. - const fakeRootPid = 3900000 + (process.pid % 100000); - const treeStubDir = mkdtempSync(join(tmpdir(), 'release-reap-tree-')); - writeFileSync( - join(treeStubDir, 'ps'), - [ - '#!/bin/bash', - '# Tree-walk probe: stop the ancestor chain at the wipe shell.', - 'case "$*" in *-p*) exit 0 ;; esac', - '# Agent-root listing: one other registration tree.', - `case "$*" in *args=*) echo "${fakeRootPid} /opt/actions-runner/bin/Runner.Listener" ;; esac`, - '# Live listing: a detached leftover, a concurrent job parented', - "# under that other tree's root, and the root itself. The", - '# orphan line tracks reality (kill -0): after the reap kills', - '# it, the retry listing must come back empty or the wipe', - '# fails closed on a phantom survivor.', - 'case "$*" in *stat=*)', - ` if kill -0 ${orphanPid} 2>/dev/null; then echo "${orphanPid} 1 S orphan-leftover"; fi`, - ` echo "${bystanderPid} ${fakeRootPid} S concurrent-job-worker"`, - ` echo "${fakeRootPid} 1 S Runner.Listener"`, - ' ;;', - 'esac', - 'exit 0', - '', - ].join('\n'), - ); - chmodSync(join(treeStubDir, 'ps'), 0o755); - const { result, base } = runWipe({ - PATH: `${treeStubDir}:${process.env.PATH}`, - }); - try { - expect(result.status).toBe(0); - expect(`${result.stdout}${result.stderr}`).not.toContain( - 'survived SIGKILL', - ); - // The detached leftover died... - expect(() => process.kill(orphanPid, 0)).toThrow(); - // ...but the concurrent job under the other registration's - // agent tree survived the reap. - expect(() => process.kill(bystanderPid, 0)).not.toThrow(); - } finally { - for (const pid of [orphanPid, bystanderPid]) { - try { - process.kill(pid, 'SIGKILL'); - } catch { - // already reaped — expected - } - } - rmSync(treeStubDir, { recursive: true, force: true }); - rmSync(base, { recursive: true, force: true }); - } - } - // Symlinked runner workspace: refused BEFORE any chown/chmod/wipe — // a prior pool job may have replaced it with a link to redirect the // whole guard chain (heal, containment, wipe) to an attacker-chosen @@ -1249,27 +943,19 @@ describe('Live Host feed contract', () => { }); describe('release lane runner routing', () => { - // The exact conditional runs-on the ECS migration routes the release - // lanes through: repository guard + MAINTAINER_ECS_RUNNER_DISABLED kill - // switch + both the ecs-qwen and ubuntu-latest branches. Same pinning - // shape as qwen-autofix-workflow.test.js's heavy-job runs-on tripwire. const ecsRunsOn = '${{ (github.repository == \'QwenLM/qwen-code\' && vars.MAINTAINER_ECS_RUNNER_DISABLED != \'true\') && fromJSON(\'["self-hosted", "linux", "x64", "ecs-qwen"]\') || fromJSON(\'["ubuntu-latest"]\') }}'; - it('pins every routed release job to the conditional ECS runs-on', () => { - const conditionalJobs = [ + it('routes validation jobs to ECS with a hosted emergency fallback', () => { + const validationJobs = [ 'prepare', 'quality', 'integration_none', 'integration_docker', ]; - for (const name of conditionalJobs) { + for (const name of validationJobs) { const job = releaseYaml.jobs[name]; expect(job, `job missing from release.yml: ${name}`).toBeTruthy(); - // Reverting any lane to a hosted runner, dropping the kill-switch - // clause, or typoing the expression must fail here — an unpinned - // revert would silently re-pin releases to hosted capacity (the - // stall this migration exists to fix) or defeat the kill switch. expect(job['runs-on'], `runs-on drifted on job: ${name}`).toBe(ecsRunsOn); } }); @@ -1277,14 +963,6 @@ describe('release lane runner routing', () => { it('keeps publishing and failure notification on hosted runners', () => { expect(releaseYaml.jobs.publish['runs-on']).toBe('ubuntu-latest'); expect(releaseYaml.jobs.publish['runs-on']).not.toContain('ecs-qwen'); - - // notify_failure exists to report failures OF the ECS pool; in - // post-claim pool failures (runner crash, pool-wide loss) its `if:` - // gate opens while the pool is wedged, so routing it back onto the - // pool would kill the alert chain. The job is gh/jq-only, so hosted - // capacity costs nothing — sibling failure notifiers (release-sdk*, - // release-vscode-companion, qwen-code-pr-review's fallback comment) - // stay hosted for exactly this reason. expect(releaseYaml.jobs.notify_failure['runs-on']).toBe('ubuntu-latest'); expect(releaseYaml.jobs.notify_failure['runs-on']).not.toContain( 'ecs-qwen', From 3dd2329cc426a1a18db9436e195022874475bd7e Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Fri, 28 Aug 2026 19:14:53 +0800 Subject: [PATCH 32/35] chore(ci): drop prettier reflow noise from teamHelpers.test.ts Restore the type-annotation formatting from origin/main so this PR contains zero non-CI hunks. CI's prettier step is write-only (prettier --write ., no drift check), same as main carries today, so this revert is CI-neutral. Co-authored-by: Qwen-Coder --- packages/core/src/agents/team/teamHelpers.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/core/src/agents/team/teamHelpers.test.ts b/packages/core/src/agents/team/teamHelpers.test.ts index 9a0d23e3a28..5c46931c721 100644 --- a/packages/core/src/agents/team/teamHelpers.test.ts +++ b/packages/core/src/agents/team/teamHelpers.test.ts @@ -53,7 +53,9 @@ vi.mock('../../config/storage.js', async (importOriginal) => { // otherwise the real readFile runs. vi.mock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal(); - type ReadFileHook = (...args: Parameters) => unknown; + type ReadFileHook = ( + ...args: Parameters + ) => unknown; let readFileHook: ReadFileHook | undefined; return { ...actual, From 1ae358899388a5ec426df627f5dca88ec960e7b3 Mon Sep 17 00:00:00 2001 From: qwen-code-ci-bot Date: Fri, 28 Aug 2026 17:07:34 +0000 Subject: [PATCH 33/35] fix(ci): re-arm the release.yml size ratchet at its true floor (#10036) The reap removal (3c0ca80501) shrank release.yml from 58255 to 53022 bytes after the baseline entry was written, banking 5233 bytes of unreviewed ratchet headroom inside the gate's 20000-byte slack band. Lower the entry to the true floor so future growth past the allowance is reviewed again. Gate and ratchet mirror tests stay green; the probe value actual-4097 reddens the mirror. Co-authored-by: Qwen-Coder --- .github/workflows/.size-baseline | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/.size-baseline b/.github/workflows/.size-baseline index ecb00b3f19e..5155138a6fe 100644 --- a/.github/workflows/.size-baseline +++ b/.github/workflows/.size-baseline @@ -46,7 +46,7 @@ 22037 release-sdk-python.yml 19094 release-sdk.yml 14546 release-vscode-companion.yml -58255 release.yml +53022 release.yml 43717 repo-hygiene.yml 1079 scorecard-monthly.yml 10691 sdk-java.yml From 88eea3f4e82662460802ecf0dbf47eca7184a9b2 Mon Sep 17 00:00:00 2001 From: qwen-code-ci-bot Date: Fri, 28 Aug 2026 21:04:51 +0000 Subject: [PATCH 34/35] fix(ci): strip RUNNER_WORKSPACE trailing slashes before the symlink refusal (#10036) --- .github/workflows/release.yml | 1 + scripts/tests/release-workflow.test.js | 46 ++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bb27e43a98e..92cbe69ae50 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -88,6 +88,7 @@ jobs: # symlink, so refuse one outright; and no ownership/permission change # may run on a path the containment below has not accepted. RWS="${RUNNER_WORKSPACE:?}" + while [ "${RWS%/}" != "$RWS" ]; do RWS="${RWS%/}"; done if [ -L "$RWS" ]; then echo "::error::refusing to wipe: runner workspace is a symlink: ${RWS}" exit 1 diff --git a/scripts/tests/release-workflow.test.js b/scripts/tests/release-workflow.test.js index d9a979b63b6..d6a46c53a45 100644 --- a/scripts/tests/release-workflow.test.js +++ b/scripts/tests/release-workflow.test.js @@ -170,6 +170,7 @@ const canonicalWipe = `set -uo pipefail # symlink, so refuse one outright; and no ownership/permission change # may run on a path the containment below has not accepted. RWS="\${RUNNER_WORKSPACE:?}" +while [ "\${RWS%/}" != "$RWS" ]; do RWS="\${RWS%/}"; done if [ -L "$RWS" ]; then echo "::error::refusing to wipe: runner workspace is a symlink: \${RWS}" exit 1 @@ -662,6 +663,51 @@ describe('release workflow', () => { rmSync(base, { recursive: true, force: true }); } } + + // Trailing slash on a symlinked runner workspace: [ -L ] does not see + // the link through a trailing slash — path resolution dereferences it — + // while realpath -m canonicalizes THROUGH it, re-rooting the containment + // allow-list at the link's target. The raw value must be stripped + // before the -L test (the GITHUB_WORKSPACE side's ordering), or a + // mangled env carrying one trailing slash defeats the refusal. + { + const outside = mkdtempSync(join(tmpdir(), 'release-rws-slash-')); + mkdirSync(join(outside, 'qwen-code')); + const decoy = join(outside, 'qwen-code', 'decoy.txt'); + writeFileSync(decoy, 'must-survive'); + const base = mkdtempSync(join(tmpdir(), 'release-rws-slash-runner-')); + const rwsLink = join(base, 'rws-link'); + symlinkSync(outside, rwsLink); + try { + const env = { + ...process.env, + GITHUB_WORKSPACE: join(rwsLink, 'qwen-code'), + // String concatenation keeps the literal trailing slash — + // path.join would normalize it away before the script sees it. + RUNNER_WORKSPACE: `${rwsLink}/`, + RUNNER_TEMP: join(base, 'temp'), + RUNNER_TOOL_CACHE: join(base, 'tool-cache'), + GITHUB_ENV: join(base, 'github-env'), + HOME: join(base, 'home'), + XDG_CONFIG_HOME: join(base, 'home', '.config'), + }; + mkdirSync(env.HOME); + mkdirSync(env.RUNNER_TEMP); + const result = spawnSync( + 'bash', + ['-e', '-o', 'pipefail', '-c', wipeScript], + { encoding: 'utf8', env }, + ); + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain( + 'refusing to wipe: runner workspace is a symlink', + ); + expect(readFileSync(decoy, 'utf8')).toBe('must-survive'); + } finally { + rmSync(outside, { recursive: true, force: true }); + rmSync(base, { recursive: true, force: true }); + } + } }); it('checks docker availability before the docker checkout', () => { From c6bea8a6f47d57b9cc4a223e8cad531a831e9ae0 Mon Sep 17 00:00:00 2001 From: qwen-code-ci-bot Date: Fri, 28 Aug 2026 23:57:31 +0000 Subject: [PATCH 35/35] fix(ci): refuse symlinked components anywhere in the wipe path (#10036) --- .github/workflows/release.yml | 18 +++ scripts/tests/release-workflow.test.js | 151 ++++++++++++++++++++++++- 2 files changed, 168 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 92cbe69ae50..ca9dd9308d0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -93,7 +93,17 @@ jobs: echo "::error::refusing to wipe: runner workspace is a symlink: ${RWS}" exit 1 fi + # `-L` only sees the LEAF: the kernel resolves intermediate + # components too, so compare the symlink-blind lexical form + # against the full canonicalization — any difference means some + # component was a symlink re-rooting the whole chain below + # (heal, allow-list, wipe) at the link's target. + RWS_LEX="$(realpath -m -s -- "$RWS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${RUNNER_WORKSPACE}"; exit 1; } RWS="$(realpath -m -- "$RWS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${RUNNER_WORKSPACE}"; exit 1; } + if [ "$RWS" != "$RWS_LEX" ]; then + echo "::error::refusing to wipe: runner workspace resolves through a symlinked component: ${RWS_LEX} resolves to ${RWS}" + exit 1 + fi while [ "${RWS%/}" != "$RWS" ]; do RWS="${RWS%/}"; done if [ -z "$RWS" ]; then echo "::error::refusing to wipe: runner workspace resolved to /"; exit 1; fi case "$RWS" in @@ -137,7 +147,15 @@ jobs: rm -f -- "$WS" || { echo "::error::refusing to continue: could not remove ${WS}"; exit 1; } mkdir -- "$WS" || { echo "::error::refusing to continue: could not recreate ${WS}"; exit 1; } fi + # Heal only guarantees the LEAF is real; a symlinked component + # between the runner workspace and the leaf re-roots the + # containment below the same way, so apply the same comparison. + WS_LEX="$(realpath -m -s -- "$WS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${GITHUB_WORKSPACE}"; exit 1; } WS="$(realpath -m -- "$WS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize ${GITHUB_WORKSPACE}"; exit 1; } + if [ "$WS" != "$WS_LEX" ]; then + echo "::error::refusing to wipe: workspace resolves through a symlinked component: ${WS_LEX} resolves to ${WS}" + exit 1 + fi while [ "${WS%/}" != "$WS" ]; do WS="${WS%/}"; done case "$WS" in ..|../*|*/..|*/../*) echo "::error::refusing to wipe path containing '..': ${WS}"; exit 1 ;; diff --git a/scripts/tests/release-workflow.test.js b/scripts/tests/release-workflow.test.js index d6a46c53a45..e3435669e6d 100644 --- a/scripts/tests/release-workflow.test.js +++ b/scripts/tests/release-workflow.test.js @@ -25,7 +25,9 @@ import { parse } from 'yaml'; // `realpath -m` (the script's canonicalization line) is a GNU coreutils // extension. Probe the host before asserting GNU-specific path behavior. const hasGnuRealpath = - spawnSync('realpath', ['-m', '--', '/'], { stdio: 'ignore' }).status === 0; + spawnSync('realpath', ['-m', '--', '/'], { stdio: 'ignore' }).status === 0 && + spawnSync('realpath', ['-m', '-s', '--', '/'], { stdio: 'ignore' }).status === + 0; const workflow = readFileSync('.github/workflows/release.yml', 'utf8'); const releaseYaml = parse(workflow); @@ -175,7 +177,17 @@ if [ -L "$RWS" ]; then echo "::error::refusing to wipe: runner workspace is a symlink: \${RWS}" exit 1 fi +# \`-L\` only sees the LEAF: the kernel resolves intermediate +# components too, so compare the symlink-blind lexical form +# against the full canonicalization — any difference means some +# component was a symlink re-rooting the whole chain below +# (heal, allow-list, wipe) at the link's target. +RWS_LEX="$(realpath -m -s -- "$RWS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize \${RUNNER_WORKSPACE}"; exit 1; } RWS="$(realpath -m -- "$RWS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize \${RUNNER_WORKSPACE}"; exit 1; } +if [ "$RWS" != "$RWS_LEX" ]; then + echo "::error::refusing to wipe: runner workspace resolves through a symlinked component: \${RWS_LEX} resolves to \${RWS}" + exit 1 +fi while [ "\${RWS%/}" != "$RWS" ]; do RWS="\${RWS%/}"; done if [ -z "$RWS" ]; then echo "::error::refusing to wipe: runner workspace resolved to /"; exit 1; fi case "$RWS" in @@ -219,7 +231,15 @@ if [ -L "$WS" ] || [ ! -d "$WS" ]; then rm -f -- "$WS" || { echo "::error::refusing to continue: could not remove \${WS}"; exit 1; } mkdir -- "$WS" || { echo "::error::refusing to continue: could not recreate \${WS}"; exit 1; } fi +# Heal only guarantees the LEAF is real; a symlinked component +# between the runner workspace and the leaf re-roots the +# containment below the same way, so apply the same comparison. +WS_LEX="$(realpath -m -s -- "$WS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize \${GITHUB_WORKSPACE}"; exit 1; } WS="$(realpath -m -- "$WS" 2>/dev/null)" || { echo "::error::refusing to wipe: realpath unavailable, cannot canonicalize \${GITHUB_WORKSPACE}"; exit 1; } +if [ "$WS" != "$WS_LEX" ]; then + echo "::error::refusing to wipe: workspace resolves through a symlinked component: \${WS_LEX} resolves to \${WS}" + exit 1 +fi while [ "\${WS%/}" != "$WS" ]; do WS="\${WS%/}"; done case "$WS" in ..|../*|*/..|*/../*) echo "::error::refusing to wipe path containing '..': \${WS}"; exit 1 ;; @@ -708,6 +728,135 @@ describe('release workflow', () => { rmSync(base, { recursive: true, force: true }); } } + + // Symlinked INTERMEDIATE runner-workspace component: [ -L ] only + // sees the leaf, so a `_work` replaced with a link passes it, and + // realpath then re-roots the whole chain (heal, containment, wipe) + // at the link's target — the leaf test alone accepts the geometry + // and wipes the attacker-chosen tree. The lexical-vs-canonical + // comparison must refuse BEFORE any chown/chmod/wipe. + { + const outside = mkdtempSync(join(tmpdir(), 'release-rws-mid-')); + mkdirSync(join(outside, 'qwen-code', 'qwen-code'), { recursive: true }); + const decoy = join(outside, 'qwen-code', 'qwen-code', 'decoy.txt'); + writeFileSync(decoy, 'must-survive'); + chmodSync(decoy, 0o400); + const runnerRoot = mkdtempSync(join(tmpdir(), 'release-rws-mid-runner-')); + symlinkSync(outside, join(runnerRoot, '_work')); + const rws = join(runnerRoot, '_work', 'qwen-code'); + try { + const env = { + ...process.env, + GITHUB_WORKSPACE: join(rws, 'qwen-code'), + RUNNER_WORKSPACE: rws, + RUNNER_TEMP: join(runnerRoot, 'temp'), + RUNNER_TOOL_CACHE: join(runnerRoot, 'tool-cache'), + GITHUB_ENV: join(runnerRoot, 'github-env'), + HOME: join(runnerRoot, 'home'), + XDG_CONFIG_HOME: join(runnerRoot, 'home', '.config'), + }; + mkdirSync(env.HOME); + mkdirSync(env.RUNNER_TEMP); + const result = spawnSync( + 'bash', + ['-e', '-o', 'pipefail', '-c', wipeScript], + { encoding: 'utf8', env }, + ); + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain( + 'refusing to wipe: runner workspace resolves through a symlinked component', + ); + // Decoy intact — and the ownership ladder did not reach it. + expect(readFileSync(decoy, 'utf8')).toBe('must-survive'); + expect(lstatSync(decoy).mode & 0o777).toBe(0o400); + expect(existsSync(env.GITHUB_ENV)).toBe(false); + } finally { + rmSync(outside, { recursive: true, force: true }); + rmSync(runnerRoot, { recursive: true, force: true }); + } + } + + // Same refusal when the redirected target lacks the leaf: without + // the comparison the heal arm would judge the re-rooted parent + // INSIDE the re-rooted runner workspace and mkdir the leaf at the + // attacker-chosen location. + { + const outside = mkdtempSync(join(tmpdir(), 'release-rws-mid-empty-')); + mkdirSync(join(outside, 'qwen-code')); + const runnerRoot = mkdtempSync( + join(tmpdir(), 'release-rws-mid-empty-runner-'), + ); + symlinkSync(outside, join(runnerRoot, '_work')); + const rws = join(runnerRoot, '_work', 'qwen-code'); + try { + const env = { + ...process.env, + GITHUB_WORKSPACE: join(rws, 'qwen-code'), + RUNNER_WORKSPACE: rws, + RUNNER_TEMP: join(runnerRoot, 'temp'), + RUNNER_TOOL_CACHE: join(runnerRoot, 'tool-cache'), + GITHUB_ENV: join(runnerRoot, 'github-env'), + HOME: join(runnerRoot, 'home'), + XDG_CONFIG_HOME: join(runnerRoot, 'home', '.config'), + }; + mkdirSync(env.HOME); + mkdirSync(env.RUNNER_TEMP); + const result = spawnSync( + 'bash', + ['-e', '-o', 'pipefail', '-c', wipeScript], + { encoding: 'utf8', env }, + ); + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain( + 'refusing to wipe: runner workspace resolves through a symlinked component', + ); + expect(existsSync(join(outside, 'qwen-code', 'qwen-code'))).toBe(false); + } finally { + rmSync(outside, { recursive: true, force: true }); + rmSync(runnerRoot, { recursive: true, force: true }); + } + } + + // Symlinked intermediate component BELOW the runner workspace: RWS + // itself is clean, so only the workspace-side comparison catches + // the re-rooting. The link points INSIDE the runner workspace, + // where the containment allow-list alone would pass — without the + // comparison the wipe would run on the wrong sibling directory. + { + const base = mkdtempSync(join(tmpdir(), 'release-ws-mid-')); + const rws = join(base, 'rws'); + mkdirSync(join(rws, 'elsewhere', 'qwen-code'), { recursive: true }); + const decoy = join(rws, 'elsewhere', 'qwen-code', 'decoy.txt'); + writeFileSync(decoy, 'must-survive'); + symlinkSync(join(rws, 'elsewhere'), join(rws, 'qwen-code')); + const workspace = join(rws, 'qwen-code', 'qwen-code'); + try { + const env = { + ...process.env, + GITHUB_WORKSPACE: workspace, + RUNNER_WORKSPACE: rws, + RUNNER_TEMP: join(base, 'temp'), + RUNNER_TOOL_CACHE: join(base, 'tool-cache'), + GITHUB_ENV: join(base, 'github-env'), + HOME: join(base, 'home'), + XDG_CONFIG_HOME: join(base, 'home', '.config'), + }; + mkdirSync(env.HOME); + mkdirSync(env.RUNNER_TEMP); + const result = spawnSync( + 'bash', + ['-e', '-o', 'pipefail', '-c', wipeScript], + { encoding: 'utf8', env }, + ); + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain( + 'refusing to wipe: workspace resolves through a symlinked component', + ); + expect(readFileSync(decoy, 'utf8')).toBe('must-survive'); + } finally { + rmSync(base, { recursive: true, force: true }); + } + } }); it('checks docker availability before the docker checkout', () => {