Skip to content
Merged
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
50 changes: 50 additions & 0 deletions .github/workflows/smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,56 @@ jobs:
echo "node-version output: ${{ steps.nub.outputs.node-version }}"
[[ "${{ steps.nub.outputs.node-version }}" == 20* ]] || { echo "::error::expected resolved pin 20.x"; exit 1; }

eager-provision:
name: eager provision (no input) installs the project pin (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: fixture pinned to 20 (no setup-nub input)
shell: bash
# The eager-provision step resolves the project's own pin off disk at the
# checkout root with NO node-version input — so the pin must live there.
run: echo "20" > .node-version
- uses: ./
id: nub
- name: pinned Node is provisioned + emitted as output
shell: bash
run: |
set -euo pipefail
nub node ls
# Eager provision (no input) must have installed the project's pinned 20.x.
nub node ls | grep -E '(^|[^0-9])20\.' || { echo "::error::eager provision did not install the pinned Node 20.x"; exit 1; }
echo "node-version output: ${{ steps.nub.outputs.node-version }}"
[[ "${{ steps.nub.outputs.node-version }}" == 20* ]] || { echo "::error::expected resolved pin 20.x, got ${{ steps.nub.outputs.node-version }}"; exit 1; }

no-pin:
name: no project pin does not fail the action (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: fixture with NO Node pin
shell: bash
# A package.json at the checkout root with no engines/devEngines and no
# .node-version/.nvmrc: the eager provision must skip gracefully (emit a
# friendly note, exit 0), not fail the action.
run: echo '{"name":"nopin","private":true}' > package.json
- uses: ./
id: nub
# If the provision step errored on the no-pin case the job would fail here.
- name: action succeeded despite no pin
shell: bash
run: |
echo "no-pin setup succeeded"
echo "node-version output (expected empty): '${{ steps.nub.outputs.node-version }}'"

cache:
name: cache round-trip (${{ matrix.os }})
strategy:
Expand Down
17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@
Install the [nub](https://github.com/nubjs/nub) CLI on a GitHub Actions runner. A **drop-in replacement for [`actions/setup-node`](https://github.com/actions/setup-node)** — swap the `uses:` line and your existing workflow keeps working.

```yaml
- uses: actions/checkout@v4 # must run before setup-nub — it reads the project's pin files
- uses: nubjs/setup-nub@v0
- run: nub install
- run: nub run build
```

That's the whole story for most projects: nub provisions the project's pinned Node (from `.node-version` / `.nvmrc` / `package.json`) the first time it runs, and reads a standard `.npmrc` for registry auth — so the action's job is just to put `nub` on PATH and stay out of the way.
That's the whole story for most projects: setup-nub puts `nub` on PATH and **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, and nub reads a standard `.npmrc` for registry auth.

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.

## Drop-in from setup-node

Expand All @@ -32,10 +35,10 @@ That's the whole story for most projects: nub provisions the project's pinned No

## How nub's Node provisioning differs from setup-node

`setup-node` exists to **put a Node toolchain on the runner**. nub does that itself, at runtime, from the project's declared pin. So `node-version` here means something subtly different:
`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:

- `node-version` and `node-version-file` are **warm-up hints**, not pins. They pre-provision that Node into nub's cache so the first `nub` call is warm — 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.
- Leave both empty (the common case) and nub resolves + provisions the pin lazily on first use.
- 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.

This is the one place the drop-in semantics bend: setup-node's `node-version` overrides; setup-nub's pre-warms.

Expand All @@ -46,9 +49,9 @@ This is the one place the drop-in semantics bend: setup-node's `node-version` ov
| `nub-version` | `latest` | Version of nub to install — any npm semver range (`0.0.47`, `^0.0`, `latest`). |
| `node-version` | — | Pre-provision this Node into nub's cache (warm-up hint, not a pin; warns on mismatch). |
| `node-version-file` | — | Read a Node version from this file (`.node-version`, `.nvmrc`, `package.json`) and pre-provision it. |
| `cache` | `false` | Cache nub's global store and provisioned Node toolchains across runs (**boolean**, not a PM name). |
| `cache` | `false` | Cache nub's global store and provisioned Node toolchains across runs. A **boolean** — nub has one store regardless of package manager. For setup-node drop-in compatibility a PM name (`npm`/`yarn`/`pnpm`/`bun`) is also accepted and treated as truthy (any non-empty value that isn't `false` enables caching). |
| `cache-dependency-path` | auto-detect | Lockfile path(s) whose hash keys the cache. Globs / newline-delimited lists. |
| `registry-url` | — | Registry to set up for auth. Writes a neutral project `.npmrc` and wires auth to `env.NODE_AUTH_TOKEN`. |
| `registry-url` | — | Registry to set up for auth. Writes a temporary user-level `.npmrc` (via `NPM_CONFIG_USERCONFIG`) and wires auth to `env.NODE_AUTH_TOKEN`. |
| `scope` | repo owner | Scope for a scoped registry. Falls back to the repo owner for GitHub Packages. |
| `always-auth` | `false` | Write `always-auth=true` into the `.npmrc`. |
| `token` | `github.token` | GitHub-API rate-limit relief when resolving nub's version range. |
Expand Down Expand Up @@ -77,7 +80,7 @@ The cache key is `nub-<os>-<arch>-<hash(lockfile)>` with a `restore-keys` ladder

## Registry auth

`registry-url` writes a standard, neutral `.npmrc` — byte-for-byte the setup-node authutil contract — into `$RUNNER_TEMP/.npmrc` and exports `NPM_CONFIG_USERCONFIG`:
`registry-url` writes a standard, neutral, temporary user-level `.npmrc` — byte-for-byte the setup-node authutil contract — into `$RUNNER_TEMP/.npmrc` and points npm config at it via `NPM_CONFIG_USERCONFIG`:

```ini
@scope:registry=https://npm.pkg.github.com/
Expand Down
88 changes: 74 additions & 14 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ branding:

inputs:
nub-version:
description: "Version of nub to install — any semver range npm understands (0.0.47, ^0.0, latest). Default: latest."
description: "Version of nub to install — any semver range npm understands (0.0.47, ^0.1.0, latest). Default: latest."
required: false
default: "latest"
version:
Expand All @@ -29,13 +29,13 @@ inputs:
description: "Lockfile path(s) whose hash keys the cache. Supports globs / newline-delimited lists. Default: auto-detect the project's lockfile."
required: false
registry-url:
description: "Registry to set up for auth. Writes a project-level .npmrc (the neutral file nub's PM reads) and wires auth to env.NODE_AUTH_TOKEN."
description: "Registry to set up for auth. Writes a temporary user-level .npmrc (via NPM_CONFIG_USERCONFIG; the neutral file nub's PM reads) and wires auth to env.NODE_AUTH_TOKEN."
required: false
scope:
description: "Scope for a scoped registry. Falls back to the repository owner for GitHub Packages (npm.pkg.github.com)."
required: false
always-auth:
description: "Write always-auth=true into the project .npmrc."
description: "Write always-auth=true into the temporary user-level .npmrc."
required: false
default: "false"
token:
Expand Down Expand Up @@ -98,14 +98,38 @@ runs:
echo "Installed nub ${installed}"
echo "nub-version=${installed}" >> "$GITHUB_OUTPUT"

# 1b. Normalize the `cache` input. setup-nub's `cache` is a BOOLEAN, but
# setup-node's `cache` takes a PM name (npm/yarn/pnpm/bun). A mechanical
# drop-in that leaves `cache: npm` in place would compare unequal to
# 'true' and silently disable caching. So treat any non-empty value that
# isn't literally false as truthy, and note when a PM name was passed.
- name: Normalize cache input
id: cacheflag
shell: bash
env:
CACHE_IN: ${{ inputs.cache }}
run: |
set -euo pipefail
raw="$CACHE_IN"
lc="$(echo "$raw" | tr '[:upper:]' '[:lower:]')"
case "$lc" in
""|false|"0"|no|off) enabled=false ;;
*) enabled=true ;;
esac
case "$lc" in
npm|yarn|pnpm|bun)
echo "::notice::setup-nub's \`cache\` is a boolean (nub has one store regardless of package manager); the value \"${raw}\" enables caching." ;;
esac
echo "enabled=$enabled" >> "$GITHUB_OUTPUT"

# 2. Resolve the cache paths from nub itself (never hardcode XDG layout).
# `nub store path` prints the resolved CAS store dir on stdout. The Node
# toolchain dir is <cache>/node; derive <cache> from XDG with the ~/.cache
# fallback that nub uses. The PM packument cache is best-effort (small +
# re-fetchable; a wrong path is a cheap miss, not breakage).
- name: Resolve cache paths
id: cachepaths
if: inputs.cache == 'true'
if: steps.cacheflag.outputs.enabled == 'true'
shell: bash
run: |
set -euo pipefail
Expand All @@ -121,7 +145,7 @@ runs:
# the auto-detected lockfile glob).
- name: Compute cache key
id: cachekey
if: inputs.cache == 'true'
if: steps.cacheflag.outputs.enabled == 'true'
shell: bash
env:
DEP_PATH: ${{ inputs.cache-dependency-path }}
Expand All @@ -143,7 +167,7 @@ runs:
# a partial warm hit even on a fresh lockfile (huge over cold).
- name: Cache nub store
id: cache
if: inputs.cache == 'true'
if: steps.cacheflag.outputs.enabled == 'true'
uses: actions/cache@v4
with:
path: |
Expand Down Expand Up @@ -202,30 +226,66 @@ runs:
echo "NPM_CONFIG_USERCONFIG=${npmrc}" >> "$GITHUB_ENV"
echo "Wrote registry auth to ${npmrc}"

