Skip to content

fix(cache): isolate cache keys per working_directory in monorepos - #360

Merged
jdx merged 2 commits into
jdx:mainfrom
chadxz:fix/monorepo-caching
Jan 18, 2026
Merged

fix(cache): isolate cache keys per working_directory in monorepos#360
jdx merged 2 commits into
jdx:mainfrom
chadxz:fix/monorepo-caching

Conversation

@chadxz

@chadxz chadxz commented Jan 13, 2026

Copy link
Copy Markdown
Contributor

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

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.

  • New cache flow: restoreMiseBinaryCache/saveMiseBinaryCache (key: {prefix}-binary-{platform}-{version}-{dirHash}) runs before installation; restoreToolsCache/saveToolsCache uses a default key derived from mise config ls --json for the specified working_directory
  • Uses explicit mise binary path and preserves it during tools cache restore via withBinaryBackup to avoid overwrites
  • Default tools key still supports template inputs; includes lockfile handling and guards to disable caching on failures
  • mise_dir hash included in binary cache key to prevent cross-dir collisions
  • Adds .github/workflows/test-monorepo-cache.yml with scenarios verifying monorepo cache isolation, unrelated-config no-bust, parent-config change bust, lockfile change bust, identical-content different-path isolation, and mise_dir-key differentiation
  • Updates compiled dist/ artifacts; minor docs entry AGENTS.md

Written by Cursor Bugbot for commit 434d5fe. This will update automatically on new commits. Configure here.

@chadxz
chadxz requested a review from jdx as a code owner January 13, 2026 08:01
@chadxz

chadxz commented Jan 13, 2026

Copy link
Copy Markdown
Contributor Author

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 }}

@chadxz

chadxz commented Jan 13, 2026

Copy link
Copy Markdown
Contributor Author

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:

  • kept cache-hit output semantics the same - if either the tool cache or binary cache are missed, cache-hit is false.
  • configuration options for cache key prefix and cache key apply to tool cache, not binary cache. I think in general people would want to customize the tool cache. The binary cache follows the version, which I think is what people would want.

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

@jdx

jdx commented Jan 16, 2026

Copy link
Copy Markdown
Owner

bugbot run

@jdx
jdx enabled auto-merge (squash) January 16, 2026 23:19
@jdx
jdx disabled auto-merge January 16, 2026 23:19

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread dist/index.js
@chadxz
chadxz force-pushed the fix/monorepo-caching branch 4 times, most recently from 102649f to 37b9a78 Compare January 17, 2026 04:40
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
@chadxz
chadxz force-pushed the fix/monorepo-caching branch from 37b9a78 to 434d5fe Compare January 17, 2026 04:48
@jdx

jdx commented Jan 17, 2026

Copy link
Copy Markdown
Owner

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@chadxz
chadxz force-pushed the fix/monorepo-caching branch from 811da51 to 772d090 Compare January 18, 2026 03:34
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.
@chadxz
chadxz force-pushed the fix/monorepo-caching branch from 772d090 to 39db9ab Compare January 18, 2026 03:53
@jdx
jdx merged commit 891faa7 into jdx:main Jan 18, 2026
25 checks passed
jdx pushed a commit that referenced this pull request Jan 18, 2026
---
## [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 -->
jdx added a commit that referenced this pull request Jan 20, 2026
jdx added a commit that referenced this pull request Jan 20, 2026
…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 -->
chadxz added a commit to chadxz/mise-action that referenced this pull request Jan 28, 2026
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>
chadxz added a commit to chadxz/mise-action that referenced this pull request May 20, 2026
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>
chadxz added a commit to chadxz/mise-action that referenced this pull request Jul 1, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants