diff --git a/.github/workflows/generate-sandboxes.yml b/.github/workflows/generate-sandboxes.yml index 5b1981567083..66dbc49400ce 100644 --- a/.github/workflows/generate-sandboxes.yml +++ b/.github/workflows/generate-sandboxes.yml @@ -80,12 +80,12 @@ jobs: with: node-version-file: '.nvmrc' - # Node's bundled npm is older than 11.10.0 and silently ignores - # NPM_CONFIG_MIN_RELEASE_AGE during scaffold. Keep in sync with - # BEFORE_SANDBOX_NPM_MIN_VERSION in scripts/sandbox/utils/yarn.ts. + # Node's bundled npm is older than 11.17.0 and silently ignores + # NPM_CONFIG_MIN_RELEASE_AGE / min-release-age-exclude during scaffold. + # Keep in sync with BEFORE_SANDBOX_NPM_MIN_VERSION in scripts/sandbox/utils/yarn.ts. - name: Install npm with min-release-age support working-directory: ${{ github.workspace }} - run: npm install -g npm@11.10.0 + run: npm install -g npm@11.17.0 - name: Setup git user run: | diff --git a/code/lib/cli-storybook/src/sandbox-templates.ts b/code/lib/cli-storybook/src/sandbox-templates.ts index 30e0bbabcebe..dd1e0c1f2460 100644 --- a/code/lib/cli-storybook/src/sandbox-templates.ts +++ b/code/lib/cli-storybook/src/sandbox-templates.ts @@ -990,11 +990,13 @@ export const baseTemplates = { // create-expo-app pins the current SDK, whose packages are released // together and are routinely younger than the gate window. // `babel-preset-expo` is part of that set despite not matching `expo-*`. + // `multitars` is pulled in transitively by the Expo CLI toolchain. minAgeGateExemptions: [ 'expo', 'expo-*', '@expo/*', 'babel-preset-expo', + 'multitars', 'react-native', '@react-native/*', ], @@ -1021,17 +1023,12 @@ export const baseTemplates = { }, }, 'react-native-web-vite/rn-cli-ts': { - // NOTE: create-expo-app installs React 18.2.0. But yarn portal - // expects 18.3.1 (dunno why). Therefore to run this in dev you - // must either: - // - edit the sandbox package.json to depend on react 18.3.1, OR - // - build/run the sandbox in --no-link mode, which is fine - // - // Users & CI won't see this limitation because they are not using - // yarn portals. name: 'React Native CLI Latest (Vite | TypeScript)', script: 'npx @react-native-community/cli@latest init --skip-install --install-pods=false --directory={{beforeDir}} rnapp', + // The CLI downloads `@react-native-community/template` during init even with + // --skip-install; those packages ship in lockstep with each RN release. + minAgeGateExemptions: ['@react-native-community/*', 'react-native', '@react-native/*'], expected: { framework: '@storybook/react-native-web-vite', renderer: '@storybook/react', diff --git a/scripts/sandbox/generate.ts b/scripts/sandbox/generate.ts index 1f2f429457d1..70151a80a9b6 100755 --- a/scripts/sandbox/generate.ts +++ b/scripts/sandbox/generate.ts @@ -38,6 +38,7 @@ import { preapproveLocallyPublishedPackages, refreshBeforeStorybookLockfile, setupYarn, + writeScaffoldNpmrc, } from './utils/yarn.ts'; const isCI = process.env.GITHUB_ACTIONS === 'true' || process.env.CI === 'true'; @@ -297,6 +298,14 @@ const runGenerators = async ( NPM_CONFIG_MIN_RELEASE_AGE: String(BEFORE_SANDBOX_NPM_MIN_RELEASE_AGE_DAYS), }; + const scaffoldCwd = script.includes('{{beforeDir}}') ? createBaseDir : createBeforeDir; + if (minAgeGateExemptions?.length) { + if (scaffoldCwd === createBeforeDir) { + await mkdir(createBeforeDir, { recursive: true }); + } + await writeScaffoldNpmrc(scaffoldCwd, minAgeGateExemptions); + } + // Some tools refuse to run inside an existing directory and replace the contents, // where as others are very picky about what directories can be called. So we need to // handle different modes of operation. diff --git a/scripts/sandbox/utils/yarn.test.ts b/scripts/sandbox/utils/yarn.test.ts index dd846412acfb..0707c38803d8 100644 --- a/scripts/sandbox/utils/yarn.test.ts +++ b/scripts/sandbox/utils/yarn.test.ts @@ -14,6 +14,7 @@ import { ensureNpmSupportsMinReleaseAge, preapproveLocallyPublishedPackages, refreshBeforeStorybookLockfile, + writeScaffoldNpmrc, } from './yarn.ts'; vi.mock('node:fs/promises', { spy: true }); @@ -240,7 +241,7 @@ describe('ensureNpmSupportsMinReleaseAge', () => { }); it(`accepts npm ${BEFORE_SANDBOX_NPM_MIN_VERSION} and newer`, async () => { - vi.mocked(runCommand).mockResolvedValue({ stdout: '11.10.0\n' } as never); + vi.mocked(runCommand).mockResolvedValue({ stdout: '11.17.0\n' } as never); await expect(ensureNpmSupportsMinReleaseAge()).resolves.toBeUndefined(); @@ -249,10 +250,26 @@ describe('ensureNpmSupportsMinReleaseAge', () => { }); it('fails when npm is older than the min-release-age floor', async () => { - vi.mocked(runCommand).mockResolvedValue({ stdout: '10.9.8\n' } as never); + vi.mocked(runCommand).mockResolvedValue({ stdout: '11.10.0\n' } as never); await expect(ensureNpmSupportsMinReleaseAge()).rejects.toThrow( - new RegExp(`npm >= ${BEFORE_SANDBOX_NPM_MIN_VERSION}.*found 10\\.9\\.8`) + new RegExp(`npm >= ${BEFORE_SANDBOX_NPM_MIN_VERSION}.*found 11\\.10\\.0`) + ); + }); +}); + +describe('writeScaffoldNpmrc', () => { + it('writes min-release-age and exclude patterns for npm scaffolds', async () => { + await writeScaffoldNpmrc(SANDBOX, ['@react-native-community/*', 'multitars']); + + expect(vol.readFileSync(`${SANDBOX}/.npmrc`, 'utf-8')).toBe( + 'min-release-age=7\nmin-release-age-exclude[]=@react-native-community/*\nmin-release-age-exclude[]=multitars\n' ); }); + + it('does nothing when the allowlist is empty', async () => { + await writeScaffoldNpmrc(SANDBOX, []); + + expect(vol.existsSync(`${SANDBOX}/.npmrc`)).toBe(false); + }); }); diff --git a/scripts/sandbox/utils/yarn.ts b/scripts/sandbox/utils/yarn.ts index 9c4f81bfe04a..c3b61df9133c 100644 --- a/scripts/sandbox/utils/yarn.ts +++ b/scripts/sandbox/utils/yarn.ts @@ -67,19 +67,41 @@ export const BEFORE_SANDBOX_MIN_AGE_GATE = '7d'; export const BEFORE_SANDBOX_MIN_AGE_MINUTES = 7 * 24 * 60; /** npm `min-release-age` is in days (npm 11.10+). */ export const BEFORE_SANDBOX_NPM_MIN_RELEASE_AGE_DAYS = 7; -/** npm below this version silently ignores `NPM_CONFIG_MIN_RELEASE_AGE`. */ -export const BEFORE_SANDBOX_NPM_MIN_VERSION = '11.10.0'; +/** + * npm below this version silently ignores `NPM_CONFIG_MIN_RELEASE_AGE`. + * 11.17+ is required for `min-release-age-exclude`, which templates with + * `minAgeGateExemptions` rely on during npx/npm scaffolds. + */ +export const BEFORE_SANDBOX_NPM_MIN_VERSION = '11.17.0'; export async function ensureNpmSupportsMinReleaseAge() { const { stdout } = await runCommand('npm --version', { cwd: process.cwd() }); const version = String(stdout).trim(); if (!semver.gte(version, BEFORE_SANDBOX_NPM_MIN_VERSION)) { throw new Error( - `Sandbox generation requires npm >= ${BEFORE_SANDBOX_NPM_MIN_VERSION} so NPM_CONFIG_MIN_RELEASE_AGE is honored (found ${version}). Upgrade with: npm install -g npm@${BEFORE_SANDBOX_NPM_MIN_VERSION}` + `Sandbox generation requires npm >= ${BEFORE_SANDBOX_NPM_MIN_VERSION} so NPM_CONFIG_MIN_RELEASE_AGE and min-release-age-exclude are honored (found ${version}). Upgrade with: npm install -g npm@${BEFORE_SANDBOX_NPM_MIN_VERSION}` ); } } +/** + * Templates with a Yarn allowlist also need matching npm exclusions when their + * before-script runs through npx/npm. Array config cannot be expressed reliably + * via environment variables, so write a scratch `.npmrc` in the scaffold cwd. + */ +export async function writeScaffoldNpmrc(cwd: string, minAgeGateExemptions: string[]) { + if (!minAgeGateExemptions.length) { + return; + } + + const lines = [ + `min-release-age=${BEFORE_SANDBOX_NPM_MIN_RELEASE_AGE_DAYS}`, + ...minAgeGateExemptions.map((pattern) => `min-release-age-exclude[]=${pattern}`), + ]; + + await writeFile(join(cwd, '.npmrc'), `${lines.join('\n')}\n`); +} + interface RefreshLockfileOptions { cwd: string; debug?: boolean;