UNPKG

@salesforce/plugin-release-management

Version:
248 lines 12.7 kB
/* * Copyright (c) 2020, salesforce.com, inc. * All rights reserved. * Licensed under the BSD 3-Clause license. * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ import { promisify } from 'node:util'; import { exec as execSync } from 'node:child_process'; import { Flags, SfCommand, Ux } from '@salesforce/sf-plugins-core'; import { ensureString } from '@salesforce/ts-types'; import { Env } from '@salesforce/kit'; import { Octokit } from '@octokit/core'; import { Messages, SfError } from '@salesforce/core'; import { PackageRepo } from '../../../repository.js'; const exec = promisify(execSync); Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-release-management', 'cli.release.build'); export default class build extends SfCommand { static description = messages.getMessage('description'); static summary = messages.getMessage('description'); static examples = messages.getMessages('examples'); static aliases = ['cli:latestrc:build']; static flags = { 'start-from-npm-dist-tag': Flags.string({ summary: messages.getMessage('flags.start-from-npm-dist-tag.summary'), char: 'd', aliases: ['rctag'], deprecateAliases: true, exactlyOne: ['start-from-npm-dist-tag', 'start-from-github-ref'], }), 'start-from-github-ref': Flags.string({ summary: messages.getMessage('flags.start-from-github-ref.summary'), char: 'g', exactlyOne: ['start-from-npm-dist-tag', 'start-from-github-ref'], }), 'release-channel': Flags.string({ summary: messages.getMessage('flags.release-channel.summary'), char: 'c', required: true, }), 'build-only': Flags.boolean({ summary: messages.getMessage('flags.build-only.summary'), default: false, }), resolutions: Flags.boolean({ summary: messages.getMessage('flags.resolutions.summary'), default: true, allowNo: true, }), only: Flags.string({ summary: messages.getMessage('flags.only.summary'), multiple: true, delimiter: ',', }), 'pinned-deps': Flags.boolean({ summary: messages.getMessage('flags.pinned-deps.summary'), default: true, allowNo: true, }), jit: Flags.boolean({ summary: messages.getMessage('flags.jit.summary'), default: true, allowNo: true, }), label: Flags.string({ summary: messages.getMessage('flags.label.summary'), multiple: true, }), patch: Flags.boolean({ summary: messages.getMessage('flags.patch.summary'), }), empty: Flags.boolean({ summary: messages.getMessage('flags.empty.summary'), }), 'pr-base-branch': Flags.string({ summary: messages.getMessage('flags.pr-base-branch.summary'), }), }; /* eslint-disable complexity */ async run() { const { flags } = await this.parse(build); const pushChangesToGitHub = !flags['build-only']; const isPrerelease = !['latest', 'latest-rc', 'nightly'].includes(flags['release-channel']); if (isPrerelease) { this.log(`NOTE: The release channel '${flags['release-channel']}' is not one of 'latest', 'latest-rc', 'nightly'. It will released as a prerelease.`); } const auth = pushChangesToGitHub ? ensureString(new Env().getString('GH_TOKEN') ?? new Env().getString('GITHUB_TOKEN'), 'The GH_TOKEN env var is required to push changes to GitHub. Use the --build-only flag to skip GitHub operations (a manual push will then be needed)') : undefined; // if the github ref is not provided, the dist tag must be const ref = flags['start-from-github-ref'] ?? (await this.distTagToGithubRef(ensureString(flags['start-from-npm-dist-tag']))); // Check out "starting point" // Works with sha (detached): "git checkout f476e8e" // Works with remote branch: "git checkout my-branch" // Works with tag (detached): "git checkout 7.174.0" await this.exec(`git checkout ${ref}`); // If the pr-base-branch flag is provided, use it let baseBranch = flags['pr-base-branch']; if (!baseBranch) { // If not, determine the pr base on other conditions // Note: the base branch for 'nightly' will always be 'main' baseBranch = flags['release-channel'] !== 'nightly' && pushChangesToGitHub ? await this.createAndPushBaseBranch(ref) : 'main'; } const repo = await PackageRepo.create({ ux: new Ux({ jsonEnabled: this.jsonEnabled() }) }); // Get the current version for the "starting point" const currentVersion = repo.package.packageJson.version; // TODO: We might want to check and see if nextVersion exists in npm // Determine the next version based on if --patch was passed in or if it is a prerelease const nextVersion = repo.package.determineNextVersion(flags.patch, isPrerelease ? flags['release-channel'] : undefined); repo.nextVersion = nextVersion; const branchName = `release/${nextVersion}`; // Ensure branch does not already exist on the remote (origin) // We only look at remote branches since they are likely generated // We do not want to delete a locally built `cli:release:build` branch if (pushChangesToGitHub && (await this.exec(`git ls-remote --heads origin ${branchName}`))) { await this.exec(`git push origin --delete ${branchName}`); } this.log(`Starting from '${ref}' (${currentVersion}) and creating branch '${branchName}'`); // Create a new branch that matches the next version await this.exec(`git switch -c ${branchName}`); // bump the version in the pjson to the next version for this tag this.log(`Setting the version to ${nextVersion}`); repo.package.packageJson.version = nextVersion; if (flags.empty) { this.log(`Creating empty release PR for ${nextVersion}`); } else if (flags.only) { this.log(`Bumping the following dependencies only: ${flags.only.join(', ')}`); const bumped = repo.package.bumpDependencyVersions(flags.only); if (!bumped.length) { throw new SfError('No version changes made. Confirm you are passing the correct dependency and version to --only.'); } } else { // bump resolution deps if (flags.resolutions) { this.log('Bumping resolutions in the package.json to their "latest"'); repo.package.packageJson.resolutions = repo.package.bumpResolutions('latest'); } // pin the pinned dependencies if (flags['pinned-deps']) { this.log('Pinning dependencies in pinnedDependencies to "latest-rc"'); repo.package.pinDependencyVersions('latest-rc'); } if (flags.jit) { this.log('Bumping just-in-time plugins to "latest-rc"'); repo.package.bumpJit('latest-rc'); } } repo.package.writePackageJson(); // Run an install to generate the lock file (skip all pre/post scripts) await this.exec('yarn install --ignore-scripts'); // Remove duplicates in the lockfile await this.exec('npx yarn-deduplicate'); // Run an install with deduplicated dependencies (with scripts) await this.exec('yarn install'); // Generate a new readme with the latest dependencies. await this.exec('yarn oclif readme --no-aliases --repository-prefix "<%- repo %>/blob/<%- version %>/<%- commandPath %>"'); this.log('Updates complete'); if (pushChangesToGitHub) { const octokit = new Octokit({ auth }); await this.maybeSetGitConfig(octokit); // commit package.json/yarn.lock and potentially command-snapshot changes await this.exec('git add .'); await this.exec(`git commit -m "chore(release): bump to ${nextVersion}"`); await this.exec(`git push --set-upstream origin ${branchName} --no-verify`); if (!repo.package.packageJson.repository) { throw new SfError('The repository field is required in the package.json. This is used to determine the repo owner and name to create the release PR.'); } const [repoOwner, repoName] = repo.package.packageJson.repository.split('/'); const releaseDetails = ` > **Note** > Patches and prereleases often require very specific starting points and changes. > These changes often cannot be shipped from \`main\` since it is ahead in commits. > Because of this the release process is different, they "ship" from a branch based on the starting ref (\`${ref}\`). > Once your PR is ready to be released, merge it into \`${baseBranch}\`.`; const includeReleaseDetails = isPrerelease || (flags.patch && flags['release-channel'] !== 'nightly'); const pr = await octokit.request('POST /repos/{owner}/{repo}/pulls', { owner: repoOwner, repo: repoName, head: branchName, base: baseBranch, title: `Release PR for ${nextVersion} as ${flags['release-channel']}`, body: `Building ${nextVersion}\n[skip-validate-pr]\n${includeReleaseDetails ? releaseDetails : ''}`, }); if (flags.label) { await octokit.request('POST /repos/{owner}/{repo}/issues/{issue_number}/labels', { owner: repoOwner, repo: repoName, // eslint-disable-next-line camelcase issue_number: pr.data.number, labels: flags.label, }); } } } async distTagToGithubRef(distTag) { this.log(`Flag '--start-from-npm-dist-tag' passed, looking up version for ${distTag}`); const temp = await PackageRepo.create({ ux: new Ux({ jsonEnabled: this.jsonEnabled() }) }); return temp.package.getDistTags(temp.package.packageJson.name)[distTag]; } async createAndPushBaseBranch(ref) { // Since patches and prereleases can be created from any previous dist-tag or github ref, // it is unlikely that we would be able to merge these into main. // Before we make any changes, push the starting point ref to a branch to use as our PR `base`. // The create-cli-release.yml GHA will watch for merges into this base branch to trigger a release const baseBranch = `release-base/${ref}`; // Create new branch based on ref await this.exec(`git checkout -b ${baseBranch}`); // Ensure the base branch does not exist at remote before attempting to push if (await this.exec(`git ls-remote --heads origin ${baseBranch}`)) { await this.exec(`git push origin --delete ${baseBranch}`); } await this.exec(`git push -u origin ${baseBranch} --no-verify`); return baseBranch; } async exec(command, silent = false) { try { const { stdout } = await exec(command); if (!silent) { this.styledHeader(command); this.log(stdout); } return stdout; } catch (err) { // An error will throw before `stdout` is able to be log above. The child_process.exec adds stdout and stderr to the error object const error = err; this.log(error.stdout); throw new SfError(err.message); } } async maybeSetGitConfig(octokit) { const username = await this.exec('git config user.name', true); const email = await this.exec('git config user.email', true); if (!username || !email) { const user = await octokit.request('GET /user'); if (!username && user.data.name) await this.exec(`git config user.name "${user.data.name}"`); if (!email && user.data.email) await this.exec(`git config user.email "${user.data.email}"`); } } } //# sourceMappingURL=build.js.map