UNPKG

@ossjs/release

Version:

Minimalistic, opinionated, and predictable release automation tool.

520 lines (436 loc) 14.5 kB
import { until } from 'until-async' import { invariant, format } from 'outvariant' import { type BuilderCallback } from 'yargs' import { Command } from '#/src/Command.js' import { createContext, type ReleaseContext, } from '#/src/utils/create-context.js' import { getInfo } from '#/src/utils/git/get-info.js' import { getNextReleaseType } from '#/src/utils/get-next-release-type.js' import { getNextVersion } from '#/src/utils/get-next-version.js' import { getCommits } from '#/src/utils/git/get-commits.js' import { getCurrentBranch } from '#/src/utils/git/get-current-branch.js' import { getLatestRelease } from '#/src/utils/git/get-latest-release.js' import { bumpPackageJson } from '#/src/utils/bump-package-json.js' import { getTags } from '#/src/utils/git/get-tags.js' import { execAsync } from '#/src/utils/exec-async.js' import { commit } from '#/src/utils/git/commit.js' import { createTag } from '#/src/utils/git/create-tag.js' import { push } from '#/src/utils/git/push.js' import { getReleaseRefs } from '#/src/utils/release-notes/get-release-refs.js' import { parseCommits, type ParsedCommitWithHash, } from '#/src/utils/git/parse-commits.js' import { createComment } from '#/src/utils/github/create-comment.js' import { createReleaseComment } from '#/src/utils/create-release-comment.js' import { demandGitHubToken } from '#/src/utils/env.js' import { Notes } from '#/src/commands/notes.js' import { type ReleaseProfile } from '#/src/utils/get-config.js' import { lintPackage } from '../utils/lint-package.js' interface PublishArgv { profile: string dryRun?: boolean } export type RevertAction = () => Promise<void> export class Publish extends Command<PublishArgv> { static command = 'publish' static description = 'Publish the package' static builder: BuilderCallback<{}, PublishArgv> = (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', }) } private profile: ReleaseProfile = null as any private context: ReleaseContext = null as any /** * The list of clean-up functions to invoke if release fails. */ private revertQueue: Array<RevertAction> = [] public run = async (): Promise<void> => { const profileName = this.argv.profile const profileDefinition = this.config.profiles.find((definedProfile) => { return definedProfile.name === profileName }) 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 demandGitHubToken().catch((error) => { this.log.error(error.message) process.exit(1) }) this.revertQueue = [] // Extract repository information (remote/owner/name). const repo = await getInfo().catch((error) => { this.log.error(error) throw new Error('Failed to get Git repository information') }) const branchName = await getCurrentBranch().catch((error) => { this.log.error(error) throw new Error('Failed to get the current branch name') }) this.log.info( 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 getTags() const latestRelease = await getLatestRelease(tags) if (latestRelease) { this.log.info( 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 getCommits({ since: latestRelease?.hash, }) this.log.info( format( 'found %d new %s:\n%s', rawCommits.length, rawCommits.length > 1 ? 'commits' : 'commit', rawCommits .map((commit) => format(' - %s %s', commit.hash, commit.subject)) .join('\n'), ), ) const commits = await parseCommits(rawCommits) this.log.info(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 = 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 = getNextVersion(prevVersion, nextReleaseType) this.context = createContext({ repo, latestRelease, nextRelease: { version: nextVersion, publishedAt: new Date(), }, }) this.log.info( format( 'release type "%s": %s -> %s', nextReleaseType, prevVersion.replace(/^v/, ''), this.context.nextRelease.version, ), ) // Lint the package using "publint" for common issues early. // No point in proceeding with the release if the package is faulty. this.log.info('linting the packed package...') const [lintError] = await until(() => { return lintPackage() }) if (lintError) { this.log.error(lintError.message) return process.exit(1) } this.log.info('package is healthy and ready for publishing!') // Bump the version in package.json without committing it. if (this.argv.dryRun) { this.log.warn( format( 'skip version bump in package.json in dry-run mode (next: %s)', nextVersion, ), ) } else { bumpPackageJson(nextVersion) this.log.info( format('bumped version in package.json to: %s', nextVersion), ) } // Execute the publishing script. await this.runReleaseScript() const [resultError, resultData] = await 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 (resultError) { this.log.error(resultError.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, resultData.releaseUrl) if (this.argv.dryRun) { this.log.warn( format( 'release "%s" completed in dry-run mode!', this.context.nextRelease.tag, ), ) return } this.log.info( format('release "%s" completed!', this.context.nextRelease.tag), ) } /** * Execute the release script specified in the configuration. */ private async runReleaseScript(): Promise<void> { const env = { RELEASE_VERSION: this.context.nextRelease.version, } this.log.info( 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( format('executing publishing script for profile "%s": %s'), this.profile.name, this.profile.use, ) const releaseScriptPromise = 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. */ private async revertChanges(): Promise<void> { let revert: RevertAction | undefined while ((revert = this.revertQueue.pop())) { await revert?.() } } /** * Create a release commit in Git. */ private async createReleaseCommit(): Promise<void> { const message = `chore(release): ${this.context.nextRelease.tag}` if (this.argv.dryRun) { this.log.warn( format('skip creating a release commit in dry-run mode: "%s"', message), ) return } const [commitError, commitData] = await until(() => { return commit({ files: ['package.json'], message, }) }) invariant( commitError == null, 'Failed to create release commit!\n', commitError, ) this.log.info(format('created a release commit at "%s"!', commitData.hash)) this.revertQueue.push(async () => { this.log.info('reverting the release commit...') const hasChanges = await execAsync('git diff') if (hasChanges) { this.log.info('detected uncommitted changes, stashing...') await execAsync('git stash') } await execAsync('git reset --hard HEAD~1').finally(async () => { if (hasChanges) { this.log.info('unstashing uncommitted changes...') await execAsync('git stash pop') } }) }) } /** * Create a release tag in Git. */ private async createReleaseTag(): Promise<void> { const nextTag = this.context.nextRelease.tag if (this.argv.dryRun) { this.log.warn( format('skip creating a release tag in dry-run mode: %s', nextTag), ) return } const [tagError, tagData] = await until(async () => { const tag = await createTag(nextTag) await execAsync(`git push origin ${tag}`) return tag }) invariant(tagError == null, 'Failed to tag the release!\n', tagError) this.revertQueue.push(async () => { const tagToRevert = this.context.nextRelease.tag this.log.info(format('reverting the release tag "%s"...', tagToRevert)) await execAsync(`git tag -d ${tagToRevert}`) await execAsync(`git push --delete origin ${tagToRevert}`) }) this.log.info(format('created release tag "%s"!', tagData)) } /** * Generate release notes from the given commits. */ private async generateReleaseNotes( commits: ParsedCommitWithHash[], ): Promise<string> { this.log.info( format('generating release notes for %d commits...', commits.length), ) const releaseNotes = await 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. */ private async pushToRemote(): Promise<void> { if (this.argv.dryRun) { this.log.warn('skip pushing release to Git in dry-run mode') return } const [pushError] = await until(() => push()) invariant( pushError == null, 'Failed to push changes to origin!\n', pushError, ) this.log.info( format('pushed changes to "%s" (origin)!', this.context.repo.remote), ) } /** * Create a new GitHub release. */ private async createGitHubRelease(releaseNotes: string): Promise<string> { 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.createRelease(this.context, releaseNotes) const { html_url: releaseUrl } = release this.log.info(format('created release: %s', releaseUrl)) return releaseUrl } /** * Comment on referenced GitHub issues and pull requests. */ private async commentOnIssues( commits: ParsedCommitWithHash[], releaseUrl: string, ): Promise<void> { this.log.info('commenting on referenced GitHib issues...') const referencedIssueIds = await getReleaseRefs(commits) const issuesCount = referencedIssueIds.size const releaseCommentText = createReleaseComment({ profile: this.profile.name, context: this.context, releaseUrl, }) if (issuesCount === 0) { this.log.info('no referenced GitHub issues, nothing to comment!') return } this.log.info(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( format( 'skip commenting on %d GitHub %s:\n%s', issuesCount, issuesNoun, issuesDisplayList, ), ) return } this.log.info( format( 'commenting on %d GitHub %s:\n%s', issuesCount, issuesNoun, issuesDisplayList, ), ) const commentPromises: Promise<void>[] = [] for (const issueId of referencedIssueIds) { commentPromises.push( createComment(issueId, releaseCommentText).catch((error) => { this.log.error( format('commenting on issue "%s" failed: %s', error.message), ) }), ) } await Promise.allSettled(commentPromises) } }