#!/usr/bin/env node import { createProjectGraphAsync, workspaceRoot } from '@nx/devkit'; import { styleText } from 'node:util'; import { execSync } from 'node:child_process'; import { rmSync, writeFileSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { URL } from 'node:url'; import { isRelativeVersionKeyword } from 'nx/src/command-line/release/utils/semver'; import { ReleaseType, major, parse } from 'semver'; import yargs from 'yargs'; const LARGE_BUFFER = 1024 * 1000000; // DO NOT MODIFY, even for testing. This only gates releases to latest. const VALID_AUTHORS_FOR_LATEST = [ 'jaysoo', 'JamesHenry', 'FrozenPandaz', 'vsavkin', ]; (async () => { const options = parseArgs(); // Perform minimal logging by default let isVerboseLogging = process.env.NX_VERBOSE_LOGGING === 'true'; if (options.clearLocalRegistry) { rmSync(join(__dirname, '../dist/local-registry/storage'), { recursive: true, force: true, }); } // Ensure all the native-packages directories are available at the top level of the build directory, enabling consistent packageRoot structure execSync(`pnpm nx copy-native-package-directories nx`, { stdio: isVerboseLogging ? [0, 1, 2] : 'ignore', maxBuffer: LARGE_BUFFER, windowsHide: false, }); // Expected to run as part of the Github `publish` workflow if (!options.local && process.env.GITHUB_ACTIONS) { // Delete all .node files that were built during the previous steps // Always run before the artifacts step because we still need the .node files for native-packages execSync('find ./dist ./packages/nx/dist -name "*.node" -delete', { stdio: [0, 1, 2], maxBuffer: LARGE_BUFFER, windowsHide: false, }); execSync('pnpm nx run-many --target=artifacts', { stdio: [0, 1, 2], maxBuffer: LARGE_BUFFER, windowsHide: false, }); } const runNxReleaseVersion = () => { let versionCommand = `pnpm nx release version${ options.version ? ` --specifier ${options.version}` : '' }`; if (options.dryRun) { versionCommand += ' --dry-run'; } if (isVerboseLogging) { versionCommand += ' --verbose'; } console.log(`> ${versionCommand}`); execSync(versionCommand, { stdio: isVerboseLogging ? [0, 1, 2] : 'ignore', maxBuffer: LARGE_BUFFER, windowsHide: false, }); }; const packagesToReset = [ 'packages/angular', 'packages/angular-rspack', 'packages/angular-rspack-compiler', 'packages/create-nx-plugin', 'packages/create-nx-workspace', 'packages/devkit', 'packages/detox', 'packages/dotnet', 'packages/cypress', 'packages/docker', 'packages/esbuild', 'packages/eslint', 'packages/eslint-plugin', 'packages/expo', 'packages/express', 'packages/gradle', 'packages/jest', 'packages/js', 'packages/maven', 'packages/module-federation', 'packages/nest', 'packages/next', 'packages/node', 'packages/nuxt', 'packages/nx', 'packages/playwright', 'packages/plugin', 'packages/react', 'packages/react-native', 'packages/remix', 'packages/rollup', 'packages/rsbuild', 'packages/rspack', 'packages/storybook', 'packages/vite', 'packages/vitest', 'packages/vue', 'packages/web', 'packages/webpack', 'packages/workspace', ]; const packageSnapshots: { [key: string]: string } = {}; for (const packagePath of packagesToReset) { const packageJsonPath = join(workspaceRoot, packagePath, 'package.json'); const packageJson = readFileSync(packageJsonPath, 'utf-8'); packageSnapshots[packagePath] = packageJson; } const resetPackageJsons = () => { for (const packagePath of packagesToReset) { const packageJsonPath = join(workspaceRoot, packagePath, 'package.json'); writeFileSync(packageJsonPath, packageSnapshots[packagePath]); } }; // Reset package.json files even on ctrl+C process.on('SIGINT', () => { console.log('\nReceived SIGINT, restoring package.json files...'); resetPackageJsons(); process.exit(1); }); // Intended for creating a github release which triggers the publishing workflow // This runs locally (not in CI) to create the GitHub release that triggers the actual publish workflow if (!options.local && !process.env.GITHUB_ACTIONS) { // For this important use-case it makes sense to always have full logs isVerboseLogging = true; execSync('git status --ahead-behind', { windowsHide: false, }); if (isRelativeVersionKeyword(options.version)) { resetPackageJsons(); throw new Error( 'When creating actual releases, you must use an exact semver version' ); } try { runNxReleaseVersion(); execSync(`pnpm nx run-many -t add-extra-dependencies --parallel 8`, { stdio: isVerboseLogging ? [0, 1, 2] : 'ignore', maxBuffer: LARGE_BUFFER, windowsHide: false, }); execSync(`pnpm nx run nx:expand-deps`, { stdio: isVerboseLogging ? [0, 1, 2] : 'ignore', maxBuffer: LARGE_BUFFER, windowsHide: false, }); let changelogCommand = `pnpm nx release changelog ${options.version} --interactive workspace`; if (options.from) { changelogCommand += ` --from ${options.from}`; } if (options.gitRemote) { changelogCommand += ` --git-remote ${options.gitRemote}`; } if (options.dryRun) { changelogCommand += ' --dry-run'; } if (isVerboseLogging) { changelogCommand += ' --verbose'; } console.log(`> ${changelogCommand}`); execSync(changelogCommand, { stdio: isVerboseLogging ? [0, 1, 2] : 'ignore', maxBuffer: LARGE_BUFFER, windowsHide: false, }); console.log( 'Check github: https://github.com/nrwl/nx/actions/workflows/publish.yml' ); } finally { resetPackageJsons(); } process.exit(0); } try { runNxReleaseVersion(); execSync(`pnpm nx run-many -t add-extra-dependencies --parallel 8`, { stdio: isVerboseLogging ? [0, 1, 2] : 'ignore', maxBuffer: LARGE_BUFFER, windowsHide: false, }); execSync(`pnpm nx run nx:expand-deps`, { stdio: isVerboseLogging ? [0, 1, 2] : 'ignore', maxBuffer: LARGE_BUFFER, windowsHide: false, }); const distTag = determineDistTag(options.version); // If publishing locally, force all projects to not be private first if (options.local) { console.log( styleText( 'dim', `\n Publishing locally, so setting all packages with existing nx-release-publish targets to not be private. If you have created a new private package and you want it to be published, you will need to manually configure the "nx-release-publish" target using executor "@nx/js:release-publish"` ) ); const projectGraph = await createProjectGraphAsync(); for (const proj of Object.values(projectGraph.nodes)) { if (proj.data.targets?.['nx-release-publish']) { const packageJsonPath = join( workspaceRoot, // Mirror the @nx/js:release-publish executor default: when packageRoot // is not set explicitly, it falls back to the project root. proj.data.targets?.['nx-release-publish']?.options?.packageRoot ?? proj.data.root, 'package.json' ); try { const packageJson = require(packageJsonPath); if (packageJson.private) { console.log( '- Publishing private package locally:', packageJson.name ); writeFileSync( packageJsonPath, JSON.stringify({ ...packageJson, private: false }) ); } } catch {} } } } if (!options.local && (!distTag || distTag === 'latest')) { // We are only expecting non-local latest releases to be performed within publish.yml on GitHub const author = process.env.GITHUB_ACTOR ?? ''; if (!VALID_AUTHORS_FOR_LATEST.includes(author)) { throw new Error( `The GitHub user "${author}" is not allowed to publish to "latest". Please request one of the following users to carry out the release: ${VALID_AUTHORS_FOR_LATEST.join( ', ' )}` ); } } // Clean up tsconfig files before publishing console.log('Cleaning up tsconfig files...'); execSync('node ./scripts/cleanup-tsconfig-files.js', { stdio: isVerboseLogging ? [0, 1, 2] : 'ignore', maxBuffer: LARGE_BUFFER, windowsHide: false, }); hackFixForDevkitPeerDependencies(); // Run with dynamic output-style so that we have more minimal logs by default but still always see errors let publishCommand = `pnpm nx release publish --registry=${getRegistry()} --tag=${distTag} --output-style=dynamic --parallel=8`; if (options.dryRun) { publishCommand += ' --dry-run'; } console.log(`\n> ${publishCommand}`); execSync(publishCommand, { stdio: [0, 1, 2], maxBuffer: LARGE_BUFFER, windowsHide: false, }); if (!options.dryRun) { let version; if (['minor', 'major', 'patch'].includes(options.version)) { version = execSync(`npm view nx@${distTag} version`, { windowsHide: false, }) .toString() .trim(); } else { version = options.version; } console.log(styleText('green', ` > Published version: ` + version)); console.log( styleText('dim', ` Use: npx create-nx-workspace@${version}\n`) ); } } finally { resetPackageJsons(); } process.exit(0); })(); function parseArgs() { const registry = getRegistry(); const registryIsLocalhost = registry.hostname === 'localhost'; const parsedArgs = yargs .scriptName('pnpm nx-release') .wrap(144) .strictOptions() .version(false) .command( '$0 [version]', 'This script is for publishing Nx both locally and publically' ) .option('dryRun', { type: 'boolean', description: 'Dry-run flag to be passed to all `nx release` commands', }) .option('clearLocalRegistry', { type: 'boolean', description: 'Clear existing versions in the local registry so that you can republish the same version', default: true, }) .option('local', { type: 'boolean', description: 'Publish Nx locally, not to actual NPM', alias: 'l', default: true, }) .option('force', { type: 'boolean', description: "Don't use this unless you really know what it does", hidden: true, }) .option('from', { type: 'string', description: 'Git ref to pass to `nx release changelog`. Not applicable for local publishing or e2e tests.', }) .positional('version', { type: 'string', description: 'The version to publish. This does not need to be passed and can be inferred.', default: 'minor', coerce: (version: string) => { const isGithubActions = !!process.env.GITHUB_ACTIONS; if ( isGithubActions && !registryIsLocalhost && isRelativeVersionKeyword(version) ) { // Print error rather than throw to avoid yargs noise in this specifically handled case console.error( 'Error: The release script was triggered in a GitHub Actions workflow, to a non-local registry, but a relative version keyword was provided. This is an unexpected combination.' ); process.exit(1); } if (version !== 'canary') { return version; } /** * Handle the special case of `canary` * * Use the base version from nx@next so that canary versions * are always aligned with the current next/beta release line. */ const currentNextVersion = execSync('npm view nx@next version', { windowsHide: false, }) .toString() .trim(); const parsedNext = parse(currentNextVersion); if (!parsedNext) { throw new Error( `Unable to parse the current next version from the npm registry: "${currentNextVersion}"` ); } const canaryBaseVersion = `${parsedNext.major}.${parsedNext.minor}.${parsedNext.patch}`; // Create YYYYMMDD string const date = new Date(); const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); // Months are 0-indexed const day = String(date.getDate()).padStart(2, '0'); const YYYYMMDD = `${year}${month}${day}`; // Get the current short git sha const gitSha = execSync('git rev-parse --short HEAD', { windowsHide: false, }) .toString() .trim(); const canaryVersion = `${canaryBaseVersion}-canary.${YYYYMMDD}-${gitSha}`; console.log(`\nDerived canary version dynamically`, { currentNextVersion, canaryVersion, }); return canaryVersion; }, }) .option('gitRemote', { type: 'string', description: 'Alternate git remote name to publish tags to (useful for testing changelog)', default: 'origin', }) .example( '$0', `By default, this will locally publish a minor version bump as latest. Great for local development. Most developers should only need this.` ) .example( '$0 --local false 2.3.4-beta.0', `This will really publish a new version to npm as next.` ) .example( '$0 --local false 2.3.4', `Given the current latest major version on npm is 2, this will really publish a new version to npm as latest.` ) .example( '$0 --local false 1.3.4-beta.0', `Given the current latest major version on npm is 2, this will really publish a new version to npm as previous.` ) .group( ['local', 'clearLocalRegistry'], 'Local Publishing Options for most developers' ) .group( ['gitRemote', 'force'], 'Real Publishing Options for actually publishing to NPM' ) .demandOption('version') .check((args) => { if (!args.local) { if (!process.env.GH_TOKEN) { throw new Error('process.env.GH_TOKEN is not set'); } if (!args.force && registryIsLocalhost) { throw new Error( 'Registry is still set to localhost! Run "pnpm local-registry disable" or pass --force' ); } } else { if (!args.force && !registryIsLocalhost) { throw new Error('--local was passed and registry is not localhost'); } } return true; }) .parseSync(); return parsedArgs; } function getRegistry() { return new URL( execSync('npm config get registry', { windowsHide: false, }) .toString() .trim() ); } function determineDistTag( newVersion: string ): 'latest' | 'next' | 'previous' | 'canary' | 'pull-request' { // Special case of canary if (newVersion.includes('canary')) { return 'canary'; } // Special case of PR release (e.g. 22.5.0-pr.1234.abc1234) if (/^\d+\.\d+\.\d+-pr\./.test(newVersion)) { return 'pull-request'; } // For a relative version keyword, it cannot be previous if (isRelativeVersionKeyword(newVersion)) { const prereleaseKeywords: ReleaseType[] = [ 'premajor', 'preminor', 'prepatch', 'prerelease', ]; return prereleaseKeywords.includes(newVersion) ? 'next' : 'latest'; } const parsedGivenVersion = parse(newVersion); if (!parsedGivenVersion) { throw new Error( `Unable to parse the given version: "${newVersion}". Is it valid semver?` ); } const currentLatestVersion = execSync('npm view nx version', { windowsHide: false, }) .toString() .trim(); const parsedCurrentLatestVersion = parse(currentLatestVersion); if (!parsedCurrentLatestVersion) { throw new Error( `The current version resolved from the npm registry could not be parsed (resolved "${currentLatestVersion}")` ); } const distTag = parsedGivenVersion.prerelease.length > 0 ? 'next' : parsedGivenVersion.major < parsedCurrentLatestVersion.major ? 'previous' : 'latest'; return distTag; } //TODO(@Coly010): Remove this after fixing up the release peer dep handling function hackFixForDevkitPeerDependencies() { const { readFileSync, writeFileSync } = require('fs'); const devkitPackageJson = JSON.parse( readFileSync('./packages/devkit/package.json', 'utf-8') ); const beforeVersion = devkitPackageJson.peerDependencies['nx']; const majorVersion = major(beforeVersion); if (!beforeVersion.includes('<') && majorVersion !== 0) { console.log( '@nx/devkit peer dependencies range is broken - needs release fix. Patching it to avoid broken publishes.' ); devkitPackageJson.peerDependencies['nx'] = `>= ${majorVersion - 1} <= ${ majorVersion + 1 } || ^${majorVersion}.0.0-0`; writeFileSync( './packages/devkit/package.json', JSON.stringify(devkitPackageJson, null, 2) ); } }