diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c578ac0..a08f62d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,6 +82,9 @@ jobs: uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: current + # setup-node caches npm when package.json has devEngines, and caching + # needs a lockfile, which this repo doesn't keep + package-manager-cache: false - name: Run npm install run: npm install --workspaces=false - name: Run npm lint @@ -114,6 +117,7 @@ jobs: uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: current + package-manager-cache: false # the packages import each other, so link the workspaces - name: Run npm install run: npm install --workspaces --include-workspace-root @@ -147,6 +151,7 @@ jobs: uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: ${{ matrix.node-version }} + package-manager-cache: false - name: Install most recent npm run: npm install -g npm - name: Run npm install -w ${{ matrix.package }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 9e1d200..fb546a8 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -39,6 +39,9 @@ jobs: uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: lts/* + # setup-node caches npm when package.json has devEngines, and caching + # needs a lockfile, which this repo doesn't keep + package-manager-cache: false - name: Run npm install run: npm install --workspaces=false - name: Build the docs diff --git a/.github/workflows/nightly-static-build.yaml b/.github/workflows/nightly-static-build.yaml index 67bfd43..b60408d 100644 --- a/.github/workflows/nightly-static-build.yaml +++ b/.github/workflows/nightly-static-build.yaml @@ -21,6 +21,9 @@ jobs: uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: latest + # setup-node caches npm when package.json has devEngines, and caching + # needs a lockfile, which this repo doesn't keep + package-manager-cache: false - name: Run npm install run: npm install --workspaces --include-workspace-root - name: Run build:default diff --git a/.npmrc b/.npmrc index 43c97e7..043a42d 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,16 @@ package-lock=false + +## Avoid installing packages published in last 3 days. +min-release-age=3 + +## Don't install packages from git urls. +allow-git=none + +## Disable git entirely (false is a POSIX command that always fails). +git=false + +## Disable installing dependencies from remote URLs. +allow-remote=none + +## Pin allowed scripts to exact versions of dependencies to prevent unexpected script execution. +allow-scripts-pin=true diff --git a/README.md b/README.md index f4bb872..873a621 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,10 @@ nodevu is composed of a set of modules: ## Development +Installing needs npm 11.17 or newer, which ships with Node.js 24 and later. `.npmrc` skips packages published in the last 3 days and blocks git and URL dependencies. npm only runs the dependency install scripts that `allowScripts` in `package.json` approves, and `npm approve-scripts ` adds one pinned to its installed version. + - `npm test` and `npm run coverage` run every module's tests. -- `npm run lint` checks the whole repo with oxlint and oxfmt, and checks with [docov](https://github.com/cutenode/docov) that everything the modules export has JSDoc. `docov.jsonc` sets what it scans. +- `npm run lint` checks the whole repo with oxlint and oxfmt, and checks with [docov](https://github.com/cutenode/docov) that everything the modules export has JSDoc. `docov.jsonc` sets what it scans. `npm run lint:harden` tests `tools/harden/` and runs [@lavamoat/harden](https://github.com/LavaMoat/LavaMoat/tree/main/packages/harden) to check that `.npmrc` and `package.json` keep every hardening setting, except the ones `tools/harden/decisions.json` turns off. - `npm run types` type-checks the JSDoc and writes each module's TypeScript declarations to its `types/` folder. Commit them: CI fails when they're out of date. - `npm run docs:dev` builds the docs from `docs/pages/` with [doc-kit](https://doc-kit.nodejs.org/) and serves them locally. `npm run docs` only builds them, into `out/`. diff --git a/package.json b/package.json index b3db116..26ff416 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,9 @@ "description": "comprehensive node.js version tooling", "main": "index.js", "scripts": { - "lint": "oxlint && oxfmt --check && docov", + "lint": "oxlint && oxfmt --check && docov && npm run lint:harden", "lint:write": "oxlint --fix && oxfmt", + "lint:harden": "node --test tools/harden/test/*.test.mjs && harden check -p npm -l strict --json | node tools/harden/check.mjs", "coverage": "npm run coverage --ws --if-present", "test": "npm run test --ws", "updates": "npm run updates --ws", @@ -43,6 +44,7 @@ "@doc-kit/cli": "1.0.5", "@doc-kit/generator-react": "0.5.0", "@docov/cli": "0.1.1", + "@lavamoat/harden": "0.8.1", "@node-core/ui-components": "1.8.1", "@oxlint/plugins": "1.89.0", "@types/luxon": "3.7.6", @@ -55,5 +57,15 @@ }, "overrides": { "hast-util-raw": "9.1.0" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": ">=11.17.0", + "onFail": "error" + } + }, + "allowScripts": { + "fsevents@2.3.3": false } } diff --git a/tools/harden/check.mjs b/tools/harden/check.mjs new file mode 100644 index 0000000..24fa565 --- /dev/null +++ b/tools/harden/check.mjs @@ -0,0 +1,21 @@ +// `harden check` can only check a whole level, so this reads its strict +// `--json` report from stdin and fails on every opinion that isn't applied, +// except the ones decisions.json turns off. +import { readFile } from 'node:fs/promises'; +import { text } from 'node:stream/consumers'; + +const decisions = JSON.parse( + await readFile(new URL('decisions.json', import.meta.url), 'utf8'), +); + +const { checks } = JSON.parse(await text(process.stdin)); + +const missing = checks.filter( + ({ id, satisfied }) => satisfied !== 1 && decisions[id] !== false, +); + +for (const { id } of missing) { + console.error(`harden: ${id} is not applied`); +} + +process.exitCode = missing.length === 0 ? 0 : 1; diff --git a/tools/harden/decisions.json b/tools/harden/decisions.json new file mode 100644 index 0000000..8bb0f8b --- /dev/null +++ b/tools/harden/decisions.json @@ -0,0 +1,6 @@ +{ + "n_scripts": "n_allowscripts", + "n_deny_existing_lifecycle": true, + "n_nogit": true, + "n_runner": false +} diff --git a/tools/harden/test/check.test.mjs b/tools/harden/test/check.test.mjs new file mode 100644 index 0000000..df0720f --- /dev/null +++ b/tools/harden/test/check.test.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert'; +import { spawnSync } from 'node:child_process'; +import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const script = fileURLToPath(new URL('../check.mjs', import.meta.url)); + +/** + * Runs check.mjs with `input` on stdin, as `harden check --json` would pipe it. + * @param {string} input + */ +function check(input) { + return spawnSync(process.execPath, [script], { input, encoding: 'utf8' }); +} + +/** + * Builds a `harden check --json` report where only `unapplied` aren't satisfied. + * @param {string[]} unapplied + */ +function report(unapplied) { + const checks = ['n_engines', 'n_nogit', 'n_runner'].map((id) => ({ + id, + satisfied: unapplied.includes(id) ? 0 : 1, + })); + + return JSON.stringify({ checks }); +} + +describe('check.mjs', () => { + it('passes when every opinion is applied', () => { + const result = check(report([])); + + assert.strictEqual(result.status, 0); + assert.strictEqual(result.stderr, ''); + }); + + it('fails on an opinion that is not applied', () => { + const result = check(report(['n_nogit'])); + + assert.strictEqual(result.status, 1); + assert.strictEqual(result.stderr, 'harden: n_nogit is not applied\n'); + }); + + it('passes when the only unapplied opinion is declined in decisions.json', () => { + const result = check(report(['n_runner'])); + + assert.strictEqual(result.status, 0); + assert.strictEqual(result.stderr, ''); + }); + + it('fails on empty input', () => { + const result = check(''); + + assert.notStrictEqual(result.status, 0); + assert.match(result.stderr, /SyntaxError/); + }); +});