diff --git a/actions/dependency-cooldown/dist/index.mjs b/actions/dependency-cooldown/dist/index.mjs index 8917b41..6af93f3 100644 --- a/actions/dependency-cooldown/dist/index.mjs +++ b/actions/dependency-cooldown/dist/index.mjs @@ -9549,8 +9549,9 @@ 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 { 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,8 +12111,45 @@ function collectDetailed(lockfilePath, content) { // src/scan.ts var run = promisify(execFile); var IGNORED_DIRECTORY_NAMES = [".git", "node_modules", "target", ".dart_tool"]; +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; + } + 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 globSync( + const lockfiles = globSync( LOCKFILE_FILENAMES.map((filename) => `**/${filename}`), { cwd: repoDir, @@ -12122,6 +12160,7 @@ function discoverLockfiles(repoDir) { onlyFiles: true } ).sort(); + return omitDummyBunLockbCoveredByTextLock(lockfiles, dummyBunLockbPathsOnDisk(repoDir, lockfiles)); } async function scanWorkingTree(repoDir, lockfiles) { const result = { dependencies: [], uncheckable: [] }; @@ -12136,12 +12175,40 @@ 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 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) { try { diff --git a/docs/dependency-cooldown.md b/docs/dependency-cooldown.md index bb7a28d..1e60e33 100644 --- a/docs/dependency-cooldown.md +++ b/docs/dependency-cooldown.md @@ -128,8 +128,15 @@ 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. 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 45dd2f6..329410f 100644 --- a/packages/dependency-cooldown/src/scan.ts +++ b/packages/dependency-cooldown/src/scan.ts @@ -1,6 +1,7 @@ import { execFile } from "node:child_process"; +import { readFileSync } from "node:fs"; 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,8 +22,67 @@ const run = promisify(execFile); */ export const IGNORED_DIRECTORY_NAMES = [".git", "node_modules", "target", ".dart_tool"]; +/** + * 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. + */ +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; + } + 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 globSync( + const lockfiles = globSync( LOCKFILE_FILENAMES.map((filename) => `**/${filename}`), { cwd: repoDir, @@ -33,6 +93,7 @@ export function discoverLockfiles(repoDir: string): string[] { onlyFiles: true, }, ).sort(); + return omitDummyBunLockbCoveredByTextLock(lockfiles, dummyBunLockbPathsOnDisk(repoDir, lockfiles)); } export interface ScanResult { @@ -55,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. @@ -64,12 +159,16 @@ export async function listLockfilesAtRef(repoDir: string, ref: string): Promise< cwd: repoDir, maxBuffer: 64 * 1024 * 1024, }); - return stdout + 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), + ); } 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..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,6 +248,63 @@ describe("check mode", () => { expect(summary()).toContain("Checked 6 newly introduced dependency version(s)"); }); + 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", CLOUDFLARE_PAGES_BUN_LOCKB_MARKER); + 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("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"); + + 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..3a601ec --- /dev/null +++ b/packages/dependency-cooldown/test/scan.test.ts @@ -0,0 +1,145 @@ +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 { + CLOUDFLARE_PAGES_BUN_LOCKB_MARKER, + discoverLockfiles, + listLockfilesAtRef, +} 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); +} + +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 only when it is the Cloudflare Pages dummy beside bun.lock", () => { + write("bun.lock", '{ "packages": {} }\n'); + write("bun.lockb", CLOUDFLARE_PAGES_BUN_LOCKB_MARKER); + + expect(discoverLockfiles(repoDir)).toEqual(["bun.lock"]); + }); + + 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", "#!\u0000binary\u0000"); + + expect(discoverLockfiles(repoDir)).toEqual(["apps/web/bun.lockb", "bun.lock"]); + }); + + it("omits a nested dummy bun.lockb next to bun.lock", () => { + write("apps/web/bun.lock", '{ "packages": {} }\n'); + 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"]); + }); +});