Skip to content

fix: add x-action-forwarded guard and E2E tests for server action forwarding loop #4287

fix: add x-action-forwarded guard and E2E tests for server action forwarding loop

fix: add x-action-forwarded guard and E2E tests for server action forwarding loop #4287

Workflow file for this run

name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_call: # allow other workflows to run CI as a gate
permissions:
contents: read
concurrency:
group: ci-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
name: Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/setup
- name: Build plugin (needed for benchmark workspace type resolution)
run: vp run build
- run: vp run check
- run: vp run knip
- name: Bash syntax check
run: |
for f in scripts/*.sh; do
bash -n "$f" || { echo "Syntax error in $f"; exit 1; }
done
test-unit:
name: Vitest (unit)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/setup
- run: vp test run --project unit
test-integration:
name: Vitest (integration ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3]
shardTotal: [3]
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/setup
- name: Build plugin (needed by ecosystem and nextjs-compat fixtures)
run: vp run build
# Coverage is gated to push-to-main runs to keep PR feedback fast.
# Istanbul instrumentation adds ~10–25% to each shard's wall-clock.
- run: vp test run --project integration ${{ github.event_name == 'push' && '--coverage' || '' }} --reporter=blob --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
env:
CI: true
- uses: actions/upload-artifact@v7
if: ${{ !cancelled() }}
with:
name: blob-report-${{ matrix.shardIndex }}
path: .vitest-reports/*
include-hidden-files: true
retention-days: 1
test-integration-merge:
name: Vitest (integration report)
if: ${{ !cancelled() }}
needs: [test-integration]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/setup
- uses: actions/download-artifact@v7
with:
path: .vitest-reports
pattern: blob-report-*
merge-multiple: true
# --coverage tells vitest to invoke coverageProvider.mergeReports() on the
# coverage data embedded in each shard's blob, producing a unified report.
# Only enabled on push-to-main, matching the shard config above.
- run: vp test run --merge-reports ${{ github.event_name == 'push' && '--coverage' || '' }}
env:
CI: true
- name: Publish coverage matrix to job summary
if: ${{ !cancelled() && hashFiles('coverage/coverage-summary.json') != '' }}
run: node scripts/coverage-summary.mjs >> "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@v7
if: ${{ !cancelled() && hashFiles('coverage/index.html') != '' }}
with:
name: coverage-html
path: coverage
retention-days: 14
create-next-app:
name: create-next-app (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/setup
- name: Build plugin
run: vp run build
- name: Pack vinext for local install
run: vp pm pack --pack-destination "${{ runner.temp }}"
working-directory: packages/vinext
- name: Scaffold a fresh create-next-app project
run: vp dlx create-next-app@16.2.6 "${{ runner.temp }}/cna-test" --yes
- name: Deny build scripts in scaffolded project
working-directory: ${{ runner.temp }}/cna-test
shell: bash
# pnpm 11 may auto-add placeholder allowBuilds entries during the
# scaffold install. Replace them with explicit false values.
run: |
node -e '
const fs = require("fs");
const f = "pnpm-workspace.yaml";
let y = fs.existsSync(f) ? fs.readFileSync(f, "utf8") : "";
y = y.replace(/allowBuilds:[\s\S]*?(?=\n\S|\n*$)/, "");
y = y.trimEnd() + "\n\nallowBuilds:\n sharp: false\n unrs-resolver: false\n";
fs.writeFileSync(f, y);
'
- name: Pin pnpm in scaffolded project to vinext's version
working-directory: ${{ runner.temp }}/cna-test
shell: bash
# create-next-app's bundled pnpm links node_modules to a major-version
# store (e.g. v10). vp's bundled pnpm can drift to a newer major (v11),
# which then refuses to operate on the existing node_modules with
# ERR_PNPM_UNEXPECTED_STORE. vp respects the project's packageManager
# field, so writing the same one vinext itself uses makes vp's pnpm
# match the store layout from create-next-app. Tracks any future
# bump in vinext's root package.json automatically.
run: |
# cd into the workspace to read package.json via a relative path —
# ${{ github.workspace }} contains backslashes on Windows that get
# eaten by the JS string literal parser when interpolated inline.
PKG_MGR=$(cd "${{ github.workspace }}" && node -p "require('./package.json').packageManager")
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf-8'));
pkg.packageManager = '$PKG_MGR';
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
"
echo "Pinned packageManager to $PKG_MGR"
- name: Install vinext from local tarball
working-directory: ${{ runner.temp }}/cna-test
shell: bash
run: vp add "${{ runner.temp }}"/vinext-*.tgz
- name: Run vinext init
working-directory: ${{ runner.temp }}/cna-test
run: vp exec vinext init --skip-check
- name: Start dev server and verify HTTP 200
working-directory: ${{ runner.temp }}/cna-test
shell: bash
run: |
vp exec vite dev --port 3099 &
SERVER_PID=$!
for i in $(seq 1 30); do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3099/ || true)
if [ "$STATUS" = "200" ]; then
echo "Server responded with HTTP 200 (attempt $i)"
kill "$SERVER_PID" 2>/dev/null || true
exit 0
fi
sleep 1
done
echo "Server did not respond with HTTP 200 within 30 seconds"
kill "$SERVER_PID" 2>/dev/null || true
exit 1
e2e:
name: E2E (${{ matrix.project }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
project:
- pages-router
- app-router
- app-router-chrome-browser-specific
- app-router-webkit-browser-specific
- cloudflare-pages-router
- pages-router-prod
- cloudflare-workers
- cloudflare-dev
- cloudflare-pages-router-dev
- static-export
- app-with-src
- standalone-output
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/setup
- name: Build plugin
run: vp run build
# Resolve the exact Playwright version from the lockfile so the cache key
# only changes when Playwright itself is bumped, not on unrelated lockfile
# churn. Falls back to a hash of pnpm-lock.yaml if parsing fails.
- name: Resolve Playwright version
id: playwright-version
run: |
set -euo pipefail
version=$(grep -E "^\s+playwright:\s+[0-9]" pnpm-lock.yaml | head -n1 | awk '{print $2}' || true)
if [ -z "$version" ]; then
version="unknown-${{ hashFiles('pnpm-lock.yaml') }}"
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "Resolved Playwright version: $version"
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v5
with:
path: ~/.cache/ms-playwright
key: playwright-${{ matrix.project == 'app-router-webkit-browser-specific' && 'webkit' || 'chromium' }}-${{ runner.os }}-v${{ steps.playwright-version.outputs.version }}
restore-keys: |
playwright-${{ matrix.project == 'app-router-webkit-browser-specific' && 'webkit' || 'chromium' }}-${{ runner.os }}-
# Cache the apt download cache for WebKit's system dependencies.
# `playwright install-deps webkit` always runs `apt-get install`, but with
# the package archives cached we skip the (slow) download step on hits.
- name: Cache apt archives (WebKit deps)
if: ${{ matrix.project == 'app-router-webkit-browser-specific' }}
uses: actions/cache@v5
with:
path: |
~/.cache/apt-archives
key: apt-webkit-${{ runner.os }}-v${{ steps.playwright-version.outputs.version }}
restore-keys: |
apt-webkit-${{ runner.os }}-
# Install Chromium browser binaries. Skip when the cache is fully hit —
# `playwright install` is a no-op for already-installed browsers but the
# CLI startup itself adds a few seconds per job.
- name: Install Playwright Chromium
if: ${{ matrix.project != 'app-router-webkit-browser-specific' && steps.playwright-cache.outputs.cache-hit != 'true' }}
run: vp exec playwright install chromium
# Install WebKit browser binaries only on cache miss. System deps are
# installed in a separate step below so we can cache them independently.
- name: Install Playwright WebKit (browser only)
if: ${{ matrix.project == 'app-router-webkit-browser-specific' && steps.playwright-cache.outputs.cache-hit != 'true' }}
run: vp exec playwright install webkit
# Install WebKit system dependencies. This always runs because apt
# packages are root-level and not part of ~/.cache/ms-playwright. The
# apt archive cache above keeps this fast on hits by avoiding re-downloads.
# `playwright install-deps` invokes apt-get under sudo internally.
- name: Install Playwright WebKit system dependencies
if: ${{ matrix.project == 'app-router-webkit-browser-specific' }}
run: |
set -euo pipefail
mkdir -p ~/.cache/apt-archives
sudo mkdir -p /var/cache/apt/archives
# Seed apt's archive dir from our cache so apt skips re-downloading
# .deb files it already has from a previous run.
if [ -n "$(ls -A ~/.cache/apt-archives 2>/dev/null)" ]; then
sudo cp -rn ~/.cache/apt-archives/. /var/cache/apt/archives/ || true
fi
# Tell apt not to delete .deb files after install so they survive
# for our post-step cache copy.
echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' | sudo tee /etc/apt/apt.conf.d/01keep-debs >/dev/null
vp exec playwright install-deps webkit
# Persist newly downloaded archives back to our cache directory.
sudo find /var/cache/apt/archives -maxdepth 1 -name '*.deb' -exec cp -n {} ~/.cache/apt-archives/ \;
sudo chown -R "$(id -u):$(id -g)" ~/.cache/apt-archives
- run: vp run test:e2e
env:
CI: true
PLAYWRIGHT_PROJECT: ${{ matrix.project }}
- uses: actions/upload-artifact@v7
if: failure()
with:
name: playwright-report-${{ matrix.project }}
path: playwright-report/
retention-days: 7
ci:
name: CI
if: ${{ always() }}
needs: [check, test-unit, test-integration-merge, create-next-app, e2e]
runs-on: ubuntu-latest
steps:
- name: All checks passed
if: ${{ !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }}
run: echo "All checks passed"
- name: Some checks failed
if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
run: exit 1