From 5f2ce5ee47c643524a49ae9f59b3007a1e362dc9 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Mon, 22 Jun 2026 11:26:00 -0700 Subject: [PATCH 1/2] Cache by default (mirror setup-node) + DX fixes Mirror actions/setup-node's default-on caching: add a package-manager-cache input (default true) and change cache to auto-enable when the project looks installable (a lockfile, or a package.json packageManager/devEngines field). An explicit cache value still wins; package-manager-cache: false (or cache: false) disables. Previously caching required opt-in cache: true, which silently gave drop-in migrators no caching. Also add three low-risk DX inputs drawn from top actions/setup-node issues: - working-directory: resolve the Node pin and lockfile from a subdirectory, for monorepos (setup-node#706). - cache-key-prefix: inject a prefix segment into the cache key to scope or bust caches independently of the lockfile (setup-node#1320). - fold the project's Node pin into the cache key so a Node-version bump invalidates the stale toolchain cache (setup-node#1171). Extend smoke.yml with jobs asserting caching auto-enables by default on a lockfile project and that package-manager-cache: false disables it. Update README and action.yml input docs. --- .github/workflows/smoke.yml | 59 +++++++++++++++++ README.md | 19 ++++-- action.yml | 124 +++++++++++++++++++++++++++++------- 3 files changed, 173 insertions(+), 29 deletions(-) diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index b68c9d8..a350635 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -169,6 +169,65 @@ 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: cache step ran (cache-hit output is a defined boolean) + shell: bash + run: | + set -euo pipefail + ch="${{ steps.nub.outputs.cache-hit }}" + echo "cache-hit: '${ch}'" + # When caching is active the actions/cache step runs and emits true/false; + # when inactive the step is skipped and the output is empty. + case "$ch" in + true|false) echo "caching auto-enabled by default — OK" ;; + *) echo "::error::expected caching auto-enabled (cache-hit true/false), got '${ch}'"; exit 1 ;; + esac + + 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: cache step skipped (cache-hit output empty) + shell: bash + run: | + set -euo pipefail + ch="${{ steps.nub.outputs.cache-hit }}" + echo "cache-hit: '${ch}'" + [ -z "$ch" ] || { echo "::error::expected caching disabled (empty cache-hit), got '${ch}'"; exit 1; } + echo "caching disabled by package-manager-cache: false — OK" + auth-npmrc: name: registry .npmrc shape (${{ matrix.os }}) strategy: diff --git a/README.md b/README.md index 15da0b7..5178b0b 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 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. @@ -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: @@ -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`. | @@ -66,17 +71,19 @@ Accepted for setup-node compatibility but **ignored** (never errors): `check-lat |---|---| | `nub-version` | The installed nub version (bare `v`). | | `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 nub's store cache was restored (only meaningful when caching is active). | ## 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 (`/node`) - the PM packument cache (best-effort) -The cache key is `nub---` 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-----` 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 `` 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 diff --git a/action.yml b/action.yml index 81d970b..325d116 100644 --- a/action.yml +++ b/action.yml @@ -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----), 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 @@ -64,7 +74,7 @@ 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 nub's store cache was restored (only meaningful when caching is active)." value: ${{ steps.cache.outputs.cache-hit }} runs: @@ -98,28 +108,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). @@ -142,27 +187,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<> "$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 @@ -174,9 +240,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, :registry, _authToken @@ -245,9 +312,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 From 28d88de389502d4b1c165f6e689ed2c52b27ff75 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Mon, 22 Jun 2026 11:33:10 -0700 Subject: [PATCH 2/2] Fix cache-default smoke assertions; add caching-enabled output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cache-default-on smoke job asserted on cache-hit to detect whether caching was active, but actions/cache sets cache-hit to an empty string on a full miss (always the case on a first CI run) even when caching is enabled and the cache will be saved at job end. The auto-enable implementation was correct — the cache step ran and reported a miss — but the assertion was wrong. Add a caching-enabled action output sourced from the resolved cache decision (steps.cacheflag.outputs.enabled), independent of whether a cache was hit, and assert on it in both the auto-enable and disable smoke jobs. Document the new output and clarify cache-hit's miss-is-empty semantics in the README. --- .github/workflows/smoke.yml | 23 +++++++++++------------ README.md | 3 ++- action.yml | 5 ++++- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index a350635..59bb264 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -188,18 +188,17 @@ jobs: - uses: ./ id: nub # deliberately no `cache:` / `package-manager-cache:` input - - name: cache step ran (cache-hit output is a defined boolean) + - 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 "cache-hit: '${ch}'" - # When caching is active the actions/cache step runs and emits true/false; - # when inactive the step is skipped and the output is empty. - case "$ch" in - true|false) echo "caching auto-enabled by default — OK" ;; - *) echo "::error::expected caching auto-enabled (cache-hit true/false), got '${ch}'"; exit 1 ;; - esac + 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 }}) @@ -219,13 +218,13 @@ jobs: id: nub with: package-manager-cache: "false" - - name: cache step skipped (cache-hit output empty) + - name: caching is inactive (caching-enabled output is false) shell: bash run: | set -euo pipefail - ch="${{ steps.nub.outputs.cache-hit }}" - echo "cache-hit: '${ch}'" - [ -z "$ch" ] || { echo "::error::expected caching disabled (empty cache-hit), got '${ch}'"; exit 1; } + 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: diff --git a/README.md b/README.md index 5178b0b..a8401f1 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,8 @@ Accepted for setup-node compatibility but **ignored** (never errors): `check-lat |---|---| | `nub-version` | The installed nub version (bare `v`). | | `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 when caching is active). | +| `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 diff --git a/action.yml b/action.yml index 325d116..ff05f7e 100644 --- a/action.yml +++ b/action.yml @@ -74,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 when caching is active)." + 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"