UNPKG

@release-it-plugins/workspaces

Version:

release-it plugin for bumping and publishing workspaces

604 lines (482 loc) 16.9 kB
import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import semver from 'semver'; import urlJoin from 'url-join'; import walkSync from 'walk-sync'; import detectNewline from 'detect-newline'; import detectIndent from 'detect-indent'; import { Plugin } from 'release-it'; import validatePeerDependencies from 'validate-peer-dependencies'; import YAML from 'yaml'; import { execaCommand } from 'execa'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); validatePeerDependencies(__dirname); const options = { write: false }; const ROOT_MANIFEST_PATH = './package.json'; const PNPM_WORKSPACE_PATH = './pnpm-workspace.yaml'; const REGISTRY_TIMEOUT = 10000; const DEFAULT_TAG = 'latest'; const NPM_BASE_URL = 'https://www.npmjs.com'; const NPM_DEFAULT_REGISTRY = 'https://registry.npmjs.org'; const DETECT_TRAILING_WHITESPACE = /\s+$/; function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function rejectAfter(ms, error) { await sleep(ms); throw error; } function discoverWorkspaces() { let { publishConfig, workspaces } = JSON.parse(fs.readFileSync(path.resolve(ROOT_MANIFEST_PATH))); if (!workspaces && hasPnpm()) { ({ packages: workspaces } = YAML.parse( fs.readFileSync(path.resolve(PNPM_WORKSPACE_PATH), { encoding: 'utf-8' }) )); } return { publishConfig, workspaces }; } function hasPnpm() { return fs.existsSync('./pnpm-lock.yaml') || fs.existsSync(PNPM_WORKSPACE_PATH); } function resolveWorkspaces(workspaces) { if (Array.isArray(workspaces)) { return workspaces; } else if (workspaces !== null && typeof workspaces === 'object') { return workspaces.packages; } throw new Error( "This package doesn't use workspaces. (package.json doesn't contain a `workspaces` property)" ); } function parseVersion(raw) { if (!raw) return { version: null, isPreRelease: false, preReleaseId: null }; const version = semver.valid(raw) ? raw : semver.coerce(raw); const parsed = semver.parse(version); const isPreRelease = parsed.prerelease.length > 0; const preReleaseId = isPreRelease && isNaN(parsed.prerelease[0]) ? parsed.prerelease[0] : null; return { version, isPreRelease, preReleaseId, }; } function findAdditionalManifests(root, manifestPaths) { if (!Array.isArray(manifestPaths)) { return null; } let packageJSONFiles = walkSync('.', { globs: manifestPaths, }); let manifests = packageJSONFiles.map((file) => { let absolutePath = path.join(root, file); let pkgInfo = JSONFile.for(absolutePath); let relativeRoot = path.dirname(file); return { root: path.join(root, relativeRoot), relativeRoot, pkgInfo, }; }); return manifests; } const jsonFiles = new Map(); class JSONFile { static for(path) { if (jsonFiles.has(path)) { return jsonFiles.get(path); } let jsonFile = new this(path); jsonFiles.set(path, jsonFile); return jsonFile; } constructor(filename) { let contents = fs.readFileSync(filename, { encoding: 'utf8' }); this.filename = filename; this.pkg = JSON.parse(contents); this.lineEndings = detectNewline(contents); this.indent = detectIndent(contents).amount; let trailingWhitespace = DETECT_TRAILING_WHITESPACE.exec(contents); this.trailingWhitespace = trailingWhitespace ? trailingWhitespace : ''; } write() { let contents = JSON.stringify(this.pkg, null, this.indent).replace(/\n/g, this.lineEndings); fs.writeFileSync(this.filename, contents + this.trailingWhitespace, { encoding: 'utf8' }); } } export default class WorkspacesPlugin extends Plugin { static isEnabled(options) { return fs.existsSync(ROOT_MANIFEST_PATH) && options !== false; } constructor(...args) { super(...args); this.registerPrompts({ publish: { type: 'confirm', message: (context) => { const { distTag, packagesToPublish } = context['@release-it-plugins/workspaces']; return this._formatPublishMessage(distTag, packagesToPublish); }, default: true, }, otp: { type: 'input', message: () => `Please enter OTP for ${this.isPNPM() ? 'pnpm' : 'npm'}:`, }, 'publish-as-public': { type: 'confirm', message(context) { const { currentPackage } = context['@release-it-plugins/workspaces']; return `Publishing ${currentPackage.name} failed because \`publishConfig.access\` is not set in its \`package.json\`.\n Would you like to publish ${currentPackage.name} as a public package?`; }, }, }); const { publishConfig, workspaces } = discoverWorkspaces(); this.setContext({ publishConfig, workspaces: this.options.workspaces || resolveWorkspaces(workspaces), root: process.cwd(), }); } async init() { if (this.options.skipChecks) return; const validations = Promise.all([this.isRegistryUp(), this.isAuthenticated()]); await Promise.race([ validations, rejectAfter(REGISTRY_TIMEOUT, new Error(`Timed out after ${REGISTRY_TIMEOUT}ms.`)), ]); const [isRegistryUp, isAuthenticated] = await validations; if (!isRegistryUp) { throw new Error(`Unable to reach npm registry (timed out after ${REGISTRY_TIMEOUT}ms).`); } if (!isAuthenticated) { const program = this.isPNPM() ? 'pnpm' : 'npm'; throw new Error( `Not authenticated with ${program}. Please \`${program} login\` and try again.` ); } } beforeBump() { const workspaces = this.getWorkspaces(); const messages = ['Workspaces to process:', ...workspaces.map((w) => ` ${w.relativeRoot}`)]; this.log.log(messages.join('\n')); } async bump(version) { let { distTag } = this.options; if (!distTag) { const { isPreRelease, preReleaseId } = parseVersion(version); distTag = this.options.distTag || isPreRelease ? preReleaseId : DEFAULT_TAG; } const workspaces = this.getWorkspaces(); const packagesToPublish = workspaces .filter((w) => !w.isPrivate) .map((workspace) => workspace.name); this.setContext({ distTag, packagesToPublish, version, }); const task = async () => { const { isDryRun } = this.config; const updateVersion = (pkgInfo) => { let { pkg } = pkgInfo; let originalVersion = pkg.version; if (originalVersion === version) { this.log.warn(`\tDid not update version (already at ${version}).`); } this.log.exec(`\tversion: -> ${version} (from ${originalVersion})`); if (!isDryRun) { pkg.version = version; } }; workspaces.forEach(({ relativeRoot, pkgInfo }) => { this.log.exec(`Processing ${relativeRoot}/package.json:`); updateVersion(pkgInfo); this._updateDependencies(pkgInfo, version); if (!isDryRun) { pkgInfo.write(); } }); const additionalManifests = this.getAdditionalManifests(); if (additionalManifests.dependencyUpdates) { additionalManifests.dependencyUpdates.forEach(({ relativeRoot, pkgInfo }) => { this.log.exec( `Processing additionManifest.dependencyUpdates for ${relativeRoot}/package.json:` ); this._updateDependencies(pkgInfo, version); if (!isDryRun) { pkgInfo.write(); } }); } if (additionalManifests.versionUpdates) { additionalManifests.versionUpdates.forEach(({ relativeRoot, pkgInfo }) => { this.log.exec( `Processing additionManifest.versionUpdates for ${relativeRoot}/package.json:` ); updateVersion(pkgInfo); if (!isDryRun) { pkgInfo.write(); } }); } /** * In workflows where publishing is handled on C.I., * and not be release-it, the environment will often require * that the lockfile be updated -- this is usually done by * running the install command for the package manager. */ if (hasPnpm()) { await this.exec(`pnpm install`); } }; return this.spinner.show({ task, label: 'npm version' }); } async release() { if (this.options.publish === false) return; // creating a stable object that is shared across all package publishes // this ensures that we don't accidentally prompt multiple times (e.g. once // per package) due to loosing the otp value after each `this.publish` call const otp = { value: this.options.otp, }; const tag = this.getContext('distTag'); const task = async () => { await this.eachWorkspace(async (workspaceInfo) => { await this.publish({ tag, workspaceInfo, otp }); }); }; await this.step({ task, label: 'npm publish', prompt: 'publish' }); } async afterRelease() { let workspaces = this.getWorkspaces(); workspaces.forEach((workspaceInfo) => { if (workspaceInfo.isReleased) { this.log.log(`🔗 ${this.getReleaseUrl(workspaceInfo)}`); } }); } _buildReplacementDepencencyVersion(existingVersion, newVersion) { let prefix = ''; let range = existingVersion; let suffix = newVersion; // preserve workspace protocol prefix, // tools that use this replace with a real version during the publish the process if (existingVersion.startsWith('workspace:')) { prefix = 'workspace:'; range = existingVersion.slice(prefix.length); suffix = range.length > 1 ? newVersion : ''; } // capture any leading characters up to the first digit const operator = range.match(/^[^0-9]*/)[0]; if ('*' === range) { return `${prefix}*`; } return `${prefix}${operator}${suffix}`; } _updateDependencies(pkgInfo, newVersion) { const { isDryRun } = this.config; const workspaces = this.getWorkspaces(); const { pkg } = pkgInfo; const updateDependencies = (dependencyType) => { let dependencies = pkg[dependencyType]; if (dependencies) { for (let dependency in dependencies) { if (workspaces.find((w) => w.name === dependency)) { const existingVersion = dependencies[dependency]; const replacementVersion = this._buildReplacementDepencencyVersion( existingVersion, newVersion ); this.log.exec( `\t${dependencyType}: \`${dependency}\` -> ${replacementVersion} (from ${existingVersion})` ); if (!isDryRun) { dependencies[dependency] = replacementVersion; } } } } }; updateDependencies('dependencies'); updateDependencies('devDependencies'); updateDependencies('optionalDependencies'); updateDependencies('peerDependencies'); } _formatPublishMessage(distTag, packageNames) { const messages = [ 'Preparing to publish:', ...packageNames.map((name) => ` ${name}${distTag === 'latest' ? '' : `@${distTag}`}`), ' Publish to npm:', ]; return messages.join('\n'); } async isRegistryUp() { const registry = this.getRegistry(); const program = this.isPNPM() ? 'pnpm' : 'npm'; try { await this.exec(`${program} ping --registry ${registry}`); return true; } catch (error) { if (/code E40[04]|404.*(ping not found|No content for path)/.test(error)) { this.log.warn(`Ignoring unsupported \`${program} ping\` command response.`); return true; } return false; } } async isAuthenticated() { const registry = this.getRegistry(); const program = this.isPNPM() ? 'pnpm' : 'npm'; try { await this.exec(`${program} whoami --registry ${registry}`); return true; } catch (error) { this.debug(error); if (/code E40[04]/.test(error)) { this.log.warn(`Ignoring unsupported \`${program} whoami\` command response.`); return true; } return false; } } getReleaseUrl(workspaceInfo) { const registry = this.getRegistry(); const baseUrl = registry !== NPM_DEFAULT_REGISTRY ? registry : NPM_BASE_URL; return urlJoin(baseUrl, 'package', workspaceInfo.name); } getRegistry() { const { publishConfig } = this.getContext(); const registries = publishConfig ? publishConfig.registry ? [publishConfig.registry] : Object.keys(publishConfig) .filter((key) => key.endsWith('registry')) .map((key) => publishConfig[key]) : []; return registries[0] || NPM_DEFAULT_REGISTRY; } isPNPM() { return hasPnpm(); } async publish({ tag, workspaceInfo, otp, access } = {}) { const isScoped = workspaceInfo.name.startsWith('@'); const pathToWorkspace = `./${workspaceInfo.relativeRoot}`; const publishCommand = this.getContext().publishCommand; const args = [pathToWorkspace, '--tag', tag]; if (access) args.push('--access', access); if (otp.value) args.push('--otp', otp.value); if (this.config.isDryRun) args.push('--dry-run'); if (workspaceInfo.isPrivate) { this.debug(`${workspaceInfo.name}: Skip publish (package is private)`); return; } try { if (!publishCommand) { const program = this.isPNPM() ? 'pnpm' : 'npm'; const command = `${program} publish ${args.join(' ')}`; await this.exec(command, { options }); } else { const env = { RELEASE_IT_WORKSPACES_PATH_TO_WORKSPACE: pathToWorkspace, RELEASE_IT_WORKSPACES_TAG: tag ?? '', RELEASE_IT_WORKSPACES_ACCESS: access ?? '', RELEASE_IT_WORKSPACES_OTP: otp.value ?? '', RELEASE_IT_WORKSPACES_DRY_RUN: String(this.config.isDryRun), ...process.env, }; await execaCommand(publishCommand, { env, stdio: 'inherit' }); } workspaceInfo.isReleased = true; } catch (err) { this.debug(err); if (/one-time pass/.test(err)) { if (otp.value != null) { this.log.warn('The provided OTP is incorrect or has expired.'); } await this.step({ prompt: 'otp', task(newOtp) { otp.value = newOtp; }, }); return await this.publish({ tag, workspaceInfo, otp, access }); } else if (isScoped && /private packages/.test(err)) { let publishAsPublic = false; await this.step({ prompt: 'publish-as-public', task(value) { publishAsPublic = value; }, }); if (publishAsPublic) { return await this.publish({ tag, workspaceInfo, otp, access: 'public' }); } else { this.log.warn(`${workspaceInfo.name} was not published.`); } } throw err; } } async eachWorkspace(action) { let workspaces = this.getWorkspaces(); for (let workspaceInfo of workspaces) { try { this.setContext({ currentPackage: workspaceInfo, }); await action(workspaceInfo); } finally { this.setContext({ currentPackage: null, }); } } } getAdditionalManifests() { let root = this.getContext('root'); let additionalManifestsConfig = this.getContext('additionalManifests'); let additionalManifests = { dependencyUpdates: null, versionUpdates: null, }; let versionUpdates = ['package.json']; if (additionalManifestsConfig) { additionalManifests.dependencyUpdates = findAdditionalManifests( root, additionalManifestsConfig.dependencyUpdates ); if (additionalManifestsConfig.versionUpdates) { versionUpdates = additionalManifestsConfig.versionUpdates; } } additionalManifests.versionUpdates = findAdditionalManifests(root, versionUpdates); this._additionalManifests = additionalManifests; return this._additionalManifests; } getWorkspaces() { if (this._workspaces) { return this._workspaces; } let root = this.getContext('root'); let workspaces = this.getContext('workspaces'); let packageJSONFiles = walkSync('.', { globs: workspaces.map((glob) => `${glob}/package.json`), }); this._workspaces = packageJSONFiles.map((file) => { let absolutePath = path.join(root, file); let pkgInfo = JSONFile.for(absolutePath); let relativeRoot = path.dirname(file); return { root: path.join(root, relativeRoot), relativeRoot, name: pkgInfo.pkg.name, isPrivate: !!pkgInfo.pkg.private, isReleased: false, pkgInfo, }; }); return this._workspaces; } }