@finesoft/front 0.1.75 → 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/README.md +2 -411
- package/dist/browser.d.mts +2 -0
- package/dist/browser.mjs +1 -0
- package/dist/index.d.mts +2 -1248
- package/dist/index.mjs +54 -3557
- package/dist/server-data-DGbiKzMS.d.mts +1249 -0
- package/dist/start-app-BdXBCcor.mjs +2 -0
- 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 +12 -3
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
# Advanced: multi-tenant scopes
|
|
2
|
+
|
|
3
|
+
A multi-tenant app serves multiple customers from one deployment, but each customer's request must see their own:
|
|
4
|
+
|
|
5
|
+
- Database connection / API client
|
|
6
|
+
- Logger / metrics tagged with the tenant
|
|
7
|
+
- Feature flags / pricing / branding
|
|
8
|
+
- Cached translations / content
|
|
9
|
+
|
|
10
|
+
The DI container's child scopes are the right primitive. This recipe shows how to wire per-tenant isolation through a `beforeLoad` guard.
|
|
11
|
+
|
|
12
|
+
## Mental model
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
Framework
|
|
16
|
+
└── parent Container ← shared services (HTTP pool, base recorder, ...)
|
|
17
|
+
└── per-request scope ← framework creates this for every SSR request
|
|
18
|
+
↑
|
|
19
|
+
└── tenant overrides registered by a beforeLoad guard
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The per-request scope is created by the framework automatically. Your guard registers tenant-specific overrides on top. Anything you don't override falls through to the parent.
|
|
23
|
+
|
|
24
|
+
## Step 1: identify the tenant
|
|
25
|
+
|
|
26
|
+
This is your business logic. Common sources:
|
|
27
|
+
|
|
28
|
+
- **Subdomain**: `acme.myapp.com` → `acme`
|
|
29
|
+
- **Path prefix**: `/t/acme/...` → `acme`
|
|
30
|
+
- **Header**: `X-Tenant-Id: acme`
|
|
31
|
+
- **Authenticated user**: 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
|
+
## Step 2: load tenant config
|
|
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
|
+
// Load from your config store — file, 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
|
+
A real implementation would have cache invalidation on config updates. For most apps, refresh on a timer or via a webhook is enough.
|
|
73
|
+
|
|
74
|
+
## Step 3: register tenant services in a guard
|
|
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
|
+
// Register tenant-specific services on the request 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
|
+
// Decorate the parent's recorder with tenant context
|
|
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
|
+
Key insights:
|
|
120
|
+
|
|
121
|
+
- **The scope is already created by the framework.** You register on `ctx.container` — that's the request scope.
|
|
122
|
+
- **Fall-through is automatic.** Anything not registered here resolves from the parent container.
|
|
123
|
+
- **Decorate, don't replace.** The recorder is wrapped with tenant fields rather than replaced — base behavior (HTTP transmission, batching) stays in place.
|
|
124
|
+
|
|
125
|
+
## Step 4: install the guard globally
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
// src/bootstrap.ts
|
|
129
|
+
import { type Framework, defineRoutes } from "@finesoft/front";
|
|
130
|
+
import { tenantGuard } from "./lib/guards/tenant";
|
|
131
|
+
// ... other imports
|
|
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` runs before any route-specific guard, so by the time any controller executes, the tenant scope is set up.
|
|
145
|
+
|
|
146
|
+
## Step 5: controllers transparently get the right services
|
|
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"); // tenant-specific client
|
|
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
|
+
The controller doesn't know about tenants — it just resolves `userApi` and gets the right one for this request.
|
|
171
|
+
|
|
172
|
+
## Cross-tenant prohibitions
|
|
173
|
+
|
|
174
|
+
To prevent a user authenticated for tenant A from accessing tenant B's data:
|
|
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
|
+
// Apply after tenantGuard:
|
|
188
|
+
framework.middleware.use("beforeLoad", tenantGuard);
|
|
189
|
+
framework.middleware.use("beforeLoad", sameTenantGuard);
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Guard order matters — `tenantGuard` must register `tenantConfig` before `sameTenantGuard` reads it.
|
|
193
|
+
|
|
194
|
+
## Hydration considerations
|
|
195
|
+
|
|
196
|
+
Tenant configs include `featureFlags`, which are read on both server and client. The framework's `PrefetchedIntents` serialization handles this — the browser receives the same flag values the server saw, so client-side reads stay consistent.
|
|
197
|
+
|
|
198
|
+
Tenant config itself is **not** automatically serialized. If your view needs to display `tenantConfig.displayName`, the controller should include it in the `Page` object:
|
|
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
|
+
The `Page` is serialized, so it survives the SSR → CSR boundary. The full `TenantConfig` (with secrets) should never end up in a `Page`.
|
|
215
|
+
|
|
216
|
+
## Browser-side considerations
|
|
217
|
+
|
|
218
|
+
`tenantGuard` runs on the browser too — on initial navigation and on every subsequent navigation. For browser-only apps (`renderMode: "csr"`), this is the only time it runs.
|
|
219
|
+
|
|
220
|
+
But the browser can't safely resolve secrets like `apiToken`. Two approaches:
|
|
221
|
+
|
|
222
|
+
**Approach 1: server proxies all API calls.** The browser hits `/api/users` (your proxy), which forwards to `${upstreamUrl}/users` with the auth header injected from `process.env[apiTokenEnvKey]`. The browser never sees the token.
|
|
223
|
+
|
|
224
|
+
**Approach 2: short-lived session token.** The server issues a JWT scoped to the tenant; the browser uses it for direct upstream calls. Token rotation handled by your auth layer.
|
|
225
|
+
|
|
226
|
+
Most apps go with approach 1. The framework's proxy router is designed for it.
|
|
227
|
+
|
|
228
|
+
## Caveats
|
|
229
|
+
|
|
230
|
+
### Don't cache tenant scopes across requests
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
// BAD
|
|
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
|
+
// Use scope...
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Each request needs its own scope — even for the same tenant — because:
|
|
247
|
+
|
|
248
|
+
- Other guards add request-specific overrides (auth, trace id) that shouldn't leak across requests
|
|
249
|
+
- The scope holds resolved instances of stateful services; sharing them across requests breaks isolation
|
|
250
|
+
|
|
251
|
+
Tenant **config** can be cached. Tenant **scope** cannot.
|
|
252
|
+
|
|
253
|
+
### Be careful with shared service instances
|
|
254
|
+
|
|
255
|
+
If you cache the `UserApi` instance at module level instead of registering a factory, all requests share state:
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
// BAD
|
|
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
|
+
If `UserApi` has any request-scoped state (interceptors that capture closures over request-specific values), they'll bleed across tenants. Register a fresh factory; let the container cache it per scope.
|
|
271
|
+
|
|
272
|
+
## Testing
|
|
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
|
+
## Related
|
|
327
|
+
|
|
328
|
+
- [Chapter 7: DI container](../07-di-container.md) — scopes and fallback resolution
|
|
329
|
+
- [Chapter 3: Middleware](../03-middleware.md) — global guards
|
|
330
|
+
- [Pitfall: container scope leak](../pitfalls/container-scope-leak.md) — what goes wrong if you cache scopes
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# Engineering: CI & release flow
|
|
2
|
+
|
|
3
|
+
How the framework itself is released, and how to set up the same workflow for an app that depends on it.
|
|
4
|
+
|
|
5
|
+
## What ships
|
|
6
|
+
|
|
7
|
+
Only `@finesoft/front` is published to npm. The internal `core` / `browser` / `ssr` / `server` packages are bundled into `front` via `tsdown`'s `noExternal: [@finesoft/*]`.
|
|
8
|
+
|
|
9
|
+
This means:
|
|
10
|
+
|
|
11
|
+
- One npm package for users to install
|
|
12
|
+
- Internal refactors don't bump multiple versions
|
|
13
|
+
- Single CHANGELOG to read
|
|
14
|
+
|
|
15
|
+
`create-finesoft-app` is its own published package (a CLI), separate from the framework runtime.
|
|
16
|
+
|
|
17
|
+
## The release workflow
|
|
18
|
+
|
|
19
|
+
The repo ships a single `.github/workflows/release.yml` that handles everything inline. Trigger: push to `main`.
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
push to main
|
|
23
|
+
│
|
|
24
|
+
▼
|
|
25
|
+
Checkout main (with PAT, not GITHUB_TOKEN)
|
|
26
|
+
│
|
|
27
|
+
▼
|
|
28
|
+
Reconcile npm registry with main
|
|
29
|
+
├── npm == main? → continue
|
|
30
|
+
├── main > npm? → catch-up publish current main version
|
|
31
|
+
└── npm > main? → error, manual investigation
|
|
32
|
+
│
|
|
33
|
+
▼
|
|
34
|
+
Generate auto-changeset (one patch per push)
|
|
35
|
+
│
|
|
36
|
+
▼
|
|
37
|
+
Apply version bump
|
|
38
|
+
├── changes? → continue
|
|
39
|
+
└── no changes? → done, nothing to publish
|
|
40
|
+
│
|
|
41
|
+
▼
|
|
42
|
+
Commit "chore(release): version packages"
|
|
43
|
+
│
|
|
44
|
+
▼
|
|
45
|
+
Build all packages, publish @finesoft/front to npm
|
|
46
|
+
│
|
|
47
|
+
▼
|
|
48
|
+
Push commit + tag back to main (with rebase retry)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Why one inline workflow instead of changesets/action's PR mode
|
|
52
|
+
|
|
53
|
+
The standard changesets workflow opens a PR ("Version Packages") that, when merged, triggers a second workflow run that publishes. **But `GITHUB_TOKEN`-merged commits don't trigger subsequent workflows** (GitHub's anti-recursion safety) — the publish never fires. The inline workflow does everything in one run, no PR hop.
|
|
54
|
+
|
|
55
|
+
### Why a PAT instead of `GITHUB_TOKEN`
|
|
56
|
+
|
|
57
|
+
The repo has a ruleset enforcing signed commits, linear history, and required PRs on `main`. Bypass actors include `RepositoryRole=5 (admin)` but **not** `github-actions[bot]`. GitHub's UI does not allow adding the bot to the bypass list. Push from a PAT owned by an admin user matches the existing bypass entry.
|
|
58
|
+
|
|
59
|
+
The PAT is `Contents: Read & Write` only — the minimum needed for `git push`.
|
|
60
|
+
|
|
61
|
+
## Concurrency
|
|
62
|
+
|
|
63
|
+
```yaml
|
|
64
|
+
concurrency: release-${{ github.ref }}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Multiple pushes to `main` queue rather than cancel. This matters because:
|
|
68
|
+
|
|
69
|
+
- Cancellation mid-publish leaves npm in an inconsistent state
|
|
70
|
+
- Each push must wait for the previous to finish to avoid version-number races
|
|
71
|
+
- The next run's reconcile step picks up whatever the previous one published
|
|
72
|
+
|
|
73
|
+
## Idempotency
|
|
74
|
+
|
|
75
|
+
`changeset publish` skips versions already on npm. So if push to `main` fails after publish:
|
|
76
|
+
|
|
77
|
+
- npm: has version 0.1.75
|
|
78
|
+
- main: stuck at 0.1.74
|
|
79
|
+
|
|
80
|
+
The next release run's reconcile detects `main < npm`, refuses to "catch up backwards," and errors out. Manual remediation: open a PR that bumps `packages/front/package.json` to match npm and merges. Subsequent pushes resume normally.
|
|
81
|
+
|
|
82
|
+
## Setting up the same for an app
|
|
83
|
+
|
|
84
|
+
Most apps don't need a publish step — they have deploys instead. But the changeset + auto-bump shape still works:
|
|
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 # your deploy command
|
|
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
|
+
Replace `vp run deploy` with your platform's deploy command (Vercel, Cloudflare, your own infra).
|
|
156
|
+
|
|
157
|
+
## Conventional commits + auto-changeset
|
|
158
|
+
|
|
159
|
+
The `release:auto:changeset` script (in this repo, generates one patch changeset per push) is intentionally simple — every merged PR becomes one patch bump. For semver-driven versioning, replace it with a script that:
|
|
160
|
+
|
|
161
|
+
- Reads `git log` since the last tag
|
|
162
|
+
- Maps commit prefixes (`feat:`, `fix:`, `BREAKING:`) to changeset types
|
|
163
|
+
- Writes the right `.changeset/*.md`
|
|
164
|
+
|
|
165
|
+
The framework's repo uses patch-only because:
|
|
166
|
+
|
|
167
|
+
- Every push is a small change; large changes go through review and become small commits anyway
|
|
168
|
+
- Real breaking changes are rare and warrant manual changesets
|
|
169
|
+
- It avoids a class of "the prefix lies" bugs
|
|
170
|
+
|
|
171
|
+
Pick the policy that matches how your team commits.
|
|
172
|
+
|
|
173
|
+
## Per-PR validation (`quality.yml`)
|
|
174
|
+
|
|
175
|
+
The repo also has a `quality.yml` workflow on PRs:
|
|
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
|
+
The `check` job (format + lint + types) is gated off by `if: false` in this repo because Vite+ runs them all locally via pre-commit. Re-enable it if your team doesn't run pre-commit hooks consistently.
|
|
201
|
+
|
|
202
|
+
## CodeQL
|
|
203
|
+
|
|
204
|
+
The repo enables CodeQL on a schedule and PRs. Scan scope is restricted to `packages/{core,browser,ssr,server,front}/src/**` — tests, templates, scripts, and the scaffolder are excluded.
|
|
205
|
+
|
|
206
|
+
For application repos, enable the default CodeQL config — its noise is low and it catches real issues (open redirects, SQL injection, secret exposure).
|
|
207
|
+
|
|
208
|
+
## Required status checks
|
|
209
|
+
|
|
210
|
+
The repo ruleset requires:
|
|
211
|
+
|
|
212
|
+
- `Coverage` (from `quality.yml`)
|
|
213
|
+
- `CodeQL`
|
|
214
|
+
|
|
215
|
+
PRs cannot merge until both pass. The release workflow bypasses these via the admin PAT — release runs after merge, on `main`, so the checks already passed on the PR.
|
|
216
|
+
|
|
217
|
+
## Migration: from changesets PR mode to inline
|
|
218
|
+
|
|
219
|
+
If you're moving an existing repo from `changesets/action` (PR mode):
|
|
220
|
+
|
|
221
|
+
1. Delete the old release workflow
|
|
222
|
+
2. Create the inline workflow above
|
|
223
|
+
3. Generate a fine-grained PAT, store as `RELEASE_PUSH_TOKEN`
|
|
224
|
+
4. The first push to main after this change will:
|
|
225
|
+
- Detect main == npm (no catch-up needed)
|
|
226
|
+
- Generate one patch changeset
|
|
227
|
+
- Bump + publish + push back to main
|
|
228
|
+
|
|
229
|
+
If there's a pending "Version Packages" PR from the old workflow, close it without merging. The auto-changeset will pick up everything from there.
|
|
230
|
+
|
|
231
|
+
## What can go wrong
|
|
232
|
+
|
|
233
|
+
| Symptom | Cause | Fix |
|
|
234
|
+
| ----------------------------------------------- | --------------------------------------------------------------------------------- | -------------------------------------------------------------- |
|
|
235
|
+
| `[remote rejected] HEAD -> main` | PAT actor not in ruleset bypass; PAT lacks `Contents: Write` | Verify PAT scope; confirm push actor is an admin user |
|
|
236
|
+
| `npm ... is ahead of main` | Previous run published but failed to push | Open a PR syncing `packages/front/package.json` to npm version |
|
|
237
|
+
| Workflow doesn't trigger after a release commit | `if: "!startsWith(github.event.head_commit.message, 'chore(release):'"` filtering | Working as intended — recursion prevention |
|
|
238
|
+
| Pre-commit hook (`vp check`) fails CI | Local formatter not run | `vp check --fix` locally; commit; rerun |
|
|
239
|
+
|
|
240
|
+
## See also
|
|
241
|
+
|
|
242
|
+
- The actual release workflow: `.github/workflows/release.yml`
|
|
243
|
+
- The actual quality workflow: `.github/workflows/quality.yml`
|
|
244
|
+
- [Changesets docs](https://github.com/changesets/changesets) — for understanding `vp run version` and `vp run release`
|