Files

218 lines
8.0 KiB
JavaScript

// 编排本地发布准备与标签推送。
import { spawnSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import { relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { checkPackageVersions } from "./check-package-versions.mjs";
import {
bumpPackageVersion,
listWorkspacePackagePaths,
readRootPackageVersion,
writeRootPackageVersion,
} from "./package-version.mjs";
import { syncPackageVersions } from "./sync-package-versions.mjs";
const workspaceDir = resolve(fileURLToPath(new URL(".", import.meta.url)), "..");
const remoteName = "origin";
const mainBranch = "main";
const toLines = (output) => output.trim().split("\n").filter(Boolean);
const commandLabel = (command, arguments_) => [command, ...arguments_].join(" ");
const runProcess = (command, arguments_, { rootDir = workspaceDir, showOutput = false } = {}) => {
const result = spawnSync(command, arguments_, {
cwd: rootDir,
encoding: "utf8",
stdio: showOutput ? "inherit" : "pipe",
});
if (result.error) throw result.error;
if (result.status !== 0) {
throw new Error(`Command failed: ${commandLabel(command, arguments_)}`);
}
return result.stdout ?? "";
};
const createCommandRunner = (rootDir, execute = runProcess) => ({
git: (arguments_, options) => execute("git", arguments_, { rootDir, ...options }),
pnpm: (arguments_, options) => execute("pnpm", arguments_, { rootDir, ...options }),
});
const getCurrentBranch = (git) => git(["branch", "--show-current"]).trim();
const assertCleanWorkingTree = (git) => {
const changes = git(["status", "--porcelain"]).trim();
if (changes) throw new Error("Release requires a clean working tree");
};
const assertNamedBranch = (git) => {
const branch = getCurrentBranch(git);
if (!branch) throw new Error("Release preparation requires a named branch");
return branch;
};
const fetchMain = (git) => git(["fetch", remoteName, mainBranch], { showOutput: true });
const assertBranchIncludesMain = (git) => {
const remoteMain = git(["rev-parse", `${remoteName}/${mainBranch}`]).trim();
const mergeBase = git(["merge-base", `${remoteName}/${mainBranch}`, "HEAD"]).trim();
if (mergeBase !== remoteMain) {
throw new Error(`Release branch must include the latest ${remoteName}/${mainBranch}`);
}
};
const releaseVersionFiles = (rootDir) => [
"package.json",
...listWorkspacePackagePaths(rootDir).map((packagePath) =>
relative(rootDir, packagePath).split(sep).join("/"),
),
];
const snapshotVersionFiles = (rootDir, versionFiles) =>
versionFiles.map((filePath) => ({
filePath,
source: readFileSync(resolve(rootDir, filePath), "utf8"),
}));
const restoreVersionFiles = (rootDir, versionFileSnapshots) => {
for (const { filePath, source } of versionFileSnapshots) {
writeFileSync(resolve(rootDir, filePath), source);
}
};
const assertExactFiles = (actualFiles, expectedFiles, description) => {
const actual = [...new Set(actualFiles)].sort();
const expected = [...new Set(expectedFiles)].sort();
if (
actual.length !== expected.length ||
actual.some((file, index) => file !== expected[index])
) {
throw new Error(
`${description} must only include ${expected.join(", ")}; found ${actual.join(", ") || "none"}`,
);
}
};
const assertOnlyVersionFilesChanged = (git, expectedFiles) => {
assertExactFiles(toLines(git(["diff", "--name-only"])), expectedFiles, "Release changes");
assertExactFiles(
toLines(git(["ls-files", "--others", "--exclude-standard"])),
[],
"Release changes",
);
git(["--no-pager", "diff", "--check"], { showOutput: true });
};
const stageVersionFiles = (git, expectedFiles) => {
git(["add", "--", ...expectedFiles], { showOutput: true });
assertExactFiles(
toLines(git(["diff", "--cached", "--name-only"])),
expectedFiles,
"Staged release changes",
);
};
const runQualityGates = (pnpm) => {
for (const arguments_ of [
["format:check"],
["--filter", "@trade-message-center/server", "db:check"],
["typecheck"],
["test"],
["build"],
]) {
pnpm(arguments_, { showOutput: true });
}
};
/** 准备下一版本、验证、提交并推送当前发布分支。 */
export const prepareRelease = ({ rootDir = workspaceDir, releaseType = "patch", execute } = {}) => {
const { git, pnpm } = createCommandRunner(rootDir, execute);
assertCleanWorkingTree(git);
const branch = assertNamedBranch(git);
fetchMain(git);
assertBranchIncludesMain(git);
const expectedFiles = releaseVersionFiles(rootDir);
const versionFileSnapshots = snapshotVersionFiles(rootDir, expectedFiles);
const previousVersion = readRootPackageVersion(rootDir);
const nextVersion = bumpPackageVersion(previousVersion, releaseType);
let staged = false;
let committed = false;
try {
writeRootPackageVersion(nextVersion, rootDir);
syncPackageVersions(rootDir);
checkPackageVersions(rootDir);
runQualityGates(pnpm);
assertOnlyVersionFilesChanged(git, expectedFiles);
stageVersionFiles(git, expectedFiles);
staged = true;
git(["commit", "-m", `chore: release ${nextVersion}`], { showOutput: true });
committed = true;
git(["push", "--set-upstream", remoteName, branch], { showOutput: true });
} catch (error) {
if (committed) {
throw new Error(
`Release ${nextVersion} was committed locally but could not be pushed`,
{
cause: error,
},
);
}
if (staged) git(["restore", "--staged", "--", ...expectedFiles], { showOutput: true });
restoreVersionFiles(rootDir, versionFileSnapshots);
throw new Error(
`Release ${nextVersion} failed before commit; restored the root and workspace package versions`,
{ cause: error },
);
}
console.info(`Prepared release ${nextVersion} on ${branch}`);
return nextVersion;
};
/** 为已合入 main 的版本创建并推送发布 tag。 */
export const publishRelease = ({ rootDir = workspaceDir, execute } = {}) => {
const { git } = createCommandRunner(rootDir, execute);
assertCleanWorkingTree(git);
const branch = getCurrentBranch(git);
if (branch !== mainBranch) throw new Error(`Publishing requires the ${mainBranch} branch`);
fetchMain(git);
const head = git(["rev-parse", "HEAD"]).trim();
const remoteMain = git(["rev-parse", `${remoteName}/${mainBranch}`]).trim();
if (head !== remoteMain) {
throw new Error(`Publishing requires HEAD to match ${remoteName}/${mainBranch}`);
}
checkPackageVersions(rootDir);
const version = readRootPackageVersion(rootDir);
const tagName = `v${version}`;
if (git(["tag", "--list", tagName]).trim()) {
throw new Error(`Local tag already exists: ${tagName}`);
}
if (git(["ls-remote", "--tags", remoteName, `refs/tags/${tagName}`]).trim()) {
throw new Error(`Remote tag already exists: ${tagName}`);
}
git(["tag", "--annotate", tagName, "--message", `Release ${tagName}`], { showOutput: true });
git(["push", remoteName, tagName], { showOutput: true });
console.info(`Published release ${tagName}`);
return tagName;
};
/** 解析 CLI 子命令并执行安全的发布阶段。 */
export const runReleaseCommand = (arguments_ = process.argv.slice(2)) => {
const [command = "prepare", releaseType, ...extraArguments] = arguments_;
if (extraArguments.length > 0)
throw new Error("Release command accepts at most one version type");
if (command === "prepare") return prepareRelease({ releaseType });
if (command === "publish" && releaseType === undefined) return publishRelease();
throw new Error("Use release prepare [patch|minor|major] or release publish");
};
if (process.argv[1] === fileURLToPath(import.meta.url)) runReleaseCommand();