@ossjs/release
Version:
Minimalistic, opinionated, and predictable release automation tool.
293 lines (291 loc) • 11.5 kB
JavaScript
import { Command } from "../Command.js";
import "../utils/get-config.js";
import { execAsync } from "../utils/exec-async.js";
import { getLatestRelease } from "../utils/git/get-latest-release.js";
import { getTags } from "../utils/git/get-tags.js";
import { getInfo } from "../utils/git/get-info.js";
import { demandGitHubToken } from "../utils/env.js";
import { createContext } from "../utils/create-context.js";
import { parseCommits } from "../utils/git/parse-commits.js";
import { getNextReleaseType } from "../utils/get-next-release-type.js";
import { getNextVersion } from "../utils/get-next-version.js";
import { getCommits } from "../utils/git/get-commits.js";
import { getCurrentBranch } from "../utils/git/get-current-branch.js";
import { bumpPackageJson } from "../utils/bump-package-json.js";
import { commit } from "../utils/git/commit.js";
import { createTag } from "../utils/git/create-tag.js";
import { push } from "../utils/git/push.js";
import { getReleaseRefs } from "../utils/release-notes/get-release-refs.js";
import { createComment } from "../utils/github/create-comment.js";
import { createReleaseComment } from "../utils/create-release-comment.js";
import { Notes } from "./notes.js";
import { lintPackage } from "../utils/lint-package.js";
import "yargs";
import { format, invariant } from "outvariant";
import { until } from "until-async";
//#region src/commands/publish.ts
var Publish = class extends Command {
static command = "publish";
static description = "Publish the package";
static builder = (yargs$1) => {
return yargs$1.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;
});
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 = [];
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));
const latestRelease = await getLatestRelease(await getTags());
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$1) => format(" - %s %s", commit$1.hash, commit$1.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;
}
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: /* @__PURE__ */ new Date()
}
});
this.log.info(format("release type \"%s\": %s -> %s", nextReleaseType, prevVersion.replace(/^v/, ""), this.context.nextRelease.version));
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!");
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));
}
await this.runReleaseScript();
const [resultError, resultData] = await until(async () => {
await this.createReleaseCommit();
await this.createReleaseTag();
await this.pushToRemote();
const releaseNotes = await this.generateReleaseNotes(commits);
return { releaseUrl: await this.createGitHubRelease(releaseNotes) };
});
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...");
await this.revertChanges();
return process.exit(1);
}
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.
*/
async runReleaseScript() {
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
} });
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(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.
*/
async createReleaseTag() {
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.
*/
async generateReleaseNotes(commits) {
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.
*/
async pushToRemote() {
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.
*/
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 { html_url: releaseUrl } = await Notes.createRelease(this.context, releaseNotes);
this.log.info(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 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 = [];
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);
}
};
//#endregion
export { Publish };