@finesoft/front 0.1.76 → 0.1.77
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/docs/01-getting-started.md +230 -0
- package/docs/02-routing-and-controllers.md +197 -0
- package/docs/03-middleware.md +214 -0
- package/docs/04-rendering-and-hydration.md +271 -0
- package/docs/05-i18n.md +243 -0
- package/docs/06-http-client.md +286 -0
- package/docs/07-di-container.md +264 -0
- package/docs/08-observability.md +290 -0
- package/docs/09-server-and-deployment.md +242 -0
- package/docs/10-features-platform-pwa.md +238 -0
- package/docs/README.md +72 -0
- package/docs/advanced/custom-action-handler.md +248 -0
- package/docs/advanced/custom-adapter.md +264 -0
- package/docs/advanced/custom-event-recorder.md +318 -0
- package/docs/advanced/inline-proxy-codegen.md +200 -0
- package/docs/advanced/multi-tenant-scopes.md +330 -0
- package/docs/engineering/ci-release-flow.md +244 -0
- package/docs/engineering/project-structure.md +296 -0
- package/docs/engineering/testing.md +317 -0
- package/docs/pitfalls/container-scope-leak.md +215 -0
- package/docs/pitfalls/i18n-bundle-size.md +182 -0
- package/docs/pitfalls/proxy-binary-payloads.md +133 -0
- package/docs/pitfalls/redirect-vs-rewrite.md +147 -0
- package/docs/pitfalls/ssr-hydration-mismatch.md +163 -0
- package/docs/pitfalls/ssr-vs-csr-globals.md +176 -0
- package/docs/zh/01-getting-started.md +230 -0
- package/docs/zh/02-routing-and-controllers.md +197 -0
- package/docs/zh/03-middleware.md +214 -0
- package/docs/zh/04-rendering-and-hydration.md +271 -0
- package/docs/zh/05-i18n.md +243 -0
- package/docs/zh/06-http-client.md +286 -0
- package/docs/zh/07-di-container.md +264 -0
- package/docs/zh/08-observability.md +287 -0
- package/docs/zh/09-server-and-deployment.md +242 -0
- package/docs/zh/10-features-platform-pwa.md +238 -0
- package/docs/zh/README.md +72 -0
- package/docs/zh/advanced/custom-action-handler.md +248 -0
- package/docs/zh/advanced/custom-adapter.md +264 -0
- package/docs/zh/advanced/custom-event-recorder.md +318 -0
- package/docs/zh/advanced/inline-proxy-codegen.md +200 -0
- package/docs/zh/advanced/multi-tenant-scopes.md +330 -0
- package/docs/zh/engineering/ci-release-flow.md +244 -0
- package/docs/zh/engineering/project-structure.md +296 -0
- package/docs/zh/engineering/testing.md +317 -0
- package/docs/zh/pitfalls/container-scope-leak.md +215 -0
- package/docs/zh/pitfalls/i18n-bundle-size.md +182 -0
- package/docs/zh/pitfalls/proxy-binary-payloads.md +133 -0
- package/docs/zh/pitfalls/redirect-vs-rewrite.md +147 -0
- package/docs/zh/pitfalls/ssr-hydration-mismatch.md +163 -0
- package/docs/zh/pitfalls/ssr-vs-csr-globals.md +176 -0
- package/package.json +2 -1
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
# 高阶:多租户 scope
|
|
2
|
+
|
|
3
|
+
多租户应用一份部署服务多个客户,每个客户的请求要看到自己的:
|
|
4
|
+
|
|
5
|
+
- 数据库连接 / API 客户端
|
|
6
|
+
- 带租户 tag 的 logger / 指标
|
|
7
|
+
- Feature flag / 价格 / 品牌
|
|
8
|
+
- 缓存的翻译 / 内容
|
|
9
|
+
|
|
10
|
+
DI 容器的子 scope 是正确的原语。本配方展示通过 `beforeLoad` 守卫接通按租户隔离。
|
|
11
|
+
|
|
12
|
+
## 心智模型
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
Framework
|
|
16
|
+
└── 父 Container ← 共享服务(HTTP 池、基础 recorder、...)
|
|
17
|
+
└── 每个请求 scope ← 框架为每个 SSR 请求创建
|
|
18
|
+
↑
|
|
19
|
+
└── 一个 beforeLoad 守卫注册的租户覆盖
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
每请求 scope 由框架自动创建。你的守卫在它之上注册租户级覆盖。你没覆盖的东西都回退到父。
|
|
23
|
+
|
|
24
|
+
## 步骤 1:识别租户
|
|
25
|
+
|
|
26
|
+
这是你的业务逻辑。常见来源:
|
|
27
|
+
|
|
28
|
+
- **子域**:`acme.myapp.com` → `acme`
|
|
29
|
+
- **路径前缀**:`/t/acme/...` → `acme`
|
|
30
|
+
- **头**:`X-Tenant-Id: acme`
|
|
31
|
+
- **鉴权用户**:cookie → session → tenant
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
// src/lib/tenants/resolve.ts
|
|
35
|
+
import type { NavigationContext } from "@finesoft/front";
|
|
36
|
+
|
|
37
|
+
export function resolveTenant(ctx: NavigationContext): string | null {
|
|
38
|
+
const host = ctx.url.hostname;
|
|
39
|
+
const sub = host.split(".")[0];
|
|
40
|
+
if (sub && sub !== "www" && sub !== "myapp") return sub;
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## 步骤 2:加载租户配置
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
// src/lib/tenants/config.ts
|
|
49
|
+
export interface TenantConfig {
|
|
50
|
+
tenantId: string;
|
|
51
|
+
displayName: string;
|
|
52
|
+
upstreamUrl: string;
|
|
53
|
+
apiToken: string;
|
|
54
|
+
featureFlags: Record<string, unknown>;
|
|
55
|
+
locale: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const cache = new Map<string, TenantConfig>();
|
|
59
|
+
|
|
60
|
+
export async function getTenantConfig(tenantId: string): Promise<TenantConfig | null> {
|
|
61
|
+
if (cache.has(tenantId)) return cache.get(tenantId)!;
|
|
62
|
+
|
|
63
|
+
// 从配置存储读 —— 文件、DB、KV
|
|
64
|
+
const config = await loadFromStore(tenantId);
|
|
65
|
+
if (!config) return null;
|
|
66
|
+
|
|
67
|
+
cache.set(tenantId, config);
|
|
68
|
+
return config;
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
真实实现要在配置更新时让缓存失效。多数应用按定时器刷或通过 webhook 即可。
|
|
73
|
+
|
|
74
|
+
## 步骤 3:在守卫里注册租户服务
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
// src/lib/guards/tenant.ts
|
|
78
|
+
import { deny, next, type NavigationContext, DEP_KEYS } from "@finesoft/front";
|
|
79
|
+
import { resolveTenant } from "../tenants/resolve";
|
|
80
|
+
import { getTenantConfig } from "../tenants/config";
|
|
81
|
+
import { UserApi } from "../api/user";
|
|
82
|
+
import { WithFieldsRecorder } from "@finesoft/front";
|
|
83
|
+
|
|
84
|
+
export async function tenantGuard(ctx: NavigationContext) {
|
|
85
|
+
const tenantId = resolveTenant(ctx);
|
|
86
|
+
if (!tenantId) return deny(404, "Unknown tenant");
|
|
87
|
+
|
|
88
|
+
const config = await getTenantConfig(tenantId);
|
|
89
|
+
if (!config) return deny(404, "Tenant not found");
|
|
90
|
+
|
|
91
|
+
// 在请求 scope 上注册租户专属服务
|
|
92
|
+
const scope = ctx.container;
|
|
93
|
+
scope.register("tenantConfig", () => config);
|
|
94
|
+
|
|
95
|
+
scope.register(
|
|
96
|
+
"userApi",
|
|
97
|
+
() =>
|
|
98
|
+
new UserApi({
|
|
99
|
+
baseUrl: config.upstreamUrl,
|
|
100
|
+
defaultHeaders: { Authorization: `Bearer ${config.apiToken}` },
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
scope.register(DEP_KEYS.FEATURE_FLAGS, () => ({
|
|
105
|
+
get: (key, fallback) => config.featureFlags[key] ?? fallback,
|
|
106
|
+
}));
|
|
107
|
+
|
|
108
|
+
// 用租户上下文装饰父的 recorder
|
|
109
|
+
const baseRecorder = scope.parent!.resolve(DEP_KEYS.EVENT_RECORDER);
|
|
110
|
+
scope.register(
|
|
111
|
+
DEP_KEYS.EVENT_RECORDER,
|
|
112
|
+
() => new WithFieldsRecorder(baseRecorder, [{ getFields: () => ({ tenantId }) }]),
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
return next();
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
要点:
|
|
120
|
+
|
|
121
|
+
- **scope 已经由框架创建。** 你在 `ctx.container` 上注册 —— 那就是请求 scope。
|
|
122
|
+
- **回退自动。** 这里没注册的会从父容器 resolve。
|
|
123
|
+
- **装饰而不替换。** recorder 用租户字段包起来而不是替换 —— 基础行为(HTTP 传输、批处理)保持原样。
|
|
124
|
+
|
|
125
|
+
## 步骤 4:全局安装守卫
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
// src/bootstrap.ts
|
|
129
|
+
import { type Framework, defineRoutes } from "@finesoft/front";
|
|
130
|
+
import { tenantGuard } from "./lib/guards/tenant";
|
|
131
|
+
// ... 其他 import
|
|
132
|
+
|
|
133
|
+
export function bootstrap(framework: Framework): void {
|
|
134
|
+
framework.middleware.use("beforeLoad", tenantGuard);
|
|
135
|
+
|
|
136
|
+
defineRoutes(framework, [
|
|
137
|
+
{ path: "/", intentId: "home", controller: new HomeController() },
|
|
138
|
+
{ path: "/billing", intentId: "billing", controller: new BillingController() },
|
|
139
|
+
// ...
|
|
140
|
+
]);
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`tenantGuard` 在任何路由级守卫之前跑,所以任何 Controller 跑时租户 scope 都已经设好。
|
|
145
|
+
|
|
146
|
+
## 步骤 5:Controller 透明地拿到正确的服务
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
// src/controllers/billing.ts
|
|
150
|
+
export class BillingController extends BaseController<{}, BillingPage> {
|
|
151
|
+
readonly intentId = "billing";
|
|
152
|
+
|
|
153
|
+
async execute(_params, container) {
|
|
154
|
+
const config = container.resolve<TenantConfig>("tenantConfig");
|
|
155
|
+
const api = container.resolve<UserApi>("userApi"); // 租户专属客户端
|
|
156
|
+
|
|
157
|
+
const usage = await api.getUsage();
|
|
158
|
+
const invoices = await api.getInvoices();
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
kind: "billing",
|
|
162
|
+
tenantName: config.displayName,
|
|
163
|
+
usage,
|
|
164
|
+
invoices,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Controller 不知道租户存在 —— 它只 resolve `userApi`,拿到本请求对应的那个。
|
|
171
|
+
|
|
172
|
+
## 跨租户禁止
|
|
173
|
+
|
|
174
|
+
防 A 租户认证的用户访问 B 租户数据:
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
async function sameTenantGuard(ctx: NavigationContext) {
|
|
178
|
+
const session = await ctx.container.resolve<SessionService>("session").current();
|
|
179
|
+
const requestedTenant = ctx.container.resolve<TenantConfig>("tenantConfig").tenantId;
|
|
180
|
+
|
|
181
|
+
if (!session) return redirect("/login");
|
|
182
|
+
if (session.tenantId !== requestedTenant) return deny(403, "Cross-tenant access forbidden");
|
|
183
|
+
|
|
184
|
+
return next();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 在 tenantGuard 之后应用:
|
|
188
|
+
framework.middleware.use("beforeLoad", tenantGuard);
|
|
189
|
+
framework.middleware.use("beforeLoad", sameTenantGuard);
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
守卫顺序重要 —— `tenantGuard` 必须先注册 `tenantConfig`,`sameTenantGuard` 才能读到。
|
|
193
|
+
|
|
194
|
+
## Hydration 考虑
|
|
195
|
+
|
|
196
|
+
租户配置含 `featureFlags`,两端都读。框架的 `PrefetchedIntents` 序列化处理这个 —— 浏览器拿到服务端看到的同样 flag 值,所以客户端读保持一致。
|
|
197
|
+
|
|
198
|
+
租户配置本身**不**自动序列化。view 要显示 `tenantConfig.displayName` 的话,Controller 应该把它放进 `Page`:
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
async execute(_params, container) {
|
|
202
|
+
const config = container.resolve<TenantConfig>("tenantConfig");
|
|
203
|
+
return {
|
|
204
|
+
kind: "home",
|
|
205
|
+
tenant: {
|
|
206
|
+
id: config.tenantId,
|
|
207
|
+
displayName: config.displayName,
|
|
208
|
+
},
|
|
209
|
+
// ...
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
`Page` 被序列化所以能跨 SSR → CSR 边界。完整 `TenantConfig`(含秘密)永不应该出现在 `Page` 里。
|
|
215
|
+
|
|
216
|
+
## 浏览器端考虑
|
|
217
|
+
|
|
218
|
+
`tenantGuard` 在浏览器也跑 —— 首次导航和每次后续导航。仅浏览器应用(`renderMode: "csr"`)只在这跑。
|
|
219
|
+
|
|
220
|
+
但浏览器不能安全地 resolve `apiToken` 这种秘密。两种方法:
|
|
221
|
+
|
|
222
|
+
**方法 1:服务器代理所有 API 调用。** 浏览器打 `/api/users`(你的 proxy),它转发到 `${upstreamUrl}/users` 并从 `process.env[apiTokenEnvKey]` 注入 auth 头。浏览器从不见 token。
|
|
223
|
+
|
|
224
|
+
**方法 2:短期 session token。** 服务器颁发 scope 到租户的 JWT;浏览器用它直接调上游。token 轮换由你的 auth 层处理。
|
|
225
|
+
|
|
226
|
+
多数应用走方法 1。框架的 proxy router 就是为这个设计的。
|
|
227
|
+
|
|
228
|
+
## 注意事项
|
|
229
|
+
|
|
230
|
+
### 别跨请求缓存租户 scope
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
// 不好
|
|
234
|
+
const tenantScopeCache = new Map<string, Container>();
|
|
235
|
+
|
|
236
|
+
async function tenantGuard(ctx) {
|
|
237
|
+
let scope = tenantScopeCache.get(tenantId);
|
|
238
|
+
if (!scope) {
|
|
239
|
+
scope = framework.container.createScope();
|
|
240
|
+
tenantScopeCache.set(tenantId, scope);
|
|
241
|
+
}
|
|
242
|
+
// 用 scope...
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
每个请求需要自己的 scope —— 即使同租户 —— 因为:
|
|
247
|
+
|
|
248
|
+
- 其他守卫加请求专属覆盖(auth、trace id),不该跨请求泄漏
|
|
249
|
+
- scope 持有有状态服务的 resolve 实例;跨请求共享破坏隔离
|
|
250
|
+
|
|
251
|
+
租户**配置**可以缓存。租户**scope**不行。
|
|
252
|
+
|
|
253
|
+
### 共享服务实例要小心
|
|
254
|
+
|
|
255
|
+
把 `UserApi` 实例缓存到模块级而不是注册工厂,所有请求共享状态:
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
// 不好
|
|
259
|
+
const apiByTenant = new Map<string, UserApi>();
|
|
260
|
+
scope.register("userApi", () => {
|
|
261
|
+
let api = apiByTenant.get(tenantId);
|
|
262
|
+
if (!api) {
|
|
263
|
+
api = new UserApi({ baseUrl: config.upstreamUrl });
|
|
264
|
+
apiByTenant.set(tenantId, api);
|
|
265
|
+
}
|
|
266
|
+
return api;
|
|
267
|
+
});
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
`UserApi` 有任何请求级状态(捕获请求专属值的拦截器闭包)就跨租户泄。注册新鲜工厂;让容器按 scope 缓存。
|
|
271
|
+
|
|
272
|
+
## 测试
|
|
273
|
+
|
|
274
|
+
```ts
|
|
275
|
+
import { describe, test, expect, vi, afterEach } from "vite-plus/test";
|
|
276
|
+
import { Container } from "@finesoft/front";
|
|
277
|
+
import { tenantGuard } from "./tenant";
|
|
278
|
+
|
|
279
|
+
afterEach(() => vi.restoreAllMocks());
|
|
280
|
+
|
|
281
|
+
describe("tenantGuard", () => {
|
|
282
|
+
test("registers tenant services for known tenant", async () => {
|
|
283
|
+
const parent = new Container();
|
|
284
|
+
const scope = parent.createScope();
|
|
285
|
+
|
|
286
|
+
vi.spyOn(await import("../tenants/config"), "getTenantConfig").mockResolvedValue({
|
|
287
|
+
tenantId: "acme",
|
|
288
|
+
displayName: "Acme Co",
|
|
289
|
+
upstreamUrl: "https://acme.example",
|
|
290
|
+
apiToken: "token-123",
|
|
291
|
+
featureFlags: { darkMode: true },
|
|
292
|
+
locale: "en-US",
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
const ctx = {
|
|
296
|
+
url: new URL("https://acme.myapp.com/"),
|
|
297
|
+
container: scope,
|
|
298
|
+
intent: { intentId: "home", params: {} },
|
|
299
|
+
getCookie: () => null,
|
|
300
|
+
getHeader: () => null,
|
|
301
|
+
isSsr: true,
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const result = await tenantGuard(ctx as any);
|
|
305
|
+
|
|
306
|
+
expect(result).toEqual({ kind: "next" });
|
|
307
|
+
expect(scope.resolve("tenantConfig")).toMatchObject({ tenantId: "acme" });
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("denies unknown tenant", async () => {
|
|
311
|
+
const ctx = {
|
|
312
|
+
url: new URL("https://unknown.myapp.com/"),
|
|
313
|
+
container: new Container(),
|
|
314
|
+
intent: { intentId: "home", params: {} },
|
|
315
|
+
getCookie: () => null,
|
|
316
|
+
getHeader: () => null,
|
|
317
|
+
isSsr: true,
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const result = await tenantGuard(ctx as any);
|
|
321
|
+
expect(result).toMatchObject({ kind: "deny", status: 404 });
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
## 参考
|
|
327
|
+
|
|
328
|
+
- [第 7 章:DI 容器](../07-di-container.md) —— scope 和回退 resolve
|
|
329
|
+
- [第 3 章:中间件](../03-middleware.md) —— 全局守卫
|
|
330
|
+
- [陷阱:container scope 泄漏](../pitfalls/container-scope-leak.md) —— 缓存 scope 会出什么问题
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# 工程实践:CI 与发布流程
|
|
2
|
+
|
|
3
|
+
框架自身的发布方式,以及给依赖它的应用设置同样 workflow 的方法。
|
|
4
|
+
|
|
5
|
+
## 发布什么
|
|
6
|
+
|
|
7
|
+
只有 `@finesoft/front` 发布到 npm。内部的 `core` / `browser` / `ssr` / `server` 包通过 `tsdown` 的 `noExternal: [@finesoft/*]` 打包进 `front`。
|
|
8
|
+
|
|
9
|
+
这意味着:
|
|
10
|
+
|
|
11
|
+
- 用户只装一个 npm 包
|
|
12
|
+
- 内部重构不会带动多个版本号
|
|
13
|
+
- 一份 CHANGELOG 可读
|
|
14
|
+
|
|
15
|
+
`create-finesoft-app` 是它自己发布的包(CLI),独立于框架运行时。
|
|
16
|
+
|
|
17
|
+
## 发布 workflow
|
|
18
|
+
|
|
19
|
+
仓库只有一个 `.github/workflows/release.yml`,内联处理一切。触发:push 到 `main`。
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
push to main
|
|
23
|
+
│
|
|
24
|
+
▼
|
|
25
|
+
Checkout main(用 PAT 而非 GITHUB_TOKEN)
|
|
26
|
+
│
|
|
27
|
+
▼
|
|
28
|
+
对账 npm 注册表与 main
|
|
29
|
+
├── npm == main? → 继续
|
|
30
|
+
├── main > npm? → catch-up publish 当前 main 版本
|
|
31
|
+
└── npm > main? → 报错,需人工排查
|
|
32
|
+
│
|
|
33
|
+
▼
|
|
34
|
+
生成自动 changeset(每次 push 一个 patch)
|
|
35
|
+
│
|
|
36
|
+
▼
|
|
37
|
+
应用版本 bump
|
|
38
|
+
├── 有变更? → 继续
|
|
39
|
+
└── 无变更? → 完成,不发布
|
|
40
|
+
│
|
|
41
|
+
▼
|
|
42
|
+
Commit "chore(release): version packages"
|
|
43
|
+
│
|
|
44
|
+
▼
|
|
45
|
+
构建所有包,publish @finesoft/front 到 npm
|
|
46
|
+
│
|
|
47
|
+
▼
|
|
48
|
+
Push commit + tag 回 main(带 rebase 重试)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### 为什么用一个内联 workflow 而不是 changesets/action 的 PR 模式
|
|
52
|
+
|
|
53
|
+
标准 changesets workflow 开一个 PR(「Version Packages」),合并时触发第二次 workflow run 来发布。**但 `GITHUB_TOKEN` 合并的 commit 不触发后续 workflow**(GitHub 反递归安全策略)—— 发布永远不跑。内联 workflow 一次 run 里做完,没有 PR hop。
|
|
54
|
+
|
|
55
|
+
### 为什么用 PAT 而不是 `GITHUB_TOKEN`
|
|
56
|
+
|
|
57
|
+
仓库 ruleset 强制签名 commit、线性历史、`main` 上必须 PR。bypass actor 包括 `RepositoryRole=5 (admin)` 但**不**包括 `github-actions[bot]`。GitHub UI 不允许把这个 bot 加进 bypass list。用 admin 用户拥有的 PAT push 命中已有的 admin bypass 条目。
|
|
58
|
+
|
|
59
|
+
PAT 只授 `Contents: Read & Write` —— `git push` 需要的最小权限。
|
|
60
|
+
|
|
61
|
+
## 并发
|
|
62
|
+
|
|
63
|
+
```yaml
|
|
64
|
+
concurrency: release-${{ github.ref }}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
多次 push 到 `main` 排队而不是取消。这点很重要:
|
|
68
|
+
|
|
69
|
+
- publish 中途取消会让 npm 处于不一致状态
|
|
70
|
+
- 每次 push 必须等前一次完成,避免版本号竞争
|
|
71
|
+
- 下一个 run 的对账步骤会捡起前一个已发布的版本
|
|
72
|
+
|
|
73
|
+
## 幂等
|
|
74
|
+
|
|
75
|
+
`changeset publish` 跳过 npm 上已有的版本。所以 push 到 `main` 在 publish 之后失败:
|
|
76
|
+
|
|
77
|
+
- npm:有 0.1.75
|
|
78
|
+
- main:还是 0.1.74
|
|
79
|
+
|
|
80
|
+
下一次 release run 的对账步骤检测到 `main < npm`,拒绝「往回 catch-up」并报错。人工补救:开一个 PR 把 `packages/front/package.json` bump 到 npm 版本并合并。之后 push 正常。
|
|
81
|
+
|
|
82
|
+
## 给应用设置同样的
|
|
83
|
+
|
|
84
|
+
大多数应用不需要 publish 步骤 —— 它们有部署。但 changeset + 自动 bump 形状仍然管用:
|
|
85
|
+
|
|
86
|
+
```yaml
|
|
87
|
+
name: Release
|
|
88
|
+
|
|
89
|
+
on:
|
|
90
|
+
push:
|
|
91
|
+
branches:
|
|
92
|
+
- main
|
|
93
|
+
|
|
94
|
+
concurrency: release-${{ github.ref }}
|
|
95
|
+
|
|
96
|
+
jobs:
|
|
97
|
+
release:
|
|
98
|
+
runs-on: ubuntu-latest
|
|
99
|
+
if: "!startsWith(github.event.head_commit.message, 'chore(release):')"
|
|
100
|
+
permissions:
|
|
101
|
+
contents: write
|
|
102
|
+
steps:
|
|
103
|
+
- uses: actions/checkout@v5
|
|
104
|
+
with:
|
|
105
|
+
ref: main
|
|
106
|
+
fetch-depth: 0
|
|
107
|
+
token: ${{ secrets.RELEASE_PUSH_TOKEN }}
|
|
108
|
+
|
|
109
|
+
- uses: voidzero-dev/setup-vp@v1
|
|
110
|
+
with:
|
|
111
|
+
node-version: 24
|
|
112
|
+
cache: true
|
|
113
|
+
|
|
114
|
+
- run: vp install --frozen-lockfile
|
|
115
|
+
|
|
116
|
+
- name: Configure git
|
|
117
|
+
run: |
|
|
118
|
+
git config user.name "github-actions[bot]"
|
|
119
|
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
120
|
+
|
|
121
|
+
- name: Generate auto changeset
|
|
122
|
+
run: vp run release:auto:changeset
|
|
123
|
+
|
|
124
|
+
- name: Apply version bump
|
|
125
|
+
id: bump
|
|
126
|
+
run: |
|
|
127
|
+
vp run version
|
|
128
|
+
if git diff --quiet; then
|
|
129
|
+
echo "should_publish=false" >> "$GITHUB_OUTPUT"
|
|
130
|
+
else
|
|
131
|
+
NEW=$(node -p "require('./package.json').version")
|
|
132
|
+
echo "version=$NEW" >> "$GITHUB_OUTPUT"
|
|
133
|
+
echo "should_publish=true" >> "$GITHUB_OUTPUT"
|
|
134
|
+
fi
|
|
135
|
+
|
|
136
|
+
- name: Commit version
|
|
137
|
+
if: steps.bump.outputs.should_publish == 'true'
|
|
138
|
+
run: |
|
|
139
|
+
git add -A
|
|
140
|
+
git commit -m "chore(release): version packages"
|
|
141
|
+
|
|
142
|
+
- name: Build
|
|
143
|
+
if: steps.bump.outputs.should_publish == 'true'
|
|
144
|
+
run: vp run build
|
|
145
|
+
|
|
146
|
+
- name: Deploy
|
|
147
|
+
if: steps.bump.outputs.should_publish == 'true'
|
|
148
|
+
run: vp run deploy # 你的部署命令
|
|
149
|
+
|
|
150
|
+
- name: Push tag and commit
|
|
151
|
+
if: steps.bump.outputs.should_publish == 'true'
|
|
152
|
+
run: git push --follow-tags origin HEAD:main
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
把 `vp run deploy` 换成你平台的部署命令(Vercel、Cloudflare、自家基建)。
|
|
156
|
+
|
|
157
|
+
## Conventional commits + 自动 changeset
|
|
158
|
+
|
|
159
|
+
`release:auto:changeset` 脚本(本仓库的,每次 push 生成一个 patch changeset)有意简单 —— 每个合并 PR 变成一次 patch bump。要语义化版本驱动,替换为这样的脚本:
|
|
160
|
+
|
|
161
|
+
- 读上一个 tag 之后的 `git log`
|
|
162
|
+
- 把 commit 前缀(`feat:`、`fix:`、`BREAKING:`)映射到 changeset 类型
|
|
163
|
+
- 写对应的 `.changeset/*.md`
|
|
164
|
+
|
|
165
|
+
框架仓库用纯 patch,因为:
|
|
166
|
+
|
|
167
|
+
- 每次 push 是小变更;大变更经过 review 后还是会变成小 commit
|
|
168
|
+
- 真正的破坏性变更很少,值得手写 changeset
|
|
169
|
+
- 避开「前缀撒谎」的一类 bug
|
|
170
|
+
|
|
171
|
+
按你团队的提交习惯挑策略。
|
|
172
|
+
|
|
173
|
+
## 按 PR 校验(`quality.yml`)
|
|
174
|
+
|
|
175
|
+
仓库还有 `quality.yml` workflow,PR 上跑:
|
|
176
|
+
|
|
177
|
+
```yaml
|
|
178
|
+
on:
|
|
179
|
+
pull_request:
|
|
180
|
+
push:
|
|
181
|
+
branches:
|
|
182
|
+
- main
|
|
183
|
+
|
|
184
|
+
jobs:
|
|
185
|
+
coverage:
|
|
186
|
+
runs-on: ubuntu-latest
|
|
187
|
+
steps:
|
|
188
|
+
- uses: actions/checkout@v5
|
|
189
|
+
- uses: voidzero-dev/setup-vp@v1
|
|
190
|
+
with: { node-version: 24, cache: true }
|
|
191
|
+
- run: vp install --frozen-lockfile
|
|
192
|
+
- run: vp test --coverage
|
|
193
|
+
- uses: actions/upload-artifact@v7
|
|
194
|
+
with:
|
|
195
|
+
name: coverage-report
|
|
196
|
+
path: reports/coverage
|
|
197
|
+
if-no-files-found: error
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
`check` job(fmt + lint + types)在本仓库被 `if: false` 关掉,因为 Vite+ 本地走 pre-commit 跑这些。如果你的团队 pre-commit hook 跑得不稳,重新打开。
|
|
201
|
+
|
|
202
|
+
## CodeQL
|
|
203
|
+
|
|
204
|
+
仓库定时和 PR 上跑 CodeQL。扫描范围限定 `packages/{core,browser,ssr,server,front}/src/**`。测试、模板、脚本、脚手架都排除。
|
|
205
|
+
|
|
206
|
+
应用仓库启用默认 CodeQL 配置即可 —— 噪声低,能抓真实问题(open redirect、SQL 注入、密钥暴露)。
|
|
207
|
+
|
|
208
|
+
## 必需状态检查
|
|
209
|
+
|
|
210
|
+
仓库 ruleset 要求:
|
|
211
|
+
|
|
212
|
+
- `Coverage`(来自 `quality.yml`)
|
|
213
|
+
- `CodeQL`
|
|
214
|
+
|
|
215
|
+
两个都过才能合 PR。release workflow 通过 admin PAT bypass —— release 在 PR 合并后跑在 `main` 上,那些 check 在 PR 上已经过了。
|
|
216
|
+
|
|
217
|
+
## 迁移:从 changesets PR 模式到内联
|
|
218
|
+
|
|
219
|
+
把现有仓库从 `changesets/action`(PR 模式)迁过来:
|
|
220
|
+
|
|
221
|
+
1. 删旧 release workflow
|
|
222
|
+
2. 创建上面的内联 workflow
|
|
223
|
+
3. 生成一个 fine-grained PAT,存为 `RELEASE_PUSH_TOKEN`
|
|
224
|
+
4. 这次改动后下一次 push 到 main 会:
|
|
225
|
+
- 检测到 main == npm(不需要 catch-up)
|
|
226
|
+
- 生成一个 patch changeset
|
|
227
|
+
- bump + publish + push 回 main
|
|
228
|
+
|
|
229
|
+
如果有旧 workflow 留下的「Version Packages」PR pending,不要合直接关掉。自动 changeset 会从那里接着写。
|
|
230
|
+
|
|
231
|
+
## 可能出错的情况
|
|
232
|
+
|
|
233
|
+
| 症状 | 原因 | 修法 |
|
|
234
|
+
| -------------------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------ |
|
|
235
|
+
| `[remote rejected] HEAD -> main` | PAT actor 不在 ruleset bypass;PAT 缺 `Contents: Write` | 验 PAT scope;确认 push actor 是 admin 用户 |
|
|
236
|
+
| `npm ... is ahead of main` | 前一次 run publish 后 push 失败 | 开 PR 把 `packages/front/package.json` 同步到 npm 版本 |
|
|
237
|
+
| 一次 release commit 后 workflow 不触发 | `if: "!startsWith(github.event.head_commit.message, 'chore(release):'"` 过滤 | 按设计 —— 防递归 |
|
|
238
|
+
| Pre-commit hook(`vp check`)CI 失败 | 本地没跑 formatter | 本地 `vp check --fix`;commit;重跑 |
|
|
239
|
+
|
|
240
|
+
## 参考
|
|
241
|
+
|
|
242
|
+
- 实际的 release workflow:`.github/workflows/release.yml`
|
|
243
|
+
- 实际的 quality workflow:`.github/workflows/quality.yml`
|
|
244
|
+
- [Changesets 文档](https://github.com/changesets/changesets) —— 理解 `vp run version` 和 `vp run release`
|