Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d28fb38
Build: Replace rollup-plugin-dts with rolldown-plugin-dts + tsgo for …
valentinpalkovic Jul 2, 2026
ab5778f
Build: satisfy noImplicitReturns in dts resolver plugin
valentinpalkovic Jul 2, 2026
4999781
Build: align enum imports with package specifiers for bundled d.ts co…
valentinpalkovic Jul 2, 2026
6dbbb02
Build: emit eslint-plugin declarations with tsc instead of tsgo
valentinpalkovic Jul 2, 2026
6d85ea9
Build: default to the tsc declaration emitter, keep tsgo opt-in
valentinpalkovic Jul 2, 2026
c562c95
Build: make d.ts generation deterministic via whole-program pre-emit
valentinpalkovic Jul 2, 2026
89e5793
Build: dedupe lockfile entries added by the rolldown toolchain
valentinpalkovic Jul 2, 2026
8251a62
Build: run NX compile with 8 workers on CI
valentinpalkovic Jul 2, 2026
ba027e8
Build: launch package builds with native node instead of yarn exec jiti
valentinpalkovic Jul 2, 2026
979a2e7
ci: pack node_modules into a gzipped tarball for the pipeline workspace
valentinpalkovic Jul 2, 2026
02dff02
Build: keep scripts/utils/constants free of ESM-only globals
valentinpalkovic Jul 2, 2026
b2e5e29
ci: parallelize the typescript-validation package checks
valentinpalkovic Jul 2, 2026
9e7a20c
ci: run sandbox E2E specs with parallel Playwright workers
valentinpalkovic Jul 2, 2026
2d47198
ci: pack sandboxes into gzipped tarballs for the pipeline workspace
valentinpalkovic Jul 2, 2026
d606ac4
ci: preserve cache validity and ownership through the sandbox tarball
valentinpalkovic Jul 2, 2026
c5a1a86
ci: fold format check and Knip into a single static-checks job
valentinpalkovic Jul 2, 2026
d56b4c5
ci: re-enable the NX cache for CI compiles
valentinpalkovic Jul 2, 2026
6e153ab
ci: build the sandbox static output inside the create job
valentinpalkovic Jul 2, 2026
14cdc09
ci: shard the sandbox dev E2E jobs across two containers
valentinpalkovic Jul 2, 2026
8875c38
ci: order the mutating E2E pass by invocation, not project dependency
valentinpalkovic Jul 2, 2026
de09118
ci: trim fixed overhead from the sandbox create flow
valentinpalkovic Jul 2, 2026
d3bb8ed
ci: treat a held dev port as ready in the dev task probe
valentinpalkovic Jul 2, 2026
f50a611
CLI: skip redundant registry probes during sandbox creation
valentinpalkovic Jul 2, 2026
ab81b91
CI: Split ci:normal into ci:core and framework/builder split labels
claude Jul 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/skills/open-pr/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Use **AskQuestion** for three questions. Options from `.github/PULL_REQUEST_TEMP

| Question | Options |
| -------- | ------- |
| CI label | `ci:normal`, `ci:merged`, `ci:daily` |
| CI label | `ci:normal`, `ci:merged`, `ci:daily`, or split labels: `ci:core` and/or framework/builder labels (`ci:react`, `ci:vue3`, `ci:angular`, `ci:vite`, `ci:webpack`, ...) |
| QA label | `qa:needed`, `qa:skip` |
| Type label | `bug`, `maintenance`, `dependencies`, `build`, `cleanup`, `documentation`, `feature request`, `BREAKING CHANGE`, `other` |

Expand Down
10 changes: 9 additions & 1 deletion .agents/skills/pr/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,21 @@ Add these labels to the PR:
- `BREAKING CHANGE` - breaks compatibility
- `other` - doesn't fit above

**CI (required, pick one):**
**CI (required, pick one cadence label OR one or more split labels):**

Cadence labels (pick at most one, do not combine with split labels):

- `ci:normal` - standard sandbox set; default for most code changes
- `ci:merged` - merged sandbox set
- `ci:daily` - daily sandbox set; use this when changes affect prerelease sandboxes or sandboxes pinned to a framework or React version other than latest
- `ci:docs` - documentation-only changes (use with `documentation` category)

Split labels (combinable with each other; faster and more targeted than cadence labels):

- `ci:core` - internal e2e tests and all non-sandbox-specific tests; use for changes that don't affect sandbox behavior
- `ci:<framework>` (e.g. `ci:react`, `ci:vue3`, `ci:angular`, `ci:svelte`, `ci:html`, `ci:preact`, `ci:web-components`, `ci:solid`) - all sandboxes for that framework
- `ci:<builder>` (`ci:vite`, `ci:webpack`, `ci:rsbuild`) - all sandboxes for that builder

**QA (required, pick one):**

Tells the release team whether manual QA is needed before the next minor release.
Expand Down
13 changes: 5 additions & 8 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,11 @@ parameters:
type: string
workflow:
default: skipped
description: Which workflow to run
enum:
- normal
- merged
- daily
- skipped
- docs
type: enum
description: >-
Which workflow to run: a single workflow (normal, merged, daily, docs, core), or a
`+`-separated list of sandbox-split atoms (a framework such as react/vue3/angular, or a
builder such as vite/webpack), e.g. "core+react". Validated by scripts/ci/main.ts.
type: string

jobs:
generate-and-run-config:
Expand Down
2 changes: 1 addition & 1 deletion .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Do not describe how YOU tested the PR code, but how a separate maintainer should

## Checklist for Maintainers

- [ ] When this PR is ready for testing, make sure to add `ci:normal`, `ci:merged` or `ci:daily` GH label to it to run a specific set of sandboxes. The particular set of sandboxes can be found in `code/lib/cli-storybook/src/sandbox-templates.ts`
- [ ] When this PR is ready for testing, add a CI label to it: `ci:normal`, `ci:merged` or `ci:daily` to run a specific set of sandboxes (see `code/lib/cli-storybook/src/sandbox-templates.ts`), or the faster split labels — `ci:core` (internal e2e tests and non-sandbox tests) combined with framework labels (`ci:react`, `ci:vue3`, `ci:angular`, ...) and/or builder labels (`ci:vite`, `ci:webpack`) that run all sandboxes for that framework or builder
- [ ] Declare whether manual QA will be needed for this PR during the next release, through `qa:needed` or `qa:skip`
- [ ] Make sure this PR contains **one** of the labels below:
<details>
Expand Down
24 changes: 23 additions & 1 deletion .github/workflows/trigger-circle-ci-workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,28 @@ jobs:
- id: daily
if: github.event_name == 'pull_request_target' && (contains(github.event.pull_request.labels.*.name, 'ci:daily'))
run: echo "workflow=daily" >> "$GITHUB_OUTPUT"
- id: split
if: github.event_name == 'pull_request_target'
env:
LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }}
run: |
# Combine the split CI labels — ci:core, ci:<framework> (e.g. ci:react,
# ci:vue3, ci:angular) and ci:<builder> (e.g. ci:vite, ci:webpack) —
# into a single `+`-separated workflow value such as "core+react".
# The cadence labels are handled by the steps above and take
# precedence. The label pattern below intentionally excludes labels
# with spaces ("ci: do not merge"); each atom is validated by the
# config generator (scripts/ci/main.ts), which fails the pipeline
# setup on unknown atoms.
WORKFLOW=$(echo "$LABELS" | jq -r '
[ .[]
| select(test("^ci:[a-z0-9-]+$"))
| ltrimstr("ci:")
| select(. != "normal" and . != "docs" and . != "merged" and . != "daily")
] | sort | join("+")')
if [ -n "$WORKFLOW" ]; then
echo "workflow=$WORKFLOW" >> "$GITHUB_OUTPUT"
fi
- id: trusted-author
env:
EVENT_NAME: ${{ github.event_name }}
Expand Down Expand Up @@ -132,7 +154,7 @@ jobs:
echo "result=$IS_TRUSTED" >> "$GITHUB_OUTPUT"
fi
outputs:
workflow: ${{ steps.normal.outputs.workflow || steps.docs.outputs.workflow || steps.merged.outputs.workflow || steps.daily.outputs.workflow }}
workflow: ${{ steps.normal.outputs.workflow || steps.docs.outputs.workflow || steps.merged.outputs.workflow || steps.daily.outputs.workflow || steps.split.outputs.workflow }}
ghBaseBranch: ${{ github.event.pull_request.base.ref }}
ghPrNumber: ${{ github.event.pull_request.number }}
ghTrustedAuthor: ${{ steps.trusted-author.outputs.result }}
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ test-results
.next
/.npmrc
tsconfig.vitest-temp.json
tsconfig.dts-tmp-*.json
.dts-emit/

.env.local

Expand Down
1 change: 1 addition & 0 deletions code/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,7 @@
"react-textarea-autosize": "^8.3.0",
"react-transition-state": "^2.3.1",
"require-from-string": "^2.0.2",
"rolldown": "^1.1.4",
"sirv": "^2.0.4",
"slash": "^5.0.0",
"source-map": "^0.7.4",
Expand Down
122 changes: 59 additions & 63 deletions code/core/scripts/generate-source-files.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,17 @@
import { existsSync } from 'node:fs';
import { mkdirSync } from 'node:fs';
import { readdir, realpath, writeFile } from 'node:fs/promises';
import os from 'node:os';
import { createRequire } from 'node:module';
import { readdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';

import { GlobalRegistrator } from '@happy-dom/global-registrator';
import { isNotNil } from 'es-toolkit/predicate';
import * as esbuild from 'esbuild';
import { format } from 'oxfmt';
import { rolldown } from 'rolldown';
import { dedent } from 'ts-dedent';

import { getWorkspace } from '../../../scripts/utils/tools.ts';
import {
BROWSER_TARGETS,
SUPPORTED_FEATURES,
} from '../src/shared/constants/environments-support.ts';

GlobalRegistrator.register({ url: 'http://localhost:3000', width: 1920, height: 1080 });

const CODE_DIR = join(import.meta.dirname, '..', '..', '..', 'code');
const CORE_ROOT_DIR = join(CODE_DIR, 'core');
const tempDir = () => realpath(os.tmpdir());
const getPath = async (prefix = '') =>
join(await tempDir(), prefix + (Math.random() + 1).toString(36).substring(7));

async function temporaryDirectory({ prefix = '' } = {}) {
const directory = await getPath(prefix);
mkdirSync(directory);
return directory;
}
async function temporaryFile({ name, extension }: { name?: string; extension?: string } = {}) {
if (name) {
if (extension !== undefined && extension !== null) {
// eslint-disable-next-line local-rules/no-uncategorized-errors
throw new Error('The `name` and `extension` options are mutually exclusive');
}

return join(await temporaryDirectory(), name);
}

return (
(await getPath()) +
(extension === undefined || extension === null ? '' : '.' + extension.replace(/^\./, ''))
);
}

// read code/frameworks subfolders and generate a list of available frameworks
// save this list into ./code/core/src/types/frameworks.ts and export it as a union type.
Expand Down Expand Up @@ -135,49 +102,78 @@ const localAlias = {
storybook: join(CORE_ROOT_DIR, 'src'),
};
async function generateExportsFile(): Promise<void> {
function removeDefault(input: string) {
return input !== 'default';
}

const destination = join(CORE_ROOT_DIR, 'src', 'manager', 'globals', 'exports.ts');
const require = createRequire(join(CORE_ROOT_DIR, 'package.json'));

const entryFile = join(CORE_ROOT_DIR, 'src', 'manager', 'globals', 'runtime.ts');
const outFile = await temporaryFile({ extension: 'js' });

await esbuild.build({
entryPoints: [entryFile],
bundle: true,
format: 'esm',
drop: ['console'],
outfile: outFile,
alias: localAlias,
legalComments: 'none',
splitting: false,
// Get the module list from the globals reference map (the source of truth)
const { globalPackages } = await import('../src/manager/globals/globals.ts');

// Extract named exports using a single rolldown build with virtual entry per module.
// Rolldown statically resolves exports without evaluating any code.
// For CJS modules (e.g. react) that don't expose ESM exports, fall back to require().
const input: Record<string, string> = {};
const virtualModules: Record<string, string> = {};

for (const mod of globalPackages) {
const key = mod.replace(/[/@]/g, '_');
input[key] = `\0${key}`;
virtualModules[`\0${key}`] = `export * from '${mod}'`;
}

const bundle = await rolldown({
input,
resolve: { alias: localAlias },
platform: 'browser',
target: BROWSER_TARGETS,
supported: SUPPORTED_FEATURES,
logLevel: 'silent',
plugins: [
{
name: 'virtual',
resolveId(id) {
if (id.startsWith('\0')) return id;
},
load(id) {
if (virtualModules[id]) return virtualModules[id];
},
},
],
});

const { globalsNameValueMap: data } = await import(pathToFileURL(outFile).href);
const { output } = await bundle.generate({ format: 'esm' });

const data: Record<string, string[]> = {};

// loop over all values of the keys of the data object and remove the default key
for (const key in data) {
const value = data[key];
if (typeof value === 'object') {
data[key] = Object.keys(
Object.fromEntries(Object.entries(value).filter(([k]) => removeDefault(k)))
).sort();
for (const chunk of output) {
if (chunk.type !== 'chunk' || !chunk.isEntry) continue;
const mod = globalPackages.find((m: string) => m.replace(/[/@]/g, '_') === chunk.name);
if (!mod) continue;

let exports = chunk.exports.filter((e: string) => e !== 'default').sort();

// CJS modules don't expose ESM exports — fall back to require()
if (exports.length === 0) {
try {
exports = Object.keys(require(mod))
.filter((k) => k !== 'default' && k !== '__esModule')
.sort();
} catch {
// ignore — module may not be requireable
}
}

data[mod] = exports;
}

// Preserve key order from globalsNameReferenceMap for deterministic output
const ordered = Object.fromEntries(globalPackages.map((mod: string) => [mod, data[mod]]));

const { code: formatted } = await format(
'exports.ts',
dedent`
// this file is generated by sourcefiles.ts
// this is done to prevent runtime dependencies from making it's way into the build/start script of the manager
// the manager builder needs to know which dependencies are 'globalized' in the ui

