From 7cb6490a5c1ac51855a3a0c6cc0627760ee9ae87 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Wed, 22 Apr 2026 18:59:08 +0700 Subject: [PATCH 1/4] Eval: add sync-storybook-version script + --skip-automigrations Adds `scripts/eval/sync-storybook-version.ts`, which bumps every benchmark repo to a given Storybook version (stable or PR canary) in one pass: clone missing repos, fail fast on dirty trees, run `npx storybook@ upgrade --skip-automigrations` from each repo root, then commit and push the resulting dep bumps. To keep those commits free of source rewrites, adds a `--skip-automigrations` flag to `storybook upgrade` that bypasses `runAutomigrations` entirely while still producing a clean upgrade (package.json + lockfile only). --- code/lib/cli-storybook/src/bin/run.ts | 4 + code/lib/cli-storybook/src/upgrade.ts | 17 +- scripts/eval/README.md | 27 ++ scripts/eval/lib/utils.ts | 2 + scripts/eval/sync-storybook-version.test.ts | 405 ++++++++++++++++++++ scripts/eval/sync-storybook-version.ts | 224 +++++++++++ scripts/package.json | 1 + 7 files changed, 675 insertions(+), 5 deletions(-) create mode 100644 scripts/eval/sync-storybook-version.test.ts create mode 100644 scripts/eval/sync-storybook-version.ts diff --git a/code/lib/cli-storybook/src/bin/run.ts b/code/lib/cli-storybook/src/bin/run.ts index 77f18d841e2f..5f91b508e4ec 100644 --- a/code/lib/cli-storybook/src/bin/run.ts +++ b/code/lib/cli-storybook/src/bin/run.ts @@ -171,6 +171,10 @@ command('upgrade') .option('-f --force', 'force the upgrade, skipping autoblockers') .option('-n --dry-run', 'Only check for upgrades, do not install') .option('-s --skip-check', 'Skip postinstall version and automigration checks') + .option( + '--skip-automigrations', + 'Skip running automigrations entirely (only update package versions and install)' + ) .option( '-c, --config-dir ', 'Directory(ies) where to load Storybook configurations from' diff --git a/code/lib/cli-storybook/src/upgrade.ts b/code/lib/cli-storybook/src/upgrade.ts index 6a2a7b79ec40..8060fd00b041 100644 --- a/code/lib/cli-storybook/src/upgrade.ts +++ b/code/lib/cli-storybook/src/upgrade.ts @@ -116,6 +116,7 @@ export const checkVersionConsistency = () => { export type UpgradeOptions = { skipCheck: boolean; + skipAutomigrations?: boolean; packageManager?: PackageManagerName; dryRun: boolean; yes: boolean; @@ -413,11 +414,17 @@ export async function upgrade(options: UpgradeOptions): Promise { } } - // Run automigrations for all projects - const { automigrationResults, detectedAutomigrations } = await runAutomigrations( - storybookProjects, - options - ); + // Run automigrations for all projects (unless explicitly skipped) + let automigrationResults: Record = {}; + let detectedAutomigrations: AutomigrationCheckResult[] = []; + if (options.skipAutomigrations) { + logger.log('Skipping automigrations (--skip-automigrations).'); + } else { + ({ automigrationResults, detectedAutomigrations } = await runAutomigrations( + storybookProjects, + options + )); + } // Install dependencies const rootPackageManager = diff --git a/scripts/eval/README.md b/scripts/eval/README.md index da658a6ce9a0..3487078dd761 100644 --- a/scripts/eval/README.md +++ b/scripts/eval/README.md @@ -111,6 +111,33 @@ node scripts/eval/sync-baselines.ts --skip-push The script ensures each repo is on its default branch with no local changes, fetches the latest from origin, replaces the `.storybook` directory with the canonical baseline, and commits/pushes if anything changed. +## Syncing the Storybook version + +`sync-storybook-version.ts` bumps every benchmark repo to a specific Storybook version. It mirrors the shape of `sync-baselines.ts`: for each project it ensures the source clone is present and clean, checks out and fast-forwards the default branch, runs `npx storybook@ upgrade --yes --force --skip-check --skip-automigrations -c /.storybook` from the **repo root**, then commits and pushes any resulting changes. Running from the repo root (with `-c` pointing at the project's `.storybook` dir) lets the Storybook CLI discover the correct workspace `package.json` in pnpm/yarn monorepos where the Storybook deps live at the workspace root and the config lives in a sub-package. + +```sh +# Upgrade every benchmark repo to a stable version +node scripts/eval/sync-storybook-version.ts --version 9.1.0 + +# Upgrade to a canary published from a Storybook PR +node scripts/eval/sync-storybook-version.ts --version 0.0.0-pr-34297-sha-abcdef12 + +# Upgrade a subset of projects +node scripts/eval/sync-storybook-version.ts --version latest --project mealdrop --project edgy + +# Dry run (commit locally but don't push) +node scripts/eval/sync-storybook-version.ts --version 9.1.0 --skip-push +``` + +The upgrade passes the following flags: + +- `--yes` — auto-accepts prompts. +- `--force` — skips the autoblocker gate (useful for canary or major-version bumps). +- `--skip-check` — skips the postinstall self-check. +- `--skip-automigrations` — prevents the CLI from rewriting source files (e.g. the `wrap-getAbsolutePath` migration). + +The commit message defaults to `Eval: upgrade Storybook to `. Run `sync-baselines.ts` afterwards if you also need to refresh the canonical `.storybook` config in every repo. + ## Collecting results After running trials, `collect-pr-data.ts` scrapes the published draft PRs and loads the data into a local SQLite database. diff --git a/scripts/eval/lib/utils.ts b/scripts/eval/lib/utils.ts index afa49482fcd5..ec91edb01af9 100644 --- a/scripts/eval/lib/utils.ts +++ b/scripts/eval/lib/utils.ts @@ -20,6 +20,8 @@ export const EXAMPLE_PROMPT_BASENAME = 'pattern-copy-play'; export const NODE_EVAL_TRIAL_SCRIPT = 'scripts/eval/eval.ts' as const; export const NODE_EVAL_RUN_BATCH_SCRIPT = 'scripts/eval/run-batch.ts' as const; export const NODE_EVAL_SYNC_BASELINES_SCRIPT = 'scripts/eval/sync-baselines.ts' as const; +export const NODE_EVAL_SYNC_STORYBOOK_VERSION_SCRIPT = + 'scripts/eval/sync-storybook-version.ts' as const; export const NODE_EVAL_COLLECT_PR_DATA_SCRIPT = 'scripts/eval/collect-pr-data.ts' as const; export const STORYBOOK_DIRNAME = '.storybook'; export const EVAL_RESULTS_DIRNAME = 'eval-results'; diff --git a/scripts/eval/sync-storybook-version.test.ts b/scripts/eval/sync-storybook-version.test.ts new file mode 100644 index 000000000000..8d40c62f12b1 --- /dev/null +++ b/scripts/eval/sync-storybook-version.test.ts @@ -0,0 +1,405 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdir } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { Project } from './lib/projects.ts'; +import { syncStorybookVersion } from './sync-storybook-version.ts'; + +let TMP = ''; + +afterEach(() => { + if (TMP) { + rmSync(TMP, { recursive: true, force: true }); + TMP = ''; + } +}); + +describe('syncStorybookVersion', () => { + it('runs the upgrade for each project, commits, and pushes', async () => { + TMP = mkdtempSync(join(tmpdir(), 'eval-sync-storybook-version-')); + const reposRoot = join(TMP, 'repos'); + const remotesRoot = join(TMP, 'remotes'); + await mkdir(reposRoot, { recursive: true }); + await mkdir(remotesRoot, { recursive: true }); + + const projects: Project[] = [ + { + name: 'mealdrop', + repo: join(remotesRoot, 'mealdrop.git'), + branch: 'main', + githubSlug: 'storybook-tmp/mealdrop', + }, + { + name: 'wikitok', + repo: join(remotesRoot, 'wikitok.git'), + branch: 'main', + githubSlug: 'storybook-tmp/wikitok', + projectDir: 'frontend', + }, + ]; + + setupRepo({ + repoRoot: join(reposRoot, 'mealdrop'), + remoteRoot: join(remotesRoot, 'mealdrop.git'), + packageJsonPath: 'package.json', + packageJson: { + name: 'mealdrop', + dependencies: { '@storybook/react-vite': '9.0.0', storybook: '9.0.0' }, + }, + }); + setupRepo({ + repoRoot: join(reposRoot, 'wikitok'), + remoteRoot: join(remotesRoot, 'wikitok.git'), + packageJsonPath: 'frontend/package.json', + packageJson: { + name: 'wikitok-frontend', + dependencies: { '@storybook/react-vite': '9.0.0', storybook: '9.0.0' }, + }, + }); + + const upgradeCalls: Array<{ + version: string; + project: string; + repoRoot: string; + projectPath: string; + configDir: string; + }> = []; + + const hookOrder: string[] = []; + + const results = await syncStorybookVersion({ + version: '9.1.0', + reposRoot, + projects, + push: true, + log: () => {}, + installProjectDeps: async ({ project }) => { + hookOrder.push(`install:${project.name}`); + }, + runUpgrade: async ({ version, project, repoRoot, projectPath, configDir }) => { + hookOrder.push(`upgrade:${project.name}`); + upgradeCalls.push({ + version, + project: project.name, + repoRoot, + projectPath, + configDir, + }); + const pkgPath = join(projectPath, 'package.json'); + const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); + for (const key of Object.keys(pkg.dependencies ?? {})) { + if (key === 'storybook' || key.startsWith('@storybook/')) { + pkg.dependencies[key] = version; + } + } + writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`); + }, + }); + + expect(hookOrder).toEqual([ + 'install:mealdrop', + 'upgrade:mealdrop', + 'install:mealdrop', + 'install:wikitok', + 'upgrade:wikitok', + 'install:wikitok', + ]); + + expect(upgradeCalls).toEqual([ + { + version: '9.1.0', + project: 'mealdrop', + repoRoot: join(reposRoot, 'mealdrop'), + projectPath: join(reposRoot, 'mealdrop'), + configDir: '.storybook', + }, + { + version: '9.1.0', + project: 'wikitok', + repoRoot: join(reposRoot, 'wikitok'), + projectPath: join(reposRoot, 'wikitok', 'frontend'), + configDir: 'frontend/.storybook', + }, + ]); + + const mealdropPkg = JSON.parse( + readFileSync(join(reposRoot, 'mealdrop', 'package.json'), 'utf-8') + ); + const wikitokPkg = JSON.parse( + readFileSync(join(reposRoot, 'wikitok', 'frontend', 'package.json'), 'utf-8') + ); + expect(mealdropPkg.dependencies.storybook).toBe('9.1.0'); + expect(mealdropPkg.dependencies['@storybook/react-vite']).toBe('9.1.0'); + expect(wikitokPkg.dependencies.storybook).toBe('9.1.0'); + expect(wikitokPkg.dependencies['@storybook/react-vite']).toBe('9.1.0'); + + expect(results.map((r) => r.project)).toEqual(['mealdrop', 'wikitok']); + expect(results.every((r) => r.changed)).toBe(true); + expect(results.every((r) => typeof r.commitSha === 'string' && r.commitSha.length > 0)).toBe( + true + ); + + expect(getLatestCommitMessage(join(reposRoot, 'mealdrop'))).toBe( + 'Eval: upgrade Storybook to 9.1.0' + ); + expect(getHead(join(reposRoot, 'mealdrop'))).toBe( + getRemoteHead(join(remotesRoot, 'mealdrop.git')) + ); + expect(getHead(join(reposRoot, 'wikitok'))).toBe( + getRemoteHead(join(remotesRoot, 'wikitok.git')) + ); + }); + + it('reports no change and skips commit when upgrade does not modify files', async () => { + TMP = mkdtempSync(join(tmpdir(), 'eval-sync-storybook-version-noop-')); + const reposRoot = join(TMP, 'repos'); + const remotesRoot = join(TMP, 'remotes'); + await mkdir(reposRoot, { recursive: true }); + await mkdir(remotesRoot, { recursive: true }); + + const projects: Project[] = [ + { + name: 'mealdrop', + repo: join(remotesRoot, 'mealdrop.git'), + branch: 'main', + githubSlug: 'storybook-tmp/mealdrop', + }, + ]; + + setupRepo({ + repoRoot: join(reposRoot, 'mealdrop'), + remoteRoot: join(remotesRoot, 'mealdrop.git'), + packageJsonPath: 'package.json', + packageJson: { + name: 'mealdrop', + dependencies: { storybook: '9.1.0' }, + }, + }); + + const headBefore = getHead(join(reposRoot, 'mealdrop')); + + const results = await syncStorybookVersion({ + version: '9.1.0', + reposRoot, + projects, + push: true, + log: () => {}, + installProjectDeps: async () => {}, + runUpgrade: async () => {}, + }); + + expect(results).toEqual([{ project: 'mealdrop', changed: false }]); + expect(getHead(join(reposRoot, 'mealdrop'))).toBe(headBefore); + }); + + it('fails fast when a target repo is dirty', async () => { + TMP = mkdtempSync(join(tmpdir(), 'eval-sync-storybook-version-dirty-')); + const reposRoot = join(TMP, 'repos'); + const remotesRoot = join(TMP, 'remotes'); + await mkdir(reposRoot, { recursive: true }); + await mkdir(remotesRoot, { recursive: true }); + + const projects: Project[] = [ + { + name: 'mealdrop', + repo: join(remotesRoot, 'mealdrop.git'), + branch: 'main', + githubSlug: 'storybook-tmp/mealdrop', + }, + ]; + + setupRepo({ + repoRoot: join(reposRoot, 'mealdrop'), + remoteRoot: join(remotesRoot, 'mealdrop.git'), + packageJsonPath: 'package.json', + packageJson: { + name: 'mealdrop', + dependencies: { storybook: '9.0.0' }, + }, + }); + + writeFileSync(join(reposRoot, 'mealdrop', 'README.md'), 'dirty\n'); + + const upgradeCalls: string[] = []; + + await expect( + syncStorybookVersion({ + version: '9.1.0', + reposRoot, + projects, + push: true, + log: () => {}, + installProjectDeps: async () => {}, + runUpgrade: async ({ project }) => { + upgradeCalls.push(project.name); + }, + }) + ).rejects.toThrow('mealdrop has local changes'); + + expect(upgradeCalls).toEqual([]); + }); + + it('auto-clones repos that have not been cloned yet', async () => { + TMP = mkdtempSync(join(tmpdir(), 'eval-sync-storybook-version-auto-clone-')); + const reposRoot = join(TMP, 'repos'); + const remotesRoot = join(TMP, 'remotes'); + await mkdir(reposRoot, { recursive: true }); + await mkdir(remotesRoot, { recursive: true }); + + const projects: Project[] = [ + { + name: 'mealdrop', + repo: join(remotesRoot, 'mealdrop.git'), + branch: 'main', + githubSlug: 'storybook-tmp/mealdrop', + }, + ]; + + setupBareRemoteWithContent({ + remoteRoot: join(remotesRoot, 'mealdrop.git'), + files: { + 'package.json': `${JSON.stringify( + { name: 'mealdrop', dependencies: { storybook: '9.0.0' } }, + null, + 2 + )}\n`, + }, + }); + + expect(existsSync(join(reposRoot, 'mealdrop'))).toBe(false); + + const results = await syncStorybookVersion({ + version: '9.1.0', + reposRoot, + projects, + push: true, + log: () => {}, + installProjectDeps: async () => {}, + runUpgrade: async ({ version, projectPath }) => { + const pkgPath = join(projectPath, 'package.json'); + const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); + pkg.dependencies.storybook = version; + writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`); + }, + }); + + expect(existsSync(join(reposRoot, 'mealdrop', '.git'))).toBe(true); + const pkg = JSON.parse(readFileSync(join(reposRoot, 'mealdrop', 'package.json'), 'utf-8')); + expect(pkg.dependencies.storybook).toBe('9.1.0'); + expect(results).toEqual([ + { project: 'mealdrop', changed: true, commitSha: getHead(join(reposRoot, 'mealdrop')) }, + ]); + expect(getHead(join(reposRoot, 'mealdrop'))).toBe( + getRemoteHead(join(remotesRoot, 'mealdrop.git')) + ); + }); + + it('honors push=false by committing locally but not pushing', async () => { + TMP = mkdtempSync(join(tmpdir(), 'eval-sync-storybook-version-skip-push-')); + const reposRoot = join(TMP, 'repos'); + const remotesRoot = join(TMP, 'remotes'); + await mkdir(reposRoot, { recursive: true }); + await mkdir(remotesRoot, { recursive: true }); + + const projects: Project[] = [ + { + name: 'mealdrop', + repo: join(remotesRoot, 'mealdrop.git'), + branch: 'main', + githubSlug: 'storybook-tmp/mealdrop', + }, + ]; + + setupRepo({ + repoRoot: join(reposRoot, 'mealdrop'), + remoteRoot: join(remotesRoot, 'mealdrop.git'), + packageJsonPath: 'package.json', + packageJson: { + name: 'mealdrop', + dependencies: { storybook: '9.0.0' }, + }, + }); + + const remoteHeadBefore = getRemoteHead(join(remotesRoot, 'mealdrop.git')); + + await syncStorybookVersion({ + version: '9.1.0', + reposRoot, + projects, + push: false, + log: () => {}, + installProjectDeps: async () => {}, + runUpgrade: async ({ version, projectPath }) => { + const pkgPath = join(projectPath, 'package.json'); + const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); + pkg.dependencies.storybook = version; + writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`); + }, + }); + + const localHead = getHead(join(reposRoot, 'mealdrop')); + expect(localHead).not.toBe(remoteHeadBefore); + expect(getRemoteHead(join(remotesRoot, 'mealdrop.git'))).toBe(remoteHeadBefore); + }); +}); + +function setupRepo(opts: { + repoRoot: string; + remoteRoot: string; + packageJsonPath: string; + packageJson: Record; +}) { + execFileSync('git', ['init', '--bare', '--initial-branch=main', opts.remoteRoot]); + execFileSync('git', ['init', '--initial-branch=main', opts.repoRoot]); + execFileSync('git', ['-C', opts.repoRoot, 'config', 'user.name', 'Test User']); + execFileSync('git', ['-C', opts.repoRoot, 'config', 'user.email', 'test@example.com']); + + const pkgPath = join(opts.repoRoot, opts.packageJsonPath); + mkdirSyncRecursive(dirname(pkgPath)); + writeFileSync(pkgPath, `${JSON.stringify(opts.packageJson, null, 2)}\n`); + + execFileSync('git', ['-C', opts.repoRoot, 'add', '-A']); + execFileSync('git', ['-C', opts.repoRoot, 'commit', '-m', 'initial']); + execFileSync('git', ['-C', opts.repoRoot, 'remote', 'add', 'origin', opts.remoteRoot]); + execFileSync('git', ['-C', opts.repoRoot, 'push', '-u', 'origin', 'main']); +} + +function setupBareRemoteWithContent(opts: { remoteRoot: string; files: Record }) { + const staging = mkdtempSync(join(tmpdir(), 'eval-sync-storybook-version-staging-')); + execFileSync('git', ['init', '--bare', '--initial-branch=main', opts.remoteRoot]); + execFileSync('git', ['clone', opts.remoteRoot, staging]); + execFileSync('git', ['-C', staging, 'config', 'user.name', 'Test User']); + execFileSync('git', ['-C', staging, 'config', 'user.email', 'test@example.com']); + for (const [path, contents] of Object.entries(opts.files)) { + mkdirSyncRecursive(join(staging, dirname(path))); + writeFileSync(join(staging, path), contents); + } + execFileSync('git', ['-C', staging, 'add', '-A']); + execFileSync('git', ['-C', staging, 'commit', '-m', 'initial']); + execFileSync('git', ['-C', staging, 'push', 'origin', 'main']); + rmSync(staging, { recursive: true, force: true }); +} + +function mkdirSyncRecursive(path: string) { + execFileSync('mkdir', ['-p', path]); +} + +function getHead(repoRoot: string) { + return execFileSync('git', ['-C', repoRoot, 'rev-parse', 'HEAD'], { + encoding: 'utf-8', + }).trim(); +} + +function getRemoteHead(remoteRoot: string) { + return execFileSync('git', ['--git-dir', remoteRoot, 'rev-parse', 'refs/heads/main'], { + encoding: 'utf-8', + }).trim(); +} + +function getLatestCommitMessage(repoRoot: string) { + return execFileSync('git', ['-C', repoRoot, 'log', '-1', '--pretty=%s'], { + encoding: 'utf-8', + }).trim(); +} diff --git a/scripts/eval/sync-storybook-version.ts b/scripts/eval/sync-storybook-version.ts new file mode 100644 index 000000000000..bd0bb5ec123d --- /dev/null +++ b/scripts/eval/sync-storybook-version.ts @@ -0,0 +1,224 @@ +import { join, relative, resolve } from 'node:path'; +import { parseArgs } from 'node:util'; +import pc from 'picocolors'; +import { x } from 'tinyexec'; +import { esMain } from '../utils/esmain.ts'; +import { installDeps } from './lib/package-manager.ts'; +import { ensureSourceClone } from './lib/prepare-trial.ts'; +import { PROJECTS, type Project } from './lib/projects.ts'; +import { + createLogger, + formatHelp, + formatTable, + getProjectPath, + getStorybookDir, + NODE_EVAL_SYNC_STORYBOOK_VERSION_SCRIPT, + REPOS_DIR, +} from './lib/utils.ts'; + +type HookArgs = { + project: Project; + repoRoot: string; + projectPath: string; + configDir: string; +}; + +type RunUpgrade = (args: HookArgs & { version: string }) => Promise; +type RunInstall = (args: HookArgs) => Promise; + +export interface SyncStorybookVersionOptions { + /** Storybook version to upgrade to (e.g. `latest`, `9.1.0`, `0.0.0-pr-1-sha-abc`). */ + version: string; + /** Per-project clones live under `reposRoot/`. Defaults to `REPOS_DIR`. */ + reposRoot?: string; + /** Subset of benchmark projects (defaults to all). */ + projects?: Project[]; + /** Push the resulting commit to origin. Defaults to true. */ + push?: boolean; + log?: (message: string) => void; + /** Test hook; defaults to running `npx storybook@ upgrade ...` from the repo root. */ + runUpgrade?: RunUpgrade; + /** Test hook; defaults to `installDeps(projectPath, ...)`. */ + installProjectDeps?: RunInstall; +} + +export interface SyncResult { + project: string; + changed: boolean; + commitSha?: string; +} + +const cliOptions = { + version: { + type: 'string' as const, + short: 'V', + description: 'Storybook version to upgrade to (e.g. latest, 9.1.0, 0.0.0-pr-1-sha-abc)', + }, + project: { + type: 'string' as const, + multiple: true, + description: 'Project(s) to sync (repeatable)', + }, + 'skip-push': { + type: 'boolean' as const, + description: 'Commit locally but do not push', + }, + help: { type: 'boolean' as const, short: 'h', description: 'Show this help and exit' }, +}; + +if (esMain(import.meta.url)) { + const { values } = parseArgs({ + args: process.argv.slice(2), + options: cliOptions, + strict: true, + }); + + if (values.help) { + console.log( + formatHelp( + `node ${NODE_EVAL_SYNC_STORYBOOK_VERSION_SCRIPT} --version [options]`, + 'Upgrade Storybook in every benchmark repo to the given version.', + cliOptions + ) + ); + process.exit(0); + } + + if (!values.version) { + console.error( + `Error: --version is required. See \`node ${NODE_EVAL_SYNC_STORYBOOK_VERSION_SCRIPT} --help\`.` + ); + process.exit(1); + } + + const selected = values.project?.length + ? PROJECTS.filter((p) => values.project?.includes(p.name)) + : PROJECTS; + + await syncStorybookVersion({ + version: values.version, + projects: selected, + push: !values['skip-push'], + log: (message) => console.log(message), + }); +} + +export async function syncStorybookVersion( + options: SyncStorybookVersionOptions +): Promise { + const { + version, + reposRoot = REPOS_DIR, + projects = PROJECTS, + push = true, + log = () => {}, + runUpgrade = defaultRunUpgrade, + installProjectDeps = defaultInstallProjectDeps, + } = options; + + if (!version) { + throw new Error('syncStorybookVersion requires a non-empty `version`'); + } + + const logger = createLogger(); + const resolved = projects.map((project) => { + const repoRoot = join(resolve(reposRoot), project.name); + const projectPath = getProjectPath(repoRoot, project.projectDir); + const configDir = relative(repoRoot, getStorybookDir(projectPath)) || '.storybook'; + return { project, repoRoot, projectPath, configDir }; + }); + + // Preflight: auto-clone missing repos and fail fast if any working tree is dirty. + for (const { project, repoRoot } of resolved) { + await ensureSourceClone(project, repoRoot, logger); + const { stdout } = await x('git', ['status', '--short'], { nodeOptions: { cwd: repoRoot } }); + if (stdout.trim()) { + throw new Error(`${project.name} has local changes: ${stdout.trim().replace(/\n/g, ', ')}`); + } + } + + const results: SyncResult[] = []; + for (const hookArgs of resolved) { + const { project, repoRoot } = hookArgs; + log(pc.bold(`\nUpgrading ${project.name} to ${version}`)); + + await x('git', ['checkout', project.branch], { nodeOptions: { cwd: repoRoot } }); + await x('git', ['pull', '--ff-only', 'origin', project.branch], { + timeout: 120_000, + nodeOptions: { cwd: repoRoot }, + }); + + // `.storybook/main.ts` needs node_modules to evaluate during `storybook upgrade`, + // so install first. Install again afterwards because the upgrade's own install + // does not always refresh sub-package lockfiles (e.g. wikitok's `frontend/`). + await installProjectDeps(hookArgs); + await runUpgrade({ version, ...hookArgs }); + await installProjectDeps(hookArgs); + + await x('git', ['add', '-A'], { nodeOptions: { cwd: repoRoot } }); + const diff = await x('git', ['diff', '--cached', '--quiet'], { + throwOnError: false, + nodeOptions: { cwd: repoRoot }, + }); + if (diff.exitCode === 0) { + log(` ${pc.dim('already on target version')}`); + results.push({ project: project.name, changed: false }); + continue; + } + + await x('git', ['commit', '--no-verify', '-m', `Eval: upgrade Storybook to ${version}`], { + nodeOptions: { cwd: repoRoot }, + }); + const head = await x('git', ['rev-parse', 'HEAD'], { nodeOptions: { cwd: repoRoot } }); + const commitSha = head.stdout.trim(); + + if (push) { + await x('git', ['push', 'origin', project.branch], { + timeout: 120_000, + nodeOptions: { cwd: repoRoot }, + }); + } + + results.push({ project: project.name, changed: true, commitSha }); + } + + log( + `\n${formatTable( + ['Project', 'Changed', 'Commit'], + results.map((r) => [r.project, r.changed ? 'yes' : 'no', r.commitSha?.slice(0, 8) ?? '-']) + )}` + ); + + return results; +} + +const defaultRunUpgrade: RunUpgrade = async ({ version, repoRoot, configDir }) => { + // `--yes`/`--force` already disable prompts. `CI`, `YARN_ENABLE_IMMUTABLE_INSTALLS`, + // and `npm_config_frozen_lockfile` would refuse lockfile updates and leave + // package.json and the lockfile out of sync, so unset them here. + const env = { + ...process.env, + YARN_ENABLE_IMMUTABLE_INSTALLS: 'false', + npm_config_frozen_lockfile: 'false', + }; + delete env.CI; + + await x( + 'npx', + [ + `storybook@${version}`, + 'upgrade', + '--yes', + '--force', + '--skip-check', + '--skip-automigrations', + '-c', + configDir, + ], + { timeout: 900_000, nodeOptions: { cwd: repoRoot, env, stdio: 'inherit' } } + ); +}; + +const defaultInstallProjectDeps: RunInstall = async ({ repoRoot, projectPath }) => { + await installDeps(projectPath, createLogger(), undefined, { stopAt: repoRoot }); +}; diff --git a/scripts/package.json b/scripts/package.json index 9ee400797e95..d644d08dfda7 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -14,6 +14,7 @@ "eval:collect-pr-data": "node ./eval/collect-pr-data.ts", "eval:run-batch": "node ./eval/run-batch.ts", "eval:sync-baselines": "node ./eval/sync-baselines.ts", + "eval:sync-storybook-version": "node ./eval/sync-storybook-version.ts", "generate-sandboxes": "jiti ./sandbox/generate.ts", "get-report-message": "jiti ./get-report-message.ts", "get-sandbox-dir": "jiti ./get-sandbox-dir.ts", From 8c8312b84e19985a2d4467dd13a3a151cc4f640a Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Wed, 22 Apr 2026 19:13:25 +0700 Subject: [PATCH 2/4] Eval: hoist default sync hooks to avoid TDZ from CLI entry The default `runUpgrade` and `installProjectDeps` hooks were `const` arrow expressions declared below the top-level `if (esMain(...))` block. When the script is invoked via `node`, the CLI path calls `syncStorybookVersion(...)` while the module body is still executing, hitting the temporal dead zone. Tests didn't catch this because `import` evaluates the whole module first. Switch both to hoisted `async function` declarations so the CLI entry can reach them regardless of source order. --- scripts/eval/sync-storybook-version.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/scripts/eval/sync-storybook-version.ts b/scripts/eval/sync-storybook-version.ts index bd0bb5ec123d..bf5997a5f2b6 100644 --- a/scripts/eval/sync-storybook-version.ts +++ b/scripts/eval/sync-storybook-version.ts @@ -192,7 +192,11 @@ export async function syncStorybookVersion( return results; } -const defaultRunUpgrade: RunUpgrade = async ({ version, repoRoot, configDir }) => { +async function defaultRunUpgrade({ + version, + repoRoot, + configDir, +}: Parameters[0]): Promise { // `--yes`/`--force` already disable prompts. `CI`, `YARN_ENABLE_IMMUTABLE_INSTALLS`, // and `npm_config_frozen_lockfile` would refuse lockfile updates and leave // package.json and the lockfile out of sync, so unset them here. @@ -217,8 +221,11 @@ const defaultRunUpgrade: RunUpgrade = async ({ version, repoRoot, configDir }) = ], { timeout: 900_000, nodeOptions: { cwd: repoRoot, env, stdio: 'inherit' } } ); -}; +} -const defaultInstallProjectDeps: RunInstall = async ({ repoRoot, projectPath }) => { +async function defaultInstallProjectDeps({ + repoRoot, + projectPath, +}: Parameters[0]): Promise { await installDeps(projectPath, createLogger(), undefined, { stopAt: repoRoot }); -}; +} From d8de741939af924e35dd2489b9a3fac4fd565722 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Wed, 22 Apr 2026 19:55:34 +0700 Subject: [PATCH 3/4] Eval: let reruns push existing local sync commits When sync-storybook-version is first run with --skip-push, a second run without that flag should publish the already-created local upgrade commit instead of reporting a no-op and requiring a manual git push in every benchmark repo. Teach the no-diff path to detect when the repo is ahead of origin and, if pushing is enabled, push that existing commit. Add a regression test for the two-step skip-push -> push workflow and document it in the eval README. --- scripts/eval/README.md | 4 +- scripts/eval/sync-storybook-version.test.ts | 66 +++++++++++++++++++++ scripts/eval/sync-storybook-version.ts | 18 +++++- 3 files changed, 84 insertions(+), 4 deletions(-) diff --git a/scripts/eval/README.md b/scripts/eval/README.md index 3487078dd761..824be37cc6a8 100644 --- a/scripts/eval/README.md +++ b/scripts/eval/README.md @@ -125,7 +125,7 @@ node scripts/eval/sync-storybook-version.ts --version 0.0.0-pr-34297-sha-abcdef1 # Upgrade a subset of projects node scripts/eval/sync-storybook-version.ts --version latest --project mealdrop --project edgy -# Dry run (commit locally but don't push) +# Commit locally without pushing yet node scripts/eval/sync-storybook-version.ts --version 9.1.0 --skip-push ``` @@ -136,7 +136,7 @@ The upgrade passes the following flags: - `--skip-check` — skips the postinstall self-check. - `--skip-automigrations` — prevents the CLI from rewriting source files (e.g. the `wrap-getAbsolutePath` migration). -The commit message defaults to `Eval: upgrade Storybook to `. Run `sync-baselines.ts` afterwards if you also need to refresh the canonical `.storybook` config in every repo. +The commit message defaults to `Eval: upgrade Storybook to `. If you review a `--skip-push` run first, rerun the same command without `--skip-push` to push the existing local upgrade commits. Run `sync-baselines.ts` afterwards if you also need to refresh the canonical `.storybook` config in every repo. ## Collecting results diff --git a/scripts/eval/sync-storybook-version.test.ts b/scripts/eval/sync-storybook-version.test.ts index 8d40c62f12b1..c53a580f6d85 100644 --- a/scripts/eval/sync-storybook-version.test.ts +++ b/scripts/eval/sync-storybook-version.test.ts @@ -343,6 +343,72 @@ describe('syncStorybookVersion', () => { expect(localHead).not.toBe(remoteHeadBefore); expect(getRemoteHead(join(remotesRoot, 'mealdrop.git'))).toBe(remoteHeadBefore); }); + + it('pushes an existing local upgrade commit on a rerun after skip-push', async () => { + TMP = mkdtempSync(join(tmpdir(), 'eval-sync-storybook-version-resume-push-')); + const reposRoot = join(TMP, 'repos'); + const remotesRoot = join(TMP, 'remotes'); + await mkdir(reposRoot, { recursive: true }); + await mkdir(remotesRoot, { recursive: true }); + + const projects: Project[] = [ + { + name: 'mealdrop', + repo: join(remotesRoot, 'mealdrop.git'), + branch: 'main', + githubSlug: 'storybook-tmp/mealdrop', + }, + ]; + + setupRepo({ + repoRoot: join(reposRoot, 'mealdrop'), + remoteRoot: join(remotesRoot, 'mealdrop.git'), + packageJsonPath: 'package.json', + packageJson: { + name: 'mealdrop', + dependencies: { storybook: '9.0.0' }, + }, + }); + + const remoteHeadBefore = getRemoteHead(join(remotesRoot, 'mealdrop.git')); + + await syncStorybookVersion({ + version: '9.1.0', + reposRoot, + projects, + push: false, + log: () => {}, + installProjectDeps: async () => {}, + runUpgrade: async ({ version, projectPath }) => { + const pkgPath = join(projectPath, 'package.json'); + const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); + pkg.dependencies.storybook = version; + writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`); + }, + }); + + const localHead = getHead(join(reposRoot, 'mealdrop')); + expect(localHead).not.toBe(remoteHeadBefore); + expect(getRemoteHead(join(remotesRoot, 'mealdrop.git'))).toBe(remoteHeadBefore); + + const results = await syncStorybookVersion({ + version: '9.1.0', + reposRoot, + projects, + push: true, + log: () => {}, + installProjectDeps: async () => {}, + runUpgrade: async ({ version, projectPath }) => { + const pkgPath = join(projectPath, 'package.json'); + const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); + pkg.dependencies.storybook = version; + writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`); + }, + }); + + expect(results).toEqual([{ project: 'mealdrop', changed: true, commitSha: localHead }]); + expect(getRemoteHead(join(remotesRoot, 'mealdrop.git'))).toBe(localHead); + }); }); function setupRepo(opts: { diff --git a/scripts/eval/sync-storybook-version.ts b/scripts/eval/sync-storybook-version.ts index bf5997a5f2b6..d639fc505250 100644 --- a/scripts/eval/sync-storybook-version.ts +++ b/scripts/eval/sync-storybook-version.ts @@ -161,8 +161,22 @@ export async function syncStorybookVersion( nodeOptions: { cwd: repoRoot }, }); if (diff.exitCode === 0) { - log(` ${pc.dim('already on target version')}`); - results.push({ project: project.name, changed: false }); + const ahead = await x('git', ['rev-list', '--count', `origin/${project.branch}..HEAD`], { + nodeOptions: { cwd: repoRoot }, + }); + if (push && ahead.stdout.trim() !== '0') { + await x('git', ['push', 'origin', project.branch], { + timeout: 120_000, + nodeOptions: { cwd: repoRoot }, + }); + const head = await x('git', ['rev-parse', 'HEAD'], { nodeOptions: { cwd: repoRoot } }); + const commitSha = head.stdout.trim(); + log(` ${pc.dim('already on target version; pushed existing local commit')}`); + results.push({ project: project.name, changed: true, commitSha }); + } else { + log(` ${pc.dim('already on target version')}`); + results.push({ project: project.name, changed: false }); + } continue; } From 448d4d4b4983891197e1cfe375c7637cca905596 Mon Sep 17 00:00:00 2001 From: Kasper Peulen Date: Wed, 22 Apr 2026 22:22:09 +0700 Subject: [PATCH 4/4] CI: type sync-storybook-version env as NodeJS.ProcessEnv --- scripts/eval/sync-storybook-version.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/eval/sync-storybook-version.ts b/scripts/eval/sync-storybook-version.ts index d639fc505250..e7ace41eff3d 100644 --- a/scripts/eval/sync-storybook-version.ts +++ b/scripts/eval/sync-storybook-version.ts @@ -214,7 +214,7 @@ async function defaultRunUpgrade({ // `--yes`/`--force` already disable prompts. `CI`, `YARN_ENABLE_IMMUTABLE_INSTALLS`, // and `npm_config_frozen_lockfile` would refuse lockfile updates and leave // package.json and the lockfile out of sync, so unset them here. - const env = { + const env: NodeJS.ProcessEnv = { ...process.env, YARN_ENABLE_IMMUTABLE_INSTALLS: 'false', npm_config_frozen_lockfile: 'false',