diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 5ca0f43..b68c9d8 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -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: diff --git a/README.md b/README.md index 3ca0217..15da0b7 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. @@ -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. | @@ -77,7 +80,7 @@ The cache key is `nub---` 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/ diff --git a/action.yml b/action.yml index fe4e604..81d970b 100644 --- a/action.yml +++ b/action.yml @@ -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: @@ -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: @@ -98,6 +98,30 @@ 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 /node; derive from XDG with the ~/.cache @@ -105,7 +129,7 @@ runs: # 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 @@ -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 }} @@ -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: | @@ -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)"