Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/bindings-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ jobs:
node-version: "22"
cache: pnpm

- name: Reject install-time lifecycle scripts (reads package.json only, before any install)
run: node scripts/check-lifecycle-scripts.mjs && node --test scripts/check-lifecycle-scripts.test.mjs

- name: Install JS dependencies
run: pnpm install --frozen-lockfile

Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ jobs:
node-version: "22"
cache: pnpm

- name: Reject install-time lifecycle scripts (reads package.json only, before any install)
run: node scripts/check-lifecycle-scripts.mjs && node --test scripts/check-lifecycle-scripts.test.mjs

- name: Install JS dependencies
run: pnpm install --frozen-lockfile

Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/line-lengths.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ jobs:
node-version: "22"
cache: pnpm

- name: Reject install-time lifecycle scripts (reads package.json only, before any install)
run: node scripts/check-lifecycle-scripts.mjs && node --test scripts/check-lifecycle-scripts.test.mjs

- name: Install JS dependencies
run: pnpm install --frozen-lockfile

Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/web.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ jobs:
- name: Check web CI configuration
run: node --test scripts/web-ci.test.mjs

- name: Reject install-time lifecycle scripts (reads package.json only, before any install)
run: node scripts/check-lifecycle-scripts.mjs && node --test scripts/check-lifecycle-scripts.test.mjs

- name: Install JS dependencies
run: pnpm install --frozen-lockfile --ignore-scripts

Expand Down
42 changes: 42 additions & 0 deletions docs/CI.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,45 @@ print order are asserted by:
```bash
node --test scripts/check-bindings-cli.test.mjs
```

## Install-time lifecycle scripts

`pnpm install` runs `preinstall`, `install`, `postinstall` and `prepare`
automatically, and `prepublish` / `prepublishOnly` run on publish flows. A new
one in any workspace `package.json` would be a new automatic execution path on
every contributor machine and CI runner, so CI rejects them by default.

`scripts/check-lifecycle-scripts.mjs` reads the root `package.json` and every
package matched by `pnpm-workspace.yaml`. It parses JSON only: it never runs a
script and never prints a script body. A failure names the manifest path and
the script name, nothing else. The step runs in every workflow job that installs
dependencies, before the install. The same script reads `.github/workflows` and
fails if a job runs `pnpm install` (or `npm ci`, `npm install`, `yarn install`)
before running the check, so a new installing job cannot leave it out.

```bash
pnpm lifecycle-scripts:check
pnpm lifecycle-scripts:test
```

The check also fails on a manifest that is not valid JSON, on a workspace glob
it cannot expand, and on an allowlist entry that no longer matches a script.

### Requesting an exception

`scripts/lifecycle-scripts-allowlist.json` is empty and should stay that way
unless there is no alternative. To add an exception, open a PR that adds an
entry:

```json
{ "path": "packages/example/package.json", "script": "postinstall", "reason": "why it is needed and why it is safe" }
```

Maintainers review it like any other code that runs on install: what the
command does, what it downloads or executes, and whether a build step or an
explicit command could replace it. The entry is scoped to that one manifest
and script, and `reason` is required. Remove the entry when the script goes
away; a stale entry fails the check.

A dependency that needs a build script is a separate question handled by
`pnpm.onlyBuiltDependencies` in the root `package.json`, not by this allowlist.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@
"time:guard:test": "node --test scripts/check-direct-time-access.test.mjs",
"errors:normalize:check": "node scripts/check-error-normalization.mjs",
"errors:normalize:test": "pnpm --filter @sub-rosa/logging test && node --test scripts/check-error-normalization.test.mjs",
"lifecycle-scripts:check": "node scripts/check-lifecycle-scripts.mjs",
"lifecycle-scripts:test": "node --test scripts/check-lifecycle-scripts.test.mjs",
"line-lengths:check": "node scripts/check-line-lengths.mjs",
"line-lengths:test": "node --test scripts/check-line-lengths.test.mjs"
}
Expand Down
236 changes: 236 additions & 0 deletions scripts/check-lifecycle-scripts.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
#!/usr/bin/env node
// Copyright (c) 2026 Sub Rosa contributors
//
// Rejects install-time lifecycle scripts in workspace package.json files.
//
// A lifecycle script runs automatically during `pnpm install`, so adding one
// later would create a new execution path on every contributor machine and CI
// runner. This check reads JSON only. It never runs a script, never shells
// out, and never prints a script body: findings carry the manifest path and
// the script name and nothing else.
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { createLogger } from "../packages/logging/src/index.cjs";

export const FORBIDDEN_SCRIPTS = [
"preinstall",
"install",
"postinstall",
"prepare",
"prepublish",
"prepublishOnly",
];

export const ALLOWLIST_PATH = "scripts/lifecycle-scripts-allowlist.json";

const defaultRoot = fileURLToPath(new URL("..", import.meta.url));

/**
* Package globs from pnpm-workspace.yaml.
*
* Only the `packages:` list is read, and only `dir/*` or literal `dir`
* entries are understood. Anything fancier (`**`, negation) throws instead of
* being skipped, because a skipped glob would leave manifests unchecked.
*/
export function readWorkspaceGlobs(root) {
const file = join(root, "pnpm-workspace.yaml");
if (!existsSync(file)) throw new Error("pnpm-workspace.yaml is missing");
const globs = [];
let inPackages = false;
for (const raw of readFileSync(file, "utf8").split(/\r?\n/)) {
const line = raw.replace(/\s+#.*$/, "");
if (/^packages:\s*$/.test(line)) {
inPackages = true;
continue;
}
if (!inPackages) continue;
if (/^\S/.test(line)) break;
const item = line.match(/^\s+-\s+["']?([^"']+?)["']?\s*$/);
if (item) globs.push(item[1]);
else if (line.trim() !== "") throw new Error(`unsupported line in pnpm-workspace.yaml packages list`);
}
if (globs.length === 0) throw new Error("pnpm-workspace.yaml lists no packages");
for (const glob of globs) {
if (!/^[\w.-]+(?:\/[\w.-]+)*(?:\/\*)?$/.test(glob)) {
throw new Error(`unsupported workspace glob: ${glob}`);
}
}
return globs;
}

function isDirectory(path) {
try {
return statSync(path).isDirectory();
} catch (error) {
if (error.code === "ENOENT" || error.code === "ENOTDIR") return false;
throw error;
}
}

/** Manifest paths (relative, forward slashes) for the root and every workspace package. */
export function findManifests(root) {
const dirs = new Set([""]);
for (const glob of readWorkspaceGlobs(root)) {
if (glob.endsWith("/*")) {
const base = glob.slice(0, -2);
if (!isDirectory(join(root, base))) continue;
for (const entry of readdirSync(join(root, base), { withFileTypes: true })) {
if (entry.isDirectory() && entry.name !== "node_modules") dirs.add(`${base}/${entry.name}`);
}
} else if (isDirectory(join(root, glob))) {
dirs.add(glob);
}
}
return [...dirs]
.map((dir) => (dir === "" ? "package.json" : `${dir}/package.json`))
.filter((path) => existsSync(join(root, path)))
.sort();
}

/** Allowlist entries: `{ path, script, reason }`. Malformed entries throw. */
export function readAllowlist(root) {
const file = join(root, ALLOWLIST_PATH);
if (!existsSync(file)) throw new Error(`${ALLOWLIST_PATH} is missing`);
let data;
try {
data = JSON.parse(readFileSync(file, "utf8"));
} catch {
throw new Error(`${ALLOWLIST_PATH} is not valid JSON`);
}
if (!data || !Array.isArray(data.allow)) throw new Error(`${ALLOWLIST_PATH} needs an "allow" array`);
data.allow.forEach((entry, i) => {
const ok =
entry &&
typeof entry.path === "string" &&
FORBIDDEN_SCRIPTS.includes(entry.script) &&
typeof entry.reason === "string" &&
entry.reason.trim() !== "";
if (!ok) throw new Error(`${ALLOWLIST_PATH} entry ${i} needs a path, a forbidden script name and a reason`);
});
return data.allow;
}

/**
* Findings for a tree. Each is `{ path, script, kind }` where kind is
* "forbidden" (script present, not allowlisted), "unreadable" (manifest is
* not JSON), or "stale" (allowlist entry that matches nothing).
*/
export function scan(root = defaultRoot) {
const allow = readAllowlist(root);
const used = new Set();
const findings = [];
for (const path of findManifests(root)) {
let manifest;
try {
manifest = JSON.parse(readFileSync(join(root, path), "utf8"));
} catch {
findings.push({ path, script: "", kind: "unreadable" });
continue;
}
const scripts = manifest && typeof manifest.scripts === "object" && manifest.scripts ? manifest.scripts : {};
for (const script of FORBIDDEN_SCRIPTS) {
if (!Object.hasOwn(scripts, script)) continue;
const index = allow.findIndex((e) => e.path === path && e.script === script);
if (index >= 0) used.add(index);
else findings.push({ path, script, kind: "forbidden" });
}
}
allow.forEach((entry, i) => {
if (!used.has(i)) findings.push({ path: entry.path, script: entry.script, kind: "stale" });
});
return findings;
}

/** Commands that install dependencies, so run install-time scripts. */
const INSTALL_COMMAND = /(^|[\s;&|(])(?:pnpm\s+(?:install|i)|npm\s+(?:ci|install|i)|yarn\s+install)(?=\s|$|;|&|\|)/;
const LIFECYCLE_CHECK = "check-lifecycle-scripts.mjs";

/**
* Jobs in one workflow that install dependencies before running this check.
* Each is `{ file, line, job }`; an empty list means every installing job runs
* the check first, so no job can run a lifecycle script the check would reject.
*
* Workflows are read as text: a job starts at a two-space key under `jobs:`,
* and comment lines never count.
*/
export function findUnguardedInstalls(file, text) {
const violations = [];
let inJobs = false;
let job = null;
let guarded = false;

text.split(/\r?\n/).forEach((raw, index) => {
if (/^jobs:\s*$/.test(raw)) {
inJobs = true;
return;
}
if (!inJobs) return;
const start = /^ ([A-Za-z0-9_-]+):\s*$/.exec(raw);
if (start) {
job = start[1];
guarded = false;
return;
}
if (/^\S/.test(raw)) {
inJobs = false;
return;
}
const line = raw.trim();
if (!job || line.startsWith("#") || /^(?:-\s+)?name:/.test(line)) return;
if (line.includes(LIFECYCLE_CHECK)) guarded = true;
if (INSTALL_COMMAND.test(line) && !guarded) violations.push({ file, line: index + 1, job });
});
return violations;
}

/** {@link findUnguardedInstalls} over every workflow in the repository. */
export function scanWorkflows(root = defaultRoot) {
const dir = join(root, ".github/workflows");
if (!existsSync(dir)) return [];
return readdirSync(dir)
.filter((name) => /\.ya?ml$/.test(name))
.sort()
.flatMap((name) => findUnguardedInstalls(`.github/workflows/${name}`, readFileSync(join(dir, name), "utf8")));
}

function main() {
const logger = createLogger("scripts.check-lifecycle-scripts");
const root = process.argv[2] ? resolve(process.argv[2]) : defaultRoot;
let findings;
let installs;
try {
findings = scan(root);
installs = scanWorkflows(root);
} catch (error) {
logger.error("lifecycle-check-misconfigured", error.message);
process.exitCode = 1;
return;
}
if (findings.length === 0 && installs.length === 0) {
logger.info(
"lifecycle-check-passed",
"No install-time lifecycle scripts in workspace manifests, and every installing job runs this check first",
);
return;
}
for (const f of findings) {
const what =
f.kind === "forbidden"
? `lifecycle script "${f.script}" is not allowlisted`
: f.kind === "stale"
? `allowlist entry for "${f.script}" matches no script`
: "manifest is not valid JSON";
logger.error("lifecycle-script-found", `${f.path}: ${what}`);
}
for (const v of installs) {
logger.error(
"lifecycle-install-unguarded",
`${v.file}:${v.line}: job "${v.job}" installs dependencies before running ${LIFECYCLE_CHECK}`,
);
}
logger.error("lifecycle-check-failed", `See docs/CI.md ("Install-time lifecycle scripts") for the exception process`);
process.exitCode = 1;
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();
Loading
Loading