@finesoft/front 0.1.76 → 0.1.78

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.
Files changed (51) hide show
  1. package/docs/01-getting-started.md +230 -0
  2. package/docs/02-routing-and-controllers.md +203 -0
  3. package/docs/03-middleware.md +220 -0
  4. package/docs/04-rendering-and-hydration.md +271 -0
  5. package/docs/05-i18n.md +243 -0
  6. package/docs/06-http-client.md +286 -0
  7. package/docs/07-di-container.md +264 -0
  8. package/docs/08-observability.md +290 -0
  9. package/docs/09-server-and-deployment.md +242 -0
  10. package/docs/10-features-platform-pwa.md +238 -0
  11. package/docs/README.md +72 -0
  12. package/docs/advanced/custom-action-handler.md +248 -0
  13. package/docs/advanced/custom-adapter.md +264 -0
  14. package/docs/advanced/custom-event-recorder.md +318 -0
  15. package/docs/advanced/inline-proxy-codegen.md +200 -0
  16. package/docs/advanced/multi-tenant-scopes.md +330 -0
  17. package/docs/engineering/ci-release-flow.md +244 -0
  18. package/docs/engineering/project-structure.md +296 -0
  19. package/docs/engineering/testing.md +317 -0
  20. package/docs/pitfalls/container-scope-leak.md +215 -0
  21. package/docs/pitfalls/i18n-bundle-size.md +182 -0
  22. package/docs/pitfalls/proxy-binary-payloads.md +133 -0
  23. package/docs/pitfalls/redirect-vs-rewrite.md +147 -0
  24. package/docs/pitfalls/ssr-hydration-mismatch.md +163 -0
  25. package/docs/pitfalls/ssr-vs-csr-globals.md +176 -0
  26. package/docs/zh/01-getting-started.md +230 -0
  27. package/docs/zh/02-routing-and-controllers.md +203 -0
  28. package/docs/zh/03-middleware.md +220 -0
  29. package/docs/zh/04-rendering-and-hydration.md +271 -0
  30. package/docs/zh/05-i18n.md +243 -0
  31. package/docs/zh/06-http-client.md +286 -0
  32. package/docs/zh/07-di-container.md +264 -0
  33. package/docs/zh/08-observability.md +287 -0
  34. package/docs/zh/09-server-and-deployment.md +242 -0
  35. package/docs/zh/10-features-platform-pwa.md +238 -0
  36. package/docs/zh/README.md +72 -0
  37. package/docs/zh/advanced/custom-action-handler.md +248 -0
  38. package/docs/zh/advanced/custom-adapter.md +264 -0
  39. package/docs/zh/advanced/custom-event-recorder.md +318 -0
  40. package/docs/zh/advanced/inline-proxy-codegen.md +200 -0
  41. package/docs/zh/advanced/multi-tenant-scopes.md +330 -0
  42. package/docs/zh/engineering/ci-release-flow.md +244 -0
  43. package/docs/zh/engineering/project-structure.md +296 -0
  44. package/docs/zh/engineering/testing.md +317 -0
  45. package/docs/zh/pitfalls/container-scope-leak.md +215 -0
  46. package/docs/zh/pitfalls/i18n-bundle-size.md +182 -0
  47. package/docs/zh/pitfalls/proxy-binary-payloads.md +133 -0
  48. package/docs/zh/pitfalls/redirect-vs-rewrite.md +147 -0
  49. package/docs/zh/pitfalls/ssr-hydration-mismatch.md +163 -0
  50. package/docs/zh/pitfalls/ssr-vs-csr-globals.md +176 -0
  51. 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`