From 973a6b6a663ccf5cd289e1fb6bffb3ef7ae6a354 Mon Sep 17 00:00:00 2001 From: umpireboulianne-design Date: Sat, 24 Oct 2026 12:15:51 -0400 Subject: [PATCH] security(ci): enforce pnpm as the single workspace package manager (#491) Remove root package-lock.json, ignore competing root lockfiles in .gitignore, add check-single-lockfile CI guard and unit tests, and document pnpm@10.13.1 in docs/CI.md. Fixes #491 --- .gitignore | 5 ++ docs/CI.md | 13 ++++ package-lock.json | 16 ----- package.json | 4 +- scripts/check-single-lockfile.mjs | 97 ++++++++++++++++++++++++++ scripts/check-single-lockfile.test.mjs | 67 ++++++++++++++++++ 6 files changed, 185 insertions(+), 17 deletions(-) delete mode 100644 package-lock.json create mode 100644 scripts/check-single-lockfile.mjs create mode 100644 scripts/check-single-lockfile.test.mjs diff --git a/.gitignore b/.gitignore index d4efb572..441dcd71 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,11 @@ dist .turbo *.tsbuildinfo .pnpm-store +/package-lock.json +/npm-shrinkwrap.json +/yarn.lock +/bun.lockb +/bun.lock # Env / secrets (NEVER commit live keys) .env diff --git a/docs/CI.md b/docs/CI.md index 4a3af755..02ffd11f 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -83,3 +83,16 @@ print order are asserted by: ```bash node --test scripts/check-bindings-cli.test.mjs ``` + +## Single workspace package manager (`pnpm@10.13.1`) + +`pnpm@10.13.1` is the required package manager and installer for the entire +workspace (`"packageManager": "pnpm@10.13.1"` in root `package.json`), and +`pnpm-lock.yaml` is the only supported root lockfile tracked in version control. +Root `package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock`, `bun.lockb`, and +`bun.lock` are ignored in `.gitignore` and rejected by CI: + +```bash +pnpm lockfile:test +pnpm lockfile:check +``` diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 3983ccde..00000000 --- a/package-lock.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "sub-rosa", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "sub-rosa", - "version": "0.1.0", - "license": "MIT", - "engines": { - "node": ">=22" - } - } - } -} diff --git a/package.json b/package.json index 66e5b7c1..705920f4 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,9 @@ "snapshot:check": "node scripts/check-snapshots.mjs", "docs:check": "node scripts/check-deploy-docs.mjs", "docs:check-links": "node scripts/check-links.js", - "coverage:test": "node --test scripts/run-ts-coverage.test.mjs && node scripts/run-ts-coverage.mjs", + "coverage:test": "node --test scripts/check-single-lockfile.test.mjs && node scripts/check-single-lockfile.mjs && node --test scripts/run-ts-coverage.test.mjs && node scripts/run-ts-coverage.mjs", + "lockfile:check": "node scripts/check-single-lockfile.mjs", + "lockfile:test": "node --test scripts/check-single-lockfile.test.mjs", "drand:test": "pnpm --filter @sub-rosa/drand-tools test", "drand:typecheck": "pnpm --filter @sub-rosa/drand-tools typecheck", "receipt:test": "pnpm --filter @sub-rosa/receipt-cli test", diff --git a/scripts/check-single-lockfile.mjs b/scripts/check-single-lockfile.mjs new file mode 100644 index 00000000..7219747b --- /dev/null +++ b/scripts/check-single-lockfile.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +// Copyright (c) 2026 Sub Rosa contributors +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { createLogger } from "../packages/logging/src/index.cjs"; + +const diagnostics = createLogger("scripts.check-single-lockfile"); + +export const REQUIRED_PACKAGE_MANAGER = "pnpm@10.13.1"; +export const REQUIRED_LOCKFILE = "pnpm-lock.yaml"; +export const FORBIDDEN_LOCKFILES = [ + "package-lock.json", + "npm-shrinkwrap.json", + "yarn.lock", + "bun.lockb", + "bun.lock", +]; + +const DEFAULT_ROOT = fileURLToPath(new URL("..", import.meta.url)); + +/** + * Validate that pnpm is the single workspace package manager and only + * `pnpm-lock.yaml` is present at the repository root. + * @param {string} [rootDir] + */ +export function checkSingleLockfile(rootDir = DEFAULT_ROOT) { + const errors = []; + + const requiredPath = resolve(rootDir, REQUIRED_LOCKFILE); + if (!existsSync(requiredPath)) { + errors.push(`Missing required root lockfile ${REQUIRED_LOCKFILE}`); + } + + for (const forbidden of FORBIDDEN_LOCKFILES) { + if (existsSync(resolve(rootDir, forbidden))) { + errors.push( + `Forbidden root lockfile "${forbidden}" found; only ${REQUIRED_LOCKFILE} is allowed`, + ); + } + } + + const gitignorePath = resolve(rootDir, ".gitignore"); + if (!existsSync(gitignorePath)) { + errors.push("Missing .gitignore at repository root"); + } else { + const gitignore = readFileSync(gitignorePath, "utf8"); + for (const pattern of ["package-lock.json", "yarn.lock", "bun.lockb", "bun.lock"]) { + if (!gitignore.includes(pattern)) { + errors.push(`.gitignore must ignore root "${pattern}"`); + } + } + } + + const pkgPath = resolve(rootDir, "package.json"); + if (!existsSync(pkgPath)) { + errors.push("Missing package.json at repository root"); + } else { + const pkg = JSON.parse(readFileSync(pkgPath, "utf8")); + if (pkg.packageManager !== REQUIRED_PACKAGE_MANAGER) { + errors.push( + `package.json packageManager must be "${REQUIRED_PACKAGE_MANAGER}" (found "${pkg.packageManager ?? ""}")`, + ); + } + } + + const ciDocsPath = resolve(rootDir, "docs/CI.md"); + if (!existsSync(ciDocsPath)) { + errors.push("Missing docs/CI.md"); + } else { + const ciDocs = readFileSync(ciDocsPath, "utf8"); + if (!ciDocs.includes(REQUIRED_PACKAGE_MANAGER)) { + errors.push(`docs/CI.md must document "${REQUIRED_PACKAGE_MANAGER}" as the required installer`); + } + } + + return { ok: errors.length === 0, errors }; +} + +function main() { + const res = checkSingleLockfile(); + if (!res.ok) { + for (const err of res.errors) { + diagnostics.error("single-lockfile-check-failed", `FAIL: ${err}`); + } + process.exit(1); + } + + diagnostics.info( + "single-lockfile-check-passed", + `PASS: ${REQUIRED_PACKAGE_MANAGER} is enforced with ${REQUIRED_LOCKFILE} as the single root lockfile.`, + ); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + main(); +} diff --git a/scripts/check-single-lockfile.test.mjs b/scripts/check-single-lockfile.test.mjs new file mode 100644 index 00000000..0a280f9f --- /dev/null +++ b/scripts/check-single-lockfile.test.mjs @@ -0,0 +1,67 @@ +// Copyright (c) 2026 Sub Rosa contributors +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, copyFileSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + FORBIDDEN_LOCKFILES, + REQUIRED_LOCKFILE, + REQUIRED_PACKAGE_MANAGER, + checkSingleLockfile, +} from "./check-single-lockfile.mjs"; + +const REPO_ROOT = fileURLToPath(new URL("..", import.meta.url)); + +function stageWorkspace(tmp) { + mkdirSync(join(tmp, "docs"), { recursive: true }); + copyFileSync(join(REPO_ROOT, ".gitignore"), join(tmp, ".gitignore")); + copyFileSync(join(REPO_ROOT, "package.json"), join(tmp, "package.json")); + copyFileSync(join(REPO_ROOT, "docs/CI.md"), join(tmp, "docs/CI.md")); + writeFileSync(join(tmp, REQUIRED_LOCKFILE), "lockfileVersion: '9.0'\n"); +} + +describe("check-single-lockfile", () => { + it("passes on the repository root", () => { + const res = checkSingleLockfile(REPO_ROOT); + assert.equal(res.ok, true, `Expected pass, got errors: ${res.errors.join("; ")}`); + }); + + it("fails when any competing lockfile is present at the root", () => { + for (const competing of FORBIDDEN_LOCKFILES) { + const tmp = mkdtempSync(join(tmpdir(), "single-lockfile-bad-")); + try { + stageWorkspace(tmp); + writeFileSync(join(tmp, competing), "{}"); + const res = checkSingleLockfile(tmp); + assert.equal(res.ok, false, `Expected failure for ${competing}`); + assert.ok( + res.errors.some((e) => e.includes(`Forbidden root lockfile "${competing}"`)), + ); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + } + }); + + it("fails when pnpm-lock.yaml is missing", () => { + const tmp = mkdtempSync(join(tmpdir(), "single-lockfile-missing-")); + try { + stageWorkspace(tmp); + rmSync(join(tmp, REQUIRED_LOCKFILE)); + const res = checkSingleLockfile(tmp); + assert.equal(res.ok, false); + assert.ok( + res.errors.some((e) => e.includes(`Missing required root lockfile ${REQUIRED_LOCKFILE}`)), + ); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("enforces pnpm@10.13.1 as the required packageManager", () => { + assert.equal(REQUIRED_PACKAGE_MANAGER, "pnpm@10.13.1"); + }); +});