[ci] Enforce minimumReleaseAge in e2e tests that install external dependencies (#95628)

We also mitigate the security impact of e2e tests by running everything
in VMs, and by not reading caches in release jobs, but we should still
be using `minimumReleaseAge` in e2e tests.

Ideally we'd also use lockfiles here too, and I'll work on that next,
but that is likely a lot more complicated because we probably need some
way to merge lockfiles, since we can't pin the next.js and react
versions in every test, and we need a reasonable/efficient way to
bulk-update all the lockfiles for tests that need it.

Depends on https://github.com/vercel/next.js/pull/95668 for a bunch of
windows path representation fixes that this ends up exposing in CI,
because adding workspace files causes pnpm to change the absolute path
representation used in the Windows junction points that it creates.
This commit is contained in:
Benjamin Woodruff
2026-07-21 14:54:01 -07:00
committed by GitHub
parent 3214020b2d
commit e630d81822
9 changed files with 227 additions and 42 deletions
+5 -2
View File
@@ -29,8 +29,11 @@ ignoredBuiltDependencies:
- ssh2
- unrs-resolver
- wasm-pack
# Keep security related settings in sync with pnpm-workspace.yaml written to
# temporary fixture dirs in file://./scripts/install-native.mjs
# - Keep security related settings in sync with pnpm-workspace.yaml written to
# temporary fixture dirs in `file://./scripts/install-native.mjs`
# - `file://./test/lib/pnpm-security-settings.js` reads and copies these values for
# isolated test dirs
blockExoticSubdeps: true
minimumReleaseAge: 2880 # 48 hrs
minimumReleaseAgeExclude:
@@ -18,6 +18,9 @@ describe('multiple-lockfiles - has-output-file-tracing-root', () => {
// So that ../package-lock.json doesn't leave the isolated testDir
subDir: 'test',
skipDeployment: true,
// The workspace file would suppress the warning itself, so the test
// wouldn't be exercising `outputFileTracingRoot`.
deleteWorkspaceFile: true,
})
if (skipped) {
@@ -18,6 +18,9 @@ describe('multiple-lockfiles - has-turbo-root', () => {
// So that ../package-lock.json doesn't leave the isolated testDir
subDir: 'test',
skipDeployment: true,
// The workspace file would suppress the warning itself, so the test
// wouldn't be exercising `turbopack.root`.
deleteWorkspaceFile: true,
})
if (skipped) {
@@ -17,6 +17,9 @@ describe('multiple-lockfiles', () => {
// So that ../package-lock.json doesn't leave the isolated testDir
subDir: 'test',
skipDeployment: true,
// The workspace file would be treated as the root and suppress the
// warning.
deleteWorkspaceFile: true,
})
if (skipped) {
+1 -18
View File
@@ -43,7 +43,7 @@ export function runTests(
),
packageJson: {
// Bootstrap with classic yarn; the install command below runs
// `yarn set version berry` which rewrites this to the berry version.
// `yarn set version` which rewrites this to the berry version.
packageManager: '[email protected]',
},
dependencies: {
@@ -55,27 +55,10 @@ export function runTests(
prev.push(`${cur}@${dependencies[cur]}`)
return prev
}, [] as string[])
const minimumReleaseAgeExclude = JSON.stringify(
JSON.stringify([
'@next/*',
'@turbo/*',
'@vercel/*',
'@workflow/*',
'babel-plugin-react-compiler',
'next',
'react',
'react-dom',
'react-is',
'react-server-dom-*',
'scheduler',
'turbo',
])
)
return [
`yarn set version 4.15.0`,
`yarn config set enableGlobalCache true`,
`yarn config set compressionLevel 0`,
`yarn config set npmPreapprovedPackages --json ${minimumReleaseAgeExclude}`,
`yarn add ${pkgs.join(' ')}`,
].join(' && ')
},
+95 -22
View File
@@ -6,6 +6,13 @@ const childProcess = require('child_process')
const { randomBytes } = require('crypto')
const { linkPackages } =
require('../../.github/actions/next-stats-action/src/prepare/repo-setup')()
const yaml = require('js-yaml')
const {
getPnpmSecuritySettings,
mergePnpmSecuritySettingsIntoYaml,
getYarnSecuritySettings,
mergeYarnSecuritySettingsIntoYaml,
} = require('./pnpm-security-settings')
const PREFER_OFFLINE = process.env.NEXT_TEST_PREFER_OFFLINE === '1'
const useRspack = process.env.NEXT_TEST_USE_RSPACK === '1'
@@ -26,20 +33,83 @@ async function installDependencies(cwd, tmpDir) {
await execa('pnpm', args, {
cwd,
stdio: ['ignore', 'inherit', 'inherit'],
env: {
...process.env,
// pnpm reads this despite claims it ignores `npm_config_*` env variables.
// This isn't set in CI but some local environments set this from the
// pnpm-workspace.yaml for unknown reasons.
// minimumReleaseAgeExclude is not propagated with environment variables
// so some installs would just fail.
// TODO: ideally every test fixture would run with minimumReleaseAgeExclude but
// that requires some work in monorepo test suites.
npm_config_minimum_release_age: undefined,
},
})
}
/**
* Finds `fileName` in the dirs from `installDir` up to `isolationRoot`
* (inclusive), or null if absent.
*
* @param {string} fileName
* @param {string} installDir
* @param {string} isolationRoot
* @returns {Promise<string | null>}
*/
async function findConfigFile(fileName, installDir, isolationRoot) {
let dir = path.resolve(installDir)
const stopDir = path.resolve(isolationRoot)
while (true) {
const file = path.join(dir, fileName)
if (await fs.pathExists(file)) {
return file
}
if (dir === stopDir) break
const parent = path.dirname(dir)
if (parent === dir) break
dir = parent
}
return null
}
/**
* Applies the supply-chain security settings from the repo root
* `pnpm-workspace.yaml` to installs in the isolated test dir, by writing (or
* merging into) a `pnpm-workspace.yaml` and a `.yarnrc.yml`. npm added
* equivalent functionality in 11.10.0; we can configure it here once we
* upgrade npm.
*
* @param {string} installDir
* @param {string} isolationRoot
* @returns {Promise<void>}
*/
async function applyInstallSecuritySettings(installDir, isolationRoot) {
const workspaceFile = await findConfigFile(
'pnpm-workspace.yaml',
installDir,
isolationRoot
)
if (workspaceFile !== null) {
await fs.writeFile(
workspaceFile,
mergePnpmSecuritySettingsIntoYaml(
await fs.readFile(workspaceFile, 'utf8')
)
)
} else {
await fs.writeFile(
path.join(installDir, 'pnpm-workspace.yaml'),
yaml.dump(getPnpmSecuritySettings())
)
}
const yarnrcFile = await findConfigFile(
'.yarnrc.yml',
installDir,
isolationRoot
)
if (yarnrcFile !== null) {
await fs.writeFile(
yarnrcFile,
mergeYarnSecuritySettingsIntoYaml(await fs.readFile(yarnrcFile, 'utf8'))
)
} else {
await fs.writeFile(
path.join(installDir, '.yarnrc.yml'),
yaml.dump(getYarnSecuritySettings())
)
}
}
/**
*
* @param {object} param0
@@ -67,11 +137,11 @@ async function createNextInstall({
.traceChild('createNextInstall')
.traceAsyncFn(async (rootSpan) => {
const origRepoDir = path.join(__dirname, '../../')
const installDir = path.join(
const isolationRoot = path.join(
tmpDir,
`next-install-${randomBytes(32).toString('hex')}`,
subDir
`next-install-${randomBytes(32).toString('hex')}`
)
const installDir = path.join(isolationRoot, subDir)
require('console').log('Creating next instance in:')
require('console').log(installDir)
@@ -231,15 +301,18 @@ async function createNextInstall({
})
}
if (installCommand) {
const installString =
typeof installCommand === 'function'
? installCommand({
dependencies: combinedDependencies,
resolutions,
})
: installCommand
const installString = installCommand
? typeof installCommand === 'function'
? installCommand({
dependencies: combinedDependencies,
resolutions,
})
: installCommand
: null
await applyInstallSecuritySettings(installDir, isolationRoot)
if (installString !== null) {
console.log('running install command', installString)
rootSpan.traceChild('run custom install').traceFn(() => {
childProcess.execSync(installString, {
+13
View File
@@ -57,6 +57,12 @@ export interface NextInstanceOpts {
patchFileDelay?: number
startServerTimeout?: number
disableAutoSkewProtection?: boolean
/**
* Delete the `pnpm-workspace.yaml` that `createNextInstall` writes for
* supply-chain gating of installs. For tests that assert on Next.js
* workspace-root detection, which the file affects.
*/
deleteWorkspaceFile?: boolean
}
/**
@@ -103,6 +109,7 @@ export class NextInstance {
public startServerTimeout: number = 10_000 // 10 seconds
public serverReadyPattern: RegExp = /✓ Ready in /
patchFileDelay: number = 0
public deleteWorkspaceFile: boolean = false
constructor(opts: NextInstanceOpts) {
this.env = {}
@@ -363,6 +370,12 @@ export class NextInstance {
}
}
if (this.deleteWorkspaceFile) {
await fs.rm(path.join(this.testDir, 'pnpm-workspace.yaml'), {
force: true,
})
}
const testDirFiles = await fs.readdir(this.testDir)
let nextConfigFile = testDirFiles.find((file) =>
+101
View File
@@ -0,0 +1,101 @@
// Reads the supply-chain security settings (`minimumReleaseAge` etc.) from
// the repository root `pnpm-workspace.yaml` so isolated test installs can
// apply the same protections without duplicating the values.
const path = require('path')
const fs = require('fs')
const yaml = require('js-yaml')
const SECURITY_SETTING_KEYS = /** @type {const} */ ([
'blockExoticSubdeps',
'minimumReleaseAge',
'minimumReleaseAgeExclude',
])
/**
* @typedef {object} PnpmSecuritySettings
* @property {boolean} blockExoticSubdeps
* @property {number} minimumReleaseAge
* @property {string[]} minimumReleaseAgeExclude
*/
/** @type {PnpmSecuritySettings | undefined} */
let cachedSettings
/** @returns {PnpmSecuritySettings} */
function getPnpmSecuritySettings() {
if (!cachedSettings) {
const workspaceFile = path.join(__dirname, '../../pnpm-workspace.yaml')
const workspaceConfig = /** @type {Record<string, unknown>} */ (
yaml.load(fs.readFileSync(workspaceFile, 'utf8'))
)
/** @type {Record<string, unknown>} */
const settings = {}
for (const key of SECURITY_SETTING_KEYS) {
if (!(key in workspaceConfig)) {
throw new Error(`Expected \`${key}\` to be set in ${workspaceFile}`)
}
settings[key] = workspaceConfig[key]
}
cachedSettings = /** @type {PnpmSecuritySettings} */ settings
}
return cachedSettings
}
/**
* The yarn berry analogues of the pnpm settings, for a `.yarnrc.yml`. Yarn
* has no equivalent of `blockExoticSubdeps`.
*
* @returns {{ npmMinimalAgeGate: string, npmPreapprovedPackages: string[] }}
*/
function getYarnSecuritySettings() {
const settings = getPnpmSecuritySettings()
return {
npmMinimalAgeGate: `${settings.minimumReleaseAge}m`,
npmPreapprovedPackages: settings.minimumReleaseAgeExclude,
}
}
/**
* Adds any of `settings`' keys not already present to a YAML document's
* text, leaving existing keys untouched so tests can override them.
*
* @param {string} yamlText
* @param {Record<string, unknown>} settings
* @returns {string}
*/
function mergeSettingsIntoYaml(yamlText, settings) {
const config = /** @type {Record<string, unknown>} */ (
yaml.load(yamlText) ?? {}
)
for (const key of Object.keys(settings)) {
if (!(key in config)) {
config[key] = settings[key]
}
}
return yaml.dump(config)
}
/**
* @param {string} yamlText text of an existing `pnpm-workspace.yaml`
* @returns {string}
*/
function mergePnpmSecuritySettingsIntoYaml(yamlText) {
return mergeSettingsIntoYaml(yamlText, getPnpmSecuritySettings())
}
/**
* @param {string} yamlText text of an existing `.yarnrc.yml`
* @returns {string}
*/
function mergeYarnSecuritySettingsIntoYaml(yamlText) {
return mergeSettingsIntoYaml(yamlText, getYarnSecuritySettings())
}
module.exports = {
SECURITY_SETTING_KEYS,
getPnpmSecuritySettings,
getYarnSecuritySettings,
mergePnpmSecuritySettingsIntoYaml,
mergeYarnSecuritySettingsIntoYaml,
}
@@ -13,6 +13,9 @@ describe('adapter-root', () => {
files: path.join(__dirname, 'fixture'),
subDir: 'sub',
skipStart: true,
// The workspace file would be treated as the workspace root, changing
// the repoRoot this test asserts on.
deleteWorkspaceFile: true,
overrideFiles: setEnvVar
? undefined
: {