@spaceflow/publish 0.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +503 -0
- package/README.md +228 -0
- package/dist/index.js +1160 -0
- package/package.json +40 -0
- package/src/index.ts +30 -0
- package/src/locales/en/publish.json +8 -0
- package/src/locales/index.ts +11 -0
- package/src/locales/zh-cn/publish.json +8 -0
- package/src/monorepo.service.ts +376 -0
- package/src/publish.command.ts +71 -0
- package/src/publish.config.ts +90 -0
- package/src/publish.module.ts +12 -0
- package/src/publish.service.ts +602 -0
- package/tsconfig.json +5 -0
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@spaceflow/publish",
|
|
3
|
+
"version": "0.21.1",
|
|
4
|
+
"description": "Spaceflow CI 发布插件,用于在发布流程中锁定/解锁分支",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Lydanne",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Lydanne/spaceflow.git",
|
|
10
|
+
"directory": "extensions/publish"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "./dist/index.js",
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@release-it/conventional-changelog": "^10.0.4",
|
|
19
|
+
"release-it": "^19.2.2",
|
|
20
|
+
"release-it-gitea": "^1.8.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^22.15.0",
|
|
24
|
+
"@spaceflow/cli": "0.19.2"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@nestjs/common": "^11.0.1",
|
|
28
|
+
"@nestjs/config": "^4.0.2",
|
|
29
|
+
"nest-commander": "^3.20.1",
|
|
30
|
+
"@spaceflow/core": "0.1.2"
|
|
31
|
+
},
|
|
32
|
+
"spaceflow": {
|
|
33
|
+
"type": "flow",
|
|
34
|
+
"entry": "."
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "spaceflow build",
|
|
38
|
+
"dev": "spaceflow dev"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import "./locales";
|
|
2
|
+
import { SpaceflowExtension, SpaceflowExtensionMetadata, t } from "@spaceflow/core";
|
|
3
|
+
import { PublishModule } from "./publish.module";
|
|
4
|
+
import { publishSchema } from "./publish.config";
|
|
5
|
+
/** publish Extension 元数据 */
|
|
6
|
+
export const publishMetadata: SpaceflowExtensionMetadata = {
|
|
7
|
+
name: "publish",
|
|
8
|
+
commands: ["publish"],
|
|
9
|
+
configKey: "publish",
|
|
10
|
+
configSchema: () => publishSchema,
|
|
11
|
+
version: "1.0.0",
|
|
12
|
+
description: t("publish:extensionDescription"),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export class PublishExtension implements SpaceflowExtension {
|
|
16
|
+
getMetadata(): SpaceflowExtensionMetadata {
|
|
17
|
+
return publishMetadata;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
getModule() {
|
|
21
|
+
return PublishModule;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default PublishExtension;
|
|
26
|
+
|
|
27
|
+
export * from "./publish.command";
|
|
28
|
+
export * from "./publish.service";
|
|
29
|
+
export * from "./publish.module";
|
|
30
|
+
export * from "./monorepo.service";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "CI publish command for branch lock/unlock during release",
|
|
3
|
+
"options.prerelease": "Prerelease tag, e.g. rc, beta, alpha, nightly",
|
|
4
|
+
"options.rehearsal": "Rehearsal mode: execute hooks but no file/git changes, sets PUBLISH_REHEARSAL=true",
|
|
5
|
+
"rehearsalMode": "🎭 REHEARSAL mode: hooks will execute, but no file/git changes",
|
|
6
|
+
"dryRunMode": "🔍 DRY-RUN mode: no actual execution",
|
|
7
|
+
"extensionDescription": "CI publish command for branch lock/unlock during release"
|
|
8
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { addLocaleResources } from "@spaceflow/core";
|
|
2
|
+
import zhCN from "./zh-cn/publish.json";
|
|
3
|
+
import en from "./en/publish.json";
|
|
4
|
+
|
|
5
|
+
/** publish 命令 i18n 资源 */
|
|
6
|
+
export const publishLocales: Record<string, Record<string, string>> = {
|
|
7
|
+
"zh-CN": zhCN,
|
|
8
|
+
en,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
addLocaleResources("publish", publishLocales);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "CI 发布命令,用于在发布流程中锁定/解锁分支",
|
|
3
|
+
"options.prerelease": "预发布标签,如 rc、beta、alpha、nightly 等",
|
|
4
|
+
"options.rehearsal": "预演模式:执行 hooks 但不修改文件/git,设置 PUBLISH_REHEARSAL=true 环境变量",
|
|
5
|
+
"rehearsalMode": "🎭 REHEARSAL mode: hooks will execute, but no file/git changes",
|
|
6
|
+
"dryRunMode": "🔍 DRY-RUN mode: no actual execution",
|
|
7
|
+
"extensionDescription": "CI 发布命令,用于在发布流程中锁定/解锁分支"
|
|
8
|
+
}
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { Injectable } from "@nestjs/common";
|
|
2
|
+
import { execSync } from "child_process";
|
|
3
|
+
import { existsSync, readFileSync } from "fs";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
|
|
6
|
+
export interface PackageInfo {
|
|
7
|
+
/** 包目录路径(相对于项目根目录) */
|
|
8
|
+
dir: string;
|
|
9
|
+
/** 包名称(从 package.json 读取) */
|
|
10
|
+
name: string;
|
|
11
|
+
/** 包版本 */
|
|
12
|
+
version: string;
|
|
13
|
+
/** workspace 依赖的包名列表 */
|
|
14
|
+
workspaceDeps: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface MonorepoAnalysisResult {
|
|
18
|
+
/** 所有变更的包 */
|
|
19
|
+
changedPackages: PackageInfo[];
|
|
20
|
+
/** 需要发布的包(包含依赖变更的包),按拓扑排序 */
|
|
21
|
+
packagesToPublish: PackageInfo[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@Injectable()
|
|
25
|
+
export class MonorepoService {
|
|
26
|
+
private readonly cwd: string;
|
|
27
|
+
|
|
28
|
+
constructor() {
|
|
29
|
+
this.cwd = process.cwd();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 分析 monorepo 变更,返回需要发布的包列表(拓扑排序后)
|
|
34
|
+
* @param dryRun 是否为 dry-run 模式
|
|
35
|
+
* @param propagateDeps 是否传递依赖变更(依赖的包变更时,依赖方也发布)
|
|
36
|
+
*/
|
|
37
|
+
async analyze(dryRun: boolean, propagateDeps = true): Promise<MonorepoAnalysisResult> {
|
|
38
|
+
const workspacePackages = this.getWorkspacePackages();
|
|
39
|
+
const allPackages = this.getAllPackageInfos(workspacePackages);
|
|
40
|
+
|
|
41
|
+
// 为每个包单独检测变更(基于各自的最新 tag)
|
|
42
|
+
const changedPackages = this.getChangedPackages(allPackages, dryRun);
|
|
43
|
+
|
|
44
|
+
if (dryRun) {
|
|
45
|
+
console.log(`📦 直接变更的包: ${changedPackages.map((p) => p.name).join(", ") || "无"}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 计算依赖传递,找出所有需要发布的包
|
|
49
|
+
const packagesToPublish = propagateDeps
|
|
50
|
+
? this.calculateAffectedPackages(changedPackages, allPackages)
|
|
51
|
+
: changedPackages;
|
|
52
|
+
|
|
53
|
+
if (dryRun) {
|
|
54
|
+
console.log(
|
|
55
|
+
`🔄 需要发布的包(含依赖传递): ${packagesToPublish.map((p) => p.name).join(", ") || "无"}`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 拓扑排序
|
|
60
|
+
const sortedPackages = this.topologicalSort(packagesToPublish, allPackages);
|
|
61
|
+
|
|
62
|
+
if (dryRun) {
|
|
63
|
+
console.log(`📋 发布顺序: ${sortedPackages.map((p) => p.name).join(" -> ") || "无"}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
changedPackages,
|
|
68
|
+
packagesToPublish: sortedPackages,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 简单解析 pnpm-workspace.yaml(只提取 packages 数组)
|
|
74
|
+
*/
|
|
75
|
+
private parseSimpleYaml(content: string): { packages?: string[] } {
|
|
76
|
+
const packages: string[] = [];
|
|
77
|
+
const lines = content.split("\n");
|
|
78
|
+
let inPackages = false;
|
|
79
|
+
|
|
80
|
+
for (const line of lines) {
|
|
81
|
+
const trimmed = line.trim();
|
|
82
|
+
if (trimmed === "packages:") {
|
|
83
|
+
inPackages = true;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (inPackages) {
|
|
87
|
+
if (trimmed.startsWith("- ")) {
|
|
88
|
+
// 提取包路径,去除引号
|
|
89
|
+
let pkg = trimmed.slice(2).trim();
|
|
90
|
+
pkg = pkg.replace(/^["']|["']$/g, "");
|
|
91
|
+
packages.push(pkg);
|
|
92
|
+
} else if (trimmed && !trimmed.startsWith("#") && !trimmed.startsWith("-")) {
|
|
93
|
+
// 遇到新的顶级 key,停止解析
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { packages: packages.length > 0 ? packages : undefined };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 从 pnpm-workspace.yaml 读取 workspace 包配置
|
|
104
|
+
*/
|
|
105
|
+
private getWorkspacePackages(): string[] {
|
|
106
|
+
const workspaceFile = join(this.cwd, "pnpm-workspace.yaml");
|
|
107
|
+
if (!existsSync(workspaceFile)) {
|
|
108
|
+
throw new Error("未找到 pnpm-workspace.yaml 文件");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const content = readFileSync(workspaceFile, "utf-8");
|
|
112
|
+
const config = this.parseSimpleYaml(content);
|
|
113
|
+
|
|
114
|
+
if (!config.packages || !Array.isArray(config.packages)) {
|
|
115
|
+
throw new Error("pnpm-workspace.yaml 中未配置 packages");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return config.packages;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 展开 workspace 包配置,获取所有实际的包目录
|
|
123
|
+
*/
|
|
124
|
+
private expandWorkspacePatterns(patterns: string[]): string[] {
|
|
125
|
+
const dirs: string[] = [];
|
|
126
|
+
|
|
127
|
+
for (const pattern of patterns) {
|
|
128
|
+
if (pattern.includes("*")) {
|
|
129
|
+
// 使用 glob 展开,这里简化处理,只支持 extensions/* 这种模式
|
|
130
|
+
const baseDir = pattern.replace("/*", "");
|
|
131
|
+
const basePath = join(this.cwd, baseDir);
|
|
132
|
+
if (existsSync(basePath)) {
|
|
133
|
+
const { readdirSync, statSync } = require("fs");
|
|
134
|
+
const entries = readdirSync(basePath) as string[];
|
|
135
|
+
for (const entry of entries) {
|
|
136
|
+
const entryPath = join(basePath, entry);
|
|
137
|
+
if (statSync(entryPath).isDirectory()) {
|
|
138
|
+
const pkgJson = join(entryPath, "package.json");
|
|
139
|
+
if (existsSync(pkgJson)) {
|
|
140
|
+
dirs.push(join(baseDir, entry));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
// 直接目录
|
|
147
|
+
const pkgJson = join(this.cwd, pattern, "package.json");
|
|
148
|
+
if (existsSync(pkgJson)) {
|
|
149
|
+
dirs.push(pattern);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return dirs;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 获取所有包的详细信息(排除私有包)
|
|
159
|
+
*/
|
|
160
|
+
private getAllPackageInfos(patterns: string[]): PackageInfo[] {
|
|
161
|
+
const dirs = this.expandWorkspacePatterns(patterns);
|
|
162
|
+
const packages: PackageInfo[] = [];
|
|
163
|
+
|
|
164
|
+
for (const dir of dirs) {
|
|
165
|
+
const pkgJsonPath = join(this.cwd, dir, "package.json");
|
|
166
|
+
if (!existsSync(pkgJsonPath)) continue;
|
|
167
|
+
|
|
168
|
+
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
|
|
169
|
+
|
|
170
|
+
// 跳过私有包
|
|
171
|
+
if (pkgJson.private === true) continue;
|
|
172
|
+
|
|
173
|
+
const workspaceDeps = this.extractWorkspaceDeps(pkgJson);
|
|
174
|
+
|
|
175
|
+
packages.push({
|
|
176
|
+
dir,
|
|
177
|
+
name: pkgJson.name,
|
|
178
|
+
version: pkgJson.version,
|
|
179
|
+
workspaceDeps,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return packages;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 提取包的 workspace 依赖
|
|
188
|
+
*/
|
|
189
|
+
private extractWorkspaceDeps(pkgJson: Record<string, unknown>): string[] {
|
|
190
|
+
const deps: string[] = [];
|
|
191
|
+
const allDeps = {
|
|
192
|
+
...(pkgJson.dependencies as Record<string, string> | undefined),
|
|
193
|
+
...(pkgJson.devDependencies as Record<string, string> | undefined),
|
|
194
|
+
...(pkgJson.peerDependencies as Record<string, string> | undefined),
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
for (const [name, version] of Object.entries(allDeps)) {
|
|
198
|
+
if (version && (version.startsWith("workspace:") || version === "*")) {
|
|
199
|
+
deps.push(name);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return deps;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 检测每个包的变更(基于各自的最新 tag)
|
|
208
|
+
*/
|
|
209
|
+
private getChangedPackages(allPackages: PackageInfo[], dryRun: boolean): PackageInfo[] {
|
|
210
|
+
const changedPackages: PackageInfo[] = [];
|
|
211
|
+
|
|
212
|
+
for (const pkg of allPackages) {
|
|
213
|
+
const hasChanges = this.hasPackageChanges(pkg);
|
|
214
|
+
if (hasChanges) {
|
|
215
|
+
changedPackages.push(pkg);
|
|
216
|
+
}
|
|
217
|
+
if (dryRun) {
|
|
218
|
+
console.log(` ${hasChanges ? "✅" : "⭕"} ${pkg.name}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return changedPackages;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 检测单个包是否有变更(基于该包的最新 tag)
|
|
227
|
+
*/
|
|
228
|
+
private hasPackageChanges(pkg: PackageInfo): boolean {
|
|
229
|
+
try {
|
|
230
|
+
// 获取该包的最新 tag(格式: @scope/pkg@version 或 pkg@version)
|
|
231
|
+
const tagPattern = `${pkg.name}@*`;
|
|
232
|
+
const latestTag = execSync(
|
|
233
|
+
`git describe --tags --abbrev=0 --match "${tagPattern}" 2>/dev/null || echo ''`,
|
|
234
|
+
{ cwd: this.cwd, encoding: "utf-8" },
|
|
235
|
+
).trim();
|
|
236
|
+
|
|
237
|
+
if (!latestTag) {
|
|
238
|
+
// 没有 tag,说明是新包,需要发布
|
|
239
|
+
console.log(`📌 ${pkg.name}: 无 tag,需要发布`);
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 检测从该 tag 到 HEAD,该包目录下是否有变更
|
|
244
|
+
const diffOutput = execSync(`git diff --name-only "${latestTag}"..HEAD -- "${pkg.dir}"`, {
|
|
245
|
+
cwd: this.cwd,
|
|
246
|
+
encoding: "utf-8",
|
|
247
|
+
}).trim();
|
|
248
|
+
|
|
249
|
+
const hasChanges = diffOutput.length > 0;
|
|
250
|
+
if (hasChanges) {
|
|
251
|
+
console.log(`📌 ${pkg.name}: ${latestTag} -> HEAD 有变更`);
|
|
252
|
+
console.log(
|
|
253
|
+
` 变更文件: ${diffOutput.split("\n").slice(0, 3).join(", ")}${diffOutput.split("\n").length > 3 ? "..." : ""}`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
return hasChanges;
|
|
257
|
+
} catch (error) {
|
|
258
|
+
// 出错时保守处理,认为有变更
|
|
259
|
+
console.log(`📌 ${pkg.name}: 检测出错,保守处理为有变更`);
|
|
260
|
+
console.log(` 错误: ${error instanceof Error ? error.message : error}`);
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* 将变更文件映射到包目录
|
|
267
|
+
*/
|
|
268
|
+
private mapFilesToPackages(files: string[], patterns: string[]): Set<string> {
|
|
269
|
+
const packageDirs = this.expandWorkspacePatterns(patterns);
|
|
270
|
+
const changedPackages = new Set<string>();
|
|
271
|
+
|
|
272
|
+
for (const file of files) {
|
|
273
|
+
for (const dir of packageDirs) {
|
|
274
|
+
if (file.startsWith(dir + "/") || file === dir) {
|
|
275
|
+
changedPackages.add(dir);
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return changedPackages;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* 计算受影响的包(包含依赖传递)
|
|
286
|
+
*/
|
|
287
|
+
private calculateAffectedPackages(
|
|
288
|
+
changedPackages: PackageInfo[],
|
|
289
|
+
allPackages: PackageInfo[],
|
|
290
|
+
): PackageInfo[] {
|
|
291
|
+
const changedNames = new Set(changedPackages.map((p) => p.name));
|
|
292
|
+
const affectedNames = new Set(changedNames);
|
|
293
|
+
|
|
294
|
+
// 构建反向依赖图:谁依赖了我
|
|
295
|
+
const reverseDeps = new Map<string, Set<string>>();
|
|
296
|
+
for (const pkg of allPackages) {
|
|
297
|
+
for (const dep of pkg.workspaceDeps) {
|
|
298
|
+
if (!reverseDeps.has(dep)) {
|
|
299
|
+
reverseDeps.set(dep, new Set());
|
|
300
|
+
}
|
|
301
|
+
reverseDeps.get(dep)!.add(pkg.name);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// BFS 传递依赖
|
|
306
|
+
const queue = [...changedNames];
|
|
307
|
+
while (queue.length > 0) {
|
|
308
|
+
const current = queue.shift()!;
|
|
309
|
+
const dependents = reverseDeps.get(current);
|
|
310
|
+
if (dependents) {
|
|
311
|
+
for (const dependent of dependents) {
|
|
312
|
+
if (!affectedNames.has(dependent)) {
|
|
313
|
+
affectedNames.add(dependent);
|
|
314
|
+
queue.push(dependent);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return allPackages.filter((p) => affectedNames.has(p.name));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* 拓扑排序:被依赖的包先发布
|
|
325
|
+
*/
|
|
326
|
+
private topologicalSort(packages: PackageInfo[], _allPackages: PackageInfo[]): PackageInfo[] {
|
|
327
|
+
const packageNames = new Set(packages.map((p) => p.name));
|
|
328
|
+
const nameToPackage = new Map(packages.map((p) => [p.name, p]));
|
|
329
|
+
|
|
330
|
+
// 构建依赖图(只考虑待发布包之间的依赖)
|
|
331
|
+
const inDegree = new Map<string, number>();
|
|
332
|
+
const graph = new Map<string, string[]>();
|
|
333
|
+
|
|
334
|
+
for (const pkg of packages) {
|
|
335
|
+
inDegree.set(pkg.name, 0);
|
|
336
|
+
graph.set(pkg.name, []);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
for (const pkg of packages) {
|
|
340
|
+
for (const dep of pkg.workspaceDeps) {
|
|
341
|
+
if (packageNames.has(dep)) {
|
|
342
|
+
graph.get(dep)!.push(pkg.name);
|
|
343
|
+
inDegree.set(pkg.name, (inDegree.get(pkg.name) || 0) + 1);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Kahn's algorithm
|
|
349
|
+
const queue: string[] = [];
|
|
350
|
+
for (const [name, degree] of inDegree) {
|
|
351
|
+
if (degree === 0) {
|
|
352
|
+
queue.push(name);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const sorted: PackageInfo[] = [];
|
|
357
|
+
while (queue.length > 0) {
|
|
358
|
+
const current = queue.shift()!;
|
|
359
|
+
sorted.push(nameToPackage.get(current)!);
|
|
360
|
+
|
|
361
|
+
for (const neighbor of graph.get(current) || []) {
|
|
362
|
+
const newDegree = (inDegree.get(neighbor) || 0) - 1;
|
|
363
|
+
inDegree.set(neighbor, newDegree);
|
|
364
|
+
if (newDegree === 0) {
|
|
365
|
+
queue.push(neighbor);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (sorted.length !== packages.length) {
|
|
371
|
+
throw new Error("检测到循环依赖,无法确定发布顺序");
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return sorted;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Command, CommandRunner, Option } from "nest-commander";
|
|
2
|
+
import { t } from "@spaceflow/core";
|
|
3
|
+
import { PublishService } from "./publish.service";
|
|
4
|
+
|
|
5
|
+
export interface PublishOptions {
|
|
6
|
+
dryRun: boolean;
|
|
7
|
+
ci: boolean;
|
|
8
|
+
prerelease?: string;
|
|
9
|
+
/** 预演模式:执行 hooks 但不修改文件/git */
|
|
10
|
+
rehearsal: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
@Command({
|
|
14
|
+
name: "publish",
|
|
15
|
+
description: t("publish:description"),
|
|
16
|
+
})
|
|
17
|
+
export class PublishCommand extends CommandRunner {
|
|
18
|
+
constructor(protected readonly publishService: PublishService) {
|
|
19
|
+
super();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async run(_passedParams: string[], options: PublishOptions): Promise<void> {
|
|
23
|
+
if (options.rehearsal) {
|
|
24
|
+
console.log(t("publish:rehearsalMode"));
|
|
25
|
+
} else if (options.dryRun) {
|
|
26
|
+
console.log(t("publish:dryRunMode"));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const context = this.publishService.getContextFromEnv(options);
|
|
31
|
+
await this.publishService.execute(context);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
console.error(
|
|
34
|
+
t("common.executionFailed", { error: error instanceof Error ? error.message : error }),
|
|
35
|
+
);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@Option({
|
|
41
|
+
flags: "-d, --dry-run",
|
|
42
|
+
description: t("common.options.dryRun"),
|
|
43
|
+
})
|
|
44
|
+
parseDryRun(val: boolean): boolean {
|
|
45
|
+
return val;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
@Option({
|
|
49
|
+
flags: "-c, --ci",
|
|
50
|
+
description: t("common.options.ci"),
|
|
51
|
+
})
|
|
52
|
+
parseCi(val: boolean): boolean {
|
|
53
|
+
return val;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@Option({
|
|
57
|
+
flags: "-p, --prerelease <tag>",
|
|
58
|
+
description: t("publish:options.prerelease"),
|
|
59
|
+
})
|
|
60
|
+
parsePrerelease(val: string): string {
|
|
61
|
+
return val;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@Option({
|
|
65
|
+
flags: "-r, --rehearsal",
|
|
66
|
+
description: t("publish:options.rehearsal"),
|
|
67
|
+
})
|
|
68
|
+
parseRehearsal(val: boolean): boolean {
|
|
69
|
+
return val;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { z } from "@spaceflow/core";
|
|
2
|
+
|
|
3
|
+
/** publish 命令配置 schema */
|
|
4
|
+
export const publishSchema = z.object({
|
|
5
|
+
/** monorepo 发布模式配置 */
|
|
6
|
+
monorepo: z
|
|
7
|
+
.object({
|
|
8
|
+
/** 是否启用 monorepo 发布模式 */
|
|
9
|
+
enabled: z.boolean().default(false),
|
|
10
|
+
/** 是否传递依赖变更(依赖的包变更时,依赖方也发布) */
|
|
11
|
+
propagateDeps: z.boolean().default(true),
|
|
12
|
+
})
|
|
13
|
+
.optional(),
|
|
14
|
+
changelog: z
|
|
15
|
+
.object({
|
|
16
|
+
/** changelog 文件输出目录 */
|
|
17
|
+
infileDir: z.string().default(".").optional(),
|
|
18
|
+
preset: z
|
|
19
|
+
.object({
|
|
20
|
+
/** preset 名称,默认 conventionalcommits */
|
|
21
|
+
name: z.string().default("conventionalcommits").optional(),
|
|
22
|
+
/** commit type 到 section 的映射 */
|
|
23
|
+
type: z
|
|
24
|
+
.array(
|
|
25
|
+
z.object({
|
|
26
|
+
type: z.string(),
|
|
27
|
+
section: z.string(),
|
|
28
|
+
}),
|
|
29
|
+
)
|
|
30
|
+
.default([]),
|
|
31
|
+
})
|
|
32
|
+
.optional(),
|
|
33
|
+
})
|
|
34
|
+
.optional(),
|
|
35
|
+
/** npm 发布配置 */
|
|
36
|
+
npm: z
|
|
37
|
+
.object({
|
|
38
|
+
/** 是否发布到 npm registry */
|
|
39
|
+
publish: z.boolean().default(false),
|
|
40
|
+
/** 包管理器,npm 或 pnpm */
|
|
41
|
+
packageManager: z.enum(["npm", "pnpm"]).default("npm"),
|
|
42
|
+
/** npm registry 地址 */
|
|
43
|
+
registry: z.string().optional(),
|
|
44
|
+
/** npm tag,如 latest、beta、next */
|
|
45
|
+
tag: z.string().default("latest"),
|
|
46
|
+
/** 是否忽略 package.json 中的版本号 */
|
|
47
|
+
ignoreVersion: z.boolean().default(true),
|
|
48
|
+
/** npm version 命令额外参数 */
|
|
49
|
+
versionArgs: z.array(z.string()).default(["--workspaces false"]),
|
|
50
|
+
/** npm/pnpm publish 命令额外参数 */
|
|
51
|
+
publishArgs: z.array(z.string()).default([]),
|
|
52
|
+
})
|
|
53
|
+
.optional(),
|
|
54
|
+
release: z
|
|
55
|
+
.object({
|
|
56
|
+
host: z.string().default("localhost"),
|
|
57
|
+
assetSourcemap: z
|
|
58
|
+
.object({
|
|
59
|
+
path: z.string(),
|
|
60
|
+
name: z.string(),
|
|
61
|
+
})
|
|
62
|
+
.optional(),
|
|
63
|
+
assets: z
|
|
64
|
+
.array(
|
|
65
|
+
z.object({
|
|
66
|
+
path: z.string(),
|
|
67
|
+
name: z.string(),
|
|
68
|
+
type: z.string(),
|
|
69
|
+
}),
|
|
70
|
+
)
|
|
71
|
+
.default([]),
|
|
72
|
+
})
|
|
73
|
+
.optional(),
|
|
74
|
+
/** git 配置 */
|
|
75
|
+
git: z
|
|
76
|
+
.object({
|
|
77
|
+
/** 允许发布的分支列表 */
|
|
78
|
+
requireBranch: z.array(z.string()).default(["main", "dev", "develop"]),
|
|
79
|
+
/** 分支锁定时允许推送的用户名白名单(如 CI 机器人) */
|
|
80
|
+
pushWhitelistUsernames: z.array(z.string()).default([]),
|
|
81
|
+
/** 是否在发布时锁定分支 */
|
|
82
|
+
lockBranch: z.boolean().default(true),
|
|
83
|
+
})
|
|
84
|
+
.optional(),
|
|
85
|
+
/** release-it hooks 配置,如 before:bump, after:bump 等 */
|
|
86
|
+
hooks: z.record(z.string(), z.union([z.string(), z.array(z.string())])).optional(),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/** publish 配置类型(从 schema 推导) */
|
|
90
|
+
export type PublishConfig = z.infer<typeof publishSchema>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Module } from "@nestjs/common";
|
|
2
|
+
import { ConfigModule } from "@nestjs/config";
|
|
3
|
+
import { GitProviderModule, ConfigReaderModule, ciConfig } from "@spaceflow/core";
|
|
4
|
+
import { PublishCommand } from "./publish.command";
|
|
5
|
+
import { PublishService } from "./publish.service";
|
|
6
|
+
import { MonorepoService } from "./monorepo.service";
|
|
7
|
+
|
|
8
|
+
@Module({
|
|
9
|
+
imports: [ConfigModule.forFeature(ciConfig), GitProviderModule.forFeature(), ConfigReaderModule],
|
|
10
|
+
providers: [PublishCommand, PublishService, MonorepoService],
|
|
11
|
+
})
|
|
12
|
+
export class PublishModule {}
|