# 6. Pre-provision Node (warm-up only). Resolve from node-version (preferred)
# or node-version-file, install it into nub's cache, and emit the resolved
# project pin as the node-version output. A mismatch with the project's
# declared pin is a warning — nub's runtime pin always wins.
- name: Pre-provision Node
# 6. Eagerly provision the project's Node so later steps are WARM. This runs
# UNCONDITIONALLY — the common (no-input) case is the whole point: a bare
# `nub node install` resolves the full pin chain (devEngines.runtime ->
# .node-version -> .nvmrc -> engines.node), resolves ranges against the
# live Node index, and installs the concrete version up front, instead of
# nub downloading it lazily mid-build. An explicit node-version /
# node-version-file still wins as a warm-up override. A "no pin" project
# is fine (skip gracefully); any other failure is a warning, not a fail —
# nub provisions lazily at runtime as a fallback. The resolved pin is
# emitted as the node-version output.
#
# NOTE: this reads the project's pin files off disk, so actions/checkout
# MUST run before setup-nub.
- name: Provision Node
id: provision
if: inputs.node-version != '' || inputs.node-version-file != ''
shell: bash
env:
NODE_VERSION: ${{ inputs.node-version }}
NODE_VERSION_FILE: ${{ inputs.node-version-file }}
run: |
set -euo pipefail

# Soft checkout-first hint: with no pin files on disk, the repo probably
# wasn't checked out yet. Don't fail — just nudge.
if [ ! -e package.json ] && [ ! -e .node-version ] && [ ! -e .nvmrc ]; then
echo "::warning::No package.json / .node-version / .nvmrc found in the working directory — did actions/checkout run before setup-nub? Eager Node provisioning needs the project's pin files on disk."
fi

want="$NODE_VERSION"
if [ -z "$want" ] && [ -n "$NODE_VERSION_FILE" ] && [ -f "$NODE_VERSION_FILE" ]; then
case "$NODE_VERSION_FILE" in
*.json) want="$(node -p "try{require('./'+process.argv[1]).engines?.node||''}catch(e){''}" "$NODE_VERSION_FILE" 2>/dev/null || true)" ;;
# Prefer devEngines.runtime.version (nub's primary signal), then fall
# back to engines.node — never just engines.node.
*.json) want="$(node -p "try{const p=require('./'+process.argv[1]);const r=p.devEngines&&p.devEngines.runtime;(r&&r.version)||(p.engines&&p.engines.node)||''}catch(e){''}" "$NODE_VERSION_FILE" 2>/dev/null || true)" ;;
*) want="$(head -n1 "$NODE_VERSION_FILE" | tr -d 'v[:space:]')" ;;
esac
fi

if [ -n "$want" ]; then
echo "Pre-provisioning Node ${want} into nub's cache"
# Explicit warm-up override.
echo "Provisioning Node ${want} into nub's cache"
nub node install "$want" || echo "::warning::nub node install ${want} failed; nub will provision lazily at runtime"
else
# Common case: resolve + install the project's own pin chain. A bare
# `nub node install` exits non-zero with a "no Node pin" message when
# the project declares no pin — that is expected, skip gracefully.
echo "Eagerly provisioning the project's pinned Node"
out=""
rc=0
out="$(nub node install 2>&1)" || rc=$?
[ -n "$out" ] && echo "$out"
if [ "$rc" -ne 0 ]; then
if printf '%s' "$out" | grep -qi 'no Node pin'; then
echo "No Node pin declared by this project; skipping eager provision (nub provisions lazily at runtime)."
else
echo "::warning::eager Node provision failed (exit ${rc}); nub will provision lazily at runtime"
fi
fi
fi

# The authoritative project pin nub will actually run (path on stdout; we
# read the version dir name after node/).
resolved="$(nub node which 2>/dev/null || true)"
Expand Down
Loading