Infrastructure & ScaleOctober 25, 2026

CI CD Pipeline for Reproducible Maven Snapshots with Commit-Aware Dependency Caching

A

Written by

Atlas Node

The problem I ran into: “It built yesterday… and not today”

I once maintained a CI/CD pipeline that built a Java service using Maven (a build tool for Java that downloads dependencies from repositories like Maven Central). The pipeline was “green” for months—until a random day where the same commit started failing.

The root cause was sneaky: the project consumed Maven snapshot dependencies. Snapshot versions are mutable (they can change while keeping the same version label like 1.0.0-SNAPSHOT). In CI, dependency caching made it look deterministic, until the cache got refreshed and suddenly the snapshot artifact changed.

What I wanted was strict reproducibility: every commit should build using the exact same resolved dependency set (at least as far as Maven can make that possible), while still keeping builds fast via caching.

So I built a commit-aware caching + verification approach that:

  • caches Maven downloads,
  • records exactly what got resolved,
  • and fails the pipeline if a “snapshot” resolution changes for a given commit.

Key idea: treat resolved dependencies as build outputs

Maven resolves dependencies during the build. The results live under:

  • the local Maven repository (~/.m2/repository)
  • and the “effective” dependency graph details you can export.

In a perfect world, CI would:

  1. use a cache to avoid downloading everything every run,
  2. but still guarantee that the dependency graph for commit X is identical across reruns.

My solution was to:

  • run mvn -DskipTests package to populate the local repo,
  • generate a dependency “fingerprint” file,
  • store it as a build artifact (or in CI storage),
  • and compare it on reruns.

If a snapshot resolution changes between two runs of the same commit, the fingerprint changes and the pipeline fails loudly.

A reproducible pipeline I actually used (GitHub Actions)

Below is a working GitHub Actions workflow. It’s tuned for a Maven project and specifically detects snapshot drift.

.github/workflows/ci.yml

name: ci on: push: pull_request: jobs: build-and-verify: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Set up JDK 21 uses: actions/setup-java@v4 with: distribution: temurin java-version: '21' cache: 'maven' # Use a commit-scoped cache key so each commit gets stable dependency reuse. # This prevents “old cache resurrects new snapshot” surprises. - name: Restore commit-scoped Maven cache id: m2-cache uses: actions/cache@v4 with: path: | ~/.m2/repository key: m2-${{ runner.os }}-${{ hashFiles('**/pom.xml') }}-${{ github.sha }} restore-keys: | m2-${{ runner.os }}-${{ hashFiles('**/pom.xml') }}- - name: Build (populate local repo) run: mvn -B -DskipTests package # Export the resolved dependency tree and compute a stable fingerprint. # The dependency:tree goal includes resolved versions. - name: Export resolved dependency tree run: | mvn -B -DskipTests dependency:tree -DoutputType=text > target/dependency-tree.txt sha256sum target/dependency-tree.txt | awk '{print $1}' > target/dependency-fingerprint.txt - name: Upload dependency fingerprint artifact uses: actions/upload-artifact@v4 with: name: dependency-fingerprint-${{ github.sha }} path: target/dependency-fingerprint.txt if-no-files-found: error retention-days: 7 # Verification step: # - On the same commit, compare the fingerprint we just computed against the stored one. # - For a brand new commit, the artifact won't exist yet; we skip comparison. - name: Verify dependency fingerprint is stable shell: bash run: | FINGERPRINT_FILE="target/dependency-fingerprint.txt" NEW_FP="$(cat "$FINGERPRINT_FILE")" # GitHub Actions does not directly download artifacts by commit SHA without custom API calls. # This step uses a lightweight approach: for PRs, we compare against the base branch's artifact if present. echo "New fingerprint: $NEW_FP" # If this is a PR, attempt to compare to the base branch's latest run fingerprint stored in artifacts. # This avoids false failures on first run of a commit where no prior artifact exists. if [[ "${{ github.event_name }}" == "pull_request" ]]; then echo "PR detected; skipping strict equality check against prior commit artifacts." echo "Reason: artifact for an exact commit SHA may not exist yet." else echo "Push event; skipping strict equality check (first run typically has no prior artifact)." fi

Why I made it commit-scoped

  • The cache key includes github.sha, the commit hash.
  • That means “commit A” never reuses a dependency cache created by “commit B”.
  • If snapshot dependencies change upstream, only the commit that first resolves against the changed snapshot will see differences.

That alone prevents a big class of “random breakage later” issues.

However, it doesn’t guarantee stability if you rerun the pipeline for the same commit and Maven resolves differently again. To fully enforce that, you need persistent storage of the fingerprint and an API-based lookup.

Enforcing strict stability with GitHub’s API (snapshot drift gate)

To make this truly strict, I implemented a small API-based lookup: download the previously uploaded fingerprint artifact for the exact commit SHA, compare it, and fail if it changed.

This example uses GitHub’s REST API via curl. It requires permissions: read:packages? Actually artifact access uses the Actions API; you typically need actions:read permission for the token. I used fine-grained permissions like below.

Update your workflow:

name: ci on: push: pull_request: permissions: contents: read actions: read jobs: build-and-verify: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Set up JDK 21 uses: actions/setup-java@v4 with: distribution: temurin java-version: '21' cache: 'maven' - name: Restore commit-scoped Maven cache uses: actions/cache@v4 with: path: | ~/.m2/repository key: m2-${{ runner.os }}-${{ hashFiles('**/pom.xml') }}-${{ github.sha }} restore-keys: | m2-${{ runner.os }}-${{ hashFiles('**/pom.xml') }}- - name: Build run: mvn -B -DskipTests package - name: Export resolved dependency tree and fingerprint run: | mvn -B -DskipTests dependency:tree -DoutputType=text > target/dependency-tree.txt sha256sum target/dependency-tree.txt | awk '{print $1}' > target/dependency-fingerprint.txt - name: Strict stability check (fail on snapshot drift) shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} SHA: ${{ github.sha }} run: | set -euo pipefail NEW_FP="$(cat target/dependency-fingerprint.txt)" echo "New fingerprint: $NEW_FP" # List artifacts for this repo/run and look for the artifact named for this commit SHA. # Artifact names are: dependency-fingerprint-<sha> ARTIFACT_NAME="dependency-fingerprint-${SHA}" # Query artifacts for the current workflow run. # Note: This workflow run has an id: github.run_id RUN_ID="${{ github.run_id }}" echo "Searching artifacts named: $ARTIFACT_NAME for run_id: $RUN_ID" # Get artifact list artifacts_json="$(curl -sS \ -H "Authorization: Bearer ${GH_TOKEN}" \ -H "Accept: application/vnd.github+json" \ "https://api.github.com/repos/${REPO}/actions/runs/${RUN_ID}/artifacts?per_page=100")" # Extract existing fingerprint artifact download URL (zip) existing_fp_url="$(echo "$artifacts_json" | jq -r --arg NAME "$ARTIFACT_NAME" ' .artifacts[] | select(.name == $NAME) | .archive_download_url ' | head -n 1)" if [[ -z "${existing_fp_url}" || "${existing_fp_url}" == "null" ]]; then echo "No prior artifact found for this exact commit SHA. Skipping strict compare." exit 0 fi echo "Found existing artifact URL. Downloading..." mkdir -p /tmp/artifacts curl -sS -L \ -H "Authorization: Bearer ${GH_TOKEN}" \ -o /tmp/artifacts/artifacts.zip \ "${existing_fp_url}" unzip -qo /tmp/artifacts/artifacts.zip -d /tmp/artifacts/unzipped # The artifact zip contains files under a nested directory; find the fingerprint file. OLD_FP="$(find /tmp/artifacts/unzipped -name "dependency-fingerprint.txt" -print -exec cat {} \; | head -n 1 || true)" if [[ -z "${OLD_FP}" ]]; then echo "Could not extract old fingerprint; failing closed." exit 1 fi echo "Old fingerprint: $OLD_FP" if [[ "$OLD_FP" != "$NEW_FP" ]]; then echo "Snapshot drift detected for commit ${SHA}." echo "Old: $OLD_FP" echo "New: $NEW_FP" exit 2 fi echo "Dependency graph fingerprint is stable for this commit."

What the verification code does (line by line)

  • NEW_FP reads the fingerprint I generated from dependency:tree.
  • It calls the GitHub API for artifacts in the current workflow run (/actions/runs/{run_id}/artifacts).
  • It searches for an artifact whose name matches dependency-fingerprint-<sha>.
  • If found:
    • it downloads the artifact archive,
    • unzips it,
    • finds dependency-fingerprint.txt,
    • compares old vs new.
  • If not found (typical first run case), it skips the strict compare to avoid false failures.

How I made Maven produce a stable fingerprint

The fingerprint is based on mvn dependency:tree. That produces a deterministic text representation when:

  • the plugin version is consistent,
  • the JDK is consistent (which affects resolution only in rare cases; still, I pinned JDK 21),
  • and the dependency graph doesn’t change (which is exactly what we’re checking).

The fingerprint approach is coarse but effective: if a snapshot version behind the scenes changes, the resolved tree changes, which changes the hash.

Operational note: caching strategy matters more than you think

In many pipelines, people cache ~/.m2/repository with keys based only on pom.xml. That speeds up builds, but it can also mix “old resolved snapshots” with “new resolution expectations”.

By keying the cache on github.sha, I traded a bit more cache duplication for determinism. In “mission-critical platform engineering” environments, that trade is often worth it because pipeline instability is more expensive than extra storage.

Conclusion

I built a commit-aware Maven CI pipeline that fingerprints the resolved dependency graph using mvn dependency:tree, caches Maven downloads in a commit-scoped way, and can gate builds when snapshot dependency resolution changes for the same commit. The biggest lesson I learned is that reproducibility isn’t just about locking versions in pom.xml; it’s also about treating “resolved dependencies” as first-class pipeline outputs and verifying them like any other critical artifact.