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

This reverts commit 891faa7084.
This commit is contained in:
jdx 2026-01-20 06:30:14 -06:00 committed by GitHub
parent c53b9236f0
commit a157c4e176
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 80 additions and 690 deletions

View file

@ -1,219 +0,0 @@
# 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"

View file

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

251
dist/index.js generated vendored
View file

@ -50025,28 +50025,21 @@ const MISE_CONFIG_FILE_PATTERNS = [
`**/.tool-versions`
];
// Default cache key template
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}}';
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}}';
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 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);
const fetchFromGitHub = core.getBooleanInput('fetch_from_github');
await setupMise(version, fetchFromGitHub);
await setEnvVars();
if (core.getBooleanInput('reshim')) {
await miseReshim();
@ -50054,12 +50047,13 @@ async function run() {
await testMise();
if (core.getBooleanInput('install')) {
await miseInstall();
if (cacheEnabled) {
await saveToolsCache(toolsCache);
if (cacheKey && core.getBooleanInput('cache_save')) {
await saveCache(cacheKey);
}
}
await miseLs();
if (core.getBooleanInput('env')) {
const loadEnv = core.getBooleanInput('env');
if (loadEnv) {
await exportMiseEnv();
}
}
@ -50167,89 +50161,21 @@ async function setEnvVars() {
core.addPath(shimsDir);
}
}
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;
}
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() {
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 key = await processCacheKeyTemplate(cacheKeyTemplate);
if (!key) {
core.info('Tools caching disabled');
return { key: '', hit: false };
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;
}
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) };
core.info(`mise cache restored from key: ${cacheKey}`);
}
async function setupMise(version, fetchFromGitHub = false) {
const miseBinDir = path.join(miseDir(), 'bin');
@ -50383,6 +50309,9 @@ 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;
@ -50395,20 +50324,16 @@ function miseDir() {
return path.join(LOCALAPPDATA, 'mise');
return path.join(os.homedir(), '.local', 'share', 'mise');
}
async function saveToolsCache(state) {
if (!core.getBooleanInput('cache_save') || state.hit || !state.key) {
return;
}
await core.group('Saving mise tools cache', async () => {
async function saveCache(cacheKey) {
await core.group(`Saving mise cache`, async () => {
const cachePath = miseDir();
if (!fs.existsSync(cachePath)) {
core.warning(`Cache folder path does not exist: ${cachePath}`);
throw new Error(`Cache folder path does not exist on disk: ${cachePath}`);
}
const cacheId = await cache.saveCache([cachePath], cacheKey);
if (cacheId === -1)
return;
}
const cacheId = await cache.saveCache([cachePath], state.key);
if (cacheId !== -1) {
core.info(`Tools cache saved with key: ${state.key}`);
}
core.info(`Cache saved from ${cachePath} with key: ${cacheKey}`);
});
}
async function getTarget() {
@ -50434,38 +50359,8 @@ 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
// 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'));
}
const fileHash = await glob.hashFiles(MISE_CONFIG_FILE_PATTERNS.join('\n'));
// Calculate install args hash
let installArgsHash = '';
if (installArgs) {
@ -50478,20 +50373,12 @@ 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
};
@ -50516,68 +50403,6 @@ 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,38 +40,23 @@ const MISE_CONFIG_FILE_PATTERNS = [
// Default cache key template
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}}'
interface CacheState {
key: string
hit: boolean
}
'{{cache_key_prefix}}-{{platform}}-{{file_hash}}{{#if version}}-{{version}}{{/if}}{{#if mise_env}}-{{mise_env}}{{/if}}{{#if install_args_hash}}-{{install_args_hash}}{{/if}}'
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)
}
// 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)
const fetchFromGitHub = core.getBooleanInput('fetch_from_github')
await setupMise(version, fetchFromGitHub)
await setEnvVars()
if (core.getBooleanInput('reshim')) {
await miseReshim()
@ -79,12 +64,13 @@ async function run(): Promise<void> {
await testMise()
if (core.getBooleanInput('install')) {
await miseInstall()
if (cacheEnabled) {
await saveToolsCache(toolsCache)
if (cacheKey && core.getBooleanInput('cache_save')) {
await saveCache(cacheKey)
}
}
await miseLs()
if (core.getBooleanInput('env')) {
const loadEnv = core.getBooleanInput('env')
if (loadEnv) {
await exportMiseEnv()
}
} catch (err) {
@ -208,104 +194,27 @@ async function setEnvVars(): Promise<void> {
}
}
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}`
async function restoreMiseCache(): Promise<string | undefined> {
core.startGroup('Restoring mise cache')
const cachePath = miseDir()
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: CacheState): Promise<void> {
if (!core.getBooleanInput('cache_save') || state.hit || !state.key) {
return
}
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> {
// Use custom cache key if provided, otherwise use default template
const cacheKeyTemplate =
core.getInput('cache_key') || DEFAULT_CACHE_KEY_TEMPLATE
const key = await processCacheKeyTemplate(cacheKeyTemplate)
const primaryKey = await processCacheKeyTemplate(cacheKeyTemplate)
if (!key) {
core.info('Tools caching disabled')
return { key: '', hit: false }
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
}
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) }
core.info(`mise cache restored from key: ${cacheKey}`)
}
async function setupMise(
@ -468,6 +377,9 @@ 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
@ -480,23 +392,18 @@ function miseDir(): string {
return path.join(os.homedir(), '.local', 'share', 'mise')
}
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 () => {
async function saveCache(cacheKey: string): Promise<void> {
await core.group(`Saving mise cache`, async () => {
const cachePath = miseDir()
if (!fs.existsSync(cachePath)) {
core.warning(`Cache folder path does not exist: ${cachePath}`)
return
throw new Error(`Cache folder path does not exist on disk: ${cachePath}`)
}
const cacheId = await cache.saveCache([cachePath], state.key)
if (cacheId !== -1) {
core.info(`Tools cache saved with key: ${state.key}`)
}
const cacheId = await cache.saveCache([cachePath], cacheKey)
if (cacheId === -1) return
core.info(`Cache saved from ${cachePath} with key: ${cacheKey}`)
})
}
@ -518,48 +425,16 @@ async function getTarget(): Promise<string> {
}
}
async function processCacheKeyTemplate(
template: string
): Promise<string | null> {
async function processCacheKeyTemplate(template: string): Promise<string> {
// 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
// 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'))
}
const fileHash = await glob.hashFiles(MISE_CONFIG_FILE_PATTERNS.join('\n'))
// Calculate install args hash
let installArgsHash = ''
@ -574,21 +449,12 @@ async function processCacheKeyTemplate(
}
}
// 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
}
@ -617,84 +483,3 @@ 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
}
}