import { join } from 'node:path'; import { program } from 'commander'; // eslint-disable-next-line depend/ban-dependencies import { execaCommand } from 'execa'; // eslint-disable-next-line depend/ban-dependencies import { readJson } from 'fs-extra'; import pRetry from 'p-retry'; import picocolors from 'picocolors'; import semver from 'semver'; import dedent from 'ts-dedent'; import { z } from 'zod'; import { esMain } from '../utils/esmain'; program .name('publish') .description('publish all packages') .requiredOption( '-T, --tag ', 'Specify which distribution tag to set for the version being published. Required, since leaving it undefined would publish with the "latest" tag' ) .option('-D, --dry-run', 'Do not publish, only output to shell', false) .option('-V, --verbose', 'Enable verbose logging', false); const optionsSchema = z .object({ tag: z.string(), verbose: z.boolean().optional(), dryRun: z.boolean().optional(), }) .refine((schema) => (schema.tag ? !semver.valid(schema.tag) : true), { message: 'The tag can not be a valid semver version, it must be a plain string like "next" or "latest"', }); type Options = { tag: string; verbose: boolean; dryRun?: boolean; }; const CODE_DIR_PATH = join(__dirname, '..', '..', 'code'); const CODE_PACKAGE_JSON_PATH = join(CODE_DIR_PATH, 'package.json'); const validateOptions = (options: { [key: string]: any }): options is Options => { optionsSchema.parse(options); return true; }; const getCurrentVersion = async (verbose?: boolean) => { if (verbose) { console.log(`πŸ“ Reading current version of Storybook...`); } const { version } = await readJson(CODE_PACKAGE_JSON_PATH); console.log(`πŸ“ Current version of Storybook is ${picocolors.green(version)}`); return version; }; const isCurrentVersionPublished = async ({ packageName, currentVersion, verbose, }: { packageName: string; currentVersion: string; verbose?: boolean; }) => { const prettyPackage = `${picocolors.blue(packageName)}@${picocolors.green(currentVersion)}`; console.log(`β›… Checking if ${prettyPackage} is published...`); if (verbose) { console.log(`Fetching from npm:`); console.log( `https://registry.npmjs.org/${picocolors.blue(packageName)}/${picocolors.green(currentVersion)}` ); } const response = await fetch(`https://registry.npmjs.org/${packageName}/${currentVersion}`); if (response.status === 404) { console.log(`🌀️ ${prettyPackage} is not published`); return false; } if (response.status !== 200) { console.error( `Unexpected status code when checking the current version on npm: ${response.status}` ); console.error(await response.text()); throw new Error( `Unexpected status code when checking the current version on npm: ${response.status}` ); } const data: any = await response.json(); if (verbose) { console.log(`Response from npm:`); console.log(data); } if (data.version !== currentVersion) { // this should never happen console.error( `Unexpected version received when checking the current version on npm: ${data.version}` ); console.error(JSON.stringify(data, null, 2)); throw new Error( `Unexpected version received when checking the current version on npm: ${data.version}` ); } console.log(`β›ˆοΈ ${prettyPackage} is published`); return true; }; const buildAllPackages = async () => { console.log(`πŸ—οΈ Building all packages...`); await execaCommand('yarn task --task=compile --start-from=compile --no-link', { stdio: 'inherit', cleanup: true, cwd: CODE_DIR_PATH, }); console.log(`πŸ—οΈ Packages successfully built`); }; const publishAllPackages = async ({ tag, verbose, dryRun, }: { tag: string; verbose?: boolean; dryRun?: boolean; }) => { console.log(`πŸ“¦ Publishing all packages...`); const command = `yarn workspaces foreach --all --parallel --no-private --verbose npm publish --tolerate-republish --tag ${tag}`; if (verbose) { console.log(`πŸ“¦ Executing: ${command}`); } if (dryRun) { console.log(`πŸ“¦ Dry run, skipping publish. Would have executed: ${picocolors.blue(command)}`); return; } /** * 'yarn npm publish' will fail if just one package fails to publish. But it will continue through * with all the other packages, and --tolerate-republish makes it okay to publish the same version * again. So we can safely retry the whole publishing process if it fails. It's not uncommon for * the registry to fail often, which Yarn catches by checking the registry after a package has * been published. */ await pRetry( () => execaCommand(command, { stdio: 'inherit', cleanup: true, cwd: CODE_DIR_PATH, }), { retries: 4, onFailedAttempt: (error) => console.log( picocolors.yellow( dedent`❗One or more packages failed to publish, retrying... This was attempt number ${error.attemptNumber}, there are ${error.retriesLeft} retries left. 🀞` ) ), } ); console.log(`πŸ“¦ Packages successfully published`); }; export const run = async (options: unknown) => { if (!validateOptions(options)) { return; } const { tag, dryRun, verbose } = options; // Get the current version from code/package.json const currentVersion = await getCurrentVersion(verbose); const isAlreadyPublished = await isCurrentVersionPublished({ currentVersion, packageName: 'storybook', verbose, }); if (isAlreadyPublished) { throw new Error( `β›” Current version (${picocolors.green(currentVersion)}) is already published, aborting.` ); } await buildAllPackages(); await publishAllPackages({ tag, verbose, dryRun }); console.log( `βœ… Published all packages with version ${picocolors.green(currentVersion)}${ tag ? ` at tag ${picocolors.blue(tag)}` : '' }` ); }; if (esMain(import.meta.url)) { const parsed = program.parse(); run(parsed.opts()).catch((err) => { console.error(err); process.exit(1); }); }