diff --git a/scripts/sandbox/publish.ts b/scripts/sandbox/publish.ts index 3797c06c8bda..10a92c982a1f 100755 --- a/scripts/sandbox/publish.ts +++ b/scripts/sandbox/publish.ts @@ -11,6 +11,7 @@ import { dirname, join, relative } from 'path'; import { temporaryDirectory } from '../../code/core/src/common/utils/cli.ts'; import { REPROS_DIRECTORY } from '../utils/constants.ts'; import { commitAllToGit } from './utils/git.ts'; +import { sanitizePublishedSandboxes } from './utils/sanitize-published-sandbox.ts'; import { getTemplatesData, renderTemplate } from './utils/template.ts'; export const logger = console; @@ -70,6 +71,13 @@ const publish = async (options: PublishOptions & { tmpFolder: string }) => { logger.log(`๐Ÿš› Moving all the repros into the repository`); await cp(REPROS_DIRECTORY, tmpFolder, { recursive: true }); + logger.log(`๐Ÿงผ Sanitizing published sandboxes (after-storybook .yarnrc.yml + install artifacts)`); + const sanitizeResult = await sanitizePublishedSandboxes(tmpFolder); + logger.log( + `๐Ÿงผ Sanitize summary: stripped ${sanitizeResult.strippedKeyCount} key(s) from ` + + `${sanitizeResult.filteredYarnrcCount} .yarnrc.yml file(s); removed ${sanitizeResult.removedPaths} excluded path(s)` + ); + await commitAllToGit({ cwd: tmpFolder, branch }); logger.info(` diff --git a/scripts/sandbox/utils/sanitize-published-sandbox.test.ts b/scripts/sandbox/utils/sanitize-published-sandbox.test.ts new file mode 100644 index 000000000000..2ac61336e0aa --- /dev/null +++ b/scripts/sandbox/utils/sanitize-published-sandbox.test.ts @@ -0,0 +1,168 @@ +import { mkdir, mkdtemp, readFile, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + EXCLUDE_GLOBS, + STRIP_KEYS, + sanitizePublishedSandboxes, +} from './sanitize-published-sandbox.ts'; + +const exists = async (path: string) => { + try { + await stat(path); + return true; + } catch { + return false; + } +}; + +describe('STRIP_KEYS', () => { + it('contains exactly the documented host-local / verdaccio keys', () => { + // Mutating this list requires deliberate code review โ€” if you are touching it, + // make sure the published sandboxes repository contract is intentional. + expect([...STRIP_KEYS]).toEqual([ + 'npmRegistryServer', + 'unsafeHttpWhitelist', + 'enableImmutableInstalls', + 'enableMirror', + 'logFilters', + 'npmMinimalAgeGate', + 'pnpFallbackMode', + 'enableGlobalCache', + 'checksumBehavior', + ]); + }); +}); + +describe('EXCLUDE_GLOBS', () => { + it('targets only known install / build artifacts', () => { + expect([...EXCLUDE_GLOBS]).toEqual([ + '**/.yarn/cache/**', + '**/.yarn/install-state.gz', + '**/.yarn/build-state.yml', + '**/.yarn/unplugged/**', + '**/.pnp.cjs', + '**/.pnp.loader.mjs', + '**/node_modules/**', + '**/.cache/**', + '**/storybook-static/**', + ]); + }); +}); + +describe('sanitizePublishedSandboxes', () => { + let root: string; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'sanitize-sandbox-')); + }); + + afterEach(async () => { + // tmpdir cleanup is best-effort; not strictly required for correctness + }); + + it('strips every STRIP_KEYS entry from after-storybook/.yarnrc.yml', async () => { + const afterDir = join(root, 'react-vite', 'default-ts', 'after-storybook'); + await mkdir(afterDir, { recursive: true }); + const yarnrc = [ + 'nodeLinker: node-modules', + 'npmRegistryServer: "http://localhost:6001/"', + 'unsafeHttpWhitelist: "localhost"', + 'enableImmutableInstalls: false', + 'enableMirror: false', + 'logFilters: []', + 'npmMinimalAgeGate: 0', + 'pnpFallbackMode: none', + 'enableGlobalCache: true', + 'checksumBehavior: ignore', + '', + ].join('\n'); + await writeFile(join(afterDir, '.yarnrc.yml'), yarnrc); + + const result = await sanitizePublishedSandboxes(root); + + expect(result.filteredYarnrcCount).toBe(1); + expect(result.strippedKeyCount).toBe(STRIP_KEYS.length); + + const sanitized = await readFile(join(afterDir, '.yarnrc.yml'), 'utf-8'); + for (const key of STRIP_KEYS) { + expect(sanitized).not.toContain(key); + } + // Non-stripped keys are preserved + expect(sanitized).toContain('nodeLinker'); + }); + + it('leaves before-storybook/.yarnrc.yml untouched', async () => { + const beforeDir = join(root, 'react-vite', 'default-ts', 'before-storybook'); + await mkdir(beforeDir, { recursive: true }); + const yarnrc = [ + 'nodeLinker: node-modules', + 'enableGlobalCache: true', + '', + ].join('\n'); + await writeFile(join(beforeDir, '.yarnrc.yml'), yarnrc); + + const result = await sanitizePublishedSandboxes(root); + + expect(result.filteredYarnrcCount).toBe(0); + const preserved = await readFile(join(beforeDir, '.yarnrc.yml'), 'utf-8'); + expect(preserved).toContain('enableGlobalCache'); + expect(preserved).toContain('nodeLinker'); + }); + + it('removes EXCLUDE_GLOBS matches from the tree', async () => { + const afterDir = join(root, 'react-vite', 'default-ts', 'after-storybook'); + const beforeDir = join(root, 'react-vite', 'default-ts', 'before-storybook'); + const afterCache = join(afterDir, '.yarn', 'cache'); + const beforeNodeModules = join(beforeDir, 'node_modules', 'some-pkg'); + + await mkdir(afterCache, { recursive: true }); + await writeFile(join(afterCache, 'pkg.zip'), 'binary blob'); + await mkdir(beforeNodeModules, { recursive: true }); + await writeFile(join(beforeNodeModules, 'index.js'), 'export {}'); + await writeFile(join(afterDir, '.pnp.cjs'), '/* zero install */'); + await writeFile(join(afterDir, 'README.md'), '# kept'); + + const result = await sanitizePublishedSandboxes(root); + + expect(result.removedPaths).toBeGreaterThan(0); + expect(await exists(afterCache)).toBe(false); + expect(await exists(beforeNodeModules)).toBe(false); + expect(await exists(join(afterDir, '.pnp.cjs'))).toBe(false); + // Non-excluded files survive + expect(await exists(join(afterDir, 'README.md'))).toBe(true); + }); + + it('writes an empty file when stripping leaves no keys', async () => { + const afterDir = join(root, 'svelte-vite', 'default-ts', 'after-storybook'); + await mkdir(afterDir, { recursive: true }); + await writeFile( + join(afterDir, '.yarnrc.yml'), + 'npmRegistryServer: "http://localhost:6001/"\n' + ); + + await sanitizePublishedSandboxes(root); + + const sanitized = await readFile(join(afterDir, '.yarnrc.yml'), 'utf-8'); + expect(sanitized).toBe(''); + }); + + it('is idempotent โ€” a second run is a no-op', async () => { + const afterDir = join(root, 'react-vite', 'default-ts', 'after-storybook'); + await mkdir(afterDir, { recursive: true }); + await writeFile( + join(afterDir, '.yarnrc.yml'), + 'nodeLinker: node-modules\nnpmRegistryServer: "http://localhost:6001/"\n' + ); + + await sanitizePublishedSandboxes(root); + const second = await sanitizePublishedSandboxes(root); + + expect(second.filteredYarnrcCount).toBe(0); + expect(second.strippedKeyCount).toBe(0); + expect(second.removedPaths).toBe(0); + }); +}); diff --git a/scripts/sandbox/utils/sanitize-published-sandbox.ts b/scripts/sandbox/utils/sanitize-published-sandbox.ts new file mode 100644 index 000000000000..d10c283018ed --- /dev/null +++ b/scripts/sandbox/utils/sanitize-published-sandbox.ts @@ -0,0 +1,108 @@ +import { readFile, rm, writeFile } from 'node:fs/promises'; + +// eslint-disable-next-line depend/ban-dependencies +import { glob } from 'glob'; +import yml from 'yaml'; + +/** + * Keys stripped from `after-storybook/.yarnrc.yml` before publishing to the public sandboxes + * repository. These are host-local or Verdaccio-bootstrap settings that would either break a + * consumer's install (e.g. `npmRegistryServer: http://localhost:6001/`) or weaken their default + * supply-chain protections (e.g. `npmMinimalAgeGate: 0`). + * + * Mutating this list requires a deliberate code review (the integrity test asserts the exact set). + */ +export const STRIP_KEYS = [ + 'npmRegistryServer', + 'unsafeHttpWhitelist', + 'enableImmutableInstalls', + 'enableMirror', + 'logFilters', + 'npmMinimalAgeGate', + 'pnpFallbackMode', + 'enableGlobalCache', + 'checksumBehavior', +] as const; + +/** + * Paths excluded from the published sandbox copy. These are install artifacts or build outputs + * that bloat the repo without providing value to consumers (who will re-run `yarn install` against + * the committed lockfile). + */ +export const EXCLUDE_GLOBS = [ + '**/.yarn/cache/**', + '**/.yarn/install-state.gz', + '**/.yarn/build-state.yml', + '**/.yarn/unplugged/**', + '**/.pnp.cjs', + '**/.pnp.loader.mjs', + '**/node_modules/**', + '**/.cache/**', + '**/storybook-static/**', +] as const; + +export type SanitizeResult = { + filteredYarnrcCount: number; + strippedKeyCount: number; + removedPaths: number; +}; + +/** + * Sanitize a directory tree that is about to be published to `storybookjs/sandboxes`. + * + * - Removes `STRIP_KEYS` from every `**\/after-storybook/.yarnrc.yml` (verdaccio/host config). + * - Removes paths matching `EXCLUDE_GLOBS` from the tree (install artifacts, build output). + * + * `before-storybook/.yarnrc.yml` is intentionally left untouched: it contains only the + * user-facing Yarn setup we want consumers to reproduce. + */ +export const sanitizePublishedSandboxes = async (rootDir: string): Promise => { + const yarnrcFiles = await glob('**/after-storybook/.yarnrc.yml', { + cwd: rootDir, + absolute: true, + dot: true, + }); + + let filteredYarnrcCount = 0; + let strippedKeyCount = 0; + + for (const file of yarnrcFiles) { + const original = await readFile(file, 'utf-8'); + if (!original.trim()) { + continue; + } + + const doc = (yml.parse(original) ?? {}) as Record; + let modified = false; + + for (const key of STRIP_KEYS) { + if (key in doc) { + delete doc[key]; + modified = true; + strippedKeyCount++; + } + } + + if (modified) { + const updated = Object.keys(doc).length === 0 ? '' : yml.stringify(doc); + await writeFile(file, updated); + filteredYarnrcCount++; + } + } + + const excluded = await glob([...EXCLUDE_GLOBS], { + cwd: rootDir, + absolute: true, + dot: true, + }); + + for (const target of excluded) { + await rm(target, { recursive: true, force: true }); + } + + return { + filteredYarnrcCount, + strippedKeyCount, + removedPaths: excluded.length, + }; +};