fix(cache): isolate cache keys per working_directory in monorepos - #360
Conversation
|
I was using this composite action in my own repository prior to this change. It's a hack, but it's where this PR was derived: name: Setup mise (monorepo)
description: |
Sets up mise with proper caching for monorepos. Uses a two-phase approach:
1. Install mise binary without caching
2. Compute cache key using `mise config ls` to get exact config files
3. Install mise tools with project-specific cache key
This ensures the cache key matches the actual tools needed, which mise-action's
default (hashing ALL mise.toml files) doesn't handle correctly in a monorepo.
inputs:
working-directory:
required: true
description: |
The project directory to install tools for. Mise will install tools from
this directory's mise.toml plus any inherited configs (e.g., root mise.toml).
outputs:
cache-key:
description: The computed cache key based on relevant mise.toml and mise.lock files
value: ${{ steps.mise-cache.outputs.key }}
runs:
using: composite
steps:
- name: Setup mise binary
uses: jdx/mise-action@146a28175021df8ca24f8ee1828cc2a60f980bd5 # v3.5.1
with:
install: false
cache: false
- name: Compute mise cache key
id: mise-cache
working-directory: ${{ inputs.working-directory }}
shell: bash
run: |
# Get config files that affect this directory
configs=$(mise config ls --json | jq -r '.[].path' | grep "^$GITHUB_WORKSPACE" | sort)
# Derive lock file paths for .toml files only (.toml -> .lock)
# Handles: mise.toml, mise.local.toml, .config/mise/config.toml, etc.
# Skips: .tool-versions (no lock file)
locks=$(echo "$configs" | grep '\.toml$' | sed 's/\.toml$/.lock/' | xargs -r ls 2>/dev/null || true)
# Hash both config and lock files
hash=$(cat $configs $locks 2>/dev/null | sha256sum | cut -d' ' -f1)
key="mise/${{ inputs.working-directory }}/$(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m)/${hash}"
echo "hash=$hash" >> $GITHUB_OUTPUT
echo "key=$key" >> $GITHUB_OUTPUT
- name: Setup mise tools
uses: jdx/mise-action@146a28175021df8ca24f8ee1828cc2a60f980bd5 # v3.5.1
with:
working_directory: ${{ inputs.working-directory }}
cache_key: ${{ steps.mise-cache.outputs.key }}
|
|
One last comment - now that mise cache and tool cache are separate, it makes the cache configuration passed to the action as well as the cache-hit output potentially confusing. I tried to go with what made the most sense here:
Happy to revisit any of this. Perhaps there'd be some desire to have additional separate outputs for bin-cache-hit vs. tools-cache-hit? Different semantics for cache-hit? LMK |
|
bugbot run |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.
102649f to
37b9a78
Compare
Problem
-------
mise-action hashes ALL mise config files in the repo to compute a single
default cache key. In a monorepo with multiple projects (e.g., apps/frontend,
apps/backend), this causes cache pollution:
1. Job A runs for apps/frontend, installs only frontend tools
2. Cache is saved with a key based on ALL configs
3. Job B runs for apps/backend, gets cache HIT (same key)
4. Job B finds frontend tools but not backend tools
5. Job B has to install all tools because they are missing from cache
Additionally, any change to an unrelated project config would bust the
cache for all projects.
Solution
--------
When working_directory is set, compute the default cache key using only the
config files that affect that directory (detected via `mise config ls --json`)
instead of globbing all configs in the repo.
This required separating binary and tools caching:
- Binary cache: restored first so mise is available for `mise config ls`
- Tools cache: default key computed after mise is installed
Key Implementation Details
--------------------------
1. Cache separation:
- restoreMiseBinaryCache/saveMiseBinaryCache for the mise binary
- restoreToolsCache/saveToolsCache for the full mise directory
- Binary cache key: `{prefix}-binary-{platform}-{version}-{dirHash}`
- Tools default cache key: based on config file contents for working_directory
2. Binary backup during tools cache restore:
The tools cache includes bin/, which could overwrite the binary that
setupMise() just installed. We use withBinaryBackup() to backup the
binary before restoring the tools cache and restore it afterward.
An alternative approach would be to only cache installs/ and shims/
instead of the full miseDir(), but that would change the caching
behavior for existing users. Using withBinaryBackup() retains the
original caching behavior while preventing the binary from being
overwritten.
3. Binary cache key includes mise_dir hash:
Prevents cache collision when users change mise_dir between runs.
Without this, a cache hit could restore the binary to the wrong location.
4. Explicit mise binary path:
Uses full path to mise binary instead of relying on PATH lookup,
avoiding potential race conditions with core.addPath().
5. Lock file handling:
- .toml files: look for corresponding .lock file
- .tool-versions: look for mise.lock in the same directory
6. Graceful degradation:
If `mise config ls` fails when working_directory is set, caching is
disabled with a warning rather than:
- Failing the action entirely, or
- Falling back to glob patterns (which would reintroduce the bug)
Backward Compatibility
----------------------
- working_directory not set: No change, uses existing glob of all configs
- working_directory set: Default cache key based on `mise config ls` output
Note on cache_key input: The `cache_key` input now only controls the tools
cache key. The binary cache key is always computed automatically based on
platform, version, and mise_dir. This is generally better since the binary
cache is version-stable and does not need custom key logic.
Test Coverage
-------------
Added test-monorepo-cache.yml with 8 test scenarios:
- install-backend/restore-frontend: Verify cache isolation
- install-frontend/unrelated-change-no-bust: Verify unrelated changes do not bust cache
- parent-config-change: Verify parent config changes bust child cache
- lock-file-change: Verify lock file changes bust cache
- install-default-mise-dir/restore-custom-mise-dir: Verify mise_dir in cache key
37b9a78 to
434d5fe
Compare
|
bugbot run |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.
| cache_key_prefix: mise-dir-${{ github.run_id }} | ||
|
|
||
| # Try to restore with a DIFFERENT mise_dir - should be cache MISS | ||
| # since the cache key includes a hash of the mise_dir path. |
There was a problem hiding this comment.
Tools cache key missing mise_dir hash causes test failure
High Severity
The tools cache key (baseTemplateData) does not include a hash of miseDir(), but the binary cache key does include dirHash. The test restore-custom-mise-dir expects a cache MISS when mise_dir changes because its comment states "the cache key includes a hash of the mise_dir path." However, since the tools cache key lacks this hash, changing mise_dir produces the same tools cache key, resulting in a cache HIT. This will restore actionlint to /tmp/custom-mise/shims/ and cause the test to fail.
There was a problem hiding this comment.
I spent some time working to understand this.
The tools cache key was indeed missing dir_hash unlike the binary cache key. However, this doesn't cause actual misbehavior because @actions/cache includes paths in its version calculation - different mise_dir values already result in cache misses due to different restore paths, regardless of whether the keys match.
I went ahead and added dir_hash to the tools cache key template for consistency with the binary cache key. This provides explicit key isolation rather than relying on @actions/cache internals.
The restore-custom-mise-dir test has been removed entirely - it passes regardless of whether the fix is applied (due to path versioning), so it doesn't actually validate this behavior and could be misleading.
811da51 to
772d090
Compare
Include mise_dir hash in tools cache key template to match the binary cache key behavior. While @actions/cache's path-based versioning already provides isolation, having explicit key isolation is more consistent and provides defense-in-depth. Remove the misleading mise_dir test - it passes regardless of whether dir_hash is in the key due to @actions/cache path versioning.
772d090 to
39db9ab
Compare
--- ## [3.6.0](https://github.com/jdx/mise-action/compare/v3.5.1..v3.6.0) - 2026-01-18 ### 🚀 Features - add option to disable shims in PATH (#340) by [@jdx](https://github.com/jdx) in [#340](#340) ### 🐛 Bug Fixes - **(cache)** isolate cache keys per working_directory in monorepos (#360) by [@chadxz](https://github.com/chadxz) in [#360](#360) - use mise_dir input when specified (#339) by [@jdx](https://github.com/jdx) in [#339](#339) - pass environment variables to mise commands (#341) by [@jdx](https://github.com/jdx) in [#341](#341) - make mise self-update output visible in logs (#355) by [@nikobockerman](https://github.com/nikobockerman) in [#355](#355) ### 📚 Documentation - fix description for `mise_toml` input (#351) by [@quad](https://github.com/quad) in [#351](#351) ### New Contributors * @chadxz made their first contribution in [#360](#360) * @nikobockerman made their first contribution in [#355](#355) * @quad made their first contribution in [#351](#351) <!-- generated by git-cliff -->
…epos" (#364) Reverts #360 #363 <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Reverts the monorepo cache isolation change and simplifies caching to a single cache for the entire `mise` directory. > > - Replace binary/tools caches with a single cache via `restoreMiseCache`/`saveCache`; set `cache-hit` from one restore > - Default key template drops `dir_hash`; `file_hash` computed from repo-wide glob patterns (no `working_directory`-specific config walk) > - Persist `PRIMARY_KEY` and `MISE_DIR` in action state; `miseDir()` reads from state > - Remove monorepo cache isolation workflow `test-monorepo-cache.yml`; minor cleanup in `AGENTS.md` > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit a157c4e. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
Why? ==== In the implementation of jdx#360, users on self-hosted runners reported that mise-bin-backup-* directories were nesting recursively inside the bin/ folder, causing exponential disk growth (75GB in 20 hours). The bug was in withBinaryBackup(): when using io.cp() to restore the backup directory to an existing destination, it copies the source INTO the destination rather than replacing it. This created nested directories that got backed up on subsequent runs, compounding the problem. See: jdx#363 How? ==== - Applied fix from PR jdx#366: backup only the mise binary file itself, not the entire bin/ directory. This avoids the io.cp directory behavior entirely. - Renamed backup prefix to mise-binary-backup-* to distinguish from old contaminated backups. - Added test workflow that verifies no nested backup directories appear after multiple cache restore cycles. - Tested with act to confirm fix prevents accumulation. ---- Fixes jdx#363 Related to jdx#360 Reverted in jdx#364 Co-authored-by: Florian Fittschen <ffittschen@gmail.com>
Why? ==== Monorepos can have several mise projects in one repository, but the action was still treating the whole repo as one cache scope. That made unrelated config changes bust caches and allowed similar project configs in different paths to collide. Restoring the full mise data dir could also overwrite the freshly installed binary, and on self-hosted runners the directory backup path could accumulate recursively until disk usage spiked. The binary cache had another subtle edge: an unspecified mise version used the literal "latest" in the cache key. That could keep restoring an older binary after upstream released a newer mise version. How? ==== Rebased the cache fix on top of current upstream main, preserving the new Node 24, Rollup, wings, Windows mise-shim, and runner-image cache key behavior. The tools cache now hashes only the mise config hierarchy that applies to working_directory or install_dir, uses workspace-relative config paths, includes mise_dir in the key, and keeps file_hash at the end of the template to avoid prefix matches. The cache-key docs now cover the new dir_hash variable. Split the internal mise binary cache from the tools cache, resolve the current latest version once before binary cache lookup and setup, and changed the restore guard to preserve only the installed mise binaries. On Windows that includes both mise.exe and mise-shim.exe; after restoring the tools cache, bin/ is recreated from those preserved files so stale backup directories can't survive. Added a monorepo cache workflow that covers scoped config hashing, unrelated config changes, lock-file changes, identical content in different paths, dir_hash isolation, config discovery failures, the legacy repo-wide glob path, dirty bin/ cleanup, repeated binary restore cycles, and Windows shim preservation. The new workflow follows upstream's current checkout, permissions, and concurrency conventions, and dist was rebuilt with the current upstream toolchain. ---- Fixes jdx#363 Related to jdx#360 Reverted in jdx#364 Co-authored-by: Florian Fittschen <ffittschen@gmail.com>
Why? ==== Monorepos can have several mise projects in one repository, but the action still treats the whole repo as one cache scope. That makes unrelated config changes bust caches and lets similar project configs in different paths collide. We're carrying this patch because upstream merged the scoped-cache fix in jdx#360 and later reverted it in jdx#364 after the tools cache restore path started polluting bin/ on self-hosted runners. Current upstream doesn't have an equivalent replacement, and our workflows still need working_directory-specific cache keys. The scoped cache key asks mise for the config hierarchy for the target directory. Mise can also report env files loaded from config, such as `[env] _.file = ".env"`. Those files aren't mise config, may contain secrets, and often don't exist in CI. Reading them for cache keys can fail setup before workflow commands run. How? ==== Reapplied the cache fix on top of current upstream main, preserving bootstrap mode, locked installs, wget fallback, Windows mise-shim, and runner-image cache key behavior. The tools cache hashes only the mise config hierarchy that applies to working_directory or install_dir. It uses workspace-relative config paths, includes mise_dir in the key, keeps the bootstrap hash, and keeps file_hash at the end of the template. Split the internal mise binary cache from the tools cache so mise is available before computing scoped config keys. The restore guard now preserves only the installed mise binaries. On Windows that includes both mise.exe and mise-shim.exe; after restoring the tools cache, bin/ is recreated from those preserved files so stale backup directories can't survive. Filtered the scoped config list to mise config and lock files before hashing. Missing or non-file paths are skipped at debug level while real config discovery and read failures still fail the action. Added a monorepo cache workflow that covers scoped config hashing, unrelated config changes, lock-file changes, identical content in different paths, dir_hash isolation, config discovery failures, missing env files, legacy repo-wide glob behavior, dirty bin/ cleanup, and Windows shim preservation. Rebuilt dist with the current upstream toolchain and ran `aubr all` plus `actionlint`. ---- Fixes jdx#363 Related to jdx#360 Reverted in jdx#364 Co-authored-by: Florian Fittschen <ffittschen@gmail.com>
Problem
mise-action hashes ALL mise config files in the repo to compute a single default cache key. In a monorepo with multiple projects (e.g., apps/frontend, apps/backend), this causes cache pollution:
Additionally, any change to an unrelated project config would bust the cache for all projects.
Solution
When working_directory is set, compute the default cache key using only the config files that affect that directory (detected via
mise config ls --json) instead of globbing all configs in the repo.This required separating binary and tools caching:
mise config lsKey Implementation Details
Cache separation:
{prefix}-binary-{platform}-{version}-{dirHash}Binary backup during tools cache restore: The tools cache includes bin/, which could overwrite the binary that setupMise() just installed. We use withBinaryBackup() to backup the binary before restoring the tools cache and restore it afterward.
An alternative approach would be to only cache installs/ and shims/ instead of the full miseDir(), but that would change the caching behavior for existing users. Using withBinaryBackup() retains the original caching behavior while preventing the binary from being overwritten.
Binary cache key includes mise_dir hash: Prevents cache collision when users change mise_dir between runs. Without this, a cache hit could restore the binary to the wrong location.
Explicit mise binary path: Uses full path to mise binary instead of relying on PATH lookup, avoiding potential race conditions with core.addPath().
Lock file handling:
Graceful degradation: If
mise config lsfails when working_directory is set, caching is disabled with a warning rather than:Backward Compatibility
mise config lsoutputNote on cache_key input: The
cache_keyinput now only controls the tools cache key. The binary cache key is always computed automatically based on platform, version, and mise_dir. This is generally better since the binary cache is version-stable and does not need custom key logic.Test Coverage
Added test-monorepo-cache.yml with 8 test scenarios:
Final Note
Currently, the default tool cache key includes the mise version. This was in place prior, so it was left intact. With this change and the splitting of the mise version cache from the tool cache, we could safely remove the mise version from the tool cache key. Left this for a subsequent change if desired.
Note
Fixes cache key pollution in monorepos by scoping tool cache keys to the
working_directory's config hierarchy and separating binary vs. tools caching.restoreMiseBinaryCache/saveMiseBinaryCache(key:{prefix}-binary-{platform}-{version}-{dirHash}) runs before installation;restoreToolsCache/saveToolsCacheuses a default key derived frommise config ls --jsonfor the specifiedworking_directorymisebinary path and preserves it during tools cache restore viawithBinaryBackupto avoid overwritesmise_dirhash included in binary cache key to prevent cross-dir collisions.github/workflows/test-monorepo-cache.ymlwith scenarios verifying monorepo cache isolation, unrelated-config no-bust, parent-config change bust, lockfile change bust, identical-content different-path isolation, andmise_dir-key differentiationdist/artifacts; minor docs entryAGENTS.mdWritten by Cursor Bugbot for commit 434d5fe. This will update automatically on new commits. Configure here.