@ossjs/release
Version:
Minimalistic, opinionated, and predictable release automation tool.
520 lines (436 loc) • 14.5 kB
text/typescript
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)
}
}