Caching MPS Build Output in GitHub Actions

Rationale

It’s 16:30 on a slow Wednesday, you’re finishing up your pull request for the day. You get a comment, requesting to change the name of a variable so it’s easier to understand. Sure, your commit takes 10 seconds to finalize. And now you have to wait 30 minutes for the build, staring through the window and waving the colleagues goodbye one by one.

GitHub Actions has a caching mechanism and MPS generated output is exactly the kind of "expensive to produce, cheap to store" artifact that benefits from caching.

A match made in heaven.

This post is a checklist for setting it up and a summary of the gotchas we hit along the way.

The Problem

The project is the domain experts side, so it has no language modules, just solutions. It uses Maven, it has tens of solutions with hundreds of models. Lots of people work on separate branches, this means a lot of repeat building of mostly the same things.

On every push, we start a fresh runner, it pulls the dependencies, starts MPS and starts chugging through generators. This happens on a 1 line change, and on a 100 lines change.

The Approach

Persist the generated output between runs and only regenerate what has actually changed.

GitHub Actions provides actions/cache for storing files in the GitHub Cache. However, as is with everything MPS, it’s not that simple. MPS generated output directories (source_gen, classes_gen, source_gen.caches, test_gen and test_gen.caches) sit in the same folder as the module file and model directories. So we have to pick which directories will be saved.

To solve this, we ended up with two composite actions:

  • restore-mps-cache — runs before the build, restores cached generated output.

  • save-mps-cache — runs after the build, saves the new generated output if it’s new.

Both are called from inside our build-mps composite action, so the caching logic stays out of the main pipeline file.

Splitting Save and Restore

The standard GitHub action actions/cache@v5 handles both restore and save in a single step, but it gives us less control.

So we decided to re-use its components, actions/cache/restore and actions/cache/save, separately. That way we can skip the save when the restore was a full cache hit (no point re-saving identical content).

Cache files are branch specific so if we save identical cache files in separate branches they will consume extra space on the disk. We tried to handle this as much as possible using restore keys, because our project is storage hungry.

name: restore-mps-cache
description: >-
  Restores MPS generated output [source_gen(.caches), classes_gen, test_gen(.caches)] under {your-mps-project-base-path}/.

inputs:
  mps-cache-key:
    description: Cache key for MPS generated output
    required: true
  mps-cache-restore-keys:
    description: Fallback restore keys for MPS cache (newline-separated)
    required: false
    default: ''

outputs:
  mps-cache-hit:
    description: Whether an MPS cache was restored
    value: ${{ steps.restore_mps.outputs.cache-matched-key != '' }}

runs:
  using: composite
  steps:
    - name: Restore MPS Generated Output
      id: restore_mps
      uses: actions/cache/restore@v5
      with:
        path: ${{ github.workspace }}/mps-gen-cache.tar.zst
        key: ${{ inputs.mps-cache-key }}
        restore-keys: ${{ inputs.mps-cache-restore-keys }}

    - name: Unpack MPS Generated Output
      if: steps.restore_mps.outputs.cache-hit == 'true'
      shell: bash
      run: |
        tar -x --zstd \
        -f ${{ github.workspace }}/mps-gen-cache.tar.zst \
        -C ${{ github.workspace }}
actions/cache/restore@v5 has had reliability issues. If you hit weird "cache not found" errors despite the entry visibly existing, this is a candidate for investigation.

Tarring the Generated Output

The first wall we hit was an Argument list too long error from the cache action. Our project produces around 350 individual _gen directories, and passing all of them as a list of paths to the cache action exceeded the OS argument limit.

The fix is to tar everything into a single archive before saving and untar it on restore.

name: save-mps-cache
description: >-
  Saves MPS generated output [source_gen(.caches), classes_gen, test_gen(.caches)] under {your-mps-project-base-path}/.

inputs:
  mps-cache-key:
    description: Cache key for MPS generated output
    required: true
  mps-cache-hit:
    description: Whether a full cache hit occurred during restore. Skips save if true.
    required: false
    default: 'false'

