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
58 changes: 58 additions & 0 deletions .github/workflows/smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,64 @@ jobs:
# actions/cache won't re-restore. The cache is saved at job end; a re-run
# of this workflow exercises the warm path. Informational here.

cache-default-on:
name: caching auto-enables by default on a lockfile project (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: lockfile fixture (no cache input)
shell: bash
# A lockfile at the checkout root with NO cache input must auto-enable
# caching, mirroring setup-node's default-on behavior.
run: |
echo '{"name":"d","private":true}' > package.json
echo '{}' > package-lock.json
- uses: ./
id: nub
# deliberately no `cache:` / `package-manager-cache:` input
- name: caching is active (caching-enabled output is true)
shell: bash
run: |
set -euo pipefail
ce="${{ steps.nub.outputs.caching-enabled }}"
ch="${{ steps.nub.outputs.cache-hit }}"
echo "caching-enabled: '${ce}' / cache-hit: '${ch}'"
# cache-hit is empty on a first-run full miss even when caching is active
# (actions/cache semantics), so assert on caching-enabled instead.
[ "$ce" = "true" ] || { echo "::error::expected caching auto-enabled (caching-enabled=true), got '${ce}'"; exit 1; }
echo "caching auto-enabled by default — OK"

cache-disable:
name: package-manager-cache=false disables caching (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: lockfile fixture (would auto-enable)
shell: bash
run: |
echo '{"name":"d","private":true}' > package.json
echo '{}' > package-lock.json
- uses: ./
id: nub
with:
package-manager-cache: "false"
- name: caching is inactive (caching-enabled output is false)
shell: bash
run: |
set -euo pipefail
ce="${{ steps.nub.outputs.caching-enabled }}"
echo "caching-enabled: '${ce}'"
[ "$ce" = "false" ] || { echo "::error::expected caching disabled (caching-enabled=false), got '${ce}'"; exit 1; }
echo "caching disabled by package-manager-cache: false — OK"

auth-npmrc:
name: registry .npmrc shape (${{ matrix.os }})
strategy:
Expand Down
20 changes: 14 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.
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.

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.

Expand All @@ -29,10 +29,12 @@ Because the eager provision reads the project's pin files off disk, **`actions/c
- uses: nubjs/setup-nub@v0
with:
node-version: 20 # pre-provisions Node 20 into nub's cache (warm-up hint)
cache: true # boolean — nub has one store regardless of lockfile
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

`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:
Expand All @@ -49,8 +51,11 @@ 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. 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` | auto | Explicitly enable/disable caching of nub's global store and provisioned Node toolchains. A **boolean**; a setup-node PM name (`npm`/`yarn`/`pnpm`/`bun`) is also accepted and treated as truthy. Leave **unset** to auto-enable when the project looks installable (mirrors setup-node). |
| `package-manager-cache` | `true` | Set to `false` to disable the automatic caching. Mirrors setup-node's input of the same name. An explicit `cache` value still wins. |
| `cache-dependency-path` | auto-detect | Lockfile path(s) whose hash keys the cache. Globs / newline-delimited lists. |
| `cache-key-prefix` | — | Prefix injected into the cache key, to scope or bust caches independently of the lockfile. |
| `working-directory` | checkout root | Directory to resolve the project's Node pin and lockfile from. For monorepos where `package.json`/`.node-version` live in a subdirectory. |
| `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`. |
Expand All @@ -66,17 +71,20 @@ Accepted for setup-node compatibility but **ignored** (never errors): `check-lat
|---|---|
| `nub-version` | The installed nub version (bare `v<semver>`). |
| `node-version` | The Node version nub resolves for the project. Empty when nothing was provisioned. |
| `cache-hit` | Whether nub's store cache was restored (only meaningful with `cache: true`). |
| `cache-hit` | Whether an exact cache match was restored — `true` on an exact key hit, `false` on a `restore-keys` partial hit, empty on a full miss (e.g. the first run), mirroring `actions/cache`. |
| `caching-enabled` | Whether caching is active for this run (`true`/`false`), reflecting the resolved `cache`/`package-manager-cache`/auto-detect decision — independent of whether a cache was hit. |

## Caching

With `cache: true`, the action caches nub's durable, cross-run directories:
**Caching is on by default**, mirroring `actions/setup-node`. It auto-enables when the project looks installable — a lockfile (`pnpm-lock.yaml`, `package-lock.json`, `bun.lock`/`bun.lockb`, `yarn.lock`) is present, or `package.json` declares a `packageManager` or `devEngines` field. The action caches nub's durable, cross-run directories:

- the global content-addressed store (resolved from `nub store path` — the big win)
- the provisioned Node toolchain dir (`<cache>/node`)
- the PM packument cache (best-effort)

The cache key is `nub-<os>-<arch>-<hash(lockfile)>` with a `restore-keys` ladder so a fresh lockfile still gets a warm store. `cache` defaults to `false` (opt-in).
The cache key is `nub-<os>-<arch>-<prefix>-<hash(node-pin)>-<hash(lockfile)>` with a `restore-keys` ladder so a fresh lockfile still gets a warm store, and a Node-version bump invalidates the stale toolchain. `cache-key-prefix` injects the `<prefix>` segment for scoping or busting caches independently of the lockfile.

To **disable** caching, set `package-manager-cache: false` (or `cache: false`). An explicit `cache` value always wins over the auto-detection.

## Registry auth

Expand Down
127 changes: 104 additions & 23 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,22 @@ inputs:
description: "Read a Node version from this file (.node-version, .nvmrc, package.json) and pre-provision it. Redundant with nub's own resolution; an explicit warm-up alternative to node-version."
required: false
cache:
description: "Cache nub's global store and provisioned Node toolchains across runs (boolean). Default: false."
description: "Cache nub's global store and provisioned Node toolchains across runs. A boolean (true/false); for setup-node drop-in compatibility a PM name (npm/yarn/pnpm/bun) is also accepted and treated as truthy. Leave UNSET to auto-enable when a lockfile or a package.json packageManager/devEngines field is present (mirrors setup-node). Set false to disable."
required: false
default: "false"
default: ""
package-manager-cache:
description: "Set to false to disable automatic caching. By default (true), caching is enabled when a lockfile or a package.json packageManager/devEngines field is present. Mirrors setup-node's input of the same name. An explicit cache value still wins."
required: false
default: "true"
cache-dependency-path:
description: "Lockfile path(s) whose hash keys the cache. Supports globs / newline-delimited lists. Default: auto-detect the project's lockfile."
required: false
cache-key-prefix:
description: "Prefix injected into the cache key (nub-<os>-<arch>-<prefix>-<hash>), letting you scope or bust caches independently of the lockfile. Default: none."
required: false
working-directory:
description: "Directory to resolve the project's Node pin and lockfile from. Default: the checkout root. Use for monorepos where package.json/.node-version live in a subdirectory."
required: false
registry-url:
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
Expand Down Expand Up @@ -64,8 +74,11 @@ outputs:
description: "The Node version nub resolves for the project (nub node which). Empty when nothing was provisioned."
value: ${{ steps.provision.outputs.node-version }}
cache-hit:
description: "Whether nub's store cache was restored (only meaningful with cache: true)."
description: "Whether an exact nub-store cache match was restored. 'true' on an exact key hit, 'false' on a restore-keys partial hit, empty on a full miss (e.g. the first run) — mirrors actions/cache. Use caching-enabled to tell whether caching is active."
value: ${{ steps.cache.outputs.cache-hit }}
caching-enabled:
description: "Whether caching is active for this run ('true'/'false'). Reflects the resolved cache/package-manager-cache/auto-detect decision, independent of whether a cache was hit."
value: ${{ steps.cacheflag.outputs.enabled }}

runs:
using: "composite"
Expand Down Expand Up @@ -98,28 +111,63 @@ 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
# 1b. Decide whether caching is active, mirroring setup-node's model:
# caching is ON BY DEFAULT and auto-enables when the project looks
# installable (a lockfile, or a package.json packageManager/devEngines
# field). The `cache` input is an explicit override; `package-manager-cache:
# false` is the disable knob. Precedence:
# 1. explicit `cache` value -> wins (false disables; any other value
# enables; a PM name like `npm`/`pnpm` is accepted for setup-node
# drop-in compat and treated as truthy — nub has one store regardless).
# 2. `package-manager-cache: false` -> disables.
# 3. otherwise -> AUTO: enable iff a lockfile or a
# packageManager/devEngines signal is present.
- name: Resolve caching state
id: cacheflag
shell: bash
env:
CACHE_IN: ${{ inputs.cache }}
PMC_IN: ${{ inputs.package-manager-cache }}
WORKDIR: ${{ inputs.working-directory }}
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
pmc="$(echo "${PMC_IN:-true}" | tr '[:upper:]' '[:lower:]')"
dir="${WORKDIR:-.}"

if [ -n "$lc" ]; then
# 1. explicit `cache` value wins.
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
elif [ "$pmc" = "false" ] || [ "$pmc" = "0" ] || [ "$pmc" = "no" ] || [ "$pmc" = "off" ]; then
# 2. caching explicitly disabled.
enabled=false
else
# 3. AUTO (mirrors setup-node default-on): enable when the project looks
# installable. A lockfile is the strongest signal; otherwise a
# package.json that declares a packageManager or devEngines also opts
# in (setup-node enables when packageManager/devEngines is present).
enabled=false
for f in pnpm-lock.yaml package-lock.json bun.lock bun.lockb yarn.lock; do
[ -f "$dir/$f" ] && enabled=true && break
done
if [ "$enabled" = "false" ] && [ -f "$dir/package.json" ]; then
# require() treats a bare path as a module lookup, so pass an absolute
# path to read the package.json reliably for any working-directory.
pj="$(cd "$dir" && pwd)/package.json"
if node -e "const p=require(process.argv[1]);process.exit((p.packageManager||p.devEngines)?0:1)" "$pj" 2>/dev/null; then
enabled=true
fi
fi
[ "$enabled" = "true" ] && echo "Caching auto-enabled (lockfile or packageManager/devEngines present). Disable with package-manager-cache: false."
fi
echo "enabled=$enabled" >> "$GITHUB_OUTPUT"

# 2. Resolve the cache paths from nub itself (never hardcode XDG layout).
Expand All @@ -142,27 +190,48 @@ runs:
} >> "$GITHUB_OUTPUT"

# 3. Compute the cache key from the lockfile (cache-dependency-path overrides
# the auto-detected lockfile glob).
# the auto-detected lockfile glob). The lockfile glob is resolved relative
# to working-directory so monorepo subdirs are detected; cache-dependency-path
# is honored verbatim (it may already be a repo-relative path / glob).
- name: Compute cache key
id: cachekey
if: steps.cacheflag.outputs.enabled == 'true'
shell: bash
env:
DEP_PATH: ${{ inputs.cache-dependency-path }}
WORKDIR: ${{ inputs.working-directory }}
run: |
set -euo pipefail
dir="${WORKDIR:-.}"
prefix=""
[ "$dir" != "." ] && prefix="${dir%/}/"
if [ -n "$DEP_PATH" ]; then
dep_glob="$DEP_PATH"
else
# auto-detect: first present lockfile (pnpm > npm > bun > yarn)
# auto-detect: first present lockfile (pnpm > npm > bun > yarn) under
# the working directory. Emit a workspace-relative path so hashFiles()
# resolves it (hashFiles is rooted at GITHUB_WORKSPACE).
dep_glob=""
for f in pnpm-lock.yaml package-lock.json bun.lock bun.lockb yarn.lock; do
[ -f "$f" ] && dep_glob="$f" && break
[ -f "$dir/$f" ] && dep_glob="${prefix}${f}" && break
done
dep_glob="${dep_glob:-package.json}"
dep_glob="${dep_glob:-${prefix}package.json}"
fi
echo "dep-glob=$dep_glob" >> "$GITHUB_OUTPUT"

# Fold the project's Node pin into the cache key so a Node-version bump
# invalidates the toolchain cache (setup-node#1171). We hash the pin SOURCE
# files rather than the installed version (provision runs later); any pin
# change busts the key, identical pins reuse it. Emitted as a newline-
# delimited glob list for hashFiles() (multiline GITHUB_OUTPUT value).
{
echo "pin-glob<<PINEOF"
for f in .node-version .nvmrc; do
[ -f "$dir/$f" ] && echo "${prefix}${f}"
done
echo "PINEOF"
} >> "$GITHUB_OUTPUT"

# 4. Restore/save nub's store + Node toolchains. The restore-keys ladder gives
# a partial warm hit even on a fresh lockfile (huge over cold).
- name: Cache nub store
Expand All @@ -174,9 +243,10 @@ runs:
${{ steps.cachepaths.outputs.store }}
${{ steps.cachepaths.outputs.node-cache }}
${{ steps.cachepaths.outputs.pm-cache }}
key: nub-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles(steps.cachekey.outputs.dep-glob) }}
key: nub-${{ runner.os }}-${{ runner.arch }}-${{ inputs.cache-key-prefix }}-${{ hashFiles(steps.cachekey.outputs.pin-glob) }}-${{ hashFiles(steps.cachekey.outputs.dep-glob) }}
restore-keys: |
nub-${{ runner.os }}-${{ runner.arch }}-
nub-${{ runner.os }}-${{ runner.arch }}-${{ inputs.cache-key-prefix }}-${{ hashFiles(steps.cachekey.outputs.pin-glob) }}-
nub-${{ runner.os }}-${{ runner.arch }}-${{ inputs.cache-key-prefix }}-

# 5. Write a NEUTRAL project .npmrc for registry auth — byte-for-byte the
# setup-node authutil contract (registry, <scope>:registry, _authToken
Expand Down Expand Up @@ -245,9 +315,20 @@ runs:
env:
NODE_VERSION: ${{ inputs.node-version }}
NODE_VERSION_FILE: ${{ inputs.node-version-file }}
WORKDIR: ${{ inputs.working-directory }}
run: |
set -euo pipefail

# Resolve the project pin from working-directory (monorepo subdir support).
# nub reads pin files from cwd, so cd there before provisioning.
if [ -n "${WORKDIR:-}" ]; then
if [ -d "$WORKDIR" ]; then
cd "$WORKDIR"
else
echo "::warning::working-directory '${WORKDIR}' does not exist; resolving the Node pin from the checkout root instead."
fi
fi

# 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
Expand Down
Loading