mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
[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:
+5
-2
@@ -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:
|
||||
|
||||
+3
@@ -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) {
|
||||
|
||||
@@ -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(' && ')
|
||||
},
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
: {
|
||||
|
||||
Reference in New Issue
Block a user