@decocms/start 4.4.0 → 4.6.0
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/.cursor/skills/deco-edge-caching/SKILL.md +14 -10
- package/.github/workflows/lockfile-check.yml +35 -0
- package/package.json +1 -1
- package/scripts/migrate/phase-report.ts +7 -7
- package/scripts/migrate/phase-scaffold.ts +17 -1
- package/scripts/migrate/phase-verify.ts +1 -0
- package/scripts/migrate/post-cleanup/rules.ts +349 -0
- package/scripts/migrate/post-cleanup/runner.test.ts +216 -0
- package/scripts/migrate/templates/lockfile-check-yml.test.ts +26 -0
- package/scripts/migrate/templates/lockfile-check-yml.ts +66 -0
- package/scripts/migrate/templates/package-json.ts +13 -1
- package/scripts/migrate-post-cleanup.ts +5 -3
- package/scripts/migrate.ts +9 -4
- package/src/sdk/workerEntry.ts +39 -21
- package/src/vite/plugin.js +55 -0
- package/src/vite/plugin.test.js +60 -1
- package/vitest.config.ts +1 -1
|
@@ -183,17 +183,21 @@ This is per-isolate in-memory cache (V8 Map). Resets on cold start. Includes req
|
|
|
183
183
|
|
|
184
184
|
## Cache Versioning with BUILD_HASH
|
|
185
185
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
186
|
+
Every cached entry's key is suffixed with `__v=<hash>` so a new deploy starts a fresh cache namespace and previously-cached HTML (which references now-deleted asset filenames like `/assets/main-XYZ.js`) stops being served the moment the new worker is live. Old entries become orphaned and expire naturally — no purge endpoint call required.
|
|
187
|
+
|
|
188
|
+
The hash is resolved automatically by `decoVitePlugin()` at build time and injected into the worker bundle as `__DECO_BUILD_HASH__`. Resolution order:
|
|
189
|
+
|
|
190
|
+
1. `WORKERS_CI_COMMIT_SHA` — Cloudflare Workers Builds default env var ([CF docs](https://developers.cloudflare.com/changelog/2025-06-10-default-env-vars/)). This is the production deploy path-of-record per `MIGRATION_TOOLING_PLAN.md` D6.3.
|
|
191
|
+
2. `git rev-parse --short=12 HEAD` — for someone running `wrangler deploy` from a developer laptop.
|
|
192
|
+
3. `Date.now().toString(36)` — last-resort fallback so the cache-bust invariant never silently regresses.
|
|
193
|
+
|
|
194
|
+
`createDecoWorkerEntry` reads `env.BUILD_HASH` first (explicit override path, e.g. `wrangler deploy --var BUILD_HASH:foo`) and falls back to the `__DECO_BUILD_HASH__` constant. Sites running `decoVitePlugin()` get the behaviour for free — **no per-site dashboard, `wrangler.jsonc`, or `--var` configuration required**.
|
|
195
195
|
|
|
196
|
-
The
|
|
196
|
+
The active version is exposed on every cached response via the `X-Cache-Version` header for observability. Confirm a new deploy is shipping the right hash with:
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
curl -sI https://www.example.com/ | grep -i x-cache-version
|
|
200
|
+
```
|
|
197
201
|
|
|
198
202
|
## Site-Level Cache Pattern Registration
|
|
199
203
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
name: lockfile-check
|
|
2
|
+
|
|
3
|
+
# PR-time guardrail: re-runs the same install our release workflow
|
|
4
|
+
# runs on main (`bun install --frozen-lockfile`). Fails the PR if
|
|
5
|
+
# bun.lock would have rejected the install — closes the loop so drift
|
|
6
|
+
# can never reach main.
|
|
7
|
+
#
|
|
8
|
+
# Pairs with:
|
|
9
|
+
# - "packageManager": "bun@1.3.5" in package.json
|
|
10
|
+
# - .gitignore bans on package-lock.json / yarn.lock / pnpm-lock.yaml
|
|
11
|
+
# - the deco-post-cleanup audit's lockfile-* rules
|
|
12
|
+
#
|
|
13
|
+
# Adjust `bun-version` here in lockstep with the package.json
|
|
14
|
+
# `packageManager` field.
|
|
15
|
+
|
|
16
|
+
on:
|
|
17
|
+
pull_request:
|
|
18
|
+
paths:
|
|
19
|
+
- "package.json"
|
|
20
|
+
- "bun.lock"
|
|
21
|
+
- ".github/workflows/lockfile-check.yml"
|
|
22
|
+
|
|
23
|
+
permissions:
|
|
24
|
+
contents: read
|
|
25
|
+
|
|
26
|
+
jobs:
|
|
27
|
+
frozen-install:
|
|
28
|
+
runs-on: ubuntu-latest
|
|
29
|
+
steps:
|
|
30
|
+
- uses: actions/checkout@v4
|
|
31
|
+
- uses: oven-sh/setup-bun@v2
|
|
32
|
+
with:
|
|
33
|
+
bun-version: 1.3.5
|
|
34
|
+
- name: bun install --frozen-lockfile
|
|
35
|
+
run: bun install --frozen-lockfile
|
package/package.json
CHANGED
|
@@ -187,21 +187,21 @@ export function report(ctx: MigrationContext): void {
|
|
|
187
187
|
lines.push("## Next Steps");
|
|
188
188
|
lines.push("");
|
|
189
189
|
lines.push("```bash");
|
|
190
|
-
lines.push("# 1. Install dependencies");
|
|
191
|
-
lines.push("
|
|
190
|
+
lines.push("# 1. Install dependencies (bun is the canonical PM)");
|
|
191
|
+
lines.push("bun install");
|
|
192
192
|
lines.push("");
|
|
193
193
|
lines.push("# 2. Generate CMS blocks and schema");
|
|
194
|
-
lines.push("
|
|
195
|
-
lines.push("
|
|
194
|
+
lines.push("bun run generate:blocks");
|
|
195
|
+
lines.push("bun run generate:schema");
|
|
196
196
|
lines.push("");
|
|
197
197
|
lines.push("# 3. Generate routes");
|
|
198
|
-
lines.push("
|
|
198
|
+
lines.push("bunx tsr generate");
|
|
199
199
|
lines.push("");
|
|
200
200
|
lines.push("# 4. Type check");
|
|
201
|
-
lines.push("
|
|
201
|
+
lines.push("bunx tsc --noEmit");
|
|
202
202
|
lines.push("");
|
|
203
203
|
lines.push("# 5. Find unused code");
|
|
204
|
-
lines.push("
|
|
204
|
+
lines.push("bun run knip");
|
|
205
205
|
lines.push("");
|
|
206
206
|
lines.push("# 6. Run dev server");
|
|
207
207
|
lines.push("npm run dev");
|
|
@@ -2,7 +2,8 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import type { MigrationContext } from "./types";
|
|
4
4
|
import { log, logPhase } from "./types";
|
|
5
|
-
import { generatePackageJson } from "./templates/package-json";
|
|
5
|
+
import { CANONICAL_BUN_VERSION, generatePackageJson } from "./templates/package-json";
|
|
6
|
+
import { generateLockfileCheckYml } from "./templates/lockfile-check-yml";
|
|
6
7
|
import { generateTsconfig } from "./templates/tsconfig";
|
|
7
8
|
import { generateViteConfig } from "./templates/vite-config";
|
|
8
9
|
import { generateKnipConfig } from "./templates/knip-config";
|
|
@@ -72,6 +73,16 @@ export function scaffold(ctx: MigrationContext): void {
|
|
|
72
73
|
tabWidth: 2,
|
|
73
74
|
}, null, 2) + "\n");
|
|
74
75
|
|
|
76
|
+
// PR-time lockfile guardrail. Lives in the site repo (per-site, not
|
|
77
|
+
// a centralised reusable workflow) because per D6.3 we are not
|
|
78
|
+
// scaffolding caller stubs into storefronts. Bun version is pinned
|
|
79
|
+
// in lockstep with `package.json` via CANONICAL_BUN_VERSION.
|
|
80
|
+
writeFile(
|
|
81
|
+
ctx,
|
|
82
|
+
".github/workflows/lockfile-check.yml",
|
|
83
|
+
generateLockfileCheckYml(CANONICAL_BUN_VERSION),
|
|
84
|
+
);
|
|
85
|
+
|
|
75
86
|
// Server entry files (server.ts, worker-entry.ts, router.tsx, runtime.ts, context.ts)
|
|
76
87
|
writeMultiFile(ctx, generateServerEntry(ctx));
|
|
77
88
|
|
|
@@ -238,6 +249,11 @@ vite.config.timestamp_*
|
|
|
238
249
|
# IDE
|
|
239
250
|
.vscode/
|
|
240
251
|
.idea/
|
|
252
|
+
|
|
253
|
+
# Lockfiles — bun is canonical, prevent accidental drift
|
|
254
|
+
package-lock.json
|
|
255
|
+
yarn.lock
|
|
256
|
+
pnpm-lock.yaml
|
|
241
257
|
`;
|
|
242
258
|
}
|
|
243
259
|
|
|
@@ -17,6 +17,7 @@ const REQUIRED_FILES = [
|
|
|
17
17
|
// Workers Builds (D6.3) -- configured in the CF dashboard, not via
|
|
18
18
|
// GitHub workflow files in the site repo.
|
|
19
19
|
".github/workflows/regen-blocks.yml",
|
|
20
|
+
".github/workflows/lockfile-check.yml",
|
|
20
21
|
"knip.config.ts",
|
|
21
22
|
".prettierrc",
|
|
22
23
|
"src/server.ts",
|
|
@@ -1239,6 +1239,345 @@ const ruleHtmxResidue: Rule = {
|
|
|
1239
1239
|
},
|
|
1240
1240
|
};
|
|
1241
1241
|
|
|
1242
|
+
/* ------------------------------------------------------------------ */
|
|
1243
|
+
/* Rule 9 — `lockfile-multiple` — multiple lockfiles tracked */
|
|
1244
|
+
/* ------------------------------------------------------------------ */
|
|
1245
|
+
|
|
1246
|
+
/**
|
|
1247
|
+
* Per the fleet-wide bun-canonical decision, every storefront commits
|
|
1248
|
+
* exactly one lockfile: `bun.lock`. This rule fires when any of the
|
|
1249
|
+
* non-bun lockfiles co-exist with bun.lock — that's the exact pattern
|
|
1250
|
+
* that broke Cloudflare Workers Builds with `lockfile had changes, but
|
|
1251
|
+
* lockfile is frozen` (the dual-lockfile drift).
|
|
1252
|
+
*
|
|
1253
|
+
* The `--fix` deletes the offending non-bun lockfiles. Adding the
|
|
1254
|
+
* `.gitignore` bans is a separate concern handled by `packageManager-missing`
|
|
1255
|
+
* (which also nudges the site to add the bans), so this rule stays
|
|
1256
|
+
* focused.
|
|
1257
|
+
*/
|
|
1258
|
+
const NON_BUN_LOCKFILES = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml"];
|
|
1259
|
+
|
|
1260
|
+
const ruleLockfileMultiple: Rule = {
|
|
1261
|
+
id: "lockfile-multiple",
|
|
1262
|
+
title: "Multiple lockfiles tracked alongside bun.lock",
|
|
1263
|
+
run({ siteDir, fs }: RuleContext): Finding[] {
|
|
1264
|
+
const bunLock = `${siteDir}/bun.lock`;
|
|
1265
|
+
if (!fs.exists(bunLock)) return [];
|
|
1266
|
+
const findings: Finding[] = [];
|
|
1267
|
+
for (const name of NON_BUN_LOCKFILES) {
|
|
1268
|
+
const abs = `${siteDir}/${name}`;
|
|
1269
|
+
if (!fs.exists(abs)) continue;
|
|
1270
|
+
findings.push({
|
|
1271
|
+
rule: "lockfile-multiple",
|
|
1272
|
+
severity: "warning",
|
|
1273
|
+
file: name,
|
|
1274
|
+
message: `${name} co-exists with bun.lock — Cloudflare Workers Builds picks bun and the other will silently drift`,
|
|
1275
|
+
fix: `rm ${name} (bun.lock is the canonical lockfile)`,
|
|
1276
|
+
meta: { lockfile: name },
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
return findings;
|
|
1280
|
+
},
|
|
1281
|
+
applyFix({ siteDir }, findings, writer): FixAction[] {
|
|
1282
|
+
const actions: FixAction[] = [];
|
|
1283
|
+
for (const f of findings) {
|
|
1284
|
+
writer.deleteFile(`${siteDir}/${f.file}`);
|
|
1285
|
+
actions.push({
|
|
1286
|
+
file: f.file,
|
|
1287
|
+
kind: "delete",
|
|
1288
|
+
detail: `deleted (bun.lock is canonical)`,
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
return actions;
|
|
1292
|
+
},
|
|
1293
|
+
};
|
|
1294
|
+
|
|
1295
|
+
/* ------------------------------------------------------------------ */
|
|
1296
|
+
/* Rule 10 — `lockfile-missing` — package.json without bun.lock */
|
|
1297
|
+
/* ------------------------------------------------------------------ */
|
|
1298
|
+
|
|
1299
|
+
/**
|
|
1300
|
+
* A storefront with `package.json` but no committed lockfile cannot
|
|
1301
|
+
* run `bun install --frozen-lockfile` in CI — Cloudflare Workers
|
|
1302
|
+
* Builds either falls back to a non-reproducible install or fails
|
|
1303
|
+
* outright depending on the build image. Detect-only because the
|
|
1304
|
+
* fix (`bun install`) requires network access and a working bun
|
|
1305
|
+
* toolchain that the audit shouldn't shell out to from inside its
|
|
1306
|
+
* own runner.
|
|
1307
|
+
*/
|
|
1308
|
+
const ruleLockfileMissing: Rule = {
|
|
1309
|
+
id: "lockfile-missing",
|
|
1310
|
+
title: "Lockfile missing",
|
|
1311
|
+
run({ siteDir, fs }: RuleContext): Finding[] {
|
|
1312
|
+
const pkg = `${siteDir}/package.json`;
|
|
1313
|
+
if (!fs.exists(pkg)) return [];
|
|
1314
|
+
const bunLock = `${siteDir}/bun.lock`;
|
|
1315
|
+
if (fs.exists(bunLock)) return [];
|
|
1316
|
+
// If a non-bun lockfile is present, `lockfile-multiple` doesn't
|
|
1317
|
+
// apply but the site is still mid-migration to bun. We flag the
|
|
1318
|
+
// missing bun.lock here so the operator runs `bun install` to
|
|
1319
|
+
// produce the canonical one.
|
|
1320
|
+
return [
|
|
1321
|
+
{
|
|
1322
|
+
rule: "lockfile-missing",
|
|
1323
|
+
severity: "warning",
|
|
1324
|
+
file: "bun.lock",
|
|
1325
|
+
message: `No bun.lock committed — frozen installs cannot run on CF Workers Builds`,
|
|
1326
|
+
fix: `Run \`bun install\` and commit the resulting bun.lock`,
|
|
1327
|
+
meta: {},
|
|
1328
|
+
},
|
|
1329
|
+
];
|
|
1330
|
+
},
|
|
1331
|
+
};
|
|
1332
|
+
|
|
1333
|
+
/* ------------------------------------------------------------------ */
|
|
1334
|
+
/* Rule 11 — `lockfile-drift` — bun.lock out of sync with package.json */
|
|
1335
|
+
/* ------------------------------------------------------------------ */
|
|
1336
|
+
|
|
1337
|
+
/**
|
|
1338
|
+
* Detects the head-on case behind the `lockfile had changes, but
|
|
1339
|
+
* lockfile is frozen` Workers Builds error: a direct dependency in
|
|
1340
|
+
* `package.json` has no version in `bun.lock` that satisfies the
|
|
1341
|
+
* declared range.
|
|
1342
|
+
*
|
|
1343
|
+
* Coverage is intentionally pragmatic — we recognise the four most
|
|
1344
|
+
* common range shapes (`^a.b.c`, `~a.b.c`, plain `a.b.c`, and the
|
|
1345
|
+
* sentinels `*` / `latest` / `next` / git/github specs which we skip).
|
|
1346
|
+
* Everything else falls back to "present in lockfile?", treating
|
|
1347
|
+
* unknown ranges as satisfied as long as the name appears at all.
|
|
1348
|
+
* The goal is high signal on the storefront fleet's actual failure
|
|
1349
|
+
* mode; full npm-semver fidelity is out of scope for this rule.
|
|
1350
|
+
*
|
|
1351
|
+
* Detect-only: regenerating bun.lock requires running `bun install`,
|
|
1352
|
+
* which the audit script doesn't do (would need network + bun in
|
|
1353
|
+
* PATH). Operators run it manually and re-run the audit.
|
|
1354
|
+
*/
|
|
1355
|
+
const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)(?:-[\w.+-]+)?$/;
|
|
1356
|
+
|
|
1357
|
+
function parseSemver(v: string): [number, number, number] | null {
|
|
1358
|
+
const m = SEMVER_RE.exec(v.trim());
|
|
1359
|
+
if (!m) return null;
|
|
1360
|
+
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
function gte(a: [number, number, number], b: [number, number, number]): boolean {
|
|
1364
|
+
if (a[0] !== b[0]) return a[0] > b[0];
|
|
1365
|
+
if (a[1] !== b[1]) return a[1] > b[1];
|
|
1366
|
+
return a[2] >= b[2];
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
function lt(a: [number, number, number], b: [number, number, number]): boolean {
|
|
1370
|
+
if (a[0] !== b[0]) return a[0] < b[0];
|
|
1371
|
+
if (a[1] !== b[1]) return a[1] < b[1];
|
|
1372
|
+
return a[2] < b[2];
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/**
|
|
1376
|
+
* Minimal `range satisfies version` check. Returns:
|
|
1377
|
+
* - `true` when the range is satisfied,
|
|
1378
|
+
* - `false` when it is definitely violated,
|
|
1379
|
+
* - `null` when we don't recognise the range shape and the caller
|
|
1380
|
+
* should fall back to a presence check.
|
|
1381
|
+
*/
|
|
1382
|
+
function satisfiesRange(version: string, range: string): boolean | null {
|
|
1383
|
+
const trimmed = range.trim();
|
|
1384
|
+
// Sentinels and non-numeric specs we don't try to evaluate.
|
|
1385
|
+
if (
|
|
1386
|
+
trimmed === "*" ||
|
|
1387
|
+
trimmed === "latest" ||
|
|
1388
|
+
trimmed === "next" ||
|
|
1389
|
+
trimmed.startsWith("workspace:") ||
|
|
1390
|
+
trimmed.startsWith("file:") ||
|
|
1391
|
+
trimmed.startsWith("link:") ||
|
|
1392
|
+
trimmed.startsWith("git+") ||
|
|
1393
|
+
trimmed.startsWith("github:") ||
|
|
1394
|
+
trimmed.includes("://")
|
|
1395
|
+
) {
|
|
1396
|
+
return null;
|
|
1397
|
+
}
|
|
1398
|
+
const ver = parseSemver(version);
|
|
1399
|
+
if (!ver) return null;
|
|
1400
|
+
// Caret: ^a.b.c → >=a.b.c <(a+1).0.0 when a > 0
|
|
1401
|
+
// ^0.b.c → >=0.b.c <0.(b+1).0 when a == 0 && b > 0
|
|
1402
|
+
// ^0.0.c → exactly 0.0.c
|
|
1403
|
+
if (trimmed.startsWith("^")) {
|
|
1404
|
+
const base = parseSemver(trimmed.slice(1));
|
|
1405
|
+
if (!base) return null;
|
|
1406
|
+
if (!gte(ver, base)) return false;
|
|
1407
|
+
let upper: [number, number, number];
|
|
1408
|
+
if (base[0] > 0) upper = [base[0] + 1, 0, 0];
|
|
1409
|
+
else if (base[1] > 0) upper = [0, base[1] + 1, 0];
|
|
1410
|
+
else upper = [0, 0, base[2] + 1];
|
|
1411
|
+
return lt(ver, upper);
|
|
1412
|
+
}
|
|
1413
|
+
// Tilde: ~a.b.c → >=a.b.c <a.(b+1).0
|
|
1414
|
+
if (trimmed.startsWith("~")) {
|
|
1415
|
+
const base = parseSemver(trimmed.slice(1));
|
|
1416
|
+
if (!base) return null;
|
|
1417
|
+
return gte(ver, base) && lt(ver, [base[0], base[1] + 1, 0]);
|
|
1418
|
+
}
|
|
1419
|
+
// Plain pin: a.b.c → exact match.
|
|
1420
|
+
const exact = parseSemver(trimmed);
|
|
1421
|
+
if (exact) return exact[0] === ver[0] && exact[1] === ver[1] && exact[2] === ver[2];
|
|
1422
|
+
return null;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
/**
|
|
1426
|
+
* Pull every `"<name>@<version>"` token out of bun.lock for a given
|
|
1427
|
+
* package name. Bun's lockfile format embeds the version inside the
|
|
1428
|
+
* package descriptor array, e.g.:
|
|
1429
|
+
*
|
|
1430
|
+
* "@decocms/start": ["@decocms/start@2.1.1", ...]
|
|
1431
|
+
*
|
|
1432
|
+
* We scan all such occurrences (a single package may appear multiple
|
|
1433
|
+
* times if the dep tree pulled different versions).
|
|
1434
|
+
*/
|
|
1435
|
+
function lockfileVersionsOf(lockfile: string, name: string): string[] {
|
|
1436
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1437
|
+
const re = new RegExp(`['"]${escaped}@([^'"\\s]+)['"]`, "g");
|
|
1438
|
+
const seen = new Set<string>();
|
|
1439
|
+
for (const m of lockfile.matchAll(re)) {
|
|
1440
|
+
seen.add(m[1]);
|
|
1441
|
+
}
|
|
1442
|
+
return [...seen];
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
const ruleLockfileDrift: Rule = {
|
|
1446
|
+
id: "lockfile-drift",
|
|
1447
|
+
title: "bun.lock drifted vs package.json direct dependencies",
|
|
1448
|
+
run({ siteDir, fs }: RuleContext): Finding[] {
|
|
1449
|
+
const pkgPath = `${siteDir}/package.json`;
|
|
1450
|
+
const lockPath = `${siteDir}/bun.lock`;
|
|
1451
|
+
if (!fs.exists(pkgPath) || !fs.exists(lockPath)) return [];
|
|
1452
|
+
let parsed: unknown;
|
|
1453
|
+
try {
|
|
1454
|
+
parsed = JSON.parse(fs.readText(pkgPath));
|
|
1455
|
+
} catch {
|
|
1456
|
+
return [];
|
|
1457
|
+
}
|
|
1458
|
+
if (typeof parsed !== "object" || parsed === null) return [];
|
|
1459
|
+
const pkg = parsed as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
|
|
1460
|
+
const lockText = fs.readText(lockPath);
|
|
1461
|
+
const drifted: { name: string; range: string; locked: string[] }[] = [];
|
|
1462
|
+
const buckets: Record<string, string>[] = [pkg.dependencies ?? {}, pkg.devDependencies ?? {}];
|
|
1463
|
+
for (const bucket of buckets) {
|
|
1464
|
+
for (const [name, range] of Object.entries(bucket)) {
|
|
1465
|
+
const versions = lockfileVersionsOf(lockText, name);
|
|
1466
|
+
if (versions.length === 0) {
|
|
1467
|
+
drifted.push({ name, range, locked: [] });
|
|
1468
|
+
continue;
|
|
1469
|
+
}
|
|
1470
|
+
// If at least one locked version satisfies the range, we're fine.
|
|
1471
|
+
// For unknown range shapes (return null), treat presence as
|
|
1472
|
+
// satisfaction — pragmatic, see rule docstring.
|
|
1473
|
+
let satisfied = false;
|
|
1474
|
+
for (const v of versions) {
|
|
1475
|
+
const result = satisfiesRange(v, range);
|
|
1476
|
+
if (result === true || result === null) {
|
|
1477
|
+
satisfied = true;
|
|
1478
|
+
break;
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
if (!satisfied) drifted.push({ name, range, locked: versions });
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
if (drifted.length === 0) return [];
|
|
1485
|
+
return [
|
|
1486
|
+
{
|
|
1487
|
+
rule: "lockfile-drift",
|
|
1488
|
+
severity: "warning",
|
|
1489
|
+
file: "bun.lock",
|
|
1490
|
+
message: `${drifted.length} direct dep(s) not satisfied by bun.lock — frozen install will fail`,
|
|
1491
|
+
fix: `Run \`bun install\` to refresh bun.lock, then commit. Drift: ${drifted
|
|
1492
|
+
.slice(0, 5)
|
|
1493
|
+
.map((d) => `${d.name} ${d.range} (locked: ${d.locked.length > 0 ? d.locked.join(", ") : "none"})`)
|
|
1494
|
+
.join("; ")}${drifted.length > 5 ? `; +${drifted.length - 5} more` : ""}`,
|
|
1495
|
+
meta: { drifted },
|
|
1496
|
+
},
|
|
1497
|
+
];
|
|
1498
|
+
},
|
|
1499
|
+
};
|
|
1500
|
+
|
|
1501
|
+
/* ------------------------------------------------------------------ */
|
|
1502
|
+
/* Rule 12 — `package-manager-missing` — no packageManager field */
|
|
1503
|
+
/* ------------------------------------------------------------------ */
|
|
1504
|
+
|
|
1505
|
+
/**
|
|
1506
|
+
* Without a `packageManager` field in package.json, neither developers
|
|
1507
|
+
* nor CI are forced to agree on a PM. Anyone running `npm install`
|
|
1508
|
+
* or `yarn` produces an alternate lockfile that risks drifting from
|
|
1509
|
+
* `bun.lock` and breaking Workers Builds. The fleet-wide canonical
|
|
1510
|
+
* value is `bun@<CANONICAL_BUN_VERSION>`; bumping that constant here
|
|
1511
|
+
* propagates to all `--fix` runs.
|
|
1512
|
+
*/
|
|
1513
|
+
const CANONICAL_PACKAGE_MANAGER = "bun@1.3.5";
|
|
1514
|
+
|
|
1515
|
+
const rulePackageManagerMissing: Rule = {
|
|
1516
|
+
id: "package-manager-missing",
|
|
1517
|
+
title: "Missing packageManager field in package.json",
|
|
1518
|
+
run({ siteDir, fs }: RuleContext): Finding[] {
|
|
1519
|
+
const pkgPath = `${siteDir}/package.json`;
|
|
1520
|
+
if (!fs.exists(pkgPath)) return [];
|
|
1521
|
+
let parsed: unknown;
|
|
1522
|
+
try {
|
|
1523
|
+
parsed = JSON.parse(fs.readText(pkgPath));
|
|
1524
|
+
} catch {
|
|
1525
|
+
return [];
|
|
1526
|
+
}
|
|
1527
|
+
if (typeof parsed !== "object" || parsed === null) return [];
|
|
1528
|
+
const pkg = parsed as { packageManager?: string };
|
|
1529
|
+
if (typeof pkg.packageManager === "string" && pkg.packageManager.length > 0) {
|
|
1530
|
+
return [];
|
|
1531
|
+
}
|
|
1532
|
+
return [
|
|
1533
|
+
{
|
|
1534
|
+
rule: "package-manager-missing",
|
|
1535
|
+
severity: "info",
|
|
1536
|
+
file: "package.json",
|
|
1537
|
+
message: `Missing "packageManager" field — contributors and CF Workers Builds may pick different PMs`,
|
|
1538
|
+
fix: `Set "packageManager": "${CANONICAL_PACKAGE_MANAGER}" in package.json`,
|
|
1539
|
+
meta: { canonical: CANONICAL_PACKAGE_MANAGER },
|
|
1540
|
+
},
|
|
1541
|
+
];
|
|
1542
|
+
},
|
|
1543
|
+
applyFix({ siteDir, fs }, findings, writer): FixAction[] {
|
|
1544
|
+
if (findings.length === 0) return [];
|
|
1545
|
+
const pkgPath = `${siteDir}/package.json`;
|
|
1546
|
+
if (!fs.exists(pkgPath)) return [];
|
|
1547
|
+
const content = fs.readText(pkgPath);
|
|
1548
|
+
let parsed: Record<string, unknown>;
|
|
1549
|
+
try {
|
|
1550
|
+
parsed = JSON.parse(content) as Record<string, unknown>;
|
|
1551
|
+
} catch {
|
|
1552
|
+
return [];
|
|
1553
|
+
}
|
|
1554
|
+
if (typeof parsed.packageManager === "string" && parsed.packageManager.length > 0) {
|
|
1555
|
+
return [];
|
|
1556
|
+
}
|
|
1557
|
+
// Insert `packageManager` directly after `license` to match the
|
|
1558
|
+
// convention used across the storefront fleet. Falls back to the
|
|
1559
|
+
// end of the object when `license` is absent.
|
|
1560
|
+
const ordered: Record<string, unknown> = {};
|
|
1561
|
+
let inserted = false;
|
|
1562
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
1563
|
+
ordered[k] = v;
|
|
1564
|
+
if (!inserted && k === "license") {
|
|
1565
|
+
ordered.packageManager = CANONICAL_PACKAGE_MANAGER;
|
|
1566
|
+
inserted = true;
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
if (!inserted) ordered.packageManager = CANONICAL_PACKAGE_MANAGER;
|
|
1570
|
+
writer.writeText(pkgPath, `${JSON.stringify(ordered, null, 2)}\n`);
|
|
1571
|
+
return [
|
|
1572
|
+
{
|
|
1573
|
+
file: "package.json",
|
|
1574
|
+
kind: "edit",
|
|
1575
|
+
detail: `set packageManager: "${CANONICAL_PACKAGE_MANAGER}"`,
|
|
1576
|
+
},
|
|
1577
|
+
];
|
|
1578
|
+
},
|
|
1579
|
+
};
|
|
1580
|
+
|
|
1242
1581
|
export const ALL_RULES: Rule[] = [
|
|
1243
1582
|
ruleDeadLibShims,
|
|
1244
1583
|
ruleObsoleteVitePlugins,
|
|
@@ -1249,12 +1588,18 @@ export const ALL_RULES: Rule[] = [
|
|
|
1249
1588
|
ruleFrameworkTodos,
|
|
1250
1589
|
ruleLocalFrameworkDuplicate,
|
|
1251
1590
|
ruleHtmxResidue,
|
|
1591
|
+
ruleLockfileMultiple,
|
|
1592
|
+
ruleLockfileMissing,
|
|
1593
|
+
ruleLockfileDrift,
|
|
1594
|
+
rulePackageManagerMissing,
|
|
1252
1595
|
];
|
|
1253
1596
|
|
|
1254
1597
|
/** Exported for direct unit tests. */
|
|
1255
1598
|
export const _internals = {
|
|
1256
1599
|
extractExports,
|
|
1257
1600
|
symbolUsedOutsideLib,
|
|
1601
|
+
satisfiesRange,
|
|
1602
|
+
lockfileVersionsOf,
|
|
1258
1603
|
rules: {
|
|
1259
1604
|
ruleDeadLibShims,
|
|
1260
1605
|
ruleObsoleteVitePlugins,
|
|
@@ -1265,5 +1610,9 @@ export const _internals = {
|
|
|
1265
1610
|
ruleLocalWidgetsTypes,
|
|
1266
1611
|
ruleFrameworkTodos,
|
|
1267
1612
|
ruleLocalFrameworkDuplicate,
|
|
1613
|
+
ruleLockfileMultiple,
|
|
1614
|
+
ruleLockfileMissing,
|
|
1615
|
+
ruleLockfileDrift,
|
|
1616
|
+
rulePackageManagerMissing,
|
|
1268
1617
|
},
|
|
1269
1618
|
};
|
|
@@ -668,7 +668,9 @@ describe("runAudit — totals", () => {
|
|
|
668
668
|
"dead-runtime-shim",
|
|
669
669
|
"local-framework-duplicate",
|
|
670
670
|
"local-widgets-types",
|
|
671
|
+
"lockfile-multiple",
|
|
671
672
|
"obsolete-vite-plugins",
|
|
673
|
+
"package-manager-missing",
|
|
672
674
|
"vtex-shim-regression",
|
|
673
675
|
].sort(),
|
|
674
676
|
);
|
|
@@ -1450,3 +1452,217 @@ describe("rule: local-framework-duplicate", () => {
|
|
|
1450
1452
|
expect(r.supportsAutoFix).toBe(true);
|
|
1451
1453
|
});
|
|
1452
1454
|
});
|
|
1455
|
+
|
|
1456
|
+
describe("rule: lockfile-multiple", () => {
|
|
1457
|
+
it("flags package-lock.json and yarn.lock when bun.lock exists", () => {
|
|
1458
|
+
const fs = makeFs({
|
|
1459
|
+
"/site/package.json": "{}",
|
|
1460
|
+
"/site/bun.lock": "{}",
|
|
1461
|
+
"/site/package-lock.json": "{}",
|
|
1462
|
+
"/site/yarn.lock": "",
|
|
1463
|
+
});
|
|
1464
|
+
const report = runAudit(SITE, fs);
|
|
1465
|
+
const r = report.rules.find((r) => r.rule === "lockfile-multiple")!;
|
|
1466
|
+
expect(r.findings.map((f) => f.file).sort()).toEqual(["package-lock.json", "yarn.lock"]);
|
|
1467
|
+
});
|
|
1468
|
+
|
|
1469
|
+
it("does not flag when only bun.lock is present", () => {
|
|
1470
|
+
const fs = makeFs({
|
|
1471
|
+
"/site/package.json": "{}",
|
|
1472
|
+
"/site/bun.lock": "{}",
|
|
1473
|
+
});
|
|
1474
|
+
const report = runAudit(SITE, fs);
|
|
1475
|
+
const r = report.rules.find((r) => r.rule === "lockfile-multiple")!;
|
|
1476
|
+
expect(r.findings).toEqual([]);
|
|
1477
|
+
});
|
|
1478
|
+
|
|
1479
|
+
it("does not fire when bun.lock is absent (lockfile-missing handles that case)", () => {
|
|
1480
|
+
const fs = makeFs({
|
|
1481
|
+
"/site/package.json": "{}",
|
|
1482
|
+
"/site/package-lock.json": "{}",
|
|
1483
|
+
});
|
|
1484
|
+
const report = runAudit(SITE, fs);
|
|
1485
|
+
const r = report.rules.find((r) => r.rule === "lockfile-multiple")!;
|
|
1486
|
+
expect(r.findings).toEqual([]);
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1489
|
+
it("--fix deletes the offending lockfiles", () => {
|
|
1490
|
+
const { fs, writer, store } = makeMutableFs({
|
|
1491
|
+
"/site/package.json": "{}",
|
|
1492
|
+
"/site/bun.lock": "{}",
|
|
1493
|
+
"/site/package-lock.json": "{}",
|
|
1494
|
+
"/site/yarn.lock": "",
|
|
1495
|
+
});
|
|
1496
|
+
runAudit(SITE, fs, { writer });
|
|
1497
|
+
expect(store["/site/package-lock.json"]).toBeUndefined();
|
|
1498
|
+
expect(store["/site/yarn.lock"]).toBeUndefined();
|
|
1499
|
+
expect(store["/site/bun.lock"]).toBeDefined();
|
|
1500
|
+
});
|
|
1501
|
+
});
|
|
1502
|
+
|
|
1503
|
+
describe("rule: lockfile-missing", () => {
|
|
1504
|
+
it("fires when package.json exists without bun.lock", () => {
|
|
1505
|
+
const fs = makeFs({ "/site/package.json": "{}" });
|
|
1506
|
+
const report = runAudit(SITE, fs);
|
|
1507
|
+
const r = report.rules.find((r) => r.rule === "lockfile-missing")!;
|
|
1508
|
+
expect(r.findings).toHaveLength(1);
|
|
1509
|
+
expect(r.findings[0].file).toBe("bun.lock");
|
|
1510
|
+
expect(r.findings[0].fix).toContain("bun install");
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1513
|
+
it("does not fire when bun.lock exists", () => {
|
|
1514
|
+
const fs = makeFs({ "/site/package.json": "{}", "/site/bun.lock": "{}" });
|
|
1515
|
+
const report = runAudit(SITE, fs);
|
|
1516
|
+
const r = report.rules.find((r) => r.rule === "lockfile-missing")!;
|
|
1517
|
+
expect(r.findings).toEqual([]);
|
|
1518
|
+
});
|
|
1519
|
+
|
|
1520
|
+
it("does not fire on a directory without package.json", () => {
|
|
1521
|
+
const fs = makeFs({});
|
|
1522
|
+
const report = runAudit(SITE, fs);
|
|
1523
|
+
const r = report.rules.find((r) => r.rule === "lockfile-missing")!;
|
|
1524
|
+
expect(r.findings).toEqual([]);
|
|
1525
|
+
});
|
|
1526
|
+
});
|
|
1527
|
+
|
|
1528
|
+
describe("rule: lockfile-drift", () => {
|
|
1529
|
+
it("flags a caret range whose locked version is below the floor", () => {
|
|
1530
|
+
const pkg = JSON.stringify({
|
|
1531
|
+
dependencies: { "@decocms/apps": "^1.11.0" },
|
|
1532
|
+
});
|
|
1533
|
+
const lock = `"@decocms/apps@1.6.2"\n`;
|
|
1534
|
+
const fs = makeFs({
|
|
1535
|
+
"/site/package.json": pkg,
|
|
1536
|
+
"/site/bun.lock": lock,
|
|
1537
|
+
});
|
|
1538
|
+
const report = runAudit(SITE, fs);
|
|
1539
|
+
const r = report.rules.find((r) => r.rule === "lockfile-drift")!;
|
|
1540
|
+
expect(r.findings).toHaveLength(1);
|
|
1541
|
+
expect(r.findings[0].fix).toContain("@decocms/apps");
|
|
1542
|
+
});
|
|
1543
|
+
|
|
1544
|
+
it("does not flag when locked version satisfies the caret range", () => {
|
|
1545
|
+
const pkg = JSON.stringify({
|
|
1546
|
+
dependencies: { "@decocms/apps": "^1.11.0" },
|
|
1547
|
+
});
|
|
1548
|
+
const lock = `"@decocms/apps@1.13.2"\n`;
|
|
1549
|
+
const fs = makeFs({
|
|
1550
|
+
"/site/package.json": pkg,
|
|
1551
|
+
"/site/bun.lock": lock,
|
|
1552
|
+
});
|
|
1553
|
+
const report = runAudit(SITE, fs);
|
|
1554
|
+
const r = report.rules.find((r) => r.rule === "lockfile-drift")!;
|
|
1555
|
+
expect(r.findings).toEqual([]);
|
|
1556
|
+
});
|
|
1557
|
+
|
|
1558
|
+
it("flags a dep that's missing from bun.lock entirely", () => {
|
|
1559
|
+
const pkg = JSON.stringify({
|
|
1560
|
+
dependencies: { "missing-pkg": "^1.0.0" },
|
|
1561
|
+
});
|
|
1562
|
+
const fs = makeFs({
|
|
1563
|
+
"/site/package.json": pkg,
|
|
1564
|
+
"/site/bun.lock": `"other-pkg@1.0.0"\n`,
|
|
1565
|
+
});
|
|
1566
|
+
const report = runAudit(SITE, fs);
|
|
1567
|
+
const r = report.rules.find((r) => r.rule === "lockfile-drift")!;
|
|
1568
|
+
expect(r.findings).toHaveLength(1);
|
|
1569
|
+
});
|
|
1570
|
+
|
|
1571
|
+
it("treats * / latest / git specs as satisfied by mere presence", () => {
|
|
1572
|
+
const pkg = JSON.stringify({
|
|
1573
|
+
dependencies: {
|
|
1574
|
+
"loose-dep": "*",
|
|
1575
|
+
"latest-dep": "latest",
|
|
1576
|
+
},
|
|
1577
|
+
});
|
|
1578
|
+
const lock = `"loose-dep@9.9.9"\n"latest-dep@0.0.1"\n`;
|
|
1579
|
+
const fs = makeFs({
|
|
1580
|
+
"/site/package.json": pkg,
|
|
1581
|
+
"/site/bun.lock": lock,
|
|
1582
|
+
});
|
|
1583
|
+
const report = runAudit(SITE, fs);
|
|
1584
|
+
const r = report.rules.find((r) => r.rule === "lockfile-drift")!;
|
|
1585
|
+
expect(r.findings).toEqual([]);
|
|
1586
|
+
});
|
|
1587
|
+
|
|
1588
|
+
it("considers devDependencies too", () => {
|
|
1589
|
+
const pkg = JSON.stringify({
|
|
1590
|
+
devDependencies: { typescript: "~5.9.0" },
|
|
1591
|
+
});
|
|
1592
|
+
const lock = `"typescript@5.8.0"\n`;
|
|
1593
|
+
const fs = makeFs({
|
|
1594
|
+
"/site/package.json": pkg,
|
|
1595
|
+
"/site/bun.lock": lock,
|
|
1596
|
+
});
|
|
1597
|
+
const report = runAudit(SITE, fs);
|
|
1598
|
+
const r = report.rules.find((r) => r.rule === "lockfile-drift")!;
|
|
1599
|
+
expect(r.findings).toHaveLength(1);
|
|
1600
|
+
});
|
|
1601
|
+
|
|
1602
|
+
it("internals: satisfiesRange handles caret, tilde, exact, and sentinels", () => {
|
|
1603
|
+
const { satisfiesRange } = _internals;
|
|
1604
|
+
expect(satisfiesRange("1.13.2", "^1.11.0")).toBe(true);
|
|
1605
|
+
expect(satisfiesRange("1.6.2", "^1.11.0")).toBe(false);
|
|
1606
|
+
expect(satisfiesRange("2.0.0", "^1.11.0")).toBe(false);
|
|
1607
|
+
expect(satisfiesRange("0.5.3", "^0.5.0")).toBe(true);
|
|
1608
|
+
expect(satisfiesRange("0.6.0", "^0.5.0")).toBe(false);
|
|
1609
|
+
expect(satisfiesRange("5.9.5", "~5.9.0")).toBe(true);
|
|
1610
|
+
expect(satisfiesRange("5.10.0", "~5.9.0")).toBe(false);
|
|
1611
|
+
expect(satisfiesRange("1.2.3", "1.2.3")).toBe(true);
|
|
1612
|
+
expect(satisfiesRange("1.2.4", "1.2.3")).toBe(false);
|
|
1613
|
+
expect(satisfiesRange("1.2.3", "*")).toBeNull();
|
|
1614
|
+
expect(satisfiesRange("1.2.3", "latest")).toBeNull();
|
|
1615
|
+
expect(satisfiesRange("1.2.3", "git+https://...")).toBeNull();
|
|
1616
|
+
});
|
|
1617
|
+
});
|
|
1618
|
+
|
|
1619
|
+
describe("rule: package-manager-missing", () => {
|
|
1620
|
+
it("fires when packageManager is absent", () => {
|
|
1621
|
+
const fs = makeFs({
|
|
1622
|
+
"/site/package.json": JSON.stringify({ name: "x", license: "MIT" }),
|
|
1623
|
+
});
|
|
1624
|
+
const report = runAudit(SITE, fs);
|
|
1625
|
+
const r = report.rules.find((r) => r.rule === "package-manager-missing")!;
|
|
1626
|
+
expect(r.findings).toHaveLength(1);
|
|
1627
|
+
expect(r.findings[0].fix).toContain("bun@");
|
|
1628
|
+
});
|
|
1629
|
+
|
|
1630
|
+
it("does not fire when packageManager is set", () => {
|
|
1631
|
+
const fs = makeFs({
|
|
1632
|
+
"/site/package.json": JSON.stringify({
|
|
1633
|
+
name: "x",
|
|
1634
|
+
packageManager: "bun@1.3.5",
|
|
1635
|
+
}),
|
|
1636
|
+
});
|
|
1637
|
+
const report = runAudit(SITE, fs);
|
|
1638
|
+
const r = report.rules.find((r) => r.rule === "package-manager-missing")!;
|
|
1639
|
+
expect(r.findings).toEqual([]);
|
|
1640
|
+
});
|
|
1641
|
+
|
|
1642
|
+
it("--fix writes packageManager directly after license, preserving other keys", () => {
|
|
1643
|
+
const initial = JSON.stringify({
|
|
1644
|
+
name: "x",
|
|
1645
|
+
version: "1.0.0",
|
|
1646
|
+
license: "MIT",
|
|
1647
|
+
dependencies: { foo: "^1.0.0" },
|
|
1648
|
+
});
|
|
1649
|
+
const { fs, writer, store } = makeMutableFs({
|
|
1650
|
+
"/site/package.json": initial,
|
|
1651
|
+
});
|
|
1652
|
+
runAudit(SITE, fs, { writer });
|
|
1653
|
+
const updated = JSON.parse(store["/site/package.json"]);
|
|
1654
|
+
expect(updated.packageManager).toMatch(/^bun@/);
|
|
1655
|
+
const keys = Object.keys(updated);
|
|
1656
|
+
expect(keys.indexOf("packageManager")).toBe(keys.indexOf("license") + 1);
|
|
1657
|
+
expect(updated.dependencies).toEqual({ foo: "^1.0.0" });
|
|
1658
|
+
});
|
|
1659
|
+
|
|
1660
|
+
it("--fix appends packageManager when license is absent", () => {
|
|
1661
|
+
const { fs, writer, store } = makeMutableFs({
|
|
1662
|
+
"/site/package.json": JSON.stringify({ name: "x" }),
|
|
1663
|
+
});
|
|
1664
|
+
runAudit(SITE, fs, { writer });
|
|
1665
|
+
const updated = JSON.parse(store["/site/package.json"]);
|
|
1666
|
+
expect(updated.packageManager).toMatch(/^bun@/);
|
|
1667
|
+
});
|
|
1668
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { generateLockfileCheckYml } from "./lockfile-check-yml";
|
|
3
|
+
|
|
4
|
+
describe("generateLockfileCheckYml", () => {
|
|
5
|
+
it("emits a valid GitHub Actions workflow with the bun version pinned", () => {
|
|
6
|
+
const yaml = generateLockfileCheckYml("1.3.5");
|
|
7
|
+
expect(yaml).toContain("name: lockfile-check");
|
|
8
|
+
expect(yaml).toContain("on:");
|
|
9
|
+
expect(yaml).toContain("pull_request:");
|
|
10
|
+
expect(yaml).toContain("bun-version: 1.3.5");
|
|
11
|
+
expect(yaml).toContain("bun install --frozen-lockfile");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("strips an accidental `bun@` prefix from the version input", () => {
|
|
15
|
+
const yaml = generateLockfileCheckYml("bun@1.3.5");
|
|
16
|
+
expect(yaml).toContain("bun-version: 1.3.5");
|
|
17
|
+
expect(yaml).not.toMatch(/bun-version:\s*bun@/);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("scopes the trigger to package.json + bun.lock + the workflow itself", () => {
|
|
21
|
+
const yaml = generateLockfileCheckYml("1.3.5");
|
|
22
|
+
expect(yaml).toContain('"package.json"');
|
|
23
|
+
expect(yaml).toContain('"bun.lock"');
|
|
24
|
+
expect(yaml).toContain('".github/workflows/lockfile-check.yml"');
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates `.github/workflows/lockfile-check.yml`, the PR-time
|
|
3
|
+
* guardrail that fails any pull request which would have failed
|
|
4
|
+
* Cloudflare Workers Builds with `lockfile had changes, but lockfile
|
|
5
|
+
* is frozen`. This catches drift before it reaches main, instead of
|
|
6
|
+
* only after deploy attempts.
|
|
7
|
+
*
|
|
8
|
+
* Lives in the site repo (per-site, not centralised) because per
|
|
9
|
+
* D6.3 we are NOT scaffolding caller stubs that pull in
|
|
10
|
+
* `decocms/deco-start@vN` reusable workflows. The check is small
|
|
11
|
+
* enough that copy-paste-per-site is the right tradeoff.
|
|
12
|
+
*
|
|
13
|
+
* Bun version pinning matches the `packageManager` field in the
|
|
14
|
+
* scaffolded `package.json` (see `templates/package-json.ts`'s
|
|
15
|
+
* `CANONICAL_BUN_VERSION`). Kept in lockstep manually for now;
|
|
16
|
+
* future iterations may consolidate both into a shared constant.
|
|
17
|
+
*/
|
|
18
|
+
export function generateLockfileCheckYml(bunVersion: string): string {
|
|
19
|
+
const v = stripBunPrefix(bunVersion);
|
|
20
|
+
return `name: lockfile-check
|
|
21
|
+
|
|
22
|
+
# PR-time guardrail: re-runs the same install Cloudflare Workers Builds
|
|
23
|
+
# runs on deploy (\`bun install --frozen-lockfile\`). Fails the PR if
|
|
24
|
+
# bun.lock would have rejected the install — closes the loop so drift
|
|
25
|
+
# can never reach main, only to be caught by Workers Builds at deploy
|
|
26
|
+
# time.
|
|
27
|
+
#
|
|
28
|
+
# Pairs with:
|
|
29
|
+
# - "packageManager": "bun@${v}" in package.json
|
|
30
|
+
# - .gitignore bans on package-lock.json / yarn.lock / pnpm-lock.yaml
|
|
31
|
+
# - the deco-post-cleanup audit's lockfile-* rules
|
|
32
|
+
#
|
|
33
|
+
# Adjust \`bun-version\` here in lockstep with the package.json
|
|
34
|
+
# \`packageManager\` field.
|
|
35
|
+
|
|
36
|
+
on:
|
|
37
|
+
pull_request:
|
|
38
|
+
paths:
|
|
39
|
+
- "package.json"
|
|
40
|
+
- "bun.lock"
|
|
41
|
+
- ".github/workflows/lockfile-check.yml"
|
|
42
|
+
|
|
43
|
+
permissions:
|
|
44
|
+
contents: read
|
|
45
|
+
|
|
46
|
+
jobs:
|
|
47
|
+
frozen-install:
|
|
48
|
+
runs-on: ubuntu-latest
|
|
49
|
+
steps:
|
|
50
|
+
- uses: actions/checkout@v4
|
|
51
|
+
- uses: oven-sh/setup-bun@v2
|
|
52
|
+
with:
|
|
53
|
+
bun-version: ${v}
|
|
54
|
+
- name: bun install --frozen-lockfile
|
|
55
|
+
run: bun install --frozen-lockfile
|
|
56
|
+
`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Strip an accidental `bun@` prefix from the version string so the
|
|
61
|
+
* YAML's `bun-version` input (which expects a bare semver) doesn't
|
|
62
|
+
* receive `bun@1.3.5`.
|
|
63
|
+
*/
|
|
64
|
+
function stripBunPrefix(input: string): string {
|
|
65
|
+
return input.replace(/^bun@/, "");
|
|
66
|
+
}
|
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import { execSync } from "node:child_process";
|
|
2
2
|
import type { MigrationContext } from "../types";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Fleet-wide canonical bun version. Bumped here propagates to all newly
|
|
6
|
+
* migrated sites via the `packageManager` field AND the
|
|
7
|
+
* `lockfile-check.yml` workflow's `bun-version` input. See
|
|
8
|
+
* MIGRATION_TOOLING_PLAN.md for the bun-canonical decision.
|
|
9
|
+
*
|
|
10
|
+
* Exported because the lockfile-check workflow template reads it
|
|
11
|
+
* directly to keep both files in lockstep.
|
|
12
|
+
*/
|
|
13
|
+
export const CANONICAL_BUN_VERSION = "1.3.5";
|
|
14
|
+
|
|
4
15
|
/**
|
|
5
16
|
* Get the latest published version of an npm package.
|
|
6
17
|
* Falls back to the provided default if the lookup fails.
|
|
@@ -120,7 +131,7 @@ export function generatePackageJson(ctx: MigrationContext): string {
|
|
|
120
131
|
"format:check": 'prettier --check "src/**/*.{ts,tsx}"',
|
|
121
132
|
knip: "knip",
|
|
122
133
|
clean:
|
|
123
|
-
"rm -rf node_modules .cache dist .wrangler/state node_modules/.vite &&
|
|
134
|
+
"rm -rf node_modules .cache dist .wrangler/state node_modules/.vite && bun install",
|
|
124
135
|
"tailwind:lint":
|
|
125
136
|
"tsx scripts/tailwind-lint.ts",
|
|
126
137
|
"tailwind:fix":
|
|
@@ -128,6 +139,7 @@ export function generatePackageJson(ctx: MigrationContext): string {
|
|
|
128
139
|
},
|
|
129
140
|
author: "deco.cx",
|
|
130
141
|
license: "MIT",
|
|
142
|
+
packageManager: `bun@${CANONICAL_BUN_VERSION}`,
|
|
131
143
|
dependencies: {
|
|
132
144
|
"@decocms/apps": `^${appsVersion}`,
|
|
133
145
|
"@decocms/start": `^${startVersion}`,
|
|
@@ -78,9 +78,11 @@ function showHelp() {
|
|
|
78
78
|
--fix Auto-apply mechanical fixes for the safe rules
|
|
79
79
|
(dead-lib-shims, dead-runtime-shim, local-widgets-types,
|
|
80
80
|
vtex-shim-regression swap subset, obsolete-vite-plugins,
|
|
81
|
-
local-framework-duplicate auto-fixable subset
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
local-framework-duplicate auto-fixable subset,
|
|
82
|
+
lockfile-multiple, package-manager-missing).
|
|
83
|
+
Other rules — including htmx-residue, lockfile-missing,
|
|
84
|
+
lockfile-drift, and the warn-only entries of
|
|
85
|
+
local-framework-duplicate — stay detect-only.
|
|
84
86
|
--json Emit machine-readable JSON instead of pretty text
|
|
85
87
|
--strict Exit code 2 if any warning-severity findings exist
|
|
86
88
|
--help, -h Show this help
|
package/scripts/migrate.ts
CHANGED
|
@@ -246,11 +246,16 @@ function bootstrap(ctx: { sourceDir: string }) {
|
|
|
246
246
|
return true;
|
|
247
247
|
};
|
|
248
248
|
|
|
249
|
-
//
|
|
250
|
-
|
|
249
|
+
// bun is the fleet-wide canonical package manager for decocms storefronts.
|
|
250
|
+
// We hardcode it here (instead of sniffing process.env.npm_execpath) so a
|
|
251
|
+
// freshly-migrated site always commits a bun.lock and never accidentally
|
|
252
|
+
// ships a package-lock.json that drifts vs bun.lock under CF Workers Builds.
|
|
253
|
+
// See MIGRATION_TOOLING_PLAN.md and the package-json template for the
|
|
254
|
+
// matching `packageManager` field that pins the version.
|
|
255
|
+
const pm = "bun";
|
|
251
256
|
if (!run(`${pm} install`, "Install dependencies", true)) return;
|
|
252
|
-
run("
|
|
253
|
-
run("
|
|
257
|
+
run("bunx tsx node_modules/@decocms/start/scripts/generate-blocks.ts", "Generate CMS blocks");
|
|
258
|
+
run("bunx tsr generate", "Generate TanStack routes");
|
|
254
259
|
|
|
255
260
|
if (failures > 0) {
|
|
256
261
|
console.log(
|
package/src/sdk/workerEntry.ts
CHANGED
|
@@ -45,6 +45,17 @@ import { getAppMiddleware } from "./setupApps";
|
|
|
45
45
|
import { cleanPathForCacheKey } from "./urlUtils";
|
|
46
46
|
import { type Device, isMobileUA } from "./useDevice";
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Build-time identifier injected by `decoVitePlugin()` (see
|
|
50
|
+
* `src/vite/plugin.js`). Falls back to `undefined` if the consuming site
|
|
51
|
+
* isn't using the plugin or the symbol wasn't `define`d at bundle time.
|
|
52
|
+
*
|
|
53
|
+
* The runtime `env.BUILD_HASH` (when explicitly set, e.g. via
|
|
54
|
+
* `wrangler deploy --var BUILD_HASH:foo`) takes precedence — see
|
|
55
|
+
* `getBuildHash()` below.
|
|
56
|
+
*/
|
|
57
|
+
declare const __DECO_BUILD_HASH__: string | undefined;
|
|
58
|
+
|
|
48
59
|
/**
|
|
49
60
|
* Append Link preload headers for CSS and fonts so the browser starts
|
|
50
61
|
* fetching them before parsing HTML. Only applied to HTML responses.
|
|
@@ -673,6 +684,23 @@ export function createDecoWorkerEntry(
|
|
|
673
684
|
return parts.join("|");
|
|
674
685
|
}
|
|
675
686
|
|
|
687
|
+
/**
|
|
688
|
+
* Resolve the per-deploy cache-key version with this priority:
|
|
689
|
+
* 1. `env[cacheVersionEnv]` — explicit override (e.g. `wrangler
|
|
690
|
+
* deploy --var BUILD_HASH:foo`). Wins so callers can always
|
|
691
|
+
* force a specific value.
|
|
692
|
+
* 2. `__DECO_BUILD_HASH__` — build-time constant injected by
|
|
693
|
+
* `decoVitePlugin()` from WORKERS_CI_COMMIT_SHA / git rev-parse.
|
|
694
|
+
* This is the production path on Cloudflare Workers Builds.
|
|
695
|
+
* 3. Empty string — versioning disabled (legacy pre-plugin sites).
|
|
696
|
+
*/
|
|
697
|
+
function getBuildHash(env: Record<string, unknown>): string {
|
|
698
|
+
if (cacheVersionEnv === false) return "";
|
|
699
|
+
const fromEnv = (env[cacheVersionEnv] as string) || "";
|
|
700
|
+
if (fromEnv) return fromEnv;
|
|
701
|
+
return typeof __DECO_BUILD_HASH__ !== "undefined" ? __DECO_BUILD_HASH__ : "";
|
|
702
|
+
}
|
|
703
|
+
|
|
676
704
|
function buildCacheKey(
|
|
677
705
|
request: Request,
|
|
678
706
|
env: Record<string, unknown>,
|
|
@@ -685,11 +713,9 @@ export function createDecoWorkerEntry(
|
|
|
685
713
|
url.search = cleanUrl.search;
|
|
686
714
|
}
|
|
687
715
|
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
url.searchParams.set("__v", version);
|
|
692
|
-
}
|
|
716
|
+
const version = getBuildHash(env);
|
|
717
|
+
if (version) {
|
|
718
|
+
url.searchParams.set("__v", version);
|
|
693
719
|
}
|
|
694
720
|
|
|
695
721
|
// Include CF geo data in cache key so location matcher results don't leak
|
|
@@ -791,10 +817,8 @@ export function createDecoWorkerEntry(
|
|
|
791
817
|
for (const seg of segments) {
|
|
792
818
|
for (const cc of geoKeys) {
|
|
793
819
|
const url = new URL(p, baseUrl);
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
if (version) url.searchParams.set("__v", version);
|
|
797
|
-
}
|
|
820
|
+
const purgeVersion = getBuildHash(env);
|
|
821
|
+
if (purgeVersion) url.searchParams.set("__v", purgeVersion);
|
|
798
822
|
url.searchParams.set("__seg", hashSegment(seg));
|
|
799
823
|
if (cc) url.searchParams.set("__cf_geo", cc);
|
|
800
824
|
const key = new Request(url.toString(), { method: "GET" });
|
|
@@ -816,10 +840,8 @@ export function createDecoWorkerEntry(
|
|
|
816
840
|
for (const device of devices) {
|
|
817
841
|
for (const cc of geoKeys) {
|
|
818
842
|
const url = new URL(p, baseUrl);
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
if (version) url.searchParams.set("__v", version);
|
|
822
|
-
}
|
|
843
|
+
const purgeVersion = getBuildHash(env);
|
|
844
|
+
if (purgeVersion) url.searchParams.set("__v", purgeVersion);
|
|
823
845
|
if (device) url.searchParams.set("__cf_device", device);
|
|
824
846
|
if (cc) url.searchParams.set("__cf_geo", cc);
|
|
825
847
|
const key = new Request(url.toString(), { method: "GET" });
|
|
@@ -1190,10 +1212,8 @@ export function createDecoWorkerEntry(
|
|
|
1190
1212
|
// different regions or channels get separate cache entries.
|
|
1191
1213
|
const cacheKeyUrl = new URL(request.url);
|
|
1192
1214
|
cacheKeyUrl.searchParams.set("__body", bodyHash);
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
if (version) cacheKeyUrl.searchParams.set("__v", version);
|
|
1196
|
-
}
|
|
1215
|
+
const sfnVersion = getBuildHash(env);
|
|
1216
|
+
if (sfnVersion) cacheKeyUrl.searchParams.set("__v", sfnVersion);
|
|
1197
1217
|
if (sfnSegment) {
|
|
1198
1218
|
cacheKeyUrl.searchParams.set("__seg", hashSegment(sfnSegment));
|
|
1199
1219
|
} else if (deviceSpecificKeys) {
|
|
@@ -1409,10 +1429,8 @@ export function createDecoWorkerEntry(
|
|
|
1409
1429
|
out.headers.set("X-Cache", xCache);
|
|
1410
1430
|
out.headers.set("X-Cache-Profile", profile);
|
|
1411
1431
|
if (segment) out.headers.set("X-Cache-Segment", hashSegment(segment));
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
if (v) out.headers.set("X-Cache-Version", v);
|
|
1415
|
-
}
|
|
1432
|
+
const headerVersion = getBuildHash(env);
|
|
1433
|
+
if (headerVersion) out.headers.set("X-Cache-Version", headerVersion);
|
|
1416
1434
|
if (extra) for (const [k, v] of Object.entries(extra)) out.headers.set(k, v);
|
|
1417
1435
|
appendResourceHints(out);
|
|
1418
1436
|
return out;
|
package/src/vite/plugin.js
CHANGED
|
@@ -31,8 +31,49 @@
|
|
|
31
31
|
* export default defineConfig({ plugins: [decoVitePlugin(), ...] });
|
|
32
32
|
* ```
|
|
33
33
|
*/
|
|
34
|
+
import { execFileSync } from "node:child_process";
|
|
34
35
|
import { existsSync, readFileSync } from "node:fs";
|
|
35
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Resolve a per-build identifier for cache-key versioning.
|
|
39
|
+
*
|
|
40
|
+
* The returned string is injected into the worker bundle as the
|
|
41
|
+
* `__DECO_BUILD_HASH__` global via Vite `define`. `createDecoWorkerEntry`
|
|
42
|
+
* appends it (or `env.BUILD_HASH` if explicitly set) as `__v=<hash>` on
|
|
43
|
+
* every Cache API key, so each new deploy gets its own cache namespace
|
|
44
|
+
* — old edge-cached HTML referencing dead asset filenames stops being
|
|
45
|
+
* served the moment the new worker is live.
|
|
46
|
+
*
|
|
47
|
+
* Resolution order:
|
|
48
|
+
* 1. WORKERS_CI_COMMIT_SHA — Cloudflare Workers Builds default env var
|
|
49
|
+
* (the production deploy path-of-record). Sliced to 12 chars.
|
|
50
|
+
* 2. `git rev-parse --short=12 HEAD` — local `wrangler deploy` from a
|
|
51
|
+
* developer laptop. Try/catch so missing git or shallow clones don't
|
|
52
|
+
* fail the build.
|
|
53
|
+
* 3. `Date.now().toString(36)` — last-resort fallback so the cache-bust
|
|
54
|
+
* invariant never silently regresses to "always the same key".
|
|
55
|
+
*
|
|
56
|
+
* For dev (`command !== "build"`), the value is the literal `"dev"`.
|
|
57
|
+
*
|
|
58
|
+
* @returns {string}
|
|
59
|
+
*/
|
|
60
|
+
function resolveBuildHash() {
|
|
61
|
+
const ciSha = process.env.WORKERS_CI_COMMIT_SHA;
|
|
62
|
+
if (ciSha?.trim()) return ciSha.trim().slice(0, 12);
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const sha = execFileSync("git", ["rev-parse", "--short=12", "HEAD"], {
|
|
66
|
+
encoding: "utf-8",
|
|
67
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
68
|
+
}).trim();
|
|
69
|
+
if (sha) return sha;
|
|
70
|
+
} catch {
|
|
71
|
+
// git absent, not a repo, or shallow clone w/o history — fall through.
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return Date.now().toString(36);
|
|
75
|
+
}
|
|
76
|
+
|
|
36
77
|
// Bare-specifier stubs resolved by ID before Vite touches them.
|
|
37
78
|
/** @type {Record<string, string>} */
|
|
38
79
|
const CLIENT_STUBS = {
|
|
@@ -227,6 +268,20 @@ export function decoVitePlugin() {
|
|
|
227
268
|
};
|
|
228
269
|
}
|
|
229
270
|
|
|
271
|
+
// Inject a per-build identifier as `__DECO_BUILD_HASH__` so
|
|
272
|
+
// createDecoWorkerEntry can fall back to it when env.BUILD_HASH is
|
|
273
|
+
// unset (the default on Cloudflare Workers Builds, where there's
|
|
274
|
+
// no GH-Actions step injecting --var BUILD_HASH).
|
|
275
|
+
//
|
|
276
|
+
// Dev gets the literal "dev" so SSR doesn't crash on an undefined
|
|
277
|
+
// identifier; prod gets WORKERS_CI_COMMIT_SHA → git rev-parse →
|
|
278
|
+
// time-based fallback (see resolveBuildHash above).
|
|
279
|
+
const buildHash = command === "build" ? resolveBuildHash() : "dev";
|
|
280
|
+
cfg.define = {
|
|
281
|
+
...cfg.define,
|
|
282
|
+
__DECO_BUILD_HASH__: JSON.stringify(buildHash),
|
|
283
|
+
};
|
|
284
|
+
|
|
230
285
|
// Only split chunks for production builds — dev uses unbundled ESM.
|
|
231
286
|
if (command !== "build") return cfg;
|
|
232
287
|
return {
|
package/src/vite/plugin.test.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { decoVitePlugin } from "./plugin.js";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -52,3 +52,62 @@ describe("decoVitePlugin client stubs (regression guard)", () => {
|
|
|
52
52
|
expect(id).toBeUndefined();
|
|
53
53
|
});
|
|
54
54
|
});
|
|
55
|
+
|
|
56
|
+
describe("decoVitePlugin __DECO_BUILD_HASH__ injection", () => {
|
|
57
|
+
let originalEnv;
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
originalEnv = { ...process.env };
|
|
61
|
+
delete process.env.WORKERS_CI_COMMIT_SHA;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
process.env = originalEnv;
|
|
66
|
+
vi.restoreAllMocks();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
function callConfig(command) {
|
|
70
|
+
const p = getPlugin();
|
|
71
|
+
return p.config({}, { command });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
it("injects the literal 'dev' for non-build commands", () => {
|
|
75
|
+
const cfg = callConfig("serve");
|
|
76
|
+
expect(cfg.define.__DECO_BUILD_HASH__).toBe(JSON.stringify("dev"));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("uses WORKERS_CI_COMMIT_SHA (sliced to 12 chars) when set on a build", () => {
|
|
80
|
+
process.env.WORKERS_CI_COMMIT_SHA = "abcdef1234567890fedcba";
|
|
81
|
+
const cfg = callConfig("build");
|
|
82
|
+
expect(cfg.define.__DECO_BUILD_HASH__).toBe(JSON.stringify("abcdef123456"));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("falls back to git rev-parse when WORKERS_CI_COMMIT_SHA is unset", async () => {
|
|
86
|
+
// The plugin module imports execFileSync at top-level, so we can't easily
|
|
87
|
+
// mock it after the fact. Instead, exercise the real git binary against
|
|
88
|
+
// this repo (CI runs in the repo working tree). Assert the value is a
|
|
89
|
+
// 12-char lowercase hex SHA — that proves git was consulted, not that
|
|
90
|
+
// the time-based fallback was hit.
|
|
91
|
+
const cfg = callConfig("build");
|
|
92
|
+
const value = JSON.parse(cfg.define.__DECO_BUILD_HASH__);
|
|
93
|
+
// Either git produced a SHA (CI / dev machine inside a repo) or the
|
|
94
|
+
// time-based fallback ran. Both are acceptable; we just assert non-empty
|
|
95
|
+
// and length sanity.
|
|
96
|
+
expect(typeof value).toBe("string");
|
|
97
|
+
expect(value.length).toBeGreaterThan(0);
|
|
98
|
+
// Time-based fallback produces base36 characters; git short SHAs are
|
|
99
|
+
// 12 hex chars. Both fit in this superset regex.
|
|
100
|
+
expect(value).toMatch(/^[0-9a-z]+$/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("preserves allowedHosts behaviour (regression: define is additive, not replacing)", () => {
|
|
104
|
+
process.env.DECO_SITE_NAME = "test-site";
|
|
105
|
+
try {
|
|
106
|
+
const cfg = callConfig("serve");
|
|
107
|
+
expect(cfg.server?.allowedHosts).toContain(".deco.studio");
|
|
108
|
+
expect(cfg.define.__DECO_BUILD_HASH__).toBeDefined();
|
|
109
|
+
} finally {
|
|
110
|
+
delete process.env.DECO_SITE_NAME;
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
});
|