Skip to content

Default cache key template allows unintended prefix-match fallback; missing return prevents save after hit #382

@altendky

Description

@altendky

Summary

Two related cache bugs cause unnecessary rebuilds:

  1. Default cache key template allows unintended prefix-match fallback - Conditional trailing segments mean one workflow's key can be a prefix of another's, causing @actions/cache to restore a different workflow's cache.
  2. restoreMiseCache returns undefined on cache hit - The function only returns the primary key on a miss, so the cache is never saved after a hit. Combined with (1), the correct cache is never written and the fallback repeats every run.

Problem 1: Conditional suffixes create prefix relationships

The default cache key template uses conditional {{#if}} blocks for trailing segments:

mise-action/src/index.ts

Lines 42 to 43 in e371f56

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

@actions/cache restoreCache performs prefix matching on the primary key even without restoreKeys. The matching cascade is: exact match on primary key → prefix match on primary key → restore keys. (docs ref)

Because trailing segments are conditional, a key without them is always a prefix of a key with them:

Workflow install_args Key
A (none) mise-v0-linux-x64-<hash>
B node python mise-v0-linux-x64-<hash>-<args_hash>

A's key is a prefix of B's. If only B's cache exists, A restores it via prefix match. The cache itself isn't wrong — it just may not contain all the tools A needs (or may contain tools A doesn't need), leading to unnecessary installs. The same applies across version and mise_env variations.

Problem 2: restoreMiseCache returns undefined on hit

restoreMiseCache only returns primaryKey on a cache miss (L214). On a cache hit, it falls through without a return statement, yielding undefined:

mise-action/src/index.ts

Lines 197 to 218 in e371f56

async function restoreMiseCache(): Promise<string | undefined> {
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
}
core.info(`mise cache restored from key: ${cacheKey}`)
}

The caller uses the return value to gate saveCache, so on a hit, saveCache is never called:

mise-action/src/index.ts

Lines 65 to 70 in e371f56

if (core.getBooleanInput('install')) {
await miseInstall()
if (cacheKey && core.getBooleanInput('cache_save')) {
await saveCache(cacheKey)
}
}

Compound failure mode

With Problem 2 alone, this is harmless on an exact cache hit — the cache already exists under the correct key, so there's nothing new to save. But combined with Problem 1:

  1. Workflow B runs first → miss → install → cache saved under B's key
  2. Workflow A runs → prefix-matches B's cache → missing tools reinstalled
  3. restoreMiseCache returned undefined (hit path) → cache NOT saved under A's key
  4. Next A run → same prefix match → same reinstall → never saves
  5. Repeat forever

If Problem 2 were fixed independently, a prefix match would cause one suboptimal run (restore wrong cache, rebuild, save correct cache), and subsequent runs would hit the correct exact-match cache. Problem 1 would go from "persistent rebuild loop" to "occasional first-run penalty."

Observed in practice

The restore log below shows a prefix match — note the requested key vs. the restored key:

Restoring mise cache
  /usr/bin/ldd --version
  ldd (Ubuntu GLIBC 2.39-0ubuntu8.6) 2.39
  ...
  Cache hit for: mise-v0-linux-x64-11af6367a7fe4b577f20fd560d57f78b384692e710177df5731a4d533bffec2f
  Received 4194304 of 37680007 (11.1%), 4.0 MBs/sec
  Received 37680007 of 37680007 (100.0%), 28.6 MBs/sec
  Cache Size: ~36 MB (37680007 B)
  /usr/bin/tar -xf /home/runner/work/_temp/6422676c-cc59-4a0e-bb8a-9658bbdb83b5/cache.tzst -P -C /home/runner/work/monorepo/monorepo --use-compress-program unzstd
  Cache restored successfully
  mise cache restored from key: mise-v0-linux-x64-11af6367a7fe4b577f20fd560d57f78b384692e710177df5731a4d533bffec2f-e701a47bc66062ca3deccc874703ec0adcd663e90c68dd0eaadf3a8723a317cf

The requested primary key was mise-v0-linux-x64-11af6367... but the restored key has an additional -e701a47... suffix (the install_args_hash from a different workflow). Despite the cache being restored, tools were reinstalled, and due to Problem 2, the correct cache was never saved.

Proposed fixes

Fix 1 (prefix matching): Move file_hash to the end of the template so it acts as a terminator. Since file_hash is always present, no key can be a prefix of another. Bump cache_key_prefix default to mise-v1 to intentionally invalidate existing caches.

Fix 2 (missing return): Return primaryKey on cache hit in restoreMiseCache. This ensures the cache is saved under the correct key after install. If the restored key was an exact match, saveCache will no-op (key already exists). If it was a prefix match, the correct key gets saved for future runs.

PR plan

  • PR 1: Fix the missing return in restoreMiseCache (Problem 2) - minimal, unambiguous bug fix
  • PR 2: Restructure default cache key template (Problem 1) - includes cache_key_prefix bump to mise-v1

Both PRs will reference this issue.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions