diff --git a/.github/workflows/.size-baseline b/.github/workflows/.size-baseline index 2d9baebaaf8..1c88ba12d7f 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 +53022 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 f0f2eeb2a02..ca9dd9308d0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,11 @@ on: jobs: prepare: name: 'Prepare Release Metadata' - runs-on: 'ubuntu-latest' + # 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: |- ${{ github.repository == 'QwenLM/qwen-code' }} permissions: @@ -56,6 +60,162 @@ 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. + - &restore_release_workspace + name: 'Restore workspace ownership' + if: "${{ runner.environment == 'self-hosted' }}" + run: |- + set -uo pipefail + # 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. + # + # 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:?}" + while [ "${RWS%/}" != "$RWS" ]; do RWS="${RWS%/}"; done + 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 + ..|../*|*/..|*/../*) 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, + # 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 + # 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 ;; + 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 + # 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 + # 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 + 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' + echo 'GIT_CONFIG_PARAMETERS=' + 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' uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: @@ -88,13 +248,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: '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. + - 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' @@ -145,7 +314,8 @@ jobs: quality: name: 'Quality Checks' - runs-on: 'ubuntu-latest' + 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' }} @@ -157,19 +327,32 @@ 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. + - *restore_release_workspace + - name: 'Checkout' uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: 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: '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. + - 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' @@ -207,7 +390,8 @@ jobs: integration_none: name: 'Integration Tests (No Sandbox)' - runs-on: 'ubuntu-latest' + 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' }} @@ -219,19 +403,32 @@ 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. + - *restore_release_workspace + - name: 'Checkout' uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: 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: '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. + - 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' @@ -258,7 +455,8 @@ jobs: integration_docker: name: 'Integration Tests (Docker)' - runs-on: 'ubuntu-latest' + 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' }} @@ -270,19 +468,40 @@ 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. + - *restore_release_workspace + + - name: 'Check docker daemon' + run: |- + 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 + - name: 'Checkout' uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: 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: '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. + - 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' @@ -327,6 +546,8 @@ jobs: publish: name: 'Publish Release' + # Publishing carries release credentials, so keep it off the shared pool + # that also executes pull-request code. runs-on: 'ubuntu-latest' needs: - 'prepare' @@ -384,6 +605,10 @@ jobs: pull-requests: 'write' steps: + # Shared ECS runners can retain root-owned files from an earlier + # containerized job. Restore the reusable workspace before checkout. + - *restore_release_workspace + - name: 'Checkout' uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: @@ -396,7 +621,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' @@ -747,7 +973,20 @@ jobs: notify_failure: name: 'Notify Release Failure' + # 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' - 'quality' 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 94e10cd00c5..e3435669e6d 100644 --- a/scripts/tests/release-workflow.test.js +++ b/scripts/tests/release-workflow.test.js @@ -5,12 +5,32 @@ */ import { spawnSync } from 'node:child_process'; -import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; 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 && + spawnSync('realpath', ['-m', '-s', '--', '/'], { stdio: 'ignore' }).status === + 0; const workflow = readFileSync('.github/workflows/release.yml', 'utf8'); +const releaseYaml = parse(workflow); const cuaReleaseWorkflow = readFileSync( '.github/workflows/cd-cua-driver.yml', 'utf8', @@ -121,7 +141,815 @@ describe('CUA release workflow', () => { }); }); +// 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 +// 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 +# 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. +# +# 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:?}" +while [ "\${RWS%/}" != "$RWS" ]; do RWS="\${RWS%/}"; done +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 + ..|../*|*/..|*/../*) 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, +# 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 +# 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 ;; +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 +# 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 +# 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 +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' + echo 'GIT_CONFIG_PARAMETERS=' + 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', () => { + 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'), + ), + ); + + expect(checkoutJobs.map(([id]) => id)).toEqual([ + 'prepare', + 'quality', + 'integration_none', + 'integration_docker', + 'publish', + ]); + for (const [id, job] of checkoutJobs) { + const restoreIndex = job.steps.findIndex( + (step) => step.name === 'Restore workspace ownership', + ); + // 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); + expect(job.steps[restoreIndex]?.if, id).toBe( + "${{ runner.environment == 'self-hosted' }}", + ); + const checkoutIndex = job.steps.findIndex((step) => + String(step.uses ?? '').includes('actions/checkout'), + ); + 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); + } + }); + + 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; + + 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/, + ); + 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 }); + } + } + + // 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 }); + } + } + + // 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, + 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'), + }; + 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 }); + } + } + + // 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 }); + } + } + + // 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 }); + } + } + + // 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', () => { + 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); + // 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('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( + [ + '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', + ]) { + const steps = releaseYaml.jobs[id].steps; + // 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?.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('stages every integration package manifest after versioning', () => { expect(workflow).toContain( 'git add package.json package-lock.json packages/*/package.json packages/channels/*/package.json integrations/*/package.json', @@ -356,3 +1184,31 @@ describe('Live Host feed contract', () => { ); }); }); + +describe('release lane runner routing', () => { + const ecsRunsOn = + '${{ (github.repository == \'QwenLM/qwen-code\' && vars.MAINTAINER_ECS_RUNNER_DISABLED != \'true\') && fromJSON(\'["self-hosted", "linux", "x64", "ecs-qwen"]\') || fromJSON(\'["ubuntu-latest"]\') }}'; + + it('routes validation jobs to ECS with a hosted emergency fallback', () => { + const validationJobs = [ + 'prepare', + 'quality', + 'integration_none', + 'integration_docker', + ]; + for (const name of validationJobs) { + const job = releaseYaml.jobs[name]; + expect(job, `job missing from release.yml: ${name}`).toBeTruthy(); + expect(job['runs-on'], `runs-on drifted on job: ${name}`).toBe(ecsRunsOn); + } + }); + + 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'); + expect(releaseYaml.jobs.notify_failure['runs-on']).toBe('ubuntu-latest'); + expect(releaseYaml.jobs.notify_failure['runs-on']).not.toContain( + 'ecs-qwen', + ); + }); +});