From ee30a340f88b6a2914202c78751f8181ff0af9dc Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Mon, 22 Jun 2026 12:26:37 -0700 Subject: [PATCH] Front the provisioned Node on the global PATH; add problem matchers + auth re-export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the action provisioned Node into nub's cache but never added its bin dir to the global PATH, so bare node/npm/npx/corepack in later steps resolved to the runner's preinstalled Node rather than the specified version — diverging from actions/setup-node, whose toolcache bin dir IS PATH-fronted. - Front the nub-resolved Node bin dir onto $GITHUB_PATH. The dir is derived at runtime from `nub node which` (never a hardcoded cache path) and is correct on every OS by construction: dirname of the resolved node binary (POSIX bin/node, Windows node.exe at the version root). The stderr resolution reason is the OS-independent discriminator for host-fallback (skip fronting) vs a managed Node (front it). An explicit node-version/node-version-file is authoritative for the PATH front (matching setup-node); nub still runs the project pin at invocation time and warns on a mismatch. The bin dir is fronted ahead of nub's own bin so real npm/npx win. - node-version output is now empty on host fallback (nothing provisioned) instead of leaking the runner's host version, matching the documented contract. - Register the same three problem matchers setup-node ships (tsc, eslint-stylish, eslint-compact) so tsc/eslint output surfaces as inline annotations. - Re-export NODE_AUTH_TOKEN to the env when present (matching setup-node authutil), via the heredoc form and masked from logs. - Correct the .npmrc 'byte-for-byte' claim to 'functionally equivalent' in the action comment and README (line order differs; npm parses both identically). - Add a 3-OS differential smoke job asserting bare node/npm/npx resolve to the specified version inside the fronted dir (plus npm i -g reachability), against an actions/setup-node control. --- .github/eslint-compact.json | 18 +++++ .github/eslint-stylish.json | 22 ++++++ .github/tsc.json | 18 +++++ .github/workflows/smoke.yml | 68 ++++++++++++++++++ README.md | 23 ++++--- action.yml | 134 ++++++++++++++++++++++++++++++++---- 6 files changed, 258 insertions(+), 25 deletions(-) create mode 100644 .github/eslint-compact.json create mode 100644 .github/eslint-stylish.json create mode 100644 .github/tsc.json diff --git a/.github/eslint-compact.json b/.github/eslint-compact.json new file mode 100644 index 0000000..d577d49 --- /dev/null +++ b/.github/eslint-compact.json @@ -0,0 +1,18 @@ +{ + "problemMatcher": [ + { + "owner": "eslint-compact", + "pattern": [ + { + "regexp": "^(.+):\\sline\\s(\\d+),\\scol\\s(\\d+),\\s([Ee]rror|[Ww]arning|[Ii]nfo)\\s-\\s(.+)\\s\\((.+)\\)$", + "file": 1, + "line": 2, + "column": 3, + "severity": 4, + "message": 5, + "code": 6 + } + ] + } + ] +} diff --git a/.github/eslint-stylish.json b/.github/eslint-stylish.json new file mode 100644 index 0000000..db9655b --- /dev/null +++ b/.github/eslint-stylish.json @@ -0,0 +1,22 @@ +{ + "problemMatcher": [ + { + "owner": "eslint-stylish", + "pattern": [ + { + "regexp": "^\\s*([^\\s].*)$", + "file": 1 + }, + { + "regexp": "^\\s+(\\d+):(\\d+)\\s+(error|warning|info)\\s+(.*)\\s\\s+(.*)$", + "line": 1, + "column": 2, + "severity": 3, + "message": 4, + "code": 5, + "loop": true + } + ] + } + ] +} diff --git a/.github/tsc.json b/.github/tsc.json new file mode 100644 index 0000000..7d8df56 --- /dev/null +++ b/.github/tsc.json @@ -0,0 +1,18 @@ +{ + "problemMatcher": [ + { + "owner": "tsc", + "pattern": [ + { + "regexp": "^([^\\s].*)[\\(:](\\d+)[,:](\\d+)(?:\\):\\s+|\\s+-\\s+)(error|warning|info)\\s+TS(\\d+)\\s*:\\s*(.*)$", + "file": 1, + "line": 2, + "column": 3, + "severity": 4, + "code": 5, + "message": 6 + } + ] + } + ] +} diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 59bb264..8b241e5 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -43,6 +43,74 @@ jobs: run: | [[ "${{ steps.pinned.outputs.nub-version }}" == "v0.0.47" ]] || { echo "::error::expected v0.0.47, got ${{ steps.pinned.outputs.nub-version }}"; exit 1; } + # Differential PATH parity: after setup-nub with an explicit node-version, bare + # node/npm/npx/corepack in a LATER step must resolve to that version — the + # headline drop-in promise. We assert the MAJOR (setup-node and nub may resolve + # a different patch of 20.x, so byte-equality would flake) and confirm the + # global PATH was actually re-pointed (not the runner's preinstalled default). + # A setup-node control job runs the identical assertions as a sanity reference. + path-parity: + name: bare node/npm/npx == specified version (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: ./ + with: + node-version: "20" + - name: bare executables resolve to the specified Node 20.x + shell: bash + run: | + set -euo pipefail + node_path="$(command -v node)" + npm_path="$(command -v npm)" + echo "node: $node_path / npm: $npm_path" + nv="$(node -p 'process.versions.node')" + echo "node -v: $nv / npm -v: $(npm -v) / npx -v: $(npx -v)" + # 1. bare node is the specified MAJOR (patch may differ from setup-node's). + [[ "$nv" == 20.* ]] || { echo "::error::bare node is $nv, expected 20.x"; exit 1; } + # 2. node AND npm must resolve INSIDE the SAME fronted dir — proves the + # PATH front (not a coincidental runner default that also happens to be 20). + node_dir="$(dirname "$node_path")" + npm_dir="$(dirname "$npm_path")" + [[ "$node_dir" == "$npm_dir" ]] || { echo "::error::node ($node_dir) and npm ($npm_dir) resolve to different dirs"; exit 1; } + # 3. npm itself runs ON the fronted Node (npm reports its own Node). + npm_node="$(npm version --json | node -p 'JSON.parse(require("fs").readFileSync(0,"utf8")).node')" + [[ "$npm_node" == 20.* ]] || { echo "::error::npm runs on Node $npm_node, expected 20.x"; exit 1; } + # 4. npm i -g global-bin parity: a globally-installed CLI lands on PATH + # (the fronted Node's global prefix bin == the fronted dir for a + # self-contained tarball). + npm i -g cowsay@1.6.0 >/dev/null 2>&1 + command -v cowsay >/dev/null 2>&1 || { echo "::error::npm i -g did not put cowsay on PATH (global prefix bin not fronted)"; exit 1; } + # 5. corepack is bundled but may be disabled on newer Node; presence-only, not a hard gate. + if command -v corepack >/dev/null 2>&1; then echo "corepack: $(corepack --version 2>/dev/null || echo '(disabled)')"; fi + echo "PATH parity OK" + + path-parity-control: + name: setup-node control — bare node == 20.x (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + - name: reference assertions (identical to the setup-nub job) + shell: bash + run: | + set -euo pipefail + echo "which node: $(command -v node)" + nv="$(node -p 'process.versions.node')" + echo "node: $nv / npm: $(npm -v) / npx: $(npx -v)" + [[ "$nv" == 20.* ]] || { echo "::error::control: bare node is $nv, expected 20.x"; exit 1; } + echo "setup-node control OK" + bare-run: name: nub provisions Node + installs unaided (${{ matrix.os }}) strategy: diff --git a/README.md b/README.md index a8401f1..47df5ae 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Install the [nub](https://github.com/nubjs/nub) CLI on a GitHub Actions runner. - run: nub run build ``` -That's the whole story for most projects: setup-nub puts `nub` on PATH, **eagerly provisions the project's pinned Node** (resolved from `.node-version` / `.nvmrc` / `package.json`) so the first build step is warm instead of paying a Node download mid-build, **caches nub's store across runs by default**, and reads a standard `.npmrc` for registry auth. +That's the whole story for most projects: setup-nub puts `nub` on PATH, **provisions the right Node and fronts its bin dir on the global PATH** (so bare `node`/`npm`/`npx`/`corepack` in later steps resolve to that version, exactly like setup-node), **caches nub's store across runs by default**, and reads a standard `.npmrc` for registry auth. The Node version is resolved from the project's pin (`.node-version` / `.nvmrc` / `package.json`) by default, or from an explicit `node-version` input. Because the eager provision reads the project's pin files off disk, **`actions/checkout` must run before `setup-nub`.** With no inputs and no pin declared, the action provisions nothing and nub falls back to provisioning lazily at runtime — it never fails on a missing pin. @@ -28,29 +28,32 @@ Because the eager provision reads the project's pin files off disk, **`actions/c # after - uses: nubjs/setup-nub@v0 with: - node-version: 20 # pre-provisions Node 20 into nub's cache (warm-up hint) + node-version: 20 # provisions Node 20 and fronts it on the global PATH cache: npm # accepted; caching is on by default regardless of value registry-url: https://registry.npmjs.org ``` Like setup-node, **caching is on by default** — `cache: npm` (or `yarn`/`pnpm`/`bun`) keeps working but is no longer required to get a warm store. Disable with `package-manager-cache: false`. -## How nub's Node provisioning differs from setup-node +## Node on the global PATH -`setup-node` exists to **put a Node toolchain on the runner**. nub does that itself, from the project's declared pin. So `node-version` here means something subtly different: +Like `actions/setup-node`, setup-nub provisions a Node toolchain and **adds its bin dir to the global `PATH`**, so bare `node`/`npm`/`npx`/`corepack` in subsequent steps resolve to that version — a swap from `actions/setup-node@v4` to `nubjs/setup-nub@v0` leaves later steps that call bare `node`/`npm` behaving the same. The provisioned bin holds the real Node binaries (no nub-branded shim is fronted), and it is placed ahead of `nub`'s own bin so real `npm`/`npx` win. -- With no inputs (the common case), setup-nub **eagerly provisions the project's own pinned Node** up front — it resolves the full pin chain (`devEngines.runtime` → `.node-version` → `.nvmrc` → `engines.node`) and installs the concrete version, so later steps don't pay a lazy download. If the project declares no pin, it skips gracefully (no error) and nub provisions lazily at runtime. -- `node-version` and `node-version-file` are **warm-up hints**, not pins. They pre-provision that Node into nub's cache — but **nub still runs the project's declared Node at runtime.** If the input disagrees with the project's resolved pin, the action emits a warning and the project pin wins. +Which version is fronted: -This is the one place the drop-in semantics bend: setup-node's `node-version` overrides; setup-nub's pre-warms. +- **With an explicit `node-version` / `node-version-file`** — that version is authoritative for the PATH (matching setup-node): it is provisioned and its bin dir is fronted. `nub` itself still runs the **project's** declared pin at invocation time; if the two differ, the action emits a warning. +- **With no input (the common case)** — the project's own pinned Node is resolved (`devEngines.runtime` → `.node-version` → `.nvmrc` → `engines.node`), provisioned up front so later steps don't pay a lazy download, and its bin dir is fronted. +- **No pin and no input** — nothing is provisioned and nothing is fronted (no error); the runner's preinstalled Node stays on PATH and nub provisions lazily at runtime. + +The remaining nuance vs setup-node: when an explicit `node-version` is set, it governs the **global PATH**, but `nub