mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
218 lines
8.0 KiB
JavaScript
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();
|