UNPKG

@ossjs/release

Version:

Minimalistic, opinionated, and predictable release automation tool.

329 lines 15.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.Publish = void 0; const until_1 = require("@open-draft/until"); const outvariant_1 = require("outvariant"); const Command_1 = require("../Command"); const createContext_1 = require("../utils/createContext"); const getInfo_1 = require("../utils/git/getInfo"); const getNextReleaseType_1 = require("../utils/getNextReleaseType"); const getNextVersion_1 = require("../utils/getNextVersion"); const getCommits_1 = require("../utils/git/getCommits"); const getCurrentBranch_1 = require("../utils/git/getCurrentBranch"); const getLatestRelease_1 = require("../utils/git/getLatestRelease"); const bumpPackageJson_1 = require("../utils/bumpPackageJson"); const getTags_1 = require("../utils/git/getTags"); const execAsync_1 = require("../utils/execAsync"); const commit_1 = require("../utils/git/commit"); const createTag_1 = require("../utils/git/createTag"); const push_1 = require("../utils/git/push"); const getReleaseRefs_1 = require("../utils/release-notes/getReleaseRefs"); const parseCommits_1 = require("../utils/git/parseCommits"); const createComment_1 = require("../utils/github/createComment"); const createReleaseComment_1 = require("../utils/createReleaseComment"); const env_1 = require("../utils/env"); const notes_1 = require("./notes"); class Publish extends Command_1.Command { static command = 'publish'; static description = 'Publish the package'; static builder = (yargs) => { return yargs .usage('$0 publish [options]') .option('profile', { alias: 'p', type: 'string', default: 'latest', demandOption: true, }) .option('dry-run', { alias: 'd', type: 'boolean', default: false, demandOption: false, description: 'Print command steps without executing them', }); }; profile = null; context = null; /** * The list of clean-up functions to invoke if release fails. */ revertQueue = []; run = async () => { const profileName = this.argv.profile; const profileDefinition = this.config.profiles.find((definedProfile) => { return definedProfile.name === profileName; }); (0, outvariant_1.invariant)(profileDefinition, 'Failed to publish: no profile found by name "%s". Did you forget to define it in "release.config.json"?', profileName); this.profile = profileDefinition; await (0, env_1.demandGitHubToken)().catch((error) => { this.log.error(error.message); process.exit(1); }); await (0, env_1.demandNpmToken)().catch((error) => { this.log.error(error.message); process.exit(1); }); this.revertQueue = []; // Extract repository information (remote/owner/name). const repo = await (0, getInfo_1.getInfo)().catch((error) => { console.error(error); throw new Error('Failed to get Git repository information'); }); const branchName = await (0, getCurrentBranch_1.getCurrentBranch)().catch((error) => { console.error(error); throw new Error('Failed to get the current branch name'); }); this.log.info((0, outvariant_1.format)('preparing release for "%s/%s" from branch "%s"...', repo.owner, repo.name, branchName)); /** * Get the latest release. * @note This refers to the latest release tag at the current * state of the branch. Since Release doesn't do branch analysis, * this doesn't guarantee the latest release in general * (consider backport releases where you checkout an old SHA). */ const tags = await (0, getTags_1.getTags)(); const latestRelease = await (0, getLatestRelease_1.getLatestRelease)(tags); if (latestRelease) { this.log.info((0, outvariant_1.format)('found latest release: %s (%s)', latestRelease.tag, latestRelease.hash)); } else { this.log.info('found no previous releases, creating the first one...'); } const rawCommits = await (0, getCommits_1.getCommits)({ since: latestRelease?.hash, }); this.log.info((0, outvariant_1.format)('found %d new %s:\n%s', rawCommits.length, rawCommits.length > 1 ? 'commits' : 'commit', rawCommits .map((commit) => (0, outvariant_1.format)(' - %s %s', commit.hash, commit.subject)) .join('\n'))); const commits = await (0, parseCommits_1.parseCommits)(rawCommits); this.log.info((0, outvariant_1.format)('successfully parsed %d commit(s)!', commits.length)); if (commits.length === 0) { this.log.warn('no commits since the latest release, skipping...'); return; } // Get the next release type and version number. const nextReleaseType = (0, getNextReleaseType_1.getNextReleaseType)(commits, { prerelease: this.profile.prerelease, }); if (!nextReleaseType) { this.log.warn('committed changes do not bump version, skipping...'); return; } const prevVersion = latestRelease?.tag || 'v0.0.0'; const nextVersion = (0, getNextVersion_1.getNextVersion)(prevVersion, nextReleaseType); this.context = (0, createContext_1.createContext)({ repo, latestRelease, nextRelease: { version: nextVersion, publishedAt: new Date(), }, }); this.log.info((0, outvariant_1.format)('release type "%s": %s -> %s', nextReleaseType, prevVersion.replace(/^v/, ''), this.context.nextRelease.version)); // Bump the version in package.json without committing it. if (this.argv.dryRun) { this.log.warn((0, outvariant_1.format)('skip version bump in package.json in dry-run mode (next: %s)', nextVersion)); } else { (0, bumpPackageJson_1.bumpPackageJson)(nextVersion); this.log.info((0, outvariant_1.format)('bumped version in package.json to: %s', nextVersion)); } // Execute the publishing script. await this.runReleaseScript(); const result = await (0, until_1.until)(async () => { await this.createReleaseCommit(); await this.createReleaseTag(); await this.pushToRemote(); const releaseNotes = await this.generateReleaseNotes(commits); const releaseUrl = await this.createGitHubRelease(releaseNotes); return { releaseUrl, }; }); // Handle any errors during the release process the same way. if (result.error) { this.log.error(result.error.message); /** * @todo Suggest a standalone command to repeat the commit/tag/release * part of the publishing. The actual publish script was called anyway, * so the package has been published at this point, just the Git info * updates are missing. */ this.log.error('release failed, reverting changes...'); // Revert changes in case of errors. await this.revertChanges(); return process.exit(1); } // Comment on each relevant GitHub issue. await this.commentOnIssues(commits, result.data.releaseUrl); if (this.argv.dryRun) { this.log.warn((0, outvariant_1.format)('release "%s" completed in dry-run mode!', this.context.nextRelease.tag)); return; } this.log.info((0, outvariant_1.format)('release "%s" completed!', this.context.nextRelease.tag)); }; /** * Execute the release script specified in the configuration. */ async runReleaseScript() { const env = { RELEASE_VERSION: this.context.nextRelease.version, }; this.log.info((0, outvariant_1.format)('preparing to run the publishing script with:\n%j', env)); if (this.argv.dryRun) { this.log.warn('skip executing publishing script in dry-run mode'); return; } this.log.info((0, outvariant_1.format)('executing publishing script for profile "%s": %s'), this.profile.name, this.profile.use); const releaseScriptPromise = (0, execAsync_1.execAsync)(this.profile.use, { env: { ...process.env, ...env, }, }); // Forward the publish script's stdio to the logger. releaseScriptPromise.io.stdout?.pipe(process.stdout); releaseScriptPromise.io.stderr?.pipe(process.stderr); await releaseScriptPromise.catch((error) => { this.log.error(error); this.log.error('Failed to publish: the publish script errored. See the original error above.'); process.exit(releaseScriptPromise.io.exitCode || 1); }); this.log.info('published successfully!'); } /** * Revert those changes that were marked as revertable. */ async revertChanges() { let revert; while ((revert = this.revertQueue.pop())) { await revert(); } } /** * Create a release commit in Git. */ async createReleaseCommit() { const message = `chore(release): ${this.context.nextRelease.tag}`; if (this.argv.dryRun) { this.log.warn((0, outvariant_1.format)('skip creating a release commit in dry-run mode: "%s"', message)); return; } const commitResult = await (0, until_1.until)(() => { return (0, commit_1.commit)({ files: ['package.json'], message, }); }); (0, outvariant_1.invariant)(commitResult.error == null, 'Failed to create release commit!\n', commitResult.error); this.log.info((0, outvariant_1.format)('created a release commit at "%s"!', commitResult.data.hash)); this.revertQueue.push(async () => { this.log.info('reverting the release commit...'); const hasChanges = await (0, execAsync_1.execAsync)('git diff'); if (hasChanges) { this.log.info('detected uncommitted changes, stashing...'); await (0, execAsync_1.execAsync)('git stash'); } await (0, execAsync_1.execAsync)('git reset --hard HEAD~1').finally(async () => { if (hasChanges) { this.log.info('unstashing uncommitted changes...'); await (0, execAsync_1.execAsync)('git stash pop'); } }); }); } /** * Create a release tag in Git. */ async createReleaseTag() { const nextTag = this.context.nextRelease.tag; if (this.argv.dryRun) { this.log.warn((0, outvariant_1.format)('skip creating a release tag in dry-run mode: %s', nextTag)); return; } const tagResult = await (0, until_1.until)(async () => { const tag = await (0, createTag_1.createTag)(nextTag); await (0, execAsync_1.execAsync)(`git push origin ${tag}`); return tag; }); (0, outvariant_1.invariant)(tagResult.error == null, 'Failed to tag the release!\n', tagResult.error); this.revertQueue.push(async () => { const tagToRevert = this.context.nextRelease.tag; this.log.info((0, outvariant_1.format)('reverting the release tag "%s"...', tagToRevert)); await (0, execAsync_1.execAsync)(`git tag -d ${tagToRevert}`); await (0, execAsync_1.execAsync)(`git push --delete origin ${tagToRevert}`); }); this.log.info((0, outvariant_1.format)('created release tag "%s"!', tagResult.data)); } /** * Generate release notes from the given commits. */ async generateReleaseNotes(commits) { this.log.info((0, outvariant_1.format)('generating release notes for %d commits...', commits.length)); const releaseNotes = await notes_1.Notes.generateReleaseNotes(this.context, commits); this.log.info(`generated release notes:\n\n${releaseNotes}\n`); return releaseNotes; } /** * Push the release commit and tag to the remote. */ async pushToRemote() { if (this.argv.dryRun) { this.log.warn('skip pushing release to Git in dry-run mode'); return; } const pushResult = await (0, until_1.until)(() => (0, push_1.push)()); (0, outvariant_1.invariant)(pushResult.error == null, 'Failed to push changes to origin!\n', pushResult.error); this.log.info((0, outvariant_1.format)('pushed changes to "%s" (origin)!', this.context.repo.remote)); } /** * Create a new GitHub release. */ async createGitHubRelease(releaseNotes) { this.log.info('creating a new GitHub release...'); if (this.argv.dryRun) { this.log.warn('skip creating a GitHub release in dry-run mode'); return '#'; } const release = await notes_1.Notes.createRelease(this.context, releaseNotes); const { html_url: releaseUrl } = release; this.log.info((0, outvariant_1.format)('created release: %s', releaseUrl)); return releaseUrl; } /** * Comment on referenced GitHub issues and pull requests. */ async commentOnIssues(commits, releaseUrl) { this.log.info('commenting on referenced GitHib issues...'); const referencedIssueIds = await (0, getReleaseRefs_1.getReleaseRefs)(commits); const issuesCount = referencedIssueIds.size; const releaseCommentText = (0, createReleaseComment_1.createReleaseComment)({ context: this.context, releaseUrl, }); if (issuesCount === 0) { this.log.info('no referenced GitHub issues, nothing to comment!'); return; } this.log.info((0, outvariant_1.format)('found %d referenced GitHub issues!', issuesCount)); const issuesNoun = issuesCount === 1 ? 'issue' : 'issues'; const issuesDisplayList = Array.from(referencedIssueIds) .map((id) => ` - ${id}`) .join('\n'); if (this.argv.dryRun) { this.log.warn((0, outvariant_1.format)('skip commenting on %d GitHub %s:\n%s', issuesCount, issuesNoun, issuesDisplayList)); return; } this.log.info((0, outvariant_1.format)('commenting on %d GitHub %s:\n%s', issuesCount, issuesNoun, issuesDisplayList)); const commentPromises = []; for (const issueId of referencedIssueIds) { commentPromises.push((0, createComment_1.createComment)(issueId, releaseCommentText).catch((error) => { this.log.error((0, outvariant_1.format)('commenting on issue "%s" failed: %s', error.message)); })); } await Promise.allSettled(commentPromises); } } exports.Publish = Publish; //# sourceMappingURL=publish.js.map