export default ${JSON.stringify(data)} as const;
export default ${JSON.stringify(ordered)} as const;
`,
{ singleQuote: true }
);
Expand Down
41 changes: 37 additions & 4 deletions code/core/src/common/js-package-manager/JsPackageManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import invariant from 'tiny-invariant';

import { HandledError } from '../utils/HandledError.ts';
import type { ExecuteCommandOptions } from '../utils/command.ts';
import { optionalEnvToBoolean } from '../utils/envs.ts';
import { findFilesUp, getProjectRoot } from '../utils/paths.ts';
import storybookPackagesVersions from '../versions.ts';
import type { PackageJson, PackageJsonWithDepsAndDevDeps } from './PackageJson.ts';
Expand Down Expand Up @@ -338,10 +339,17 @@ export abstract class JsPackageManager {
const { operationDir, packageJson } = packageJsonInfo;
const dependenciesMap: Record<string, string> = {};

for (const dep of dependencies) {
const [packageName, packageVersion] = getPackageDetails(dep);
const latestVersion = await this.getVersion(packageName);
dependenciesMap[packageName] = packageVersion ?? latestVersion;
// Versions resolve concurrently (each lookup may spawn the package
// manager), and only for dependencies that don't carry one already.
// The entries array keeps package.json key order deterministic.
const entries = await Promise.all(
dependencies.map(async (dep) => {
const [packageName, packageVersion] = getPackageDetails(dep);
return [packageName, packageVersion ?? (await this.getVersion(packageName))] as const;
})
);
for (const [packageName, version] of entries) {
dependenciesMap[packageName] = version;
}

const targetDeps = packageJson[options.type] as Record<string, string>;
Expand Down Expand Up @@ -519,6 +527,21 @@ export abstract class JsPackageManager {
return cachedVersion;
}

// In monorepo sandbox creation every storybook package is published to
// the local registry at exactly the version this CLI was built with, so
// the registry probe (a package-manager subprocess per package) is a
// foregone conclusion. Real user installs never have this variable set.
if (
optionalEnvToBoolean(process.env.IN_STORYBOOK_SANDBOX) &&
packageName in storybookPackagesVersions
) {
const version = storybookPackagesVersions[packageName as StorybookPackage];
if (!constraint || satisfies(version, constraint)) {
JsPackageManager.latestVersionCache.set(cacheKey, version);
return version;
}
}

let result: string;

logger.debug(`Getting CLI versions from NPM for ${packageName}...`);
Expand Down Expand Up @@ -691,7 +714,17 @@ export abstract class JsPackageManager {
version = vitePlusVersions[packageName]!;
}

// Resolving the module's own package.json is a plain fs read (PnP
// included) and covers the common cases - installed-and-resolvable, or
// not installed at all. findInstallations spawns the package manager
// for a full dependency-graph walk, so it stays as the fallback for
// declared dependencies that don't resolve directly (e.g. pnpm's
// virtual store layouts).
if (!version) {
version = (await this.getModulePackageJSON(packageName))?.version ?? null;
}

if (!version && this.isDependencyInstalled(packageName)) {
const installations = await this.findInstallations([packageName]);
if (installations) {
version = Object.entries(installations.dependencies)[0]?.[1]?.[0].version || null;
Expand Down
20 changes: 19 additions & 1 deletion code/core/src/common/js-package-manager/NPMProxy.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { prompt } from 'storybook/internal/node-logger';
import { MinimumReleaseAgeHandledError } from 'storybook/internal/server-errors';

import { executeCommand } from '../utils/command.ts';
import { JsPackageManager } from './JsPackageManager.ts';
import storybookPackagesVersions from '../versions.ts';
import { NPMProxy } from './NPMProxy.ts';

vi.mock('storybook/internal/node-logger', () => ({
Expand All @@ -30,9 +31,16 @@ describe('NPM Proxy', () => {
vi.useRealTimers();
npmProxy = new NPMProxy();
JsPackageManager.clearLatestVersionCache();
// The repo-level .env marks test runs as sandbox context; these tests
// assert user-facing registry-probe behavior, so run them without it.
vi.stubEnv('IN_STORYBOOK_SANDBOX', 'false');
vi.spyOn(npmProxy, 'writePackageJson').mockImplementation(vi.fn());
});

afterEach(() => {
vi.unstubAllEnvs();
});

it('type should be npm', () => {
expect(npmProxy.type).toEqual('npm');
});
Expand Down Expand Up @@ -310,6 +318,16 @@ describe('NPM Proxy', () => {
});

describe('latestVersion', () => {
it('returns the monorepo version without spawning inside a sandbox task', async () => {
vi.stubEnv('IN_STORYBOOK_SANDBOX', 'true');
const executeCommandSpy = mockedExecuteCommand.mockResolvedValue({ stdout: '5.3.19' } as any);

const version = await npmProxy.latestVersion('storybook');

expect(executeCommandSpy).not.toHaveBeenCalled();
expect(version).toEqual(storybookPackagesVersions.storybook);
});

it('without constraint it returns the latest version', async () => {
const executeCommandSpy = mockedExecuteCommand.mockResolvedValue({ stdout: '5.3.19' } as any);

Expand Down
Loading
Loading