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

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.

<!-- CURSOR_SUMMARY -->
---

> [!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`
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
434d5feca5. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
This commit is contained in:
Chad McElligott 2026-01-18 13:33:20 -06:00 committed by GitHub
parent 23096c7b0b
commit 891faa7084
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 691 additions and 81 deletions

View file

@ -0,0 +1,219 @@
# Test monorepo cache isolation: when working_directory is set, each directory
# should get its own cache key based on its config hierarchy, not all configs
# in the repo. This prevents cache pollution between unrelated projects.
name: "test-monorepo-cache"
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
env:
# Simulates a monorepo with:
# - Root config with shared tool (actionlint)
# - Frontend with its own tool (shfmt)
# - Backend with its own tool (shellcheck)
# Frontend and backend both inherit the root config.
SETUP_MONOREPO: |
mkdir -p apps/frontend apps/backend
echo '[tools]' > mise.toml
echo 'actionlint = "1"' >> mise.toml
echo '[tools]' > apps/frontend/mise.toml
echo 'shfmt = "3"' >> apps/frontend/mise.toml
echo '[tools]' > apps/backend/mise.toml
echo 'shellcheck = "0.10"' >> apps/backend/mise.toml
# Simple single-tool config for mise_dir tests
SETUP_SIMPLE: |
echo '[tools]' > mise.toml
echo 'actionlint = "1"' >> mise.toml
jobs:
# Install ONLY backend tools and save cache.
# This cache should NOT be shared with frontend.
install-backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: ${{ env.SETUP_MONOREPO }}
- uses: ./
with:
working_directory: apps/backend
cache_key_prefix: monorepo-test-${{ github.run_id }}
# Try to restore frontend after backend cache exists - should be cache MISS
# since frontend has a different working_directory and thus a different cache key.
restore-frontend:
needs: install-backend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: ${{ env.SETUP_MONOREPO }}
- uses: ./
with:
working_directory: apps/frontend
install: false
cache_key_prefix: monorepo-test-${{ github.run_id }}
- name: Verify cache isolation
run: |
# With the fix, frontend has a different cache key, so cache miss = no tools
# With the bug, frontend has same key as backend, so cache hit = has shellcheck but not shfmt
if [ -f ~/.local/share/mise/shims/shellcheck ]; then
echo "FAIL: shellcheck found - frontend got backend's cache (shared key bug)"
exit 1
fi
echo "PASS: No backend tools found - cache keys are properly isolated"
# Install frontend and save cache (used by unrelated-change-no-bust)
install-frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: ${{ env.SETUP_MONOREPO }}
- uses: ./
with:
working_directory: apps/frontend
cache_key_prefix: unrelated-${{ github.run_id }}
# Test that changing an UNRELATED directory's config does NOT bust the cache.
# Only configs affecting the working_directory are hashed.
unrelated-change-no-bust:
needs: install-frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: ${{ env.SETUP_MONOREPO }}
# Modify BACKEND config (unrelated to frontend)
- run: echo 'jq = "1"' >> apps/backend/mise.toml
# Try to restore frontend - should still be a cache HIT
- uses: ./
with:
working_directory: apps/frontend
install: false
cache_key_prefix: unrelated-${{ github.run_id }}
- name: Verify cache still hits after unrelated change
run: |
if [ ! -f ~/.local/share/mise/shims/shfmt ]; then
echo "FAIL: shfmt missing - unrelated backend change busted frontend cache"
exit 1
fi
echo "PASS: Frontend cache survived unrelated backend change"
# Test that changing a parent config changes the child's cache key.
# We modify the root mise.toml and verify the cache key is different
# (cache miss on restore attempt).
parent-config-change:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: ${{ env.SETUP_MONOREPO }}
# Install frontend with original root config
- uses: ./
with:
working_directory: apps/frontend
cache_key_prefix: parent-change-${{ github.run_id }}
# Clear local mise state to simulate a fresh restore
- run: rm -rf ~/.local/share/mise/installs ~/.local/share/mise/shims
# Modify root config (add a new tool)
- run: echo 'taplo = "0.9"' >> mise.toml
# Try to restore - should be a cache MISS because root config changed
- uses: ./
id: restore-after-change
with:
working_directory: apps/frontend
install: false
cache_key_prefix: parent-change-${{ github.run_id }}
# Verify cache miss (shfmt not present because we didn't install)
- name: Verify cache miss after parent change
run: |
if [ -f ~/.local/share/mise/shims/shfmt ]; then
echo "FAIL: shfmt found - cache should have missed after parent config change"
exit 1
fi
echo "PASS: Cache missed as expected after parent config change"
# Test that changing a lock file changes the cache key.
# Lock files pin exact versions, so changes should bust the cache.
lock-file-change:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: |
mkdir -p apps/project
echo '[tools]' > apps/project/mise.toml
echo 'shfmt = "3"' >> apps/project/mise.toml
echo 'lockfile = "v1"' > apps/project/mise.lock
# Install with original lock file
- uses: ./
with:
working_directory: apps/project
cache_key_prefix: lock-change-${{ github.run_id }}
# Clear local mise state
- run: rm -rf ~/.local/share/mise/installs ~/.local/share/mise/shims
# Modify lock file
- run: echo 'lockfile = "v2"' > apps/project/mise.lock
# Try to restore - should be cache MISS because lock file changed
- uses: ./
with:
working_directory: apps/project
install: false
cache_key_prefix: lock-change-${{ github.run_id }}
- name: Verify cache miss after lock file change
run: |
if [ -f ~/.local/share/mise/shims/shfmt ]; then
echo "FAIL: shfmt found - cache should have missed after lock file change"
exit 1
fi
echo "PASS: Cache missed as expected after lock file change"
# Test that IDENTICAL content in DIFFERENT paths produces DIFFERENT cache keys.
# This prevents hash collisions from concatenating file contents without separators.
install-identical-content-alpha:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: |
mkdir -p apps/alpha
echo '[tools]' > apps/alpha/mise.toml
echo 'shfmt = "3"' >> apps/alpha/mise.toml
- uses: ./
with:
working_directory: apps/alpha
cache_key_prefix: identical-content-${{ github.run_id }}
# Try to restore with IDENTICAL content but DIFFERENT path - should be cache MISS
restore-identical-content-beta:
needs: install-identical-content-alpha
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: |
mkdir -p apps/beta
# Exact same content as alpha
echo '[tools]' > apps/beta/mise.toml
echo 'shfmt = "3"' >> apps/beta/mise.toml
- uses: ./
with:
working_directory: apps/beta
install: false
cache_key_prefix: identical-content-${{ github.run_id }}
- name: Verify different paths produce different cache keys
run: |
if [ -f ~/.local/share/mise/shims/shfmt ]; then
echo "FAIL: shfmt found - identical content in different path caused cache collision"
exit 1
fi
echo "PASS: Identical content in different paths correctly produced different cache keys"

1
AGENTS.md Symbolic link
View file

@ -0,0 +1 @@
CLAUDE.md

253
dist/index.js generated vendored
View file

@ -50025,21 +50025,28 @@ const MISE_CONFIG_FILE_PATTERNS = [
`**/.tool-versions`
];
// Default cache key template
const DEFAULT_CACHE_KEY_TEMPLATE = '{{cache_key_prefix}}-{{platform}}-{{file_hash}}{{#if version}}-{{version}}{{/if}}{{#if mise_env}}-{{mise_env}}{{/if}}{{#if install_args_hash}}-{{install_args_hash}}{{/if}}';
const DEFAULT_CACHE_KEY_TEMPLATE = '{{cache_key_prefix}}-{{platform}}-{{file_hash}}-{{dir_hash}}{{#if version}}-{{version}}{{/if}}{{#if mise_env}}-{{mise_env}}{{/if}}{{#if install_args_hash}}-{{install_args_hash}}{{/if}}';
async function run() {
try {
await setToolVersions();
await setMiseToml();
let cacheKey;
if (core.getBooleanInput('cache')) {
cacheKey = await restoreMiseCache();
}
else {
core.setOutput('cache-hit', false);
}
const version = core.getInput('version');
const fetchFromGitHub = core.getBooleanInput('fetch_from_github');
await setupMise(version, fetchFromGitHub);
const cacheEnabled = core.getBooleanInput('cache');
// Restore binary cache, install mise, save binary cache if needed
let binaryCache = { key: '', hit: false };
if (cacheEnabled) {
binaryCache = await restoreMiseBinaryCache(version);
}
await setupMise(version, core.getBooleanInput('fetch_from_github'));
if (cacheEnabled) {
await saveMiseBinaryCache(binaryCache);
}
// Restore tools cache (needs mise installed to compute key for working_directory)
let toolsCache = { key: '', hit: false };
if (cacheEnabled) {
toolsCache = await restoreToolsCache();
}
core.setOutput('cache-hit', binaryCache.hit && toolsCache.hit);
await setEnvVars();
if (core.getBooleanInput('reshim')) {
await miseReshim();
@ -50047,13 +50054,12 @@ async function run() {
await testMise();
if (core.getBooleanInput('install')) {
await miseInstall();
if (cacheKey && core.getBooleanInput('cache_save')) {
await saveCache(cacheKey);
if (cacheEnabled) {
await saveToolsCache(toolsCache);
}
}
await miseLs();
const loadEnv = core.getBooleanInput('env');
if (loadEnv) {
if (core.getBooleanInput('env')) {
await exportMiseEnv();
}
}
@ -50161,21 +50167,89 @@ async function setEnvVars() {
core.addPath(shimsDir);
}
}
async function restoreMiseCache() {
core.startGroup('Restoring mise cache');
const cachePath = miseDir();
// Use custom cache key if provided, otherwise use default template
const cacheKeyTemplate = core.getInput('cache_key') || DEFAULT_CACHE_KEY_TEMPLATE;
const primaryKey = await processCacheKeyTemplate(cacheKeyTemplate);
core.saveState('PRIMARY_KEY', primaryKey);
core.saveState('MISE_DIR', cachePath);
const cacheKey = await cache.restoreCache([cachePath], primaryKey);
core.setOutput('cache-hit', Boolean(cacheKey));
if (!cacheKey) {
core.info(`mise cache not found for ${primaryKey}`);
return primaryKey;
async function restoreMiseBinaryCache(version) {
const binPath = path.join(miseDir(), 'bin');
const platform = await getTarget();
const resolvedVersion = version || 'latest';
const cacheKeyPrefix = core.getInput('cache_key_prefix') || 'mise-v0';
// Include hash of miseDir path to handle custom mise_dir configurations
const dirHash = crypto
.createHash('sha256')
.update(miseDir())
.digest('hex')
.slice(0, 8);
const key = `${cacheKeyPrefix}-binary-${platform}-${resolvedVersion}-${dirHash}`;
const cacheKey = await core.group('Restoring mise binary cache', async () => {
const restored = await cache.restoreCache([binPath], key);
if (restored) {
core.info(`mise binary cache restored from key: ${restored}`);
}
else {
core.info(`mise binary cache not found for ${key}`);
}
return restored;
});
return { key, hit: Boolean(cacheKey) };
}
async function saveMiseBinaryCache(state) {
if (!core.getBooleanInput('cache_save') || state.hit || !state.key) {
return;
}
core.info(`mise cache restored from key: ${cacheKey}`);
await core.group('Saving mise binary cache', async () => {
const binPath = path.join(miseDir(), 'bin');
if (!fs.existsSync(binPath)) {
return;
}
const cacheId = await cache.saveCache([binPath], state.key);
if (cacheId !== -1) {
core.info(`Binary cache saved with key: ${state.key}`);
}
});
}
/**
* Runs a function while preserving the mise binary. The tools cache includes
* bin/, so restoring it could overwrite the binary that setupMise() just
* installed. This backs up the binary before and restores it after.
*/
async function withBinaryBackup(fn) {
const binPath = path.join(miseDir(), 'bin');
const binBackup = path.join(os.tmpdir(), `mise-bin-backup-${crypto.randomBytes(8).toString('hex')}`);
if (!fs.existsSync(binPath)) {
throw new Error(`Expected binary at ${binPath} but it does not exist`);
}
await io.cp(binPath, binBackup, { recursive: true });
try {
return await fn();
}
finally {
try {
await io.cp(binBackup, binPath, { recursive: true, force: true });
}
finally {
// cleanup even if the restore fails
await io.rmRF(binBackup);
}
}
}
async function restoreToolsCache() {
const cacheKeyTemplate = core.getInput('cache_key') || DEFAULT_CACHE_KEY_TEMPLATE;
const key = await processCacheKeyTemplate(cacheKeyTemplate);
if (!key) {
core.info('Tools caching disabled');
return { key: '', hit: false };
}
const cacheKey = await withBinaryBackup(() => core.group('Restoring mise tools cache', async () => {
const cachePath = miseDir();
const restored = await cache.restoreCache([cachePath], key);
if (restored) {
core.info(`mise tools cache restored from key: ${restored}`);
}
else {
core.info(`mise tools cache not found for ${key}`);
}
return restored;
}));
return { key, hit: Boolean(cacheKey) };
}
async function setupMise(version, fetchFromGitHub = false) {
const miseBinDir = path.join(miseDir(), 'bin');
@ -50309,9 +50383,6 @@ const writeFile = async (p, body) => await core.group(`Writing ${p}`, async () =
});
run();
function miseDir() {
const dir = core.getState('MISE_DIR');
if (dir)
return dir;
const miseDir = core.getInput('mise_dir');
if (miseDir)
return miseDir;
@ -50324,16 +50395,20 @@ function miseDir() {
return path.join(LOCALAPPDATA, 'mise');
return path.join(os.homedir(), '.local', 'share', 'mise');
}
async function saveCache(cacheKey) {
await core.group(`Saving mise cache`, async () => {
async function saveToolsCache(state) {
if (!core.getBooleanInput('cache_save') || state.hit || !state.key) {
return;
}
await core.group('Saving mise tools cache', async () => {
const cachePath = miseDir();
if (!fs.existsSync(cachePath)) {
throw new Error(`Cache folder path does not exist on disk: ${cachePath}`);
}
const cacheId = await cache.saveCache([cachePath], cacheKey);
if (cacheId === -1)
core.warning(`Cache folder path does not exist: ${cachePath}`);
return;
core.info(`Cache saved from ${cachePath} with key: ${cacheKey}`);
}
const cacheId = await cache.saveCache([cachePath], state.key);
if (cacheId !== -1) {
core.info(`Tools cache saved with key: ${state.key}`);
}
});
}
async function getTarget() {
@ -50359,8 +50434,38 @@ async function processCacheKeyTemplate(template) {
const cacheKeyPrefix = core.getInput('cache_key_prefix') || 'mise-v0';
const miseEnv = process.env.MISE_ENV?.replace(/,/g, '-');
const platform = await getTarget();
const workingDirectory = core.getInput('working_directory');
// Calculate file hash
const fileHash = await glob.hashFiles(MISE_CONFIG_FILE_PATTERNS.join('\n'));
// When working_directory is set, use mise config ls to get only relevant config files
// Otherwise, use the glob pattern to get all config files in the repo
let fileHash;
if (workingDirectory) {
const configFiles = await configFilesForPath(workingDirectory);
if (configFiles === null) {
// Failed to get config files, skip caching
return null;
}
// Hash the contents of the config files
// Include file path in each update to prevent collisions between different
// file arrangements with the same concatenated content (e.g., "ab"+"cdef"
// vs "abc"+"def")
const hash = crypto.createHash('sha256');
try {
for (const file of configFiles) {
hash.update(file);
const content = await fs.promises.readFile(file);
hash.update(content);
}
fileHash = hash.digest('hex');
}
catch (error) {
core.warning(`Failed to read config file for cache key: ${error}. Caching will be disabled.`);
return null;
}
}
else {
fileHash = await glob.hashFiles(MISE_CONFIG_FILE_PATTERNS.join('\n'));
}
// Calculate install args hash
let installArgsHash = '';
if (installArgs) {
@ -50373,12 +50478,20 @@ async function processCacheKeyTemplate(template) {
installArgsHash = crypto.createHash('sha256').update(tools).digest('hex');
}
}
// Calculate mise dir hash to isolate caches for different mise_dir configurations
// This matches the binary cache key which also includes dir_hash
const dirHash = crypto
.createHash('sha256')
.update(miseDir())
.digest('hex')
.slice(0, 8);
// Prepare base template data
const baseTemplateData = {
version,
cache_key_prefix: cacheKeyPrefix,
platform,
file_hash: fileHash,
dir_hash: dirHash,
mise_env: miseEnv,
install_args_hash: installArgsHash
};
@ -50403,6 +50516,68 @@ async function isMusl() {
});
return stderr.indexOf('musl') > -1;
}
/**
* Checks if a file path is contained within a directory.
*
* Uses path.relative() to compute the relative path from parent to child.
* If the result starts with ".." or is absolute, the child is outside the parent.
*
* @example
* isPathWithin("/workspace", "/workspace/src/file.ts") // true
* isPathWithin("/workspace", "/workspace-other/file.ts") // false (not a child)
* isPathWithin("/workspace", "/etc/passwd") // false (unrelated)
* isPathWithin("C:\\work", "D:\\other\\file.ts") // false (different drive on Windows)
*/
function isPathWithin(parent, child) {
const relative = path.relative(parent, child);
return !relative.startsWith('..') && !path.isAbsolute(relative);
}
/**
* Get config files that affect the given working directory using `mise config ls --json`.
* This returns the hierarchy of configs (directory's own config + inherited parents).
* Filters to only files within GITHUB_WORKSPACE and adds corresponding .lock files.
*/
async function configFilesForPath(workingDirectory) {
const githubWorkspace = process.env.GITHUB_WORKSPACE || process.cwd();
// Use explicit path to mise binary instead of relying on PATH
const miseBinPath = path.join(miseDir(), 'bin', process.platform === 'win32' ? 'mise.exe' : 'mise');
try {
const output = await exec.getExecOutput(miseBinPath, ['config', 'ls', '--json'], {
cwd: workingDirectory,
silent: true
});
const configs = JSON.parse(output.stdout);
const configFiles = [];
for (const config of configs) {
const configPath = config.path;
// Filter to only files within GITHUB_WORKSPACE
if (!isPathWithin(githubWorkspace, configPath)) {
continue;
}
configFiles.push(configPath);
// Include corresponding lock files if they exist
let lockPath;
if (configPath.endsWith('.toml')) {
// mise.toml -> mise.lock
lockPath = configPath.replace(/\.toml$/, '.lock');
}
else if (configPath.endsWith('.tool-versions')) {
// .tool-versions -> mise.lock in the same directory
lockPath = path.join(path.dirname(configPath), 'mise.lock');
}
if (lockPath &&
fs.existsSync(lockPath) &&
!configFiles.includes(lockPath)) {
configFiles.push(lockPath);
}
}
return configFiles.sort();
}
catch (error) {
core.warning(`Failed to get config files for working_directory "${workingDirectory}": ${error}. Caching will be disabled.`);
return null;
}
}
/***/ }),

2
dist/index.js.map generated vendored

File diff suppressed because one or more lines are too long

View file

@ -40,23 +40,38 @@ const MISE_CONFIG_FILE_PATTERNS = [
// Default cache key template
const DEFAULT_CACHE_KEY_TEMPLATE =
'{{cache_key_prefix}}-{{platform}}-{{file_hash}}{{#if version}}-{{version}}{{/if}}{{#if mise_env}}-{{mise_env}}{{/if}}{{#if install_args_hash}}-{{install_args_hash}}{{/if}}'
'{{cache_key_prefix}}-{{platform}}-{{file_hash}}-{{dir_hash}}{{#if version}}-{{version}}{{/if}}{{#if mise_env}}-{{mise_env}}{{/if}}{{#if install_args_hash}}-{{install_args_hash}}{{/if}}'
interface CacheState {
key: string
hit: boolean
}
async function run(): Promise<void> {
try {
await setToolVersions()
await setMiseToml()
let cacheKey: string | undefined
if (core.getBooleanInput('cache')) {
cacheKey = await restoreMiseCache()
} else {
core.setOutput('cache-hit', false)
const version = core.getInput('version')
const cacheEnabled = core.getBooleanInput('cache')
// Restore binary cache, install mise, save binary cache if needed
let binaryCache: CacheState = { key: '', hit: false }
if (cacheEnabled) {
binaryCache = await restoreMiseBinaryCache(version)
}
await setupMise(version, core.getBooleanInput('fetch_from_github'))
if (cacheEnabled) {
await saveMiseBinaryCache(binaryCache)
}
const version = core.getInput('version')
const fetchFromGitHub = core.getBooleanInput('fetch_from_github')
await setupMise(version, fetchFromGitHub)
// Restore tools cache (needs mise installed to compute key for working_directory)
let toolsCache: CacheState = { key: '', hit: false }
if (cacheEnabled) {
toolsCache = await restoreToolsCache()
}
core.setOutput('cache-hit', binaryCache.hit && toolsCache.hit)
await setEnvVars()
if (core.getBooleanInput('reshim')) {
await miseReshim()
@ -64,13 +79,12 @@ async function run(): Promise<void> {
await testMise()
if (core.getBooleanInput('install')) {
await miseInstall()
if (cacheKey && core.getBooleanInput('cache_save')) {
await saveCache(cacheKey)
if (cacheEnabled) {
await saveToolsCache(toolsCache)
}
}
await miseLs()
const loadEnv = core.getBooleanInput('env')
if (loadEnv) {
if (core.getBooleanInput('env')) {
await exportMiseEnv()
}
} catch (err) {
@ -194,27 +208,104 @@ async function setEnvVars(): Promise<void> {
}
}
async function restoreMiseCache(): Promise<string | undefined> {
core.startGroup('Restoring mise cache')
const cachePath = miseDir()
async function restoreMiseBinaryCache(version: string): Promise<CacheState> {
const binPath = path.join(miseDir(), 'bin')
const platform = await getTarget()
const resolvedVersion = version || 'latest'
const cacheKeyPrefix = core.getInput('cache_key_prefix') || 'mise-v0'
// Include hash of miseDir path to handle custom mise_dir configurations
const dirHash = crypto
.createHash('sha256')
.update(miseDir())
.digest('hex')
.slice(0, 8)
const key = `${cacheKeyPrefix}-binary-${platform}-${resolvedVersion}-${dirHash}`
// Use custom cache key if provided, otherwise use default template
const cacheKeyTemplate =
core.getInput('cache_key') || DEFAULT_CACHE_KEY_TEMPLATE
const primaryKey = await processCacheKeyTemplate(cacheKeyTemplate)
const cacheKey = await core.group('Restoring mise binary cache', async () => {
const restored = await cache.restoreCache([binPath], key)
if (restored) {
core.info(`mise binary cache restored from key: ${restored}`)
} else {
core.info(`mise binary cache not found for ${key}`)
}
return restored
})
core.saveState('PRIMARY_KEY', primaryKey)
core.saveState('MISE_DIR', cachePath)
return { key, hit: Boolean(cacheKey) }
}
const cacheKey = await cache.restoreCache([cachePath], primaryKey)
core.setOutput('cache-hit', Boolean(cacheKey))
if (!cacheKey) {
core.info(`mise cache not found for ${primaryKey}`)
return primaryKey
async function saveMiseBinaryCache(state: CacheState): Promise<void> {
if (!core.getBooleanInput('cache_save') || state.hit || !state.key) {
return
}
core.info(`mise cache restored from key: ${cacheKey}`)
await core.group('Saving mise binary cache', async () => {
const binPath = path.join(miseDir(), 'bin')
if (!fs.existsSync(binPath)) {
return
}
const cacheId = await cache.saveCache([binPath], state.key)
if (cacheId !== -1) {
core.info(`Binary cache saved with key: ${state.key}`)
}
})
}
/**
* Runs a function while preserving the mise binary. The tools cache includes
* bin/, so restoring it could overwrite the binary that setupMise() just
* installed. This backs up the binary before and restores it after.
*/
async function withBinaryBackup<T>(fn: () => Promise<T>): Promise<T> {
const binPath = path.join(miseDir(), 'bin')
const binBackup = path.join(
os.tmpdir(),
`mise-bin-backup-${crypto.randomBytes(8).toString('hex')}`
)
if (!fs.existsSync(binPath)) {
throw new Error(`Expected binary at ${binPath} but it does not exist`)
}
await io.cp(binPath, binBackup, { recursive: true })
try {
return await fn()
} finally {
try {
await io.cp(binBackup, binPath, { recursive: true, force: true })
} finally {
// cleanup even if the restore fails
await io.rmRF(binBackup)
}
}
}
async function restoreToolsCache(): Promise<CacheState> {
const cacheKeyTemplate =
core.getInput('cache_key') || DEFAULT_CACHE_KEY_TEMPLATE
const key = await processCacheKeyTemplate(cacheKeyTemplate)
if (!key) {
core.info('Tools caching disabled')
return { key: '', hit: false }
}
const cacheKey = await withBinaryBackup(() =>
core.group('Restoring mise tools cache', async () => {
const cachePath = miseDir()
const restored = await cache.restoreCache([cachePath], key)
if (restored) {
core.info(`mise tools cache restored from key: ${restored}`)
} else {
core.info(`mise tools cache not found for ${key}`)
}
return restored
})
)
return { key, hit: Boolean(cacheKey) }
}
async function setupMise(
@ -377,9 +468,6 @@ const writeFile = async (p: fs.PathLike, body: string): Promise<void> =>
run()
function miseDir(): string {
const dir = core.getState('MISE_DIR')
if (dir) return dir
const miseDir = core.getInput('mise_dir')
if (miseDir) return miseDir
@ -392,18 +480,23 @@ function miseDir(): string {
return path.join(os.homedir(), '.local', 'share', 'mise')
}
async function saveCache(cacheKey: string): Promise<void> {
await core.group(`Saving mise cache`, async () => {
async function saveToolsCache(state: CacheState): Promise<void> {
if (!core.getBooleanInput('cache_save') || state.hit || !state.key) {
return
}
await core.group('Saving mise tools cache', async () => {
const cachePath = miseDir()
if (!fs.existsSync(cachePath)) {
throw new Error(`Cache folder path does not exist on disk: ${cachePath}`)
core.warning(`Cache folder path does not exist: ${cachePath}`)
return
}
const cacheId = await cache.saveCache([cachePath], cacheKey)
if (cacheId === -1) return
core.info(`Cache saved from ${cachePath} with key: ${cacheKey}`)
const cacheId = await cache.saveCache([cachePath], state.key)
if (cacheId !== -1) {
core.info(`Tools cache saved with key: ${state.key}`)
}
})
}
@ -425,16 +518,48 @@ async function getTarget(): Promise<string> {
}
}
async function processCacheKeyTemplate(template: string): Promise<string> {
async function processCacheKeyTemplate(
template: string
): Promise<string | null> {
// Get all available variables
const version = core.getInput('version')
const installArgs = core.getInput('install_args')
const cacheKeyPrefix = core.getInput('cache_key_prefix') || 'mise-v0'
const miseEnv = process.env.MISE_ENV?.replace(/,/g, '-')
const platform = await getTarget()
const workingDirectory = core.getInput('working_directory')
// Calculate file hash
const fileHash = await glob.hashFiles(MISE_CONFIG_FILE_PATTERNS.join('\n'))
// When working_directory is set, use mise config ls to get only relevant config files
// Otherwise, use the glob pattern to get all config files in the repo
let fileHash: string
if (workingDirectory) {
const configFiles = await configFilesForPath(workingDirectory)
if (configFiles === null) {
// Failed to get config files, skip caching
return null
}
// Hash the contents of the config files
// Include file path in each update to prevent collisions between different
// file arrangements with the same concatenated content (e.g., "ab"+"cdef"
// vs "abc"+"def")
const hash = crypto.createHash('sha256')
try {
for (const file of configFiles) {
hash.update(file)
const content = await fs.promises.readFile(file)
hash.update(content)
}
fileHash = hash.digest('hex')
} catch (error) {
core.warning(
`Failed to read config file for cache key: ${error}. Caching will be disabled.`
)
return null
}
} else {
fileHash = await glob.hashFiles(MISE_CONFIG_FILE_PATTERNS.join('\n'))
}
// Calculate install args hash
let installArgsHash = ''
@ -449,12 +574,21 @@ async function processCacheKeyTemplate(template: string): Promise<string> {
}
}
// Calculate mise dir hash to isolate caches for different mise_dir configurations
// This matches the binary cache key which also includes dir_hash
const dirHash = crypto
.createHash('sha256')
.update(miseDir())
.digest('hex')
.slice(0, 8)
// Prepare base template data
const baseTemplateData = {
version,
cache_key_prefix: cacheKeyPrefix,
platform,
file_hash: fileHash,
dir_hash: dirHash,
mise_env: miseEnv,
install_args_hash: installArgsHash
}
@ -483,3 +617,84 @@ async function isMusl() {
})
return stderr.indexOf('musl') > -1
}
/**
* Checks if a file path is contained within a directory.
*
* Uses path.relative() to compute the relative path from parent to child.
* If the result starts with ".." or is absolute, the child is outside the parent.
*
* @example
* isPathWithin("/workspace", "/workspace/src/file.ts") // true
* isPathWithin("/workspace", "/workspace-other/file.ts") // false (not a child)
* isPathWithin("/workspace", "/etc/passwd") // false (unrelated)
* isPathWithin("C:\\work", "D:\\other\\file.ts") // false (different drive on Windows)
*/
function isPathWithin(parent: string, child: string): boolean {
const relative = path.relative(parent, child)
return !relative.startsWith('..') && !path.isAbsolute(relative)
}
/**
* Get config files that affect the given working directory using `mise config ls --json`.
* This returns the hierarchy of configs (directory's own config + inherited parents).
* Filters to only files within GITHUB_WORKSPACE and adds corresponding .lock files.
*/
async function configFilesForPath(
workingDirectory: string
): Promise<string[] | null> {
const githubWorkspace = process.env.GITHUB_WORKSPACE || process.cwd()
// Use explicit path to mise binary instead of relying on PATH
const miseBinPath = path.join(
miseDir(),
'bin',
process.platform === 'win32' ? 'mise.exe' : 'mise'
)
try {
const output = await exec.getExecOutput(
miseBinPath,
['config', 'ls', '--json'],
{
cwd: workingDirectory,
silent: true
}
)
const configs: Array<{ path: string }> = JSON.parse(output.stdout)
const configFiles: string[] = []
for (const config of configs) {
const configPath = config.path
// Filter to only files within GITHUB_WORKSPACE
if (!isPathWithin(githubWorkspace, configPath)) {
continue
}
configFiles.push(configPath)
// Include corresponding lock files if they exist
let lockPath: string | undefined
if (configPath.endsWith('.toml')) {
// mise.toml -> mise.lock
lockPath = configPath.replace(/\.toml$/, '.lock')
} else if (configPath.endsWith('.tool-versions')) {
// .tool-versions -> mise.lock in the same directory
lockPath = path.join(path.dirname(configPath), 'mise.lock')
}
if (
lockPath &&
fs.existsSync(lockPath) &&
!configFiles.includes(lockPath)
) {
configFiles.push(lockPath)
}
}
return configFiles.sort()
} catch (error) {
core.warning(
`Failed to get config files for working_directory "${workingDirectory}": ${error}. Caching will be disabled.`
)
return null
}
}