@williamthorsen/nmr 0.51.0 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,42 @@
1
1
  [
2
+ {
3
+ "version": "0.52.0",
4
+ "date": "2026-10-24",
5
+ "sections": [
6
+ {
7
+ "title": "🎉 Features",
8
+ "audience": "all",
9
+ "items": [
10
+ {
11
+ "description": "Adds a 📡 glyph before the host name in the rich-output `nmr` verdicts that name a host, for a passed, failed, or recalled run, which leaves plain output and verdicts reading `on this tree` unchanged. (#1105)",
12
+ "hash": "15c0d637325fff429ceae5aa5ec6e0ee19741daa",
13
+ "entry": 1
14
+ },
15
+ {
16
+ "description": "Adds the root command `lint:workflows` and its `nmr-actionlint` bin, which lint `.github/workflows/` with actionlint 1.7.12, including its shellcheck pass over each `run:` block with shellcheck 0.11.0, using binaries that nmr downloads on first use, checks against pinned SHA-256 checksums, and caches under `node_modules/.cache/nmr-tools/`. (#1107)",
17
+ "hash": "b4ddac2c4954a882d6be05962f9709e323ac3ece",
18
+ "entry": 1
19
+ },
20
+ {
21
+ "description": "Adds `lint:workflows` to the root `check`, `check:strict`, and `root:check` composites, which makes those composites and `nmr ci` fail on any actionlint finding in a repo that has workflows, need access to `github.com` on their first run, and fail on any platform other than macOS or Linux on x64 or arm64. (#1107)",
22
+ "hash": "b4ddac2c4954a882d6be05962f9709e323ac3ece",
23
+ "entry": 2
24
+ }
25
+ ]
26
+ },
27
+ {
28
+ "title": "🐛 Bug fixes",
29
+ "audience": "all",
30
+ "items": [
31
+ {
32
+ "description": "Fixes the issue that a delegated `nmr` command could not find a bin that the branch added to a workspace package, because the host's `pnpm install --frozen-lockfile` did not link new bins when the lockfile was unchanged. (#1108)",
33
+ "hash": "5b694689a51679ddfbb04f65a88d0757ca310f3f",
34
+ "entry": 1
35
+ }
36
+ ]
37
+ }
38
+ ]
39
+ },
2
40
  {
3
41
  "version": "0.51.0",
4
42
  "date": "2026-10-24",
@@ -7646,6 +7646,7 @@ var TYPECHECK_STEP = { run: "typecheck", shouldDeclineArguments: true };
7646
7646
  var ROOT_TYPECHECK_STEP = { run: "root:typecheck", shouldDeclineArguments: true };
7647
7647
  var LINT_CHECK_STEP = { run: "lint:check", shouldRunInBackground: true };
7648
7648
  var LINT_STRICT_STEP = { run: "lint:strict", shouldRunInBackground: true };
7649
+ var LINT_WORKFLOWS_STEP = { run: "lint:workflows", shouldDeclineArguments: true };
7649
7650
  var workspaceScripts = {
7650
7651
  build: ["compile"],
7651
7652
  check: [LINT_CHECK_STEP, TYPECHECK_STEP, "fmt:check", "test"],
@@ -7678,8 +7679,8 @@ function buildRootScripts(workspaceGlobs) {
7678
7679
  "audit:dev": "pnpm exec v11y --dev",
7679
7680
  "audit:prod": "pnpm exec v11y --prod",
7680
7681
  build: ["-R build"],
7681
- check: [LINT_CHECK_STEP, TYPECHECK_STEP, "fmt:check", "test"],
7682
- "check:strict": [LINT_STRICT_STEP, TYPECHECK_STEP, "fmt:check", "test"],
7682
+ check: [LINT_CHECK_STEP, LINT_WORKFLOWS_STEP, TYPECHECK_STEP, "fmt:check", "test"],
7683
+ "check:strict": [LINT_STRICT_STEP, LINT_WORKFLOWS_STEP, TYPECHECK_STEP, "fmt:check", "test"],
7683
7684
  // Excludes the audit, which in CI has a workflow of its own. The narrowed check runs against the build, so
7684
7685
  // the build declines the arguments rather than being narrowed by them.
7685
7686
  ci: [{ run: "build", shouldDeclineArguments: true }, "check:strict"],
@@ -7691,11 +7692,12 @@ function buildRootScripts(workspaceGlobs) {
7691
7692
  lint: "eslint --fix .",
7692
7693
  "lint:check": "eslint .",
7693
7694
  "lint:strict": "strict-lint",
7695
+ "lint:workflows": "nmr-actionlint",
7694
7696
  // The audit takes seconds and `ci` takes minutes, so the cheap gate fails first. The audit reads the
7695
7697
  // dependency tree, and an argument narrowing the code under test does not say anything about that tree.
7696
7698
  prepush: [{ run: "audit", shouldDeclineArguments: true }, "ci"],
7697
7699
  "report-overrides": "nmr-report-overrides",
7698
- "root:check": [ROOT_TYPECHECK_STEP, "fmt:check", "root:lint:check", "root:test"],
7700
+ "root:check": [ROOT_TYPECHECK_STEP, "fmt:check", "root:lint:check", LINT_WORKFLOWS_STEP, "root:test"],
7699
7701
  "root:lint": `eslint --fix ${ignorePatterns}.`,
7700
7702
  "root:lint:check": `eslint ${ignorePatterns}.`,
7701
7703
  "root:lint:strict": `strict-lint ${ignorePatterns}.`,
@@ -9188,7 +9190,7 @@ function hasSupportedStrictLintVersion() {
9188
9190
  });
9189
9191
  }
9190
9192
  function getMinVersion() {
9191
- const pickedFields = { "version": "0.51.0" };
9193
+ const pickedFields = { "version": "0.52.0" };
9192
9194
  if (typeof pickedFields["version"] !== "string") {
9193
9195
  throw new TypeError("nmr/package.json: 'version' must be a string");
9194
9196
  }
@@ -24,7 +24,7 @@
24
24
  "checklist": "nmr",
25
25
  "checks": [
26
26
  "@williamthorsen/nmr in devDependencies",
27
- "@williamthorsen/nmr >= 0.51.0",
27
+ "@williamthorsen/nmr >= 0.52.0",
28
28
  "pnpm-workspace.yaml exists",
29
29
  "catalogued dependencies use catalog:",
30
30
  "package.json has packageManager field",
@@ -201,7 +201,7 @@
201
201
  "path": "lib/workspace-build.ts"
202
202
  },
203
203
  {
204
- "hash": "006bcdd5",
204
+ "hash": "0d9124f7",
205
205
  "kind": "inline",
206
206
  "path": "../package.json",
207
207
  "paths": [
@@ -224,7 +224,7 @@
224
224
  "path": "../src/config.ts"
225
225
  },
226
226
  {
227
- "hash": "72ad1e0e",
227
+ "hash": "b8e98c81",
228
228
  "kind": "module",
229
229
  "path": "../src/default-scripts.ts"
230
230
  },
@@ -319,7 +319,7 @@
319
319
  "readyupVersion": "0.43.0",
320
320
  "source": "kits/default.ts",
321
321
  "sourceHash": "9fd61bf4",
322
- "targetHash": "54da2b6a"
322
+ "targetHash": "547201fd"
323
323
  }
324
324
  ]
325
325
  }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 0.52.0 — 2026-10-24
6
+
7
+ ### 🎉 Features
8
+
9
+ - Adds a 📡 glyph before the host name in the rich-output `nmr` verdicts that name a host, for a passed, failed, or recalled run, which leaves plain output and verdicts reading `on this tree` unchanged. (#1105)
10
+ - Adds the root command `lint:workflows` and its `nmr-actionlint` bin, which lint `.github/workflows/` with actionlint 1.7.12, including its shellcheck pass over each `run:` block with shellcheck 0.11.0, using binaries that nmr downloads on first use, checks against pinned SHA-256 checksums, and caches under `node_modules/.cache/nmr-tools/`. (#1107)
11
+ - Adds `lint:workflows` to the root `check`, `check:strict`, and `root:check` composites, which makes those composites and `nmr ci` fail on any actionlint finding in a repo that has workflows, need access to `github.com` on their first run, and fail on any platform other than macOS or Linux on x64 or arm64. (#1107)
12
+
13
+ ### 🐛 Bug fixes
14
+
15
+ - Fixes the issue that a delegated `nmr` command could not find a bin that the branch added to a workspace package, because the host's `pnpm install --frozen-lockfile` did not link new bins when the lockfile was unchanged. (#1108)
16
+
5
17
  ## 0.51.0 — 2026-10-24
6
18
 
7
19
  ### 🎉 Features
package/README.md CHANGED
@@ -5,12 +5,17 @@
5
5
  Context-aware script runner for pnpm monorepos. Includes an `nmr` (node-monorepo run) binary that provides centralized, consistent script execution across workspace packages and the monorepo root.
6
6
 
7
7
  <!-- section:release-notes -->
8
- ## Release notes — v0.51.0 (2026-10-24)
8
+ ## Release notes — v0.52.0 (2026-10-24)
9
9
 
10
10
  ### 🎉 Features
11
11
 
12
- - Widens the optional `vitest` peer range of `@williamthorsen/nmr` from `>=4.0.0 <5` to `>=4.0.0 <6`, adding support for Vitest 5, under which a bare `vitest` started from a package without its own config needs `--config` naming the root's `vitest.config.*`. (#1100)
13
- - Adds the `nmr-vitest` command and runs every package-scope `test*` script through it; when the package directory does not contain a `vitest.config.*` or `vite.config.*`, `nmr-vitest` passes `--config` naming the `vitest.config.*` at the monorepo root, and otherwise passes its arguments to Vitest unchanged. (#1100)
12
+ - Adds a 📡 glyph before the host name in the rich-output `nmr` verdicts that name a host, for a passed, failed, or recalled run, which leaves plain output and verdicts reading `on this tree` unchanged. (#1105)
13
+ - Adds the root command `lint:workflows` and its `nmr-actionlint` bin, which lint `.github/workflows/` with actionlint 1.7.12, including its shellcheck pass over each `run:` block with shellcheck 0.11.0, using binaries that nmr downloads on first use, checks against pinned SHA-256 checksums, and caches under `node_modules/.cache/nmr-tools/`. (#1107)
14
+ - Adds `lint:workflows` to the root `check`, `check:strict`, and `root:check` composites, which makes those composites and `nmr ci` fail on any actionlint finding in a repo that has workflows, need access to `github.com` on their first run, and fail on any platform other than macOS or Linux on x64 or arm64. (#1107)
15
+
16
+ ### 🐛 Bug fixes
17
+
18
+ - Fixes the issue that a delegated `nmr` command could not find a bin that the branch added to a workspace package, because the host's `pnpm install --frozen-lockfile` did not link new bins when the lockfile was unchanged. (#1108)
14
19
  <!-- /section:release-notes -->
15
20
 
16
21
  ## Installation
@@ -57,7 +62,7 @@ From the monorepo root:
57
62
  ```bash
58
63
  nmr test # Run root tests + recursive workspace tests
59
64
  nmr build # Build all packages
60
- nmr ci # Build, then run the strict checks
65
+ nmr ci # Build, then run the strict checks, workflow lint included
61
66
  nmr prepush # Run everything the remote runs, before a push
62
67
  ```
63
68
 
@@ -206,7 +211,7 @@ export default {
206
211
 
207
212
  ```console
208
213
  $ nmr --delegate-to imac2 ci
209
- ✅ my-repo: ci: passed on imac2 in 1m 48s
214
+ ✅ my-repo: ci: passed on 📡 imac2 in 1m 48s
210
215
  ```
211
216
 
212
217
  A delegation consults the [check-result cache](#check-result-cache) first, so a tree that already passed is not sent to the host; a pass that a host earned does not satisfy a run here. A repo whose checks must run on the machine that has the tree declares `delegate: false` in its config. [Delegating to another machine](docs/delegate.md) covers the user config, `--delegate`, `NMR_DELEGATE`, the rules that decide where a command runs, which commands can be delegated, how nmr chooses among several hosts, how the cache is consulted, what the host runs, how failures are reported, and setting up the host.
@@ -317,7 +322,7 @@ rdy list --from npm:@williamthorsen/nmr # what nmr publishes
317
322
  - [Machine lock](docs/machine-lock.md): Which commands wait for one another, where the lock lives, and bypassing it
318
323
  - [Delegating to another machine](docs/delegate.md): The user config, `--delegate`, `--delegate-to`, `NMR_DELEGATE`, where a command runs, choosing among several hosts, what the host runs, and setting it up
319
324
  - [Dependency upgrades](docs/upgrades.md): `nmr upgrade`, the upgrade policy, `report-catalog`, `report-overrides`, and `nmr-taze`
320
- - [Standalone utilities](docs/utilities.md): `nmr-clean`, `nmr-compile`, `nmr-fmt`, `nmr-vitest`, and `ensure-prepublish-hooks`
325
+ - [Standalone utilities](docs/utilities.md): `nmr-actionlint`, `nmr-clean`, `nmr-compile`, `nmr-fmt`, `nmr-vitest`, and `ensure-prepublish-hooks`
321
326
  - [Shared Vitest config](docs/vitest.md): Test tiers, test selections, the conventions check, and customization
322
327
  - [Shared Prettier config](docs/prettier.md): What it formats and how to customize it
323
328
  - [Workspace introspection](docs/workspace.md): The two workspace lookups
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ try {
3
+ await import('../dist/esm/cli-actionlint.js');
4
+ } catch (error) {
5
+ if (error.code === 'ERR_MODULE_NOT_FOUND') {
6
+ process.stderr.write('nmr-actionlint: build output not found; run `pnpm run bootstrap` first\n');
7
+ } else {
8
+ process.stderr.write(`nmr-actionlint: failed to load: ${error.message}\n`);
9
+ }
10
+ process.exit(1);
11
+ }
@@ -11,6 +11,7 @@ export const DEFAULT_CACHEABLE_COMMANDS = [
11
11
  'fmt:check',
12
12
  'lint:check',
13
13
  'lint:strict',
14
+ 'lint:workflows',
14
15
  'root:check',
15
16
  'root:lint:check',
16
17
  'root:lint:strict',
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,12 @@
1
+ import process from 'node:process';
2
+ import { reportError } from '@williamthorsen/nmr-core';
3
+ import { describeError } from '@williamthorsen/toolbelt.errors';
4
+ import { runActionlint } from "./commands/actionlint.js";
5
+ import { findMonorepoRoot } from "./workspace.js";
6
+ try {
7
+ process.exitCode = await runActionlint(findMonorepoRoot(), process.argv.slice(2));
8
+ }
9
+ catch (error) {
10
+ reportError(describeError(error));
11
+ process.exitCode = 1;
12
+ }
@@ -0,0 +1,23 @@
1
+ import { type WorkflowTool } from './workflow-tools.js';
2
+ /** How a spawned actionlint ended. */
3
+ export interface ActionlintOutcome {
4
+ error?: Error | undefined;
5
+ signal: NodeJS.Signals | null;
6
+ status: number | null;
7
+ }
8
+ export interface RunActionlintOptions {
9
+ /** Directory under which the pinned tools are cached; defaults to `node_modules/.cache/nmr-tools` at the root. */
10
+ cacheRoot?: string;
11
+ ensure?: (tool: WorkflowTool, cacheRoot: string) => Promise<string>;
12
+ spawn?: (file: string, args: string[], cwd: string) => ActionlintOutcome;
13
+ }
14
+ /**
15
+ * Lints the monorepo's GitHub Actions workflows with the pinned actionlint, running its shellcheck pass with the
16
+ * pinned shellcheck, and returns actionlint's exit status. A repo without a workflows directory passes.
17
+ */
18
+ export declare function runActionlint(monorepoRoot: string, args: readonly string[], options?: RunActionlintOptions): Promise<number>;
19
+ /**
20
+ * Builds actionlint's arguments. The pyflakes integration is disabled so that the result does not depend on whether
21
+ * the machine happens to have pyflakes on `PATH`.
22
+ */
23
+ export declare function buildActionlintArgs(shellcheckPath: string, args: readonly string[]): string[];
@@ -0,0 +1,31 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { describeSignalExitCode } from "../child-process.js";
5
+ import { ensureTool } from "./workflow-tools.js";
6
+ const WORKFLOWS_DIR = path.join('.github', 'workflows');
7
+ export async function runActionlint(monorepoRoot, args, options = {}) {
8
+ if (!fs.existsSync(path.join(monorepoRoot, WORKFLOWS_DIR))) {
9
+ console.info(`No workflows to lint: ${WORKFLOWS_DIR}/ does not exist.`);
10
+ return 0;
11
+ }
12
+ const cacheRoot = options.cacheRoot ?? path.join(monorepoRoot, 'node_modules', '.cache', 'nmr-tools');
13
+ const ensure = options.ensure ?? ((tool, root) => ensureTool(tool, { cacheRoot: root }));
14
+ const actionlintPath = await ensure('actionlint', cacheRoot);
15
+ const shellcheckPath = await ensure('shellcheck', cacheRoot);
16
+ const outcome = (options.spawn ?? spawnInherited)(actionlintPath, buildActionlintArgs(shellcheckPath, args), monorepoRoot);
17
+ if (outcome.error !== undefined) {
18
+ throw new Error(`Cannot run actionlint: ${outcome.error.message}`, { cause: outcome.error });
19
+ }
20
+ if (outcome.signal !== null) {
21
+ return describeSignalExitCode(outcome.signal);
22
+ }
23
+ return outcome.status ?? 1;
24
+ }
25
+ export function buildActionlintArgs(shellcheckPath, args) {
26
+ return [`-shellcheck=${shellcheckPath}`, '-pyflakes=', ...args];
27
+ }
28
+ function spawnInherited(file, args, cwd) {
29
+ const result = spawnSync(file, args, { cwd, stdio: 'inherit' });
30
+ return { error: result.error, signal: result.signal, status: result.status };
31
+ }
@@ -0,0 +1,39 @@
1
+ /** A program that `nmr-actionlint` downloads rather than takes from `PATH`. */
2
+ export type WorkflowTool = 'actionlint' | 'shellcheck';
3
+ /** A supported pair of `process.platform` and `process.arch`, joined by a hyphen. */
4
+ export type ToolPlatform = 'darwin-arm64' | 'darwin-x64' | 'linux-arm64' | 'linux-x64';
5
+ /** One release of a tool: where each platform's archive is, its SHA-256, and the binary's path inside it. */
6
+ export interface ToolPin {
7
+ archiveMember: string;
8
+ assets: Record<ToolPlatform, {
9
+ sha256: string;
10
+ url: string;
11
+ }>;
12
+ version: string;
13
+ }
14
+ export type ToolPins = Record<WorkflowTool, ToolPin>;
15
+ /** The subset of `fetch` that the download uses. */
16
+ export type FetchFunction = (url: string, init?: RequestInit) => Promise<Response>;
17
+ export interface EnsureToolOptions {
18
+ /** Directory under which each tool version is cached in a directory of its own. */
19
+ cacheRoot: string;
20
+ arch?: string;
21
+ fetch?: FetchFunction;
22
+ pins?: ToolPins;
23
+ platform?: NodeJS.Platform;
24
+ }
25
+ /**
26
+ * The pinned releases. To move a pin, change its version and URLs and take each SHA-256 from the release's `digest`
27
+ * as `gh api repos/{owner}/{repo}/releases/tags/{tag}` reports it, confirming actionlint's against the release's
28
+ * `checksums.txt`. The pins move only with a release of nmr.
29
+ */
30
+ export declare const TOOL_PINS: ToolPins;
31
+ /**
32
+ * Returns the path of the tool's pinned binary, downloading, verifying, and caching it first on a cache miss.
33
+ *
34
+ * A failure at any step leaves the cache without the tool, and a concurrent run never sees a partial binary: The
35
+ * binary is assembled in a scratch directory beside its target and renamed into place only once verified.
36
+ */
37
+ export declare function ensureTool(tool: WorkflowTool, options: EnsureToolOptions): Promise<string>;
38
+ /** Maps Node's platform and architecture onto a supported pair, rejecting every other pair. */
39
+ export declare function resolveToolPlatform(platform: NodeJS.Platform, arch: string): ToolPlatform;
@@ -0,0 +1,140 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import process from 'node:process';
6
+ import { describeError } from '@williamthorsen/toolbelt.errors';
7
+ import { UserError } from "../UserError.js";
8
+ const SUPPORTED_PLATFORMS = ['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64'];
9
+ const DOWNLOAD_TIMEOUT_MS = 60_000;
10
+ const ACTIONLINT_RELEASE = 'https://github.com/rhysd/actionlint/releases/download/v1.7.12';
11
+ const SHELLCHECK_RELEASE = 'https://github.com/koalaman/shellcheck/releases/download/v0.11.0';
12
+ export const TOOL_PINS = {
13
+ actionlint: {
14
+ archiveMember: 'actionlint',
15
+ assets: {
16
+ 'darwin-arm64': {
17
+ sha256: 'aba9ced2dee8d27fecca3dc7feb1a7f9a52caefa1eb46f3271ea66b6e0e6953f',
18
+ url: `${ACTIONLINT_RELEASE}/actionlint_1.7.12_darwin_arm64.tar.gz`,
19
+ },
20
+ 'darwin-x64': {
21
+ sha256: '5b44c3bc2255115c9b69e30efc0fecdf498fdb63c5d58e17084fd5f16324c644',
22
+ url: `${ACTIONLINT_RELEASE}/actionlint_1.7.12_darwin_amd64.tar.gz`,
23
+ },
24
+ 'linux-arm64': {
25
+ sha256: '325e971b6ba9bfa504672e29be93c24981eeb1c07576d730e9f7c8805afff0c6',
26
+ url: `${ACTIONLINT_RELEASE}/actionlint_1.7.12_linux_arm64.tar.gz`,
27
+ },
28
+ 'linux-x64': {
29
+ sha256: '8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8',
30
+ url: `${ACTIONLINT_RELEASE}/actionlint_1.7.12_linux_amd64.tar.gz`,
31
+ },
32
+ },
33
+ version: '1.7.12',
34
+ },
35
+ shellcheck: {
36
+ archiveMember: 'shellcheck-v0.11.0/shellcheck',
37
+ assets: {
38
+ 'darwin-arm64': {
39
+ sha256: '339b930feb1ea764467013cc1f72d09cd6b869ebf1013296ba9055ab2ffbd26f',
40
+ url: `${SHELLCHECK_RELEASE}/shellcheck-v0.11.0.darwin.aarch64.tar.gz`,
41
+ },
42
+ 'darwin-x64': {
43
+ sha256: 'c2c15e08df0e8fbc374c335b230a7ee958c313fa5714817a59aa59f1aa594f51',
44
+ url: `${SHELLCHECK_RELEASE}/shellcheck-v0.11.0.darwin.x86_64.tar.gz`,
45
+ },
46
+ 'linux-arm64': {
47
+ sha256: '68a8133197a50beb8803f8d42f9908d1af1c5540d4bb05fdfca8c1fa47decefc',
48
+ url: `${SHELLCHECK_RELEASE}/shellcheck-v0.11.0.linux.aarch64.tar.gz`,
49
+ },
50
+ 'linux-x64': {
51
+ sha256: 'b7af85e41cc99489dcc21d66c6d5f3685138f06d34651e6d34b42ec6d54fe6f6',
52
+ url: `${SHELLCHECK_RELEASE}/shellcheck-v0.11.0.linux.x86_64.tar.gz`,
53
+ },
54
+ },
55
+ version: '0.11.0',
56
+ },
57
+ };
58
+ export async function ensureTool(tool, options) {
59
+ const pin = (options.pins ?? TOOL_PINS)[tool];
60
+ const asset = pin.assets[resolveToolPlatform(options.platform ?? process.platform, options.arch ?? process.arch)];
61
+ const targetDir = path.join(options.cacheRoot, `${tool}-${pin.version}`);
62
+ const binaryPath = path.join(targetDir, tool);
63
+ if (fs.existsSync(binaryPath)) {
64
+ return binaryPath;
65
+ }
66
+ createCacheRoot(options.cacheRoot);
67
+ const scratchDir = `${targetDir}.tmp-${process.pid}`;
68
+ fs.rmSync(scratchDir, { force: true, recursive: true });
69
+ fs.mkdirSync(scratchDir);
70
+ try {
71
+ const archive = await download(asset.url, options.fetch ?? globalThis.fetch);
72
+ const actualSha256 = createHash('sha256').update(archive).digest('hex');
73
+ if (actualSha256 !== asset.sha256) {
74
+ throw new Error(`Checksum mismatch for ${asset.url}: expected sha256 ${asset.sha256}, got ${actualSha256}`);
75
+ }
76
+ const archivePath = path.join(scratchDir, 'archive.tar.gz');
77
+ fs.writeFileSync(archivePath, archive);
78
+ const extractDir = path.join(scratchDir, 'extract');
79
+ fs.mkdirSync(extractDir);
80
+ extractMember(archivePath, extractDir, pin.archiveMember);
81
+ const stageDir = path.join(scratchDir, 'stage');
82
+ fs.mkdirSync(stageDir);
83
+ const stagedBinary = path.join(stageDir, tool);
84
+ fs.renameSync(path.join(extractDir, pin.archiveMember), stagedBinary);
85
+ fs.chmodSync(stagedBinary, 0o755);
86
+ publishStage(stageDir, targetDir, binaryPath);
87
+ }
88
+ finally {
89
+ fs.rmSync(scratchDir, { force: true, recursive: true });
90
+ }
91
+ return binaryPath;
92
+ }
93
+ export function resolveToolPlatform(platform, arch) {
94
+ const key = `${platform}-${arch}`;
95
+ const supported = SUPPORTED_PLATFORMS.find((candidate) => candidate === key);
96
+ if (supported === undefined) {
97
+ throw new UserError(`Workflow linting does not support ${key}; the supported platforms are ${SUPPORTED_PLATFORMS.join(', ')}`);
98
+ }
99
+ return supported;
100
+ }
101
+ function createCacheRoot(cacheRoot) {
102
+ try {
103
+ fs.mkdirSync(cacheRoot, { recursive: true });
104
+ }
105
+ catch (error) {
106
+ throw new Error(`Cannot create the tool cache at ${cacheRoot}`, { cause: error });
107
+ }
108
+ }
109
+ async function download(url, fetchImpl) {
110
+ let response;
111
+ try {
112
+ response = await fetchImpl(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
113
+ }
114
+ catch (error) {
115
+ throw new Error(`Cannot download ${url}: ${describeError(error)}`, { cause: error });
116
+ }
117
+ if (!response.ok) {
118
+ throw new Error(`Cannot download ${url}: HTTP ${response.status}`);
119
+ }
120
+ return Buffer.from(await response.arrayBuffer());
121
+ }
122
+ function extractMember(archivePath, extractDir, member) {
123
+ const result = spawnSync('tar', ['-xzf', archivePath, '-C', extractDir, member], { encoding: 'utf8' });
124
+ if (result.error !== undefined) {
125
+ throw new Error(`Cannot run tar to extract ${member}: ${result.error.message}`, { cause: result.error });
126
+ }
127
+ if (result.status !== 0) {
128
+ throw new Error(`tar could not extract ${member} from ${archivePath}: ${result.stderr.trim()}`);
129
+ }
130
+ }
131
+ function publishStage(stageDir, targetDir, binaryPath) {
132
+ try {
133
+ fs.renameSync(stageDir, targetDir);
134
+ }
135
+ catch (error) {
136
+ if (!fs.existsSync(binaryPath)) {
137
+ throw error;
138
+ }
139
+ }
140
+ }
@@ -4,6 +4,7 @@ const TYPECHECK_STEP = { run: 'typecheck', shouldDeclineArguments: true };
4
4
  const ROOT_TYPECHECK_STEP = { run: 'root:typecheck', shouldDeclineArguments: true };
5
5
  const LINT_CHECK_STEP = { run: 'lint:check', shouldRunInBackground: true };
6
6
  const LINT_STRICT_STEP = { run: 'lint:strict', shouldRunInBackground: true };
7
+ const LINT_WORKFLOWS_STEP = { run: 'lint:workflows', shouldDeclineArguments: true };
7
8
  export const workspaceScripts = {
8
9
  build: ['compile'],
9
10
  check: [LINT_CHECK_STEP, TYPECHECK_STEP, 'fmt:check', 'test'],
@@ -35,8 +36,8 @@ export function buildRootScripts(workspaceGlobs) {
35
36
  'audit:dev': 'pnpm exec v11y --dev',
36
37
  'audit:prod': 'pnpm exec v11y --prod',
37
38
  build: ['-R build'],
38
- check: [LINT_CHECK_STEP, TYPECHECK_STEP, 'fmt:check', 'test'],
39
- 'check:strict': [LINT_STRICT_STEP, TYPECHECK_STEP, 'fmt:check', 'test'],
39
+ check: [LINT_CHECK_STEP, LINT_WORKFLOWS_STEP, TYPECHECK_STEP, 'fmt:check', 'test'],
40
+ 'check:strict': [LINT_STRICT_STEP, LINT_WORKFLOWS_STEP, TYPECHECK_STEP, 'fmt:check', 'test'],
40
41
  ci: [{ run: 'build', shouldDeclineArguments: true }, 'check:strict'],
41
42
  clean: 'nmr-clean',
42
43
  fix: ['lint', 'fmt'],
@@ -46,9 +47,10 @@ export function buildRootScripts(workspaceGlobs) {
46
47
  lint: 'eslint --fix .',
47
48
  'lint:check': 'eslint .',
48
49
  'lint:strict': 'strict-lint',
50
+ 'lint:workflows': 'nmr-actionlint',
49
51
  prepush: [{ run: 'audit', shouldDeclineArguments: true }, 'ci'],
50
52
  'report-overrides': 'nmr-report-overrides',
51
- 'root:check': [ROOT_TYPECHECK_STEP, 'fmt:check', 'root:lint:check', 'root:test'],
53
+ 'root:check': [ROOT_TYPECHECK_STEP, 'fmt:check', 'root:lint:check', LINT_WORKFLOWS_STEP, 'root:test'],
52
54
  'root:lint': `eslint --fix ${ignorePatterns}.`,
53
55
  'root:lint:check': `eslint ${ignorePatterns}.`,
54
56
  'root:lint:strict': `strict-lint ${ignorePatterns}.`,
@@ -5,5 +5,5 @@
5
5
  * `noop` is a neutral circle: Nothing ran because the repo asked for nothing to run, which is neither a
6
6
  * failure nor a block.
7
7
  */
8
- export declare const NMR_GLYPHS: Readonly<Record<"plain" | "rich", Readonly<Record<"overrides" | "clean" | "catalog" | "noop" | "package" | "recording", import("@williamthorsen/toolbelt.terminal/candidate").Glyph>>>>;
8
+ export declare const NMR_GLYPHS: Readonly<Record<"plain" | "rich", Readonly<Record<"overrides" | "clean" | "host" | "catalog" | "noop" | "package" | "recording", import("@williamthorsen/toolbelt.terminal/candidate").Glyph>>>>;
9
9
  export type NmrGlyphName = keyof (typeof NMR_GLYPHS)['rich'];
@@ -2,6 +2,7 @@ import { defineGlyphSet } from '@williamthorsen/nmr-core';
2
2
  export const NMR_GLYPHS = defineGlyphSet({
3
3
  catalog: { plain: '', rich: '\u{1F4DA}' },
4
4
  clean: { plain: '', rich: '\u{1F9F9}' },
5
+ host: { plain: '', rich: '\u{1F4E1}' },
5
6
  noop: { plain: 'NOOP', rich: '\u{26AA}' },
6
7
  overrides: { plain: '', rich: '\u{1F512}' },
7
8
  package: { plain: '', rich: '\u{1F4E6}' },
@@ -14,6 +14,8 @@ export function runHostSession(params) {
14
14
  const COMMAND_NOT_FOUND_EXIT_CODE = 127;
15
15
  const DELEGATION_FAILURE_EXIT_CODE = 255;
16
16
  const STOP_GRACE_MS = 5_000;
17
+ const NODE_MODULES_DIR = path.join(params.rootDir, 'node_modules');
18
+ const BINS_RECORD_PATH = path.join(NODE_MODULES_DIR, '.nmr-delegate-bins');
17
19
  const token = crypto.randomUUID();
18
20
  let isHolding = false;
19
21
  let activeChild;
@@ -36,6 +38,10 @@ export function runHostSession(params) {
36
38
  return;
37
39
  }
38
40
  }
41
+ const binsFingerprint = readBinsFingerprint();
42
+ if (binsFingerprint === undefined || readBinsRecord() !== binsFingerprint) {
43
+ removeWorkspaceState();
44
+ }
39
45
  const install = await runPhase('pnpm', ['install', '--frozen-lockfile'], params.rootDir, {
40
46
  ...process.env,
41
47
  CI: 'true',
@@ -46,6 +52,8 @@ export function runHostSession(params) {
46
52
  fail('install', install);
47
53
  return;
48
54
  }
55
+ if (binsFingerprint !== undefined)
56
+ writeBinsRecord(binsFingerprint);
49
57
  emit({ kind: 'run' });
50
58
  fs.mkdirSync(params.runDir, { recursive: true });
51
59
  fs.mkdirSync(params.passManifestDir, { recursive: true });
@@ -146,6 +154,42 @@ export function runHostSession(params) {
146
154
  return true;
147
155
  return state.holder !== undefined && isProcessGone(state.holder.pid);
148
156
  }
157
+ function readBinsFingerprint() {
158
+ let listing;
159
+ try {
160
+ listing = childProcess.execFileSync('git', ['ls-files', '-z', '--', '*package.json'], {
161
+ cwd: params.rootDir,
162
+ encoding: 'utf8',
163
+ stdio: ['ignore', 'pipe', 'ignore'],
164
+ });
165
+ }
166
+ catch {
167
+ return undefined;
168
+ }
169
+ const lines = listing
170
+ .split('\0')
171
+ .filter((file) => path.basename(file) === 'package.json')
172
+ .toSorted()
173
+ .map((file) => {
174
+ try {
175
+ const manifest = JSON.parse(fs.readFileSync(path.join(params.rootDir, file), 'utf8'));
176
+ const fields = typeof manifest === 'object' && manifest !== null ? { ...manifest } : {};
177
+ return JSON.stringify([file, fields['name'], fields['bin']]);
178
+ }
179
+ catch {
180
+ return JSON.stringify([file]);
181
+ }
182
+ });
183
+ return crypto.createHash('sha256').update(lines.join('\n')).digest('hex');
184
+ }
185
+ function readBinsRecord() {
186
+ try {
187
+ return fs.readFileSync(BINS_RECORD_PATH, 'utf8');
188
+ }
189
+ catch {
190
+ return undefined;
191
+ }
192
+ }
149
193
  function readErrorCode(error) {
150
194
  return typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined;
151
195
  }
@@ -218,6 +262,14 @@ export function runHostSession(params) {
218
262
  fs.rmSync(LOCK_PATH, { force: true });
219
263
  }
220
264
  }
265
+ function removeWorkspaceState() {
266
+ const names = fs.existsSync(NODE_MODULES_DIR) ? fs.readdirSync(NODE_MODULES_DIR) : [];
267
+ for (const name of names) {
268
+ if (name.startsWith('.pnpm-workspace-state') && name.endsWith('.json')) {
269
+ fs.rmSync(path.join(NODE_MODULES_DIR, name), { force: true });
270
+ }
271
+ }
272
+ }
221
273
  function runPhase(file, args, cwd, env) {
222
274
  const head = [];
223
275
  const tail = [];
@@ -274,4 +326,12 @@ export function runHostSession(params) {
274
326
  catch {
275
327
  }
276
328
  }
329
+ function writeBinsRecord(fingerprint) {
330
+ try {
331
+ fs.mkdirSync(NODE_MODULES_DIR, { recursive: true });
332
+ fs.writeFileSync(BINS_RECORD_PATH, fingerprint);
333
+ }
334
+ catch {
335
+ }
336
+ }
277
337
  }
@@ -23,7 +23,7 @@ const MIN_CUT_BYTES = Buffer.byteLength(TRUNCATION_MARK);
23
23
  const REPLAY_MARKER = 'replayed:';
24
24
  function describeOutcome(verdict, style) {
25
25
  const statuses = STATUS_GLYPHS[style];
26
- const onHost = verdict.host === undefined ? '' : ` on ${verdict.host}`;
26
+ const onHost = verdict.host === undefined ? '' : ` on ${formatHost(verdict.host, style)}`;
27
27
  switch (verdict.outcome) {
28
28
  case 'passed':
29
29
  return { marker: statuses.passed.text, phrase: `passed${onHost} in ${formatDuration(verdict.durationMs)}` };
@@ -35,9 +35,10 @@ function describeOutcome(verdict, style) {
35
35
  case 'recalled': {
36
36
  const saving = formatSaving(verdict.savedMs);
37
37
  const savingClause = saving === undefined ? '' : `, ${saving}`;
38
+ const source = verdict.host === undefined ? 'this tree' : formatHost(verdict.host, style);
38
39
  return {
39
40
  marker: statuses.skipped.text,
40
- phrase: `passed ${formatDuration(verdict.ageMs)} ago on ${verdict.host ?? 'this tree'}${savingClause}`,
41
+ phrase: `passed ${formatDuration(verdict.ageMs)} ago on ${source}${savingClause}`,
41
42
  };
42
43
  }
43
44
  case 'no-op':
@@ -87,6 +88,10 @@ function findCutTarget(sizesBytes, longestSizeBytes, overrunBytes) {
87
88
  function isWithinBudget(renderedLine) {
88
89
  return Buffer.byteLength(renderedLine) <= LINE_BUDGET_BYTES;
89
90
  }
91
+ function formatHost(host, style) {
92
+ const glyph = NMR_GLYPHS[style].host.text;
93
+ return glyph === '' ? host : `${glyph} ${host}`;
94
+ }
90
95
  function readAttribution(verdict) {
91
96
  if (verdict.outcome !== 'recalled' || verdict.replay === undefined) {
92
97
  return [];
@@ -10,7 +10,7 @@ The cache is a working-tree cache, not a build cache: It answers "has this exact
10
10
 
11
11
  ## What is cached
12
12
 
13
- Cacheable by default: `check`, `check:strict`, `ci`, `fix:check`, `fmt:check`, `lint:check`, `lint:strict`, `test`, `test:coverage`, `test:tool`, `test:unit`, `typecheck`, and the `root:` variants `root:check`, `root:lint:check`, `root:lint:strict`, `root:test`, `root:test:tool`, `root:test:unit`, and `root:typecheck`.
13
+ Cacheable by default: `check`, `check:strict`, `ci`, `fix:check`, `fmt:check`, `lint:check`, `lint:strict`, `lint:workflows`, `test`, `test:coverage`, `test:tool`, `test:unit`, `typecheck`, and the `root:` variants `root:check`, `root:lint:check`, `root:lint:strict`, `root:test`, `root:test:tool`, `root:test:unit`, and `root:typecheck`.
14
14
 
15
15
  Everything else runs every time:
16
16
 
package/docs/delegate.md CHANGED
@@ -5,7 +5,7 @@ nmr runs a command on another machine over SSH, against the working tree on this
5
5
  ```console
6
6
  $ nmr --delegate-to imac2 ci
7
7
  # ... the host's own output and verdicts ...
8
- ✅ my-repo: ci: passed on imac2 in 1m 48s
8
+ ✅ my-repo: ci: passed on 📡 imac2 in 1m 48s
9
9
  ```
10
10
 
11
11
  A machine that runs several worktrees' checks at once runs out of memory, and the [machine lock](machine-lock.md) protects it by making the checks wait for one another. Delegation moves a check to a machine that has the room for it instead. Which hosts a machine can reach is a fact about the machine, so a user config on the machine, not the repo, names them and turns delegation on.
@@ -61,7 +61,7 @@ With several hosts, nmr asks every host at once, with one `ssh` call each, wheth
61
61
 
62
62
  ```console
63
63
  $ NMR_DELEGATE=imac2,imac3 nmr ci
64
- ✅ my-repo: ci: passed on imac3 in 1m 52s
64
+ ✅ my-repo: ci: passed on 📡 imac3 in 1m 52s
65
65
  ```
66
66
 
67
67
  - **A single host is not asked.** The delegation goes to it directly.
@@ -98,7 +98,7 @@ $ nmr --delegate-to imac2 ci
98
98
 
99
99
  nmr records the working tree as it is on disk as a commit: tracked changes, staged or not, and untracked files that git does not ignore. This is the tree that a local run checks. The commit is built in a temporary index, so the repository's index, refs, and stash do not change.
100
100
 
101
- nmr pushes the commit to a checkout on the host under `~/.cache/nmr/delegate/`, without running the repository's `pre-push` hook, then runs `pnpm install --frozen-lockfile` and the command from the directory that corresponds to the local one, with nmr's own flags as they were given. The host keeps one checkout for each repository on each machine that delegates to it, so concurrent delegations from different worktrees do not share one, and `node_modules` and build output persist between runs. nmr does not remove these checkouts.
101
+ nmr pushes the commit to a checkout on the host under `~/.cache/nmr/delegate/`, without running the repository's `pre-push` hook, then runs `pnpm install --frozen-lockfile` and the command from the directory that corresponds to the local one, with nmr's own flags as they were given. The host keeps one checkout for each repository on each machine that delegates to it, so concurrent delegations from different worktrees do not share one, and `node_modules` and build output persist between runs. When the workspace packages' `bin` entries differ from those of the checkout's last successful install, nmr first removes pnpm's workspace state, so that the install links the new bins, which an unchanged lockfile would otherwise let pnpm skip; that install reruns the workspace's `prepare` scripts. nmr does not remove these checkouts.
102
102
 
103
103
  - **Ignored files stay here.** A command that needs a gitignored file, such as a local `.env`, does not find it on the host.
104
104
  - **A repository with submodules cannot be delegated**, nor can a tree containing a nested repository, because the snapshot does not include their content. A delegation flag refuses such a tree; under `NMR_DELEGATE` or the user config, the command runs here, and nmr prints one line saying why.
@@ -123,7 +123,7 @@ The host's nmr prints its output and verdicts, and nmr here adds one verdict for
123
123
  A failure of the delegation itself, rather than of the command, exits 255, the status with which `ssh` reports its own failures, and names the step that failed, after what that step printed:
124
124
 
125
125
  ```console
126
- ❌ my-repo: ci: failed on imac2 in 10.0s (exit 255) — could not prepare imac2
126
+ ❌ my-repo: ci: failed on 📡 imac2 in 10.0s (exit 255) — could not prepare imac2
127
127
  ```
128
128
 
129
129
  The steps are `prepare`, which reaches the host and creates the checkout, `push`, `check out`, and `install`. A connection lost while the command runs is reported as `lost connection to <host>`.
package/docs/scripts.md CHANGED
@@ -182,7 +182,7 @@ Neither is bound to a git hook. `prepush` is named for when a developer runs it,
182
182
 
183
183
  ### Check and quality
184
184
 
185
- `check` and `check:strict` start their lint step first and [run it in the background](#running-a-step-in-the-background), alongside typecheck, the format check, and the tests.
185
+ `check` and `check:strict` start their lint step first and [run it in the background](#running-a-step-in-the-background), alongside typecheck, the format check, and the tests. At the root, both then run [`lint:workflows`](#workflow-lint) before typecheck, and `root:check` runs it after `root:lint:check`.
186
186
 
187
187
  Neither gate computes coverage: Instrumentation multiplies the wall time of the run that they exist to keep short. Coverage is [`test:coverage`](vitest.md#test-selections), run as a step of its own; a repo that wants it inside the gate [overrides](#configuration) `check:strict`.
188
188
 
@@ -194,6 +194,14 @@ Requires `eslint` >= 10 and, for `lint:strict`, `@williamthorsen/strict-lint` >=
194
194
 
195
195
  The run never enters a package, so per-package `lint:pre` and `lint:post` hooks do not fire from the root. They still run from inside the package and under `nmr -F <package> lint`.
196
196
 
197
+ ### Workflow lint
198
+
199
+ `lint:workflows`, a root command, lints the GitHub Actions workflows under `.github/workflows/` with [actionlint](https://github.com/rhysd/actionlint), including its shellcheck pass over every `run:` block, and fails on any finding. A repo without `.github/workflows/` passes. The command runs `nmr-actionlint`, which forwards trailing arguments to actionlint, so `nmr lint:workflows .github/workflows/ci.yaml` lints that file alone; inside `check`, `check:strict`, and `root:check`, the step declines them.
200
+
201
+ The result does not depend on what the machine has installed. nmr pins actionlint 1.7.12 and shellcheck 0.11.0, downloads each from its upstream GitHub release on first use, verifies it against the SHA-256 recorded in nmr, and caches it under `node_modules/.cache/nmr-tools/` at the monorepo root. The first run therefore needs access to `github.com`, and a failed download or a checksum mismatch fails the command. The pyflakes integration is off. The pins move only with a release of nmr.
202
+
203
+ The supported platforms are macOS and Linux on x64 and arm64; on any other, the command fails and names the platform. actionlint reads its own configuration from `.github/actionlint.yaml`, through which a repo can ignore a finding.
204
+
197
205
  ### Root-only
198
206
 
199
207
  The `root:`-prefixed commands operate on root-level code only (not workspace packages).
package/docs/utilities.md CHANGED
@@ -1,6 +1,14 @@
1
1
  # Standalone utilities
2
2
 
3
- The binaries behind nmr's default `clean`, `compile`, `fmt`, and package `test` commands, and a check for the `prepublishOnly` hooks of publishable packages.
3
+ The binaries behind nmr's default `clean`, `compile`, `fmt`, `lint:workflows`, and package `test` commands, and a check for the `prepublishOnly` hooks of publishable packages.
4
+
5
+ ## `nmr-actionlint`
6
+
7
+ Lint the monorepo's GitHub Actions workflows with pinned actionlint and shellcheck binaries, forwarding any arguments to actionlint. This is the default root `lint:workflows` script; [Workflow lint](scripts.md#workflow-lint) covers the pins, the download, and the supported platforms.
8
+
9
+ ```bash
10
+ nmr-actionlint
11
+ ```
4
12
 
5
13
  ## `nmr-clean`
6
14
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamthorsen/nmr",
3
- "version": "0.51.0",
3
+ "version": "0.52.0",
4
4
  "private": false,
5
5
  "description": "Context-aware script runner for pnpm monorepos",
6
6
  "keywords": [
@@ -58,6 +58,7 @@
58
58
  "bin": {
59
59
  "ensure-prepublish-hooks": "bin/ensure-prepublish-hooks.js",
60
60
  "nmr": "bin/nmr.js",
61
+ "nmr-actionlint": "bin/nmr-actionlint.js",
61
62
  "nmr-clean": "bin/nmr-clean.js",
62
63
  "nmr-compile": "bin/nmr-compile.js",
63
64
  "nmr-fmt": "bin/nmr-fmt.js",