From 3275b2132ef68f26a5e964e51cd91b937a62505f Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Mon, 20 Apr 2026 16:11:23 -0400 Subject: [PATCH 1/3] chore(ci): harden command execution paths --- .changeset/harden-ci-execution-paths.md | 5 +++ .flue/sandbox/Dockerfile | 3 ++ .github/workflows/check-merge.yml | 58 ++++++++++++++----------- .gitpod/gitpod-setup.sh | 5 ++- packages/upgrade/src/shell.ts | 4 +- scripts/turbo-run-affected.js | 5 +-- 6 files changed, 48 insertions(+), 32 deletions(-) create mode 100644 .changeset/harden-ci-execution-paths.md diff --git a/.changeset/harden-ci-execution-paths.md b/.changeset/harden-ci-execution-paths.md new file mode 100644 index 000000000000..ff628c4ce7fd --- /dev/null +++ b/.changeset/harden-ci-execution-paths.md @@ -0,0 +1,5 @@ +--- +'@astrojs/upgrade': patch +--- + +Improves how `@astrojs/upgrade` spawns package manager commands so it does not rely on a shell diff --git a/.flue/sandbox/Dockerfile b/.flue/sandbox/Dockerfile index f5677d73db1c..310330aa8023 100644 --- a/.flue/sandbox/Dockerfile +++ b/.flue/sandbox/Dockerfile @@ -69,5 +69,8 @@ COPY .flue/sandbox/AGENTS.md /tmp/.config/opencode/AGENTS.md EXPOSE 48765 +# Default to the non-root user provided by the base image. +USER node + # Default: start OpenCode server listening on all interfaces CMD ["opencode", "serve", "--port", "48765", "--hostname", "0.0.0.0"] diff --git a/.github/workflows/check-merge.yml b/.github/workflows/check-merge.yml index 42db71d7f64d..b2716620d18e 100644 --- a/.github/workflows/check-merge.yml +++ b/.github/workflows/check-merge.yml @@ -39,38 +39,44 @@ jobs: with: files: | .changeset/**/*.md - # Intentionally ran after the changed-files step so the github API is used to identify - # changed files rather than a local git diff, this is more reliable for pull requests - # originating from a forked repository. - - name: Checkout files - id: checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - if: steps.changed-files.outputs.any_changed == 'true' - with: - ref: ${{ github.event.pull_request.head.sha }} - repository: ${{ github.event.pull_request.head.repo.full_name }} - fetch-depth: 1 - persist-credentials: false - sparse-checkout: | - .changeset - name: Check if any changesets contain minor or major changes id: check if: steps.changed-files.outputs.any_changed == 'true' env: ALL_CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }} - run: | - echo "Checking for changesets marked as minor or major" - echo "found=false" >> $GITHUB_OUTPUT + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + core.setOutput('found', 'false'); + + const regex = /["']astro["']: (minor|major)/; + const changedFiles = process.env.ALL_CHANGED_FILES.split(/\s+/).filter(Boolean); + const { owner, name: repo } = context.payload.pull_request.head.repo; + const ref = context.payload.pull_request.head.sha; - regex="[\"']astro[\"']: (minor|major)" - for file in ${ALL_CHANGED_FILES}; do - if [[ $(cat $file) =~ $regex ]]; then - version="${BASH_REMATCH[1]}" - echo "version=$version" >> $GITHUB_OUTPUT - echo "found=true" >> $GITHUB_OUTPUT - echo "$file has a $version release tag" - fi - done + for (const file of changedFiles) { + const { data } = await github.rest.repos.getContent({ + owner: owner.login, + repo, + path: file, + ref, + }); + + if (Array.isArray(data) || !('content' in data)) { + continue; + } + + const contents = Buffer.from(data.content, 'base64').toString('utf8'); + const match = contents.match(regex); + + if (match) { + const version = match[1]; + core.setOutput('version', version); + core.setOutput('found', 'true'); + console.log(`${file} has a ${version} release tag`); + break; + } + } - name: Add label uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 diff --git a/.gitpod/gitpod-setup.sh b/.gitpod/gitpod-setup.sh index b1ba15d2d84c..7dccbf1694a6 100755 --- a/.gitpod/gitpod-setup.sh +++ b/.gitpod/gitpod-setup.sh @@ -4,7 +4,10 @@ mapfile -t CONTEXT_URL_ITEMS < <(echo "$GITPOD_WORKSPACE_CONTEXT_URL" | tr '/' '\n') # Install latest pnpm -curl -fsSL https://get.pnpm.io/install.sh | SHELL=`which bash` bash - +PNPM_INSTALL_SCRIPT="$(mktemp)" +curl -fsSL https://get.pnpm.io/install.sh -o "$PNPM_INSTALL_SCRIPT" +SHELL="$(command -v bash)" bash "$PNPM_INSTALL_SCRIPT" +rm -f "$PNPM_INSTALL_SCRIPT" # Check if Gitpod started from a specific example directory in the repository if [ "${CONTEXT_URL_ITEMS[7]}" = "examples" ]; then diff --git a/packages/upgrade/src/shell.ts b/packages/upgrade/src/shell.ts index 0e4065108baf..aa7f924de220 100644 --- a/packages/upgrade/src/shell.ts +++ b/packages/upgrade/src/shell.ts @@ -34,10 +34,10 @@ export async function shell( process.once('exit', () => controller.abort()); signal = controller.signal; } + const executable = process.platform === 'win32' ? `${command}.cmd` : command; try { - child = spawn(`${command} ${flags.join(' ')}`, { + child = spawn(executable, flags, { cwd: opts.cwd, - shell: true, stdio: opts.stdio, timeout: opts.timeout, signal, diff --git a/scripts/turbo-run-affected.js b/scripts/turbo-run-affected.js index e2888e59d55c..0a95fdd8b323 100644 --- a/scripts/turbo-run-affected.js +++ b/scripts/turbo-run-affected.js @@ -49,9 +49,9 @@ for (let i = 0; i < args.length; i += 1) { turboArgs.push(arg); } -// On Windows, spawn via the shell so command resolution handles pnpm.cmd. +// On Windows, use pnpm.cmd so command resolution works without a shell. const isWindows = process.platform === 'win32'; -const pnpmCommand = 'pnpm'; +const pnpmCommand = isWindows ? 'pnpm.cmd' : 'pnpm'; const commandArgs = ['exec', 'turbo', 'run', ...turboArgs]; console.info('[turbo-run-affected] platform:', process.platform); @@ -63,7 +63,6 @@ console.info('[turbo-run-affected] command:', pnpmCommand, commandArgs.join(' ') const turbo = spawn(pnpmCommand, commandArgs, { stdio: 'inherit', env: process.env, - shell: isWindows, }); // Mirror Turbo's exit status so CI fails/succeeds correctly. From 34ff4a9c9ae2e59a544010d0dccafbfd4b9b10ab Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Mon, 20 Apr 2026 16:19:46 -0400 Subject: [PATCH 2/3] chore(ci): split out upgrade command hardening --- .changeset/harden-ci-execution-paths.md | 5 ----- packages/upgrade/src/shell.ts | 4 ++-- 2 files changed, 2 insertions(+), 7 deletions(-) delete mode 100644 .changeset/harden-ci-execution-paths.md diff --git a/.changeset/harden-ci-execution-paths.md b/.changeset/harden-ci-execution-paths.md deleted file mode 100644 index ff628c4ce7fd..000000000000 --- a/.changeset/harden-ci-execution-paths.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@astrojs/upgrade': patch ---- - -Improves how `@astrojs/upgrade` spawns package manager commands so it does not rely on a shell diff --git a/packages/upgrade/src/shell.ts b/packages/upgrade/src/shell.ts index aa7f924de220..0e4065108baf 100644 --- a/packages/upgrade/src/shell.ts +++ b/packages/upgrade/src/shell.ts @@ -34,10 +34,10 @@ export async function shell( process.once('exit', () => controller.abort()); signal = controller.signal; } - const executable = process.platform === 'win32' ? `${command}.cmd` : command; try { - child = spawn(executable, flags, { + child = spawn(`${command} ${flags.join(' ')}`, { cwd: opts.cwd, + shell: true, stdio: opts.stdio, timeout: opts.timeout, signal, From b4927c6b11001199d3e50bef2909ce01f005e196 Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Wed, 22 Apr 2026 10:38:15 -0400 Subject: [PATCH 3/3] fix(ci): restore shell spawn on Windows for turbo-run-affected The shell is required on Windows because turbo filter arguments contain special characters (globs, brackets, etc.) that cause EINVAL errors when passed directly to CreateProcess without a shell. --- scripts/turbo-run-affected.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/turbo-run-affected.js b/scripts/turbo-run-affected.js index 0a95fdd8b323..0485cb3477e3 100644 --- a/scripts/turbo-run-affected.js +++ b/scripts/turbo-run-affected.js @@ -49,9 +49,10 @@ for (let i = 0; i < args.length; i += 1) { turboArgs.push(arg); } -// On Windows, use pnpm.cmd so command resolution works without a shell. +// On Windows, spawn via the shell so command resolution handles pnpm.cmd +// and special characters in turbo filter expressions (globs, brackets, etc.). const isWindows = process.platform === 'win32'; -const pnpmCommand = isWindows ? 'pnpm.cmd' : 'pnpm'; +const pnpmCommand = 'pnpm'; const commandArgs = ['exec', 'turbo', 'run', ...turboArgs]; console.info('[turbo-run-affected] platform:', process.platform); @@ -63,6 +64,7 @@ console.info('[turbo-run-affected] command:', pnpmCommand, commandArgs.join(' ') const turbo = spawn(pnpmCommand, commandArgs, { stdio: 'inherit', env: process.env, + shell: isWindows, }); // Mirror Turbo's exit status so CI fails/succeeds correctly.