runs:
  using: composite
  steps:
    - name: Archive MPS Generated Output
      if: inputs.mps-cache-hit != 'true'
      shell: bash
      run: |
        find ${{ github.workspace }}/{your-mps-project-base-path} \
        -mindepth 2 -maxdepth 2 -type d \( -name 'source_gen' -o -name 'classes_gen' -o -name 'source_gen.caches' -o -name 'test_gen' -o -name 'test_gen.caches' \) \
        -print0 | sed -z "s|${{ github.workspace }}/||" | \
        tar -c -I"zstd -19 -T0" \
        -f ${{ github.workspace }}/mps-gen-cache.tar.zst \
        -C ${{ github.workspace }} \
        --null --files-from=-
    - name: Save MPS Generated Output
      if: inputs.mps-cache-hit != 'true'
      uses: actions/cache/save@v5
      with:
        path: ${{ github.workspace }}/mps-gen-cache.tar.zst
        key: ${{ inputs.mps-cache-key }}

The archiving step is quite big, so I’ll explain everything:

  • find uses -mindepth 2 -maxdepth 2 to target only the top-level gen directories under each solution, not their contents.

If you are going to implement this on a project with languages and generators, you have to also include the generator folders, which are usually inside {mps-language}/{generator}/*_gen, so the find command has to be extended for deeper searching. This might look like just extending the -maxdepth property to 4 but if your project is big, it might need more thought.
  • -type d \( -name 'source_gen' -o -name 'classes_gen' -o -name 'source_gen.caches' -o -name 'test_gen' -o -name 'test_gen.caches' \) specifies we are looking for directories with these names, I decided to specify them explicitly so we don’t catch anything else by accident.

  • -print0 makes find delimit its output with null bytes (\0) instead of newlines. The default newline delimiter breaks if any of the paths contain whitespaces.

  • sed -z "s|${{ github.workspace }}/||" substitutes everything up to and including ${{ github.workspace }}/ so we work in the path relative to the workspace root. -z makes sed treat null bytes as record separators.

  • tar -c -I"zstd -19 -T0": -c creates a new archive using the zstd compressor at -19 compression level and -T0 to use all available CPU cores.

Make sure the same compression flag is used on both ends. Mixing (de)compression algorithms will give you a fun afternoon.

Cache Keys and Branch Scoping

GitHub caches are scoped per branch by default. A cache saved on feature-a is not visible from feature-b. Caches saved on the default branch are restorable from all branches.

We use a key that includes the runner OS, branch name, language version and a hash of the build model files:

mps-cache-key: ${{ runner.os }}-mps-cache-${{ github.ref_name }}-${{ steps.prepare_job.outputs.language-version }}-${{ hashFiles('{your-mps-project-base-path}/build-solutions/**/models/*.mps') }}
mps-cache-restore-keys: ${{ runner.os }}-mps-cache-main-${{ steps.prepare_job.outputs.language-version }}-${{ hashFiles('{your-mps-project-base-path}/build-solutions/**/models/*.mps') }}

We use the build models so we can capture caches using the dependencies of the project modules. The hash ensures we only ever restore a cache that matches the current version and set of dependencies. This decreases the chance of us using stale caches and breaking our builds, but also ensures we get hits and not have different hashes on every change.

The mps-cache-restore-keys provides a fallback to the most recent cache from main when a branch has no cache of its own yet.

If you scope by branch only and not by content hash, there is a greater chance that a stale cache will break your build the moment a model is changed.

Compression Choice

Our first version used xz (-cJf), but we found a better option.

After comparing other algorithms, I found that zstd -19 wins for our case. It achieved near-identical size to xz, while compressing much faster, and size is the deciding factor when we are trying to fit 50 cache items in 10GB.

Algorithm Compress Decompress Size

xz

9m51s

25s

99MB

gzip

42s

16s

198MB

zstd (default)

6s

11s

171MB

zstd -19

6m36s

13s

95MB

zstd should be available on GitHub-hosted Ubuntu runners by default. Verify on your specific runner image before relying on it.

Putting It All Together

Here is what the full caching flow looks like inside a composite action:

name: run-mps-build

inputs:
  use-mps-cache:
    description: Use cache when rebuilding MPS sources for quicker pipeline runs
    required: true
  mps-cache-key:
    description: Cache key for MPS generated output
    required: true
  mps-cache-restore-keys:
    description: Fallback restore keys for MPS cache (newline-separated)
    required: false
    default: ''

runs:
  using: composite
  steps:
    - name: Restore MPS cache
      if: inputs.use-mps-cache == 'true'
      id: restore_mps
      uses: ./.github/actions/restore-mps-cache
      with:
        mps-cache-key: ${{ inputs.mps-cache-key }}
        mps-cache-restore-keys: ${{ inputs.mps-cache-restore-keys }}

    - name: Run MPS build
      # ... your build steps here ...

    - name: Save MPS cache
      id: save_mps
      uses: ./.github/actions/save-mps-cache
      with:
        mps-cache-key: ${{ inputs.mps-cache-key }}
        mps-cache-hit: ${{ steps.restore_mps.outputs.mps-cache-hit }}

The project is split into DSL MPS project and the user models MPS project. On the user models project which depended on the DSL project we added another check to both cache steps: is-snapshot.

    - name: Restore MPS cache
      if: inputs.use-mps-cache == 'true' && inputs.is-snapshot != 'true'
      id: restore_mps
      uses: ./.github/actions/restore-mps-cache
      with:
        mps-cache-key: ${{ inputs.mps-cache-key }}
        mps-cache-restore-keys: ${{ inputs.mps-cache-restore-keys }}

    - name: Run MPS build
      # ... your build steps here ...

    - name: Save MPS cache
      if: inputs.is-snapshot != 'true'
      id: save_mps
      uses: ./.github/actions/save-mps-cache
      with:
        mps-cache-key: ${{ inputs.mps-cache-key }}
        mps-cache-hit: ${{ steps.restore_mps.outputs.mps-cache-hit }}

We decided to not use cache if our language version was a SNAPSHOT, because the developer team used SNAPSHOTs for quick patches and testing. Using the cache for such versions increased the chances of using stale code and masking the changes a developer was trying to test, so we decided to not run it at all.

Enable MPS skip unmodified models property

This is the backbone of the entire operation.

None of this would matter if MPS just regenerates everything over the cache files anyway. We have to tell it via the build script to skip unmodified models.

generator options
Figure 1. Build script property

Implementation Checklist

  • Find the "Build MPS" action in your pipeline.

  • Define mps-cache-key input.

  • Optionally define mps-cache-restore-keys to fall back to the most recent visible cache.

  • Write restore-mps-cache GitHub action yml file.

  • Write save-mps-cache GitHub action yml file.

  • Call the restore-mps-cache action before the build step.

  • Call the save-mps-cache action after the build step.

  • Verify the cache was saved under Actions → Caches in the GitHub UI.

  • Add conditional input use-mps-cache for explicit control over cache usage.

  • Add conditional input is-snapshot for user model projects for extra checks during debugging.

  • Enable skip unmodified models generator option in your MPS build script.

Pitfalls and Gotchas

Argument List Too Long

If the cache action complains about argument length, you’re trying to cache too many individual paths. Tar them into one file.

Cache Immutability

GitHub does not let you overwrite an existing cache key. Same key means same content, by design. If you want to "update" a cache, you have two options:

  1. Append a unique element to the key (run ID, commit SHA) and use restore-keys for prefix matching to grab the most recent. New entry every run.

  2. Delete the existing entry before saving. Useful when you want exactly one entry per branch to keep storage bounded.

Path Mismatches Between Save and Restore

Make sure your save and restore actions use the same properties, glob patterns and paths. Mismatches here will silently produce a cache that "exists" and still build everything from scratch since it’s put somewhere else.

Add a debug step that prints the exact paths being cached and the exact paths being looked up.

GitHub Actions total cache storage

GitHub Action’s default cache storage limit is 10GB. This is not a lot considering the ~50 branches that people are working on. Each branch would potentially have its own cache, so we have to use maximum compression, even if it makes it significantly slower (see [compression-comparison]).

Entries not used for 7 days are deleted, long-lived feature branches may have to re-save their cache occasionally.

Stale Caches Breaking the Build

The cache key must include a hash of the actual model files, not just the branch name. A branch-only key will happily restore yesterday’s source_gen over today’s models, and MPS will either fail or produce wrong output. The hash is what makes the cache safer.

Random build failures

We occasionally got build failures that seemed random and were resolved once we re-ran the build without cache. This was happening on MPS 2025.1.1, the problem was found to be a transitive dependency bug that was being resolved by one of the other teams.

In the meantime I came up with a retry step that could alleviate the users' failing builds without them having to re-run them manually. The solution is a Band-Aid so be careful when using it, but it worked for us.

composite action
    - name: Run mvn build
      continue-on-error: true
      id: mvn_build
      # ... your build steps here ...

    - name: Retry without cache
      id: retry
      if: steps.mvn_build.outcome == 'failure' && steps.restore_mps.outputs.mps-cache-hit == 'true'
      uses: ./.github/actions/retry-mvn-build-without-cache
      with:
        phase: ${{ inputs.phase }}
        mps-cache-key: ${{ inputs.mps-cache-key }}

    - name: Surface build failure
      if: steps.mvn_build.outcome == 'failure' && steps.restore_mps.outputs.mps-cache-hit != 'true'
      shell: bash
      run: |
        echo "Build failed and no cache was used — this is a genuine build failure."
        exit 1

A word on how the failure handling works, because it’s easy to break.

The mvn build step uses continue-on-error: true. This is deliberate: it stops a failed build from immediately failing the job, so we get a chance to retry without the cache. The downside is that the build step alone can never fail the job anymore, so we need the two steps that follow it to decide the actual outcome.

There are three cases:

  • Build succeeds — neither follow-up step runs, the job passes.

  • Build fails with a cache hit — the retry step runs, deletes the poisoned cache, wipes the generated output, and rebuilds from a clean state. The retry’s own success or failure becomes the job’s result.

  • Build fails without a cache hit — there’s nothing to blame the cache for, so this is a genuine failure. The "Surface build failure" step runs exit 1 to fail the job, since the build step no longer can.

If you remove continue-on-error: true, the build will fail the job before the retry ever gets a chance to run, defeating the whole point. And if you add it without the "Surface build failure" step, genuine failures will pass silently. The three pieces only work together.

The condition on the surface step (mps-cache-hit != 'true') also catches the case where caching is disabled and the step correctly fails the cache-less job. So a real failure is always surfaced whether the cache was in play.

retry build without cache action

name: retry-mvn-build-without-cache
description: >-
  Recovers from a failed mvn build that used the MPS cache. Deletes the
  poisoned cache entry, wipes MPS generated output, and retries the build
  from a clean state. Fails if the retry also fails.

inputs:
  phase:
    description: mvn goals to execute
    required: true
  mps-cache-key:
    description: Cache key of the poisoned entry to delete
    required: true

runs:
  using: composite
  steps:
    - name: Annotate cache-poisoning recovery
      shell: bash
      run: echo "::warning title=MPS cache poisoning detected::Build failed on first attempt with cache key '${{ inputs.mps-cache-key }}'. Retrying without cache."

    - name: Delete poisoned cache entry
      shell: bash
      env:
        GH_TOKEN: ${{ github.token }}
        CACHE_KEY: ${{ inputs.mps-cache-key }}
        REPO: ${{ github.repository }}
      run: |
        echo "Build failed with cache hit. Deleting cache entry: ${CACHE_KEY}"
        gh cache delete "${CACHE_KEY}" --repo "${REPO}" \
          || echo "Cache delete failed (entry may already be gone); continuing."

    - name: Wipe MPS generated output
      shell: bash
      run: |
        echo "Removing MPS generated directories before retry."
        find ${{ github.workspace }}/{your-mps-project-base-path} \
          -mindepth 2 -maxdepth 2 -type d \
          \( -name 'source_gen' -o -name 'classes_gen' -o -name 'source_gen.caches' -o -name 'test_gen' -o -name 'test_gen.caches' \) \
          -exec rm -rf {} +

    - name: Retry mvn clean + build without cache
      id: mvn_retry
      shell: bash
      run: |
        mvn -B clean compile  # replace with your build goals; keep `clean`

Results

In optimal conditions — meaning a full cache hit on a branch with no model changes — the Build Phase dropped from 15 minutes to 5 minutes. Roughly a third of the original time, with most of what remained being the actual Java compilation and packaging.

Cache hits scale with how stable your models are between runs. Branches with lots of model changes will see less benefit because they invalidate the cache more often. But for the typical case of "domain expert changes a few models, the rest are untouched," the speedup is substantial.

Closing Thoughts

Most of the difficulty here came not from the caching itself but from the layers of abstractions we had to peel back: composite actions inside composite actions, MPS generating output in 350 places, GitHub’s branch scoping, immutability rules, and the cache action’s quirks around path handling. Once each of those is understood, the actual code is fairly small.

If you find yourself optimising MPS pipelines and want to share something we missed, I’d love to hear about it, you can find my email below.

About Atanas Marchev

I am a Model Driven Engineer at F1RE. I started my journey with F1RE in December 2021. I’ve worked with many aspects of programming and this way of development seems very interesting. Just the thought alone of the way one has to extract basic concepts out of complex ideas and structures seems challenging enough to make your head spin.

You can contact me at atanas@f1re.io.