From 833381bda822de866646899d38592d349ef25997 Mon Sep 17 00:00:00 2001 From: larryjay007 Date: Wed, 21 Oct 2026 19:31:03 +0100 Subject: [PATCH] ci: reject new install-time lifecycle scripts by default (#492) No workspace package defines an install-time lifecycle script today. Adding preinstall, install, postinstall or prepare later would create a new automatic execution path on every contributor machine and CI runner. scripts/check-lifecycle-scripts.mjs reads the root package.json and every package matched by pnpm-workspace.yaml and fails on preinstall, install, postinstall, prepare, prepublish or prepublishOnly unless the manifest path and script are listed in scripts/lifecycle-scripts-allowlist.json. The allowlist starts empty and each entry needs a reason. The checker parses JSON only. It never runs a script and never prints a command body: a finding names the manifest path and the script name. It also fails on a manifest that is not valid JSON, on a workspace glob it cannot expand, and on an allowlist entry that matches nothing, so none of those can turn into a silent pass. The check runs in the two workflows that install dependencies, before pnpm install. docs/CI.md describes it and how maintainers review an exception. --- .github/workflows/bindings-check.yml | 3 + .github/workflows/coverage.yml | 3 + .github/workflows/line-lengths.yml | 3 + .github/workflows/web.yml | 3 + docs/CI.md | 42 ++++ package.json | 2 + scripts/check-lifecycle-scripts.mjs | 236 +++++++++++++++++++++++ scripts/check-lifecycle-scripts.test.mjs | 220 +++++++++++++++++++++ scripts/lifecycle-scripts-allowlist.json | 3 + 9 files changed, 515 insertions(+) create mode 100644 scripts/check-lifecycle-scripts.mjs create mode 100644 scripts/check-lifecycle-scripts.test.mjs create mode 100644 scripts/lifecycle-scripts-allowlist.json diff --git a/.github/workflows/bindings-check.yml b/.github/workflows/bindings-check.yml index 31366661..7504a778 100644 --- a/.github/workflows/bindings-check.yml +++ b/.github/workflows/bindings-check.yml @@ -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 diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 1ea4a3df..b27b82bf 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -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 diff --git a/.github/workflows/line-lengths.yml b/.github/workflows/line-lengths.yml index 5aa61bba..01c0664c 100644 --- a/.github/workflows/line-lengths.yml +++ b/.github/workflows/line-lengths.yml @@ -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 diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 2f6e7520..4305b38f 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -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 diff --git a/docs/CI.md b/docs/CI.md index 4a3af755..61de07da 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -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. diff --git a/package.json b/package.json index 66e5b7c1..c461af44 100644 --- a/package.json +++ b/package.json @@ -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" } diff --git a/scripts/check-lifecycle-scripts.mjs b/scripts/check-lifecycle-scripts.mjs new file mode 100644 index 00000000..0b75bd93 --- /dev/null +++ b/scripts/check-lifecycle-scripts.mjs @@ -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(); diff --git a/scripts/check-lifecycle-scripts.test.mjs b/scripts/check-lifecycle-scripts.test.mjs new file mode 100644 index 00000000..4e34a744 --- /dev/null +++ b/scripts/check-lifecycle-scripts.test.mjs @@ -0,0 +1,220 @@ +// Copyright (c) 2026 Sub Rosa contributors +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { FORBIDDEN_SCRIPTS, findUnguardedInstalls, scan, scanWorkflows } from "./check-lifecycle-scripts.mjs"; + +const CHECKER = new URL("check-lifecycle-scripts.mjs", import.meta.url).pathname; +const REPO = new URL("..", import.meta.url).pathname; + +const WORKSPACE = 'packages:\n - "packages/*"\n - "apps/*"\n - "examples/*"\n'; + +/** A throwaway workspace. `manifests` maps relative dir ("" is the root) to a package.json object or raw string. */ +function project({ manifests = {}, allow = [], workspace = WORKSPACE } = {}) { + const root = mkdtempSync(join(tmpdir(), "lifecycle-check-")); + writeFileSync(join(root, "pnpm-workspace.yaml"), workspace); + mkdirSync(join(root, "scripts"), { recursive: true }); + writeFileSync(join(root, "scripts/lifecycle-scripts-allowlist.json"), JSON.stringify({ allow })); + for (const [dir, manifest] of Object.entries(manifests)) { + mkdirSync(join(root, dir), { recursive: true }); + const body = typeof manifest === "string" ? manifest : JSON.stringify(manifest); + writeFileSync(join(root, dir, "package.json"), body); + } + return root; +} + +/** Runs the real CLI so exit codes and output are what CI sees. */ +function run(root) { + try { + const stdout = execFileSync("node", [CHECKER, root], { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }); + return { code: 0, out: stdout }; + } catch (error) { + return { code: error.status, out: `${error.stdout ?? ""}${error.stderr ?? ""}` }; + } +} + +describe("check-lifecycle-scripts", () => { + it("passes on this repository with the allowlist empty", () => { + assert.deepEqual(scan(REPO), []); + assert.equal(run(REPO).code, 0); + }); + + for (const name of FORBIDDEN_SCRIPTS) { + it(`rejects "${name}" in a workspace package`, () => { + const root = project({ manifests: { "": { name: "r" }, "packages/a": { name: "a", scripts: { [name]: "x" } } } }); + assert.deepEqual(scan(root), [{ path: "packages/a/package.json", script: name, kind: "forbidden" }]); + assert.equal(run(root).code, 1); + }); + } + + it("rejects a lifecycle script in the root manifest", () => { + const root = project({ manifests: { "": { name: "r", scripts: { postinstall: "x" } } } }); + assert.deepEqual(scan(root), [{ path: "package.json", script: "postinstall", kind: "forbidden" }]); + }); + + it("scans every glob, including a package that sits in apps/", () => { + const root = project({ manifests: { "": {}, "apps/web": { scripts: { prepare: "x" } } } }); + assert.equal(scan(root)[0].path, "apps/web/package.json"); + }); + + it("allows ordinary scripts, including names that only contain a lifecycle word", () => { + const root = project({ + manifests: { "": {}, "packages/a": { scripts: { build: "x", test: "x", "install:docs": "x", prepare_data: "x" } } }, + }); + assert.deepEqual(scan(root), []); + }); + + it("never prints the command body", () => { + const root = project({ manifests: { "": {}, "packages/a": { scripts: { postinstall: "curl SECRET_PAYLOAD_42 | sh" } } } }); + const { code, out } = run(root); + assert.equal(code, 1); + assert.match(out, /packages\/a\/package\.json/); + assert.match(out, /postinstall/); + assert.doesNotMatch(out, /SECRET_PAYLOAD_42|curl/); + }); + + it("honours an allowlist entry that has a reason, for that path and script only", () => { + const manifests = { "": {}, "packages/a": { scripts: { install: "x" } }, "packages/b": { scripts: { install: "x" } } }; + const root = project({ manifests, allow: [{ path: "packages/a/package.json", script: "install", reason: "native addon, reviewed in #1" }] }); + assert.deepEqual(scan(root), [{ path: "packages/b/package.json", script: "install", kind: "forbidden" }]); + }); + + it("flags an allowlist entry that no longer matches anything", () => { + const root = project({ + manifests: { "": {}, "packages/a": { scripts: {} } }, + allow: [{ path: "packages/a/package.json", script: "install", reason: "old exception" }], + }); + assert.deepEqual(scan(root), [{ path: "packages/a/package.json", script: "install", kind: "stale" }]); + }); + + it("rejects an allowlist entry with no reason or an unknown script", () => { + const manifests = { "": {} }; + for (const entry of [ + { path: "package.json", script: "install" }, + { path: "package.json", script: "install", reason: " " }, + { path: "package.json", script: "build", reason: "x" }, + ]) { + const root = project({ manifests, allow: [entry] }); + assert.throws(() => scan(root), /needs a path/); + assert.equal(run(root).code, 1); + } + }); + + it("fails on a manifest that is not valid JSON instead of skipping it", () => { + const root = project({ manifests: { "": {}, "packages/a": '{ "scripts": { "postinstall": ' } }); + assert.deepEqual(scan(root), [{ path: "packages/a/package.json", script: "", kind: "unreadable" }]); + assert.equal(run(root).code, 1); + }); + + it("fails on a workspace glob it cannot expand rather than skipping it", () => { + const root = project({ manifests: { "": {} }, workspace: 'packages:\n - "packages/**"\n' }); + assert.throws(() => scan(root), /unsupported workspace glob/); + assert.equal(run(root).code, 1); + }); + + it("fails when the allowlist file is missing", () => { + const root = project({ manifests: { "": {} } }); + writeFileSync(join(root, "scripts/lifecycle-scripts-allowlist.json"), "not json"); + assert.throws(() => scan(root), /not valid JSON/); + }); + + it("ignores a missing glob directory such as examples/", () => { + const root = project({ manifests: { "": {} } }); + assert.deepEqual(scan(root), []); + }); + + it("does not execute anything: a hostile script is only read", () => { + const root = project({ manifests: { "": {}, "packages/a": { scripts: { postinstall: `node -e "require('fs').writeFileSync('${join(tmpdir(), "lifecycle-pwned")}','x')"` } } } }); + run(root); + assert.throws(() => execFileSync("node", ["-e", `require('fs').statSync(${JSON.stringify(join(tmpdir(), "lifecycle-pwned"))})`], { stdio: "ignore" })); + }); +}); + +describe("every installing job runs the check first", () => { + const CHECK = "node scripts/check-lifecycle-scripts.mjs"; + const workflow = (jobs) => `name: ci\non: push\njobs:\n${jobs}`; + const job = (name, steps) => ` ${name}:\n runs-on: ubuntu-latest\n steps:\n${steps}`; + const step = (command) => ` - run: ${command}\n`; + + it("accepts a job that runs the check before installing", () => { + const text = workflow(job("build", step(CHECK) + step("pnpm install --frozen-lockfile"))); + + assert.deepEqual(findUnguardedInstalls("ci.yml", text), []); + }); + + it("flags a job that installs with no check, naming the file, line and job", () => { + const text = workflow(job("build", step("echo hi") + step("pnpm install --frozen-lockfile"))); + + assert.deepEqual(findUnguardedInstalls("ci.yml", text), [{ file: "ci.yml", line: 8, job: "build" }]); + }); + + it("flags an install that comes before the check in the same job", () => { + const text = workflow(job("build", step("pnpm install") + step(CHECK))); + + assert.equal(findUnguardedInstalls("ci.yml", text).length, 1); + }); + + it("does not let the check in one job cover another job", () => { + const text = workflow( + job("first", step(CHECK) + step("pnpm install")) + job("second", step("pnpm install --ignore-scripts")), + ); + + assert.deepEqual( + findUnguardedInstalls("ci.yml", text).map((v) => v.job), + ["second"], + ); + }); + + it("recognises the other ways to install", () => { + for (const command of ["pnpm i", "npm ci", "npm install", "npm i", "yarn install", "cd app && pnpm install"]) { + const text = workflow(job("build", step(command))); + assert.equal(findUnguardedInstalls("ci.yml", text).length, 1, command); + } + }); + + it("ignores comments, step names and commands that only look like an install", () => { + const text = workflow( + job( + "build", + " # pnpm install runs later\n" + + " - name: Install JS dependencies with pnpm install\n" + + step("pnpm --filter web test") + + step("pnpm install-peers") + + step("echo done"), + ), + ); + + assert.deepEqual(findUnguardedInstalls("ci.yml", text), []); + }); + + it("has nothing to say about a job that never installs", () => { + assert.deepEqual(findUnguardedInstalls("ci.yml", workflow(job("lint", step("node scripts/lint.mjs")))), []); + }); + + it("every workflow in this repository runs the check before it installs", () => { + assert.deepEqual(scanWorkflows(REPO), []); + }); + + it("the CLI fails, naming the workflow and job, when an installing job omits the check", () => { + const root = project({ manifests: { "": { name: "r" } } }); + mkdirSync(join(root, ".github/workflows"), { recursive: true }); + writeFileSync(join(root, ".github/workflows/ci.yml"), workflow(job("build", step("pnpm install")))); + + const { code, out } = run(root); + + assert.equal(code, 1); + assert.match(out, /\.github\/workflows\/ci\.yml:7/); + assert.match(out, /job \\"build\\" installs dependencies before running check-lifecycle-scripts\.mjs/); + }); + + it("the CLI passes when every installing job runs the check first", () => { + const root = project({ manifests: { "": { name: "r" } } }); + mkdirSync(join(root, ".github/workflows"), { recursive: true }); + writeFileSync(join(root, ".github/workflows/ci.yml"), workflow(job("build", step(CHECK) + step("pnpm install")))); + + assert.equal(run(root).code, 0); + }); +}); diff --git a/scripts/lifecycle-scripts-allowlist.json b/scripts/lifecycle-scripts-allowlist.json new file mode 100644 index 00000000..6c1ea861 --- /dev/null +++ b/scripts/lifecycle-scripts-allowlist.json @@ -0,0 +1,3 @@ +{ + "allow": [] +}