From 5e72ea571aec080b5a1014deb58b00de846be5f8 Mon Sep 17 00:00:00 2001 From: Waqar Ali Date: Sun, 2 Aug 2026 22:39:02 +0200 Subject: [PATCH] feat(setup-node-with-cache): add lockfile-only-cache-key input The dependency cache key hashes the lockfile plus every package.json. In a workspace repo that makes the cache far more fragile than it needs to be: editing a description, bumping a package version, or adding a script invalidates the whole cache even though none of those change what gets installed. Measured on Typeform/bob-the-builder (17 workspaces), replaying both key variants over the last 60 commits on main: lockfile + all package.json (today) 30/59 = 51% of commits miss lockfile only 16/59 = 27% of commits miss That repo's cache is ~2.8GB, and a miss there costs roughly 10 extra minutes of wall-clock: every job re-downloads the cache, runs a full install, and then all of them compress and upload ~2.9GB while only one wins the save race. Safety: the lockfile already pins the fully resolved tree, and yarn/pnpm rewrite it whenever a manifest edit actually changes resolution, so a package.json change that matters always comes with a lockfile change. --frozen-lockfile turns the remaining case into a build failure rather than a silent wrong install. All 14 commits in the sample that touched a package.json without touching yarn.lock were checked by hand: 12 were non-dependency edits, 2 added packages already resolved in the lockfile. Defaults to false, so existing callers are unaffected and keep their current cache keys. hashFiles() cannot take a variable glob, so both candidate hashes are computed in a new dep-hash step and the requested one is exported for the cache steps to consume. The two places that echo the key for logging now read the same output, so the logged key always matches the real one. Co-Authored-By: Claude Opus 5 (1M context) --- .../CACHE-STRATEGY-GUIDE.md | 39 +++++++++++++++ .../setup-node-with-cache/action.yml | 48 ++++++++++++++++--- 2 files changed, 80 insertions(+), 7 deletions(-) diff --git a/shared-actions/setup-node-with-cache/CACHE-STRATEGY-GUIDE.md b/shared-actions/setup-node-with-cache/CACHE-STRATEGY-GUIDE.md index 475677f..30b80c4 100644 --- a/shared-actions/setup-node-with-cache/CACHE-STRATEGY-GUIDE.md +++ b/shared-actions/setup-node-with-cache/CACHE-STRATEGY-GUIDE.md @@ -185,6 +185,45 @@ with: --- +## Advanced: Lockfile-Only Cache Key + +By default the dependency cache key hashes the lockfile **and every `package.json`**. +In a workspace repo with many packages that makes the cache far more fragile than it +needs to be: editing a description, bumping a package version, or adding a script +invalidates the entire cache even though none of them change what gets installed. + +```yaml +uses: Typeform/.github/shared-actions/setup-node-with-cache@v1 +with: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + lockfile-only-cache-key: true +``` + +**Measured on `bob-the-builder`** (17 workspaces), over the last 60 commits on `main`: + +| Cache key | Commits that invalidate the cache | +|---|---| +| lockfile + all `package.json` (default) | 30 / 59 = **51%** | +| lockfile only | 16 / 59 = **27%** | + +**Why it is safe:** the lockfile already pins the fully resolved dependency tree. Yarn +and pnpm rewrite it whenever a manifest edit actually changes resolution, so a +`package.json` change that matters is always accompanied by a lockfile change. A change +that does not touch the lockfile cannot alter what gets installed. `--frozen-lockfile` +enforces this — a manifest edit needing new resolutions fails the build rather than +silently installing something different. + +All 14 commits in that sample that touched a `package.json` without touching +`yarn.lock` were verified by hand: 12 were non-dependency edits, and the other 2 added +packages that were already resolved in the lockfile (pinned via root `resolutions`, or +already present as a transitive dependency). + +**When to use:** +- Workspace/monorepo repos with many `package.json` files +- Large caches, where each avoided miss saves minutes rather than seconds + +--- + ## Migration Guide ### Switching from `full` to `node_modules-only` diff --git a/shared-actions/setup-node-with-cache/action.yml b/shared-actions/setup-node-with-cache/action.yml index 408a32c..648bd49 100644 --- a/shared-actions/setup-node-with-cache/action.yml +++ b/shared-actions/setup-node-with-cache/action.yml @@ -24,6 +24,19 @@ inputs: description: 'Disable restore-keys to avoid restoring stale caches (forces exact key match)' required: false default: 'false' + lockfile-only-cache-key: + description: >- + Derive the dependency cache key from the lockfile alone, ignoring package.json + files. Defaults to false, which preserves the existing behaviour of hashing the + lockfile plus every package.json. + + In a workspace repo with many packages, hashing every package.json means edits + that cannot change what gets installed (a description, a version bump, a script) + still invalidate the whole cache. Keying on the lockfile alone is safe because it + already pins the fully resolved tree, and yarn/pnpm update it whenever a manifest + edit actually changes resolution. + required: false + default: 'false' package-manager: description: 'Package manager to use: "yarn" or "pnpm"' required: false @@ -181,22 +194,43 @@ runs: echo "key-suffix=-$CACHE_MODE" >> $GITHUB_OUTPUT + # hashFiles() cannot take a variable glob, so both candidate hashes are computed + # here and the requested one is exported for the cache steps below to consume. + - name: Compute dependency cache hash + id: dep-hash + shell: bash + env: + HASH_WITH_MANIFESTS_YARN: ${{ hashFiles('**/yarn.lock', '**/package.json') }} + HASH_LOCKFILE_ONLY_YARN: ${{ hashFiles('**/yarn.lock') }} + HASH_WITH_MANIFESTS_PNPM: ${{ hashFiles('**/pnpm-lock.yaml', '**/package.json') }} + HASH_LOCKFILE_ONLY_PNPM: ${{ hashFiles('**/pnpm-lock.yaml') }} + run: | + if [ "${{ inputs.lockfile-only-cache-key }}" == "true" ]; then + echo "yarn=$HASH_LOCKFILE_ONLY_YARN" >> $GITHUB_OUTPUT + echo "pnpm=$HASH_LOCKFILE_ONLY_PNPM" >> $GITHUB_OUTPUT + echo "Cache key source: lockfile only" + else + echo "yarn=$HASH_WITH_MANIFESTS_YARN" >> $GITHUB_OUTPUT + echo "pnpm=$HASH_WITH_MANIFESTS_PNPM" >> $GITHUB_OUTPUT + echo "Cache key source: lockfile + all package.json (default)" + fi + - name: Get yarn cache if: ${{ !env.ACT && inputs.package-manager != 'pnpm' }} uses: actions/cache@v6 id: yarn-cache with: path: ${{ steps.cache-paths.outputs.paths }} - key: ${{ runner.os }}-${{ runner.arch }}-yarn-${{ hashFiles('**/yarn.lock', '**/package.json') }}${{ steps.cache-paths.outputs.key-suffix }} + key: ${{ runner.os }}-${{ runner.arch }}-yarn-${{ steps.dep-hash.outputs.yarn }}${{ steps.cache-paths.outputs.key-suffix }} restore-keys: ${{ inputs.disable-restore-keys != 'true' && format('{0}-{1}-yarn-', runner.os, runner.arch) || '' }} - + - name: Get pnpm cache if: ${{ !env.ACT && inputs.package-manager == 'pnpm' }} uses: actions/cache@v6 id: pnpm-cache with: path: ${{ steps.cache-paths-pnpm.outputs.paths }} - key: ${{ runner.os }}-${{ runner.arch }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml', '**/package.json') }}${{ steps.cache-paths-pnpm.outputs.key-suffix }} + key: ${{ runner.os }}-${{ runner.arch }}-pnpm-${{ steps.dep-hash.outputs.pnpm }}${{ steps.cache-paths-pnpm.outputs.key-suffix }} restore-keys: ${{ inputs.disable-restore-keys != 'true' && format('{0}-{1}-pnpm-', runner.os, runner.arch) || '' }} - name: Debug cache contents @@ -210,9 +244,9 @@ runs: echo "=== 🔍 Cache Debug Info ===" if [ "$PM" == "pnpm" ]; then - echo "Cache key: ${{ runner.os }}-${{ runner.arch }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml', '**/package.json') }}" + echo "Cache key: ${{ runner.os }}-${{ runner.arch }}-pnpm-${{ steps.dep-hash.outputs.pnpm }}" else - echo "Cache key: ${{ runner.os }}-${{ runner.arch }}-yarn-${{ hashFiles('**/yarn.lock', '**/package.json') }}" + echo "Cache key: ${{ runner.os }}-${{ runner.arch }}-yarn-${{ steps.dep-hash.outputs.yarn }}" fi echo "" @@ -414,11 +448,11 @@ runs: if [ "$PM" == "pnpm" ]; then CACHE_HIT="${{ steps.pnpm-cache.outputs.cache-hit }}" - CACHE_KEY="${{ runner.os }}-${{ runner.arch }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml', '**/package.json') }}" + CACHE_KEY="${{ runner.os }}-${{ runner.arch }}-pnpm-${{ steps.dep-hash.outputs.pnpm }}" INSTALL_CMD="pnpm install" else CACHE_HIT="${{ steps.yarn-cache.outputs.cache-hit }}" - CACHE_KEY="${{ runner.os }}-${{ runner.arch }}-yarn-${{ hashFiles('**/yarn.lock', '**/package.json') }}" + CACHE_KEY="${{ runner.os }}-${{ runner.arch }}-yarn-${{ steps.dep-hash.outputs.yarn }}" INSTALL_CMD="yarn install" fi