From 173d6177d1c6e2c0c81180efa642d50ea5e1ac2e Mon Sep 17 00:00:00 2001 From: Beast Date: Thu, 27 Aug 2026 17:52:26 +0800 Subject: [PATCH 1/2] fix: bun lockfile detection --- actions/dependency-cooldown/dist/index.mjs | 41 ++++++++----- docs/dependency-cooldown.md | 7 ++- packages/dependency-cooldown/src/scan.ts | 57 +++++++++++++------ .../test/end-to-end.test.ts | 29 ++++++++++ .../dependency-cooldown/test/scan.test.ts | 51 +++++++++++++++++ 5 files changed, 152 insertions(+), 33 deletions(-) create mode 100644 packages/dependency-cooldown/test/scan.test.ts diff --git a/actions/dependency-cooldown/dist/index.mjs b/actions/dependency-cooldown/dist/index.mjs index 8917b41..395ccae 100644 --- a/actions/dependency-cooldown/dist/index.mjs +++ b/actions/dependency-cooldown/dist/index.mjs @@ -9550,7 +9550,7 @@ async function resolvePublishDates(dependencies, http) { // src/scan.ts import { execFile } from "node:child_process"; import { readFile } from "node:fs/promises"; -import { join } from "node:path"; +import { basename as basename3, dirname as dirname2, join } from "node:path"; import { promisify } from "node:util"; // node_modules/tinyglobby/dist/index.mjs @@ -12110,18 +12110,31 @@ function collectDetailed(lockfilePath, content) { // src/scan.ts var run = promisify(execFile); var IGNORED_DIRECTORY_NAMES = [".git", "node_modules", "target", ".dart_tool"]; +function omitBunLockbCoveredByTextLock(lockfiles) { + const present = new Set(lockfiles); + return lockfiles.filter((path) => { + if (basename3(path) !== "bun.lockb") { + return true; + } + const directory = dirname2(path); + const textLock = directory === "." ? "bun.lock" : `${directory}/bun.lock`; + return !present.has(textLock); + }); +} function discoverLockfiles(repoDir) { - return globSync( - LOCKFILE_FILENAMES.map((filename) => `**/${filename}`), - { - cwd: repoDir, - ignore: IGNORED_DIRECTORY_NAMES.map((name) => `**/${name}/**`), - // Lockfiles under dot-directories still count; only the names above are - // skipped. - dot: true, - onlyFiles: true - } - ).sort(); + return omitBunLockbCoveredByTextLock( + globSync( + LOCKFILE_FILENAMES.map((filename) => `**/${filename}`), + { + cwd: repoDir, + ignore: IGNORED_DIRECTORY_NAMES.map((name) => `**/${name}/**`), + // Lockfiles under dot-directories still count; only the names above are + // skipped. + dot: true, + onlyFiles: true + } + ).sort() + ); } async function scanWorkingTree(repoDir, lockfiles) { const result = { dependencies: [], uncheckable: [] }; @@ -12141,7 +12154,9 @@ async function listLockfilesAtRef(repoDir, ref) { cwd: repoDir, maxBuffer: 64 * 1024 * 1024 }); - return stdout.split("\n").filter((path) => path.length > 0).filter((path) => LOCKFILE_FILENAMES.includes(path.split("/").pop())).filter((path) => !isIgnoredPath(path)).sort(); + return omitBunLockbCoveredByTextLock( + stdout.split("\n").filter((path) => path.length > 0).filter((path) => LOCKFILE_FILENAMES.includes(path.split("/").pop())).filter((path) => !isIgnoredPath(path)).sort() + ); } async function readFileAtRef(repoDir, ref, lockfile) { try { diff --git a/docs/dependency-cooldown.md b/docs/dependency-cooldown.md index bb7a28d..b5df844 100644 --- a/docs/dependency-cooldown.md +++ b/docs/dependency-cooldown.md @@ -128,8 +128,11 @@ can be established. They are listed in the job summary under "could not be age-checked" instead of being silently ignored — a dependency the tool skipped without saying so would be a hole in the policy. Review those by hand. -Bun's binary `bun.lockb` cannot be inspected at all and fails the run. Commit -the text lockfile instead: +Bun's binary `bun.lockb` cannot be inspected. A repository that only has +`bun.lockb` fails the run. If a text `bun.lock` is present in the same +directory, `bun.lockb` is ignored — that pairing is how some hosts (Cloudflare +Pages among them) detect Bun while the real resolved graph lives in `bun.lock`. +Commit the text lockfile with: ```bash bun install --save-text-lockfile diff --git a/packages/dependency-cooldown/src/scan.ts b/packages/dependency-cooldown/src/scan.ts index 45dd2f6..332e827 100644 --- a/packages/dependency-cooldown/src/scan.ts +++ b/packages/dependency-cooldown/src/scan.ts @@ -1,6 +1,6 @@ import { execFile } from "node:child_process"; import { readFile } from "node:fs/promises"; -import { join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { promisify } from "node:util"; import { globSync } from "tinyglobby"; @@ -21,18 +21,37 @@ const run = promisify(execFile); */ export const IGNORED_DIRECTORY_NAMES = [".git", "node_modules", "target", ".dart_tool"]; +/** + * bun.lockb is listed so a binary-only repository fails closed. When a text + * bun.lock sits in the same directory (Bun 1.2+ plus a Cloudflare Pages marker + * file, for example), the text lockfile is the inspectable source of truth. + */ +function omitBunLockbCoveredByTextLock(lockfiles: string[]): string[] { + const present = new Set(lockfiles); + return lockfiles.filter((path) => { + if (basename(path) !== "bun.lockb") { + return true; + } + const directory = dirname(path); + const textLock = directory === "." ? "bun.lock" : `${directory}/bun.lock`; + return !present.has(textLock); + }); +} + export function discoverLockfiles(repoDir: string): string[] { - return globSync( - LOCKFILE_FILENAMES.map((filename) => `**/${filename}`), - { - cwd: repoDir, - ignore: IGNORED_DIRECTORY_NAMES.map((name) => `**/${name}/**`), - // Lockfiles under dot-directories still count; only the names above are - // skipped. - dot: true, - onlyFiles: true, - }, - ).sort(); + return omitBunLockbCoveredByTextLock( + globSync( + LOCKFILE_FILENAMES.map((filename) => `**/${filename}`), + { + cwd: repoDir, + ignore: IGNORED_DIRECTORY_NAMES.map((name) => `**/${name}/**`), + // Lockfiles under dot-directories still count; only the names above are + // skipped. + dot: true, + onlyFiles: true, + }, + ).sort(), + ); } export interface ScanResult { @@ -64,12 +83,14 @@ export async function listLockfilesAtRef(repoDir: string, ref: string): Promise< cwd: repoDir, maxBuffer: 64 * 1024 * 1024, }); - return stdout - .split("\n") - .filter((path) => path.length > 0) - .filter((path) => LOCKFILE_FILENAMES.includes(path.split("/").pop() as string)) - .filter((path) => !isIgnoredPath(path)) - .sort(); + return omitBunLockbCoveredByTextLock( + stdout + .split("\n") + .filter((path) => path.length > 0) + .filter((path) => LOCKFILE_FILENAMES.includes(path.split("/").pop() as string)) + .filter((path) => !isIgnoredPath(path)) + .sort(), + ); } async function readFileAtRef( diff --git a/packages/dependency-cooldown/test/end-to-end.test.ts b/packages/dependency-cooldown/test/end-to-end.test.ts index 6ec7a65..719f80b 100644 --- a/packages/dependency-cooldown/test/end-to-end.test.ts +++ b/packages/dependency-cooldown/test/end-to-end.test.ts @@ -247,6 +247,35 @@ describe("check mode", () => { expect(summary()).toContain("Checked 6 newly introduced dependency version(s)"); }); + it("checks bun.lock when an empty bun.lockb sits beside it", async () => { + write("bun.lock", readFixture("bun/base/bun.lock")); + write("bun.lockb", ""); + const baseSha = commit("base"); + write("bun.lock", readFixture("bun/head/bun.lock")); + commit("bump bun"); + + const code = await run(["--mode=check", `--base-ref=${baseSha}`, `--repo-dir=${repoDir}`], { + http: httpStub({ "tslib@2.8.1": true }), + now: NOW, + }); + + expect(code).toBe(1); + expect(summary()).toContain("tslib"); + expect(summary()).toContain("bun.lock"); + }); + + it("still refuses a repository that only has bun.lockb", async () => { + write("bun.lockb", "#!\u0000binary\u0000"); + const baseSha = commit("base"); + + await expect( + run(["--mode=check", `--base-ref=${baseSha}`, `--repo-dir=${repoDir}`], { + http: httpStub({}), + now: NOW, + }), + ).rejects.toThrow(/bun\.lockb/); + }); + it("refuses to run in a repository with no lockfile", async () => { write("README.md", "nothing to lock\n"); const baseSha = commit("base"); diff --git a/packages/dependency-cooldown/test/scan.test.ts b/packages/dependency-cooldown/test/scan.test.ts new file mode 100644 index 0000000..800b464 --- /dev/null +++ b/packages/dependency-cooldown/test/scan.test.ts @@ -0,0 +1,51 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { discoverLockfiles } from "../src/scan.js"; + +let repoDir: string; + +beforeEach(() => { + repoDir = mkdtempSync(join(tmpdir(), "cooldown-scan-")); +}); + +afterEach(() => { + rmSync(repoDir, { recursive: true, force: true }); +}); + +function write(relativePath: string, contents: string): void { + const absolute = join(repoDir, relativePath); + mkdirSync(dirname(absolute), { recursive: true }); + writeFileSync(absolute, contents); +} + +describe("discoverLockfiles", () => { + it("omits bun.lockb when a text bun.lock sits beside it", () => { + write("bun.lock", '{ "packages": {} }\n'); + write("bun.lockb", ""); + + expect(discoverLockfiles(repoDir)).toEqual(["bun.lock"]); + }); + + it("still discovers bun.lockb when no text lockfile is present", () => { + write("bun.lockb", ""); + + expect(discoverLockfiles(repoDir)).toEqual(["bun.lockb"]); + }); + + it("does not treat a bun.lock in another directory as covering bun.lockb", () => { + write("bun.lock", '{ "packages": {} }\n'); + write("apps/web/bun.lockb", ""); + + expect(discoverLockfiles(repoDir)).toEqual(["apps/web/bun.lockb", "bun.lock"]); + }); + + it("omits bun.lockb next to bun.lock in a nested directory", () => { + write("apps/web/bun.lock", '{ "packages": {} }\n'); + write("apps/web/bun.lockb", ""); + + expect(discoverLockfiles(repoDir)).toEqual(["apps/web/bun.lock"]); + }); +}); From b02325e000935b474ca765efd31de3a96d844a12 Mon Sep 17 00:00:00 2001 From: Beast Date: Thu, 27 Aug 2026 21:44:34 +0800 Subject: [PATCH 2/2] fix: comparing bun lockb --- actions/dependency-cooldown/dist/index.mjs | 90 +++++++++--- docs/dependency-cooldown.md | 12 +- packages/dependency-cooldown/src/scan.ts | 132 ++++++++++++++---- .../test/end-to-end.test.ts | 33 ++++- .../dependency-cooldown/test/scan.test.ts | 108 +++++++++++++- 5 files changed, 316 insertions(+), 59 deletions(-) diff --git a/actions/dependency-cooldown/dist/index.mjs b/actions/dependency-cooldown/dist/index.mjs index 395ccae..6af93f3 100644 --- a/actions/dependency-cooldown/dist/index.mjs +++ b/actions/dependency-cooldown/dist/index.mjs @@ -9549,6 +9549,7 @@ async function resolvePublishDates(dependencies, http) { // src/scan.ts import { execFile } from "node:child_process"; +import { readFileSync as readFileSync2 } from "node:fs"; import { readFile } from "node:fs/promises"; import { basename as basename3, dirname as dirname2, join } from "node:path"; import { promisify } from "node:util"; @@ -12110,31 +12111,56 @@ function collectDetailed(lockfilePath, content) { // src/scan.ts var run = promisify(execFile); var IGNORED_DIRECTORY_NAMES = [".git", "node_modules", "target", ".dart_tool"]; -function omitBunLockbCoveredByTextLock(lockfiles) { +var CLOUDFLARE_PAGES_BUN_LOCKB_MARKER = "# THIS IS JUST DUMMY FILE FOR HELPING CLOUDFLARE DETECT BUN PACKAGE MANAGER (https://community.cloudflare.com/t/bun-not-detected-as-tool-when-using-new-bun-lock-instead-of-bun-lockb/779835)"; +var CLOUDFLARE_PAGES_BUN_LOCKB_MARKER_BYTES = Buffer.from(CLOUDFLARE_PAGES_BUN_LOCKB_MARKER); +function siblingBunLock(lockbPath) { + const directory = dirname2(lockbPath); + return directory === "." ? "bun.lock" : `${directory}/bun.lock`; +} +function isCloudflarePagesDummyBunLockb(content) { + return content.equals(CLOUDFLARE_PAGES_BUN_LOCKB_MARKER_BYTES); +} +function isDummyBunLockbOnDisk(repoDir, lockbPath) { + return isCloudflarePagesDummyBunLockb(readFileSync2(join(repoDir, lockbPath))); +} +function omitDummyBunLockbCoveredByTextLock(lockfiles, dummyLockbPaths) { const present = new Set(lockfiles); return lockfiles.filter((path) => { if (basename3(path) !== "bun.lockb") { return true; } - const directory = dirname2(path); - const textLock = directory === "." ? "bun.lock" : `${directory}/bun.lock`; - return !present.has(textLock); + if (!present.has(siblingBunLock(path))) { + return true; + } + return !dummyLockbPaths.has(path); }); } +function dummyBunLockbPathsOnDisk(repoDir, lockfiles) { + const present = new Set(lockfiles); + const dummies = /* @__PURE__ */ new Set(); + for (const path of lockfiles) { + if (basename3(path) !== "bun.lockb" || !present.has(siblingBunLock(path))) { + continue; + } + if (isDummyBunLockbOnDisk(repoDir, path)) { + dummies.add(path); + } + } + return dummies; +} function discoverLockfiles(repoDir) { - return omitBunLockbCoveredByTextLock( - globSync( - LOCKFILE_FILENAMES.map((filename) => `**/${filename}`), - { - cwd: repoDir, - ignore: IGNORED_DIRECTORY_NAMES.map((name) => `**/${name}/**`), - // Lockfiles under dot-directories still count; only the names above are - // skipped. - dot: true, - onlyFiles: true - } - ).sort() - ); + const lockfiles = globSync( + LOCKFILE_FILENAMES.map((filename) => `**/${filename}`), + { + cwd: repoDir, + ignore: IGNORED_DIRECTORY_NAMES.map((name) => `**/${name}/**`), + // Lockfiles under dot-directories still count; only the names above are + // skipped. + dot: true, + onlyFiles: true + } + ).sort(); + return omitDummyBunLockbCoveredByTextLock(lockfiles, dummyBunLockbPathsOnDisk(repoDir, lockfiles)); } async function scanWorkingTree(repoDir, lockfiles) { const result = { dependencies: [], uncheckable: [] }; @@ -12149,13 +12175,39 @@ async function scanWorkingTree(repoDir, lockfiles) { function isIgnoredPath(path) { return path.split("/").some((segment) => IGNORED_DIRECTORY_NAMES.includes(segment)); } +async function isDummyBunLockbAtRef(repoDir, ref, lockbPath) { + const { stdout } = await run("git", ["cat-file", "blob", `${ref}:${lockbPath}`], { + cwd: repoDir, + encoding: "buffer", + maxBuffer: 128 * 1024 * 1024 + }); + if (!Buffer.isBuffer(stdout)) { + throw new Error(`git cat-file blob ${ref}:${lockbPath} did not return a buffer`); + } + return isCloudflarePagesDummyBunLockb(stdout); +} +async function dummyBunLockbPathsAtRef(repoDir, ref, lockfiles) { + const present = new Set(lockfiles); + const dummies = /* @__PURE__ */ new Set(); + for (const path of lockfiles) { + if (basename3(path) !== "bun.lockb" || !present.has(siblingBunLock(path))) { + continue; + } + if (await isDummyBunLockbAtRef(repoDir, ref, path)) { + dummies.add(path); + } + } + return dummies; +} async function listLockfilesAtRef(repoDir, ref) { const { stdout } = await run("git", ["ls-tree", "-r", "--name-only", ref], { cwd: repoDir, maxBuffer: 64 * 1024 * 1024 }); - return omitBunLockbCoveredByTextLock( - stdout.split("\n").filter((path) => path.length > 0).filter((path) => LOCKFILE_FILENAMES.includes(path.split("/").pop())).filter((path) => !isIgnoredPath(path)).sort() + const lockfiles = stdout.split("\n").filter((path) => path.length > 0).filter((path) => LOCKFILE_FILENAMES.includes(path.split("/").pop())).filter((path) => !isIgnoredPath(path)).sort(); + return omitDummyBunLockbCoveredByTextLock( + lockfiles, + await dummyBunLockbPathsAtRef(repoDir, ref, lockfiles) ); } async function readFileAtRef(repoDir, ref, lockfile) { diff --git a/docs/dependency-cooldown.md b/docs/dependency-cooldown.md index b5df844..1e60e33 100644 --- a/docs/dependency-cooldown.md +++ b/docs/dependency-cooldown.md @@ -129,10 +129,14 @@ age-checked" instead of being silently ignored — a dependency the tool skipped without saying so would be a hole in the policy. Review those by hand. Bun's binary `bun.lockb` cannot be inspected. A repository that only has -`bun.lockb` fails the run. If a text `bun.lock` is present in the same -directory, `bun.lockb` is ignored — that pairing is how some hosts (Cloudflare -Pages among them) detect Bun while the real resolved graph lives in `bun.lock`. -Commit the text lockfile with: +`bun.lockb` fails the run. A sibling `bun.lockb` is ignored only when its +bytes are identical to the Cloudflare Pages dummy used by Quantus checkouts +(`# THIS IS JUST DUMMY FILE...`). Length is not enough: a different file of +the same size is rejected. Package changes are still taken from `bun.lock`; +the dummy cannot encode a graph. A real or unknown `bun.lockb` next to +`bun.lock` is rejected, because the two files can diverge (Bun 1.1 still +installs from the binary). Bun's own migration is to generate `bun.lock` and +then delete `bun.lockb`. Commit the text lockfile with: ```bash bun install --save-text-lockfile diff --git a/packages/dependency-cooldown/src/scan.ts b/packages/dependency-cooldown/src/scan.ts index 332e827..329410f 100644 --- a/packages/dependency-cooldown/src/scan.ts +++ b/packages/dependency-cooldown/src/scan.ts @@ -1,4 +1,5 @@ import { execFile } from "node:child_process"; +import { readFileSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; import { promisify } from "node:util"; @@ -22,36 +23,77 @@ const run = promisify(execFile); export const IGNORED_DIRECTORY_NAMES = [".git", "node_modules", "target", ".dart_tool"]; /** - * bun.lockb is listed so a binary-only repository fails closed. When a text - * bun.lock sits in the same directory (Bun 1.2+ plus a Cloudflare Pages marker - * file, for example), the text lockfile is the inspectable source of truth. + * Exact dummy bun.lockb committed by Quantus Cloudflare Pages checkouts (docs, + * explorer, website). Identity is this whole buffer, not its length: a + * different 189-byte file is a real lockfile and fails closed. */ -function omitBunLockbCoveredByTextLock(lockfiles: string[]): string[] { +export const CLOUDFLARE_PAGES_BUN_LOCKB_MARKER = + "# THIS IS JUST DUMMY FILE FOR HELPING CLOUDFLARE DETECT BUN PACKAGE MANAGER (https://community.cloudflare.com/t/bun-not-detected-as-tool-when-using-new-bun-lock-instead-of-bun-lockb/779835)"; + +const CLOUDFLARE_PAGES_BUN_LOCKB_MARKER_BYTES = Buffer.from(CLOUDFLARE_PAGES_BUN_LOCKB_MARKER); + +function siblingBunLock(lockbPath: string): string { + const directory = dirname(lockbPath); + return directory === "." ? "bun.lock" : `${directory}/bun.lock`; +} + +function isCloudflarePagesDummyBunLockb(content: Buffer): boolean { + return content.equals(CLOUDFLARE_PAGES_BUN_LOCKB_MARKER_BYTES); +} + +function isDummyBunLockbOnDisk(repoDir: string, lockbPath: string): boolean { + return isCloudflarePagesDummyBunLockb(readFileSync(join(repoDir, lockbPath))); +} + +/** + * bun.lockb is listed so a binary-only repository fails closed. A sibling + * bun.lock is the inspectable source of truth only when bun.lockb is the + * known Cloudflare Pages dummy; a real or unknown paired bun.lockb is kept + * so the parser rejects it instead of skipping an unreadable lockfile. + */ +function omitDummyBunLockbCoveredByTextLock( + lockfiles: string[], + dummyLockbPaths: ReadonlySet, +): string[] { const present = new Set(lockfiles); return lockfiles.filter((path) => { if (basename(path) !== "bun.lockb") { return true; } - const directory = dirname(path); - const textLock = directory === "." ? "bun.lock" : `${directory}/bun.lock`; - return !present.has(textLock); + if (!present.has(siblingBunLock(path))) { + return true; + } + return !dummyLockbPaths.has(path); }); } +function dummyBunLockbPathsOnDisk(repoDir: string, lockfiles: string[]): Set { + const present = new Set(lockfiles); + const dummies = new Set(); + for (const path of lockfiles) { + if (basename(path) !== "bun.lockb" || !present.has(siblingBunLock(path))) { + continue; + } + if (isDummyBunLockbOnDisk(repoDir, path)) { + dummies.add(path); + } + } + return dummies; +} + export function discoverLockfiles(repoDir: string): string[] { - return omitBunLockbCoveredByTextLock( - globSync( - LOCKFILE_FILENAMES.map((filename) => `**/${filename}`), - { - cwd: repoDir, - ignore: IGNORED_DIRECTORY_NAMES.map((name) => `**/${name}/**`), - // Lockfiles under dot-directories still count; only the names above are - // skipped. - dot: true, - onlyFiles: true, - }, - ).sort(), - ); + const lockfiles = globSync( + LOCKFILE_FILENAMES.map((filename) => `**/${filename}`), + { + cwd: repoDir, + ignore: IGNORED_DIRECTORY_NAMES.map((name) => `**/${name}/**`), + // Lockfiles under dot-directories still count; only the names above are + // skipped. + dot: true, + onlyFiles: true, + }, + ).sort(); + return omitDummyBunLockbCoveredByTextLock(lockfiles, dummyBunLockbPathsOnDisk(repoDir, lockfiles)); } export interface ScanResult { @@ -74,6 +116,40 @@ function isIgnoredPath(path: string): boolean { return path.split("/").some((segment) => IGNORED_DIRECTORY_NAMES.includes(segment)); } +async function isDummyBunLockbAtRef( + repoDir: string, + ref: string, + lockbPath: string, +): Promise { + const { stdout } = await run("git", ["cat-file", "blob", `${ref}:${lockbPath}`], { + cwd: repoDir, + encoding: "buffer", + maxBuffer: 128 * 1024 * 1024, + }); + if (!Buffer.isBuffer(stdout)) { + throw new Error(`git cat-file blob ${ref}:${lockbPath} did not return a buffer`); + } + return isCloudflarePagesDummyBunLockb(stdout); +} + +async function dummyBunLockbPathsAtRef( + repoDir: string, + ref: string, + lockfiles: string[], +): Promise> { + const present = new Set(lockfiles); + const dummies = new Set(); + for (const path of lockfiles) { + if (basename(path) !== "bun.lockb" || !present.has(siblingBunLock(path))) { + continue; + } + if (await isDummyBunLockbAtRef(repoDir, ref, path)) { + dummies.add(path); + } + } + return dummies; +} + /** * Lists lockfiles as they existed at `ref`, so that moving or renaming a * lockfile does not make its whole content look newly introduced. @@ -83,13 +159,15 @@ export async function listLockfilesAtRef(repoDir: string, ref: string): Promise< cwd: repoDir, maxBuffer: 64 * 1024 * 1024, }); - return omitBunLockbCoveredByTextLock( - stdout - .split("\n") - .filter((path) => path.length > 0) - .filter((path) => LOCKFILE_FILENAMES.includes(path.split("/").pop() as string)) - .filter((path) => !isIgnoredPath(path)) - .sort(), + const lockfiles = stdout + .split("\n") + .filter((path) => path.length > 0) + .filter((path) => LOCKFILE_FILENAMES.includes(path.split("/").pop() as string)) + .filter((path) => !isIgnoredPath(path)) + .sort(); + return omitDummyBunLockbCoveredByTextLock( + lockfiles, + await dummyBunLockbPathsAtRef(repoDir, ref, lockfiles), ); } diff --git a/packages/dependency-cooldown/test/end-to-end.test.ts b/packages/dependency-cooldown/test/end-to-end.test.ts index 719f80b..341c611 100644 --- a/packages/dependency-cooldown/test/end-to-end.test.ts +++ b/packages/dependency-cooldown/test/end-to-end.test.ts @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { BYPASS_REASON_MARKER } from "../src/bypass.js"; import type { HttpClient } from "../src/registries/http.js"; import { DEFAULT_BYPASS_LABEL, run } from "../src/run.js"; +import { CLOUDFLARE_PAGES_BUN_LOCKB_MARKER } from "../src/scan.js"; import { readFixture } from "./helpers/fixtures.js"; const NOW = new Date("2026-08-27T12:00:00Z"); @@ -247,9 +248,9 @@ describe("check mode", () => { expect(summary()).toContain("Checked 6 newly introduced dependency version(s)"); }); - it("checks bun.lock when an empty bun.lockb sits beside it", async () => { + it("checks bun.lock when the Cloudflare Pages dummy bun.lockb sits beside it", async () => { write("bun.lock", readFixture("bun/base/bun.lock")); - write("bun.lockb", ""); + write("bun.lockb", CLOUDFLARE_PAGES_BUN_LOCKB_MARKER); const baseSha = commit("base"); write("bun.lock", readFixture("bun/head/bun.lock")); commit("bump bun"); @@ -264,6 +265,34 @@ describe("check mode", () => { expect(summary()).toContain("bun.lock"); }); + it("refuses a PR that replaces the dummy bun.lockb with a real lockfile while bun.lock is unchanged", async () => { + write("bun.lock", readFixture("bun/base/bun.lock")); + write("bun.lockb", CLOUDFLARE_PAGES_BUN_LOCKB_MARKER); + const baseSha = commit("base"); + write("bun.lockb", "#!\u0000binary\u0000"); + commit("swap in a real bun.lockb"); + + await expect( + run(["--mode=check", `--base-ref=${baseSha}`, `--repo-dir=${repoDir}`], { + http: httpStub({}), + now: NOW, + }), + ).rejects.toThrow(/bun\.lockb/); + }); + + it("refuses a divergent non-marker bun.lockb sitting beside bun.lock", async () => { + write("bun.lock", readFixture("bun/base/bun.lock")); + write("bun.lockb", "#!\u0000binary\u0000"); + const baseSha = commit("base"); + + await expect( + run(["--mode=check", `--base-ref=${baseSha}`, `--repo-dir=${repoDir}`], { + http: httpStub({}), + now: NOW, + }), + ).rejects.toThrow(/bun\.lockb/); + }); + it("still refuses a repository that only has bun.lockb", async () => { write("bun.lockb", "#!\u0000binary\u0000"); const baseSha = commit("base"); diff --git a/packages/dependency-cooldown/test/scan.test.ts b/packages/dependency-cooldown/test/scan.test.ts index 800b464..3a601ec 100644 --- a/packages/dependency-cooldown/test/scan.test.ts +++ b/packages/dependency-cooldown/test/scan.test.ts @@ -1,9 +1,14 @@ +import { execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { discoverLockfiles } from "../src/scan.js"; +import { + CLOUDFLARE_PAGES_BUN_LOCKB_MARKER, + discoverLockfiles, + listLockfilesAtRef, +} from "../src/scan.js"; let repoDir: string; @@ -21,31 +26,120 @@ function write(relativePath: string, contents: string): void { writeFileSync(absolute, contents); } +function git(...args: string[]): string { + return execFileSync("git", args, { cwd: repoDir, encoding: "utf8" }); +} + +function commit(message: string): string { + git("add", "-A"); + git("-c", "user.email=ci@example.com", "-c", "user.name=CI", "commit", "-m", message); + return git("rev-parse", "HEAD").trim(); +} + +describe("Cloudflare Pages dummy bun.lockb", () => { + it("is the 189-byte Quantus marker with no trailing newline", () => { + expect(Buffer.byteLength(CLOUDFLARE_PAGES_BUN_LOCKB_MARKER)).toBe(189); + expect(CLOUDFLARE_PAGES_BUN_LOCKB_MARKER.endsWith("\n")).toBe(false); + expect(CLOUDFLARE_PAGES_BUN_LOCKB_MARKER).toBe( + "# THIS IS JUST DUMMY FILE FOR HELPING CLOUDFLARE DETECT BUN PACKAGE MANAGER (https://community.cloudflare.com/t/bun-not-detected-as-tool-when-using-new-bun-lock-instead-of-bun-lockb/779835)", + ); + }); +}); + describe("discoverLockfiles", () => { - it("omits bun.lockb when a text bun.lock sits beside it", () => { + it("omits bun.lockb only when it is the Cloudflare Pages dummy beside bun.lock", () => { write("bun.lock", '{ "packages": {} }\n'); - write("bun.lockb", ""); + write("bun.lockb", CLOUDFLARE_PAGES_BUN_LOCKB_MARKER); expect(discoverLockfiles(repoDir)).toEqual(["bun.lock"]); }); - it("still discovers bun.lockb when no text lockfile is present", () => { + it("keeps an empty bun.lockb sitting beside bun.lock", () => { + write("bun.lock", '{ "packages": {} }\n'); write("bun.lockb", ""); + expect(discoverLockfiles(repoDir)).toEqual(["bun.lock", "bun.lockb"]); + }); + + it("keeps a real binary bun.lockb sitting beside bun.lock", () => { + write("bun.lock", '{ "packages": {} }\n'); + write("bun.lockb", "#!\u0000binary\u0000"); + + expect(discoverLockfiles(repoDir)).toEqual(["bun.lock", "bun.lockb"]); + }); + + it("does not treat a different 189-byte file as the dummy", () => { + const impostor = `X${CLOUDFLARE_PAGES_BUN_LOCKB_MARKER.slice(1)}`; + expect(Buffer.byteLength(impostor)).toBe(189); + + write("bun.lock", '{ "packages": {} }\n'); + write("bun.lockb", impostor); + + expect(discoverLockfiles(repoDir)).toEqual(["bun.lock", "bun.lockb"]); + }); + + it("does not treat a marker with a trailing newline as the dummy", () => { + write("bun.lock", '{ "packages": {} }\n'); + write("bun.lockb", `${CLOUDFLARE_PAGES_BUN_LOCKB_MARKER}\n`); + + expect(discoverLockfiles(repoDir)).toEqual(["bun.lock", "bun.lockb"]); + }); + + it("still discovers bun.lockb when no text lockfile is present", () => { + write("bun.lockb", CLOUDFLARE_PAGES_BUN_LOCKB_MARKER); + expect(discoverLockfiles(repoDir)).toEqual(["bun.lockb"]); }); it("does not treat a bun.lock in another directory as covering bun.lockb", () => { write("bun.lock", '{ "packages": {} }\n'); - write("apps/web/bun.lockb", ""); + write("apps/web/bun.lockb", "#!\u0000binary\u0000"); expect(discoverLockfiles(repoDir)).toEqual(["apps/web/bun.lockb", "bun.lock"]); }); - it("omits bun.lockb next to bun.lock in a nested directory", () => { + it("omits a nested dummy bun.lockb next to bun.lock", () => { write("apps/web/bun.lock", '{ "packages": {} }\n'); - write("apps/web/bun.lockb", ""); + write("apps/web/bun.lockb", CLOUDFLARE_PAGES_BUN_LOCKB_MARKER); expect(discoverLockfiles(repoDir)).toEqual(["apps/web/bun.lock"]); }); + + it("keeps a nested non-marker bun.lockb next to bun.lock", () => { + write("apps/web/bun.lock", '{ "packages": {} }\n'); + write("apps/web/bun.lockb", "#!\u0000binary\u0000"); + + expect(discoverLockfiles(repoDir)).toEqual(["apps/web/bun.lock", "apps/web/bun.lockb"]); + }); +}); + +describe("listLockfilesAtRef", () => { + beforeEach(() => { + git("init", "--initial-branch=main"); + }); + + it("omits the Cloudflare Pages dummy bun.lockb beside bun.lock", async () => { + write("bun.lock", '{ "packages": {} }\n'); + write("bun.lockb", CLOUDFLARE_PAGES_BUN_LOCKB_MARKER); + const sha = commit("dummy"); + + expect(await listLockfilesAtRef(repoDir, sha)).toEqual(["bun.lock"]); + }); + + it("keeps a same-length impostor bun.lockb at that revision", async () => { + const impostor = `X${CLOUDFLARE_PAGES_BUN_LOCKB_MARKER.slice(1)}`; + write("bun.lock", '{ "packages": {} }\n'); + write("bun.lockb", impostor); + const sha = commit("impostor"); + + expect(await listLockfilesAtRef(repoDir, sha)).toEqual(["bun.lock", "bun.lockb"]); + }); + + it("keeps a real bun.lockb beside bun.lock at that revision", async () => { + write("bun.lock", '{ "packages": {} }\n'); + write("bun.lockb", "#!\u0000binary\u0000"); + const sha = commit("binary"); + + expect(await listLockfilesAtRef(repoDir, sha)).toEqual(["bun.lock", "bun.lockb"]); + }); });