skip to content

Why do dynamic and changing versions threaten build reproducibility, and what mechanisms does Gradle offer to control or forbid them?

level: seniorimportance: should knowfreq 38%

answer

  1. same commit → different artifacts = non-reproducible
  2. dependencyLocking + gradle.lockfile + --write-locks
  3. failOnDynamicVersions / failOnChangingVersions
  4. failOnNonReproducibleResolution
  5. cache windows ≠ reproducibility

basics

~10 s

They make the same source resolve different artifacts over time, so builds aren't reproducible. Gradle counters this with dependency locking (gradle.lockfile) and resolutionStrategy guards like failOnDynamicVersions() / failOnChangingVersions().

solid answer

~50 s

Dynamic selectors and changing modules resolve against *what is published at build time*, so two builds of the same commit can pull different bytes — breaking reproducibility, audit, and 'works on my machine' debugging. Gradle's controls: - **Dependency locking** — enable `dependencyLocking { lockAllConfigurations() }`, resolve once with `--write-locks`, and Gradle pins the resolved set into `gradle.lockfile`. Subsequent builds verify against it and fail on drift. This is the primary reproducibility tool for projects that *do* use dynamic/changing versions. - **Fail-fast guards** — `resolutionStrategy.failOnDynamicVersions()`, `failOnChangingVersions()`, or the combined `failOnNonReproducibleResolution()` make a build error out if such inputs are present at all. - **Tight cache windows** — `cacheDynamicVersionsFor` / `cacheChangingModulesFor` reduce drift between refreshes but don't eliminate it. The usual policy: allow dynamic/changing for fast internal iteration, but for releases either ban them with fail-fast guards or pin them with locking so the resolved graph is auditable and deterministic.

code

kotlin · 10 lines
kotlin
dependencyLocking {
    lockAllConfigurations()
}
configurations.all {
    resolutionStrategy {
        failOnDynamicVersions()
        failOnChangingVersions()
    }
}
// upgrade flow: ./gradlew dependencies --write-locks --refresh-dependencies

go deeper

for a junior

Know that dynamic/changing deps can make builds differ over time and that lockfiles exist.

for a middle

Describe the locking workflow (lockAllConfigurations, --write-locks, gradle.lockfile) at a usage level.

for a senior

Compare locking vs fail-fast guards, explain the upgrade flow, and why cache windows aren't determinism.

for a principal

Define org policy: catalogs/platforms for static versions, locking with committed lockfiles, fail-fast guards in release modules, and a reviewed upgrade cadence for supply-chain auditability.

## The reproducibility problem A reproducible build maps a given source revision to the same outputs. Dynamic versions (`1.+`, `latest.release`) and changing modules (`-SNAPSHOT`, `changing=true`) break this because resolution reads live repository state: a new matching release or a re-published snapshot changes the inputs without any source change. Consequences: non-deterministic CI, hard-to-reproduce bugs, and a fuzzy supply-chain record. ## Dependency locking Locking captures the *resolved* versions of dynamic/changing dependencies into a lockfile so future builds are pinned. ```kotlin dependencyLocking { lockAllConfigurations() } ``` Workflow: 1. `./gradlew dependencies --write-locks` (or any task with `--write-locks`) resolves and writes `gradle.lockfile` per project. 2. Normal builds read the lockfile and **fail** if resolution would differ from the locked set. 3. To upgrade, run `--write-locks` again (often with `--refresh-dependencies` to re-check newer candidates), review the diff, and commit it. Locking is what makes dynamic versions safe in practice: you get the convenience of `latest.release` plus a pinned, reviewable record. You can lock all configurations or a subset, and use lock modes (`STRICT`, `LENIENT`). ## Fail-fast guards If a project should never contain non-reproducible inputs, fail the build instead: ```kotlin configurations.all { resolutionStrategy { failOnDynamicVersions() failOnChangingVersions() // or both at once: // failOnNonReproducibleResolution() } } ``` These turn an accidental `1.+` or stray `-SNAPSHOT` into a hard error — useful as a guardrail in release modules and shared platforms. ## Cache windows are not reproducibility Shortening `cacheDynamicVersionsFor`/`cacheChangingModulesFor` only changes *how often* drift can happen, not *whether* it can. They are a freshness knob, not a determinism guarantee. Don't present them as a reproducibility solution. ## Recommended layering 1. Prefer static versions; centralise them in a version catalog / platform. 2. Where dynamic/changing is genuinely needed (internal libs, fast iteration), enable **locking** and commit lockfiles. 3. In release-critical modules, add **fail-fast guards** so nothing slips through. 4. Reserve `--refresh-dependencies --write-locks` for deliberate, reviewed upgrades.

  • If locking is enabled, can you still use 'latest.release'?
    Yes. Locking pins whatever latest.release resolved to; you keep the selector but builds are reproducible. You bump it intentionally with --write-locks.
  • What does failOnNonReproducibleResolution() add over the two specific guards?
    It's a convenience that activates both dynamic and changing failures together (and future non-reproducible categories), rather than declaring each individually.
  • Does shortening the dynamic-versions cache make the build reproducible?
    No. It only changes how often drift can occur; reproducibility requires locking or static versions.

Dynamic/changing deps are like citing 'today's newspaper' in a contract — accurate when written, ambiguous later. A lockfile is photocopying the exact edition and stapling it to the contract.

saying these in an interview costs you the question

  • Claiming short cache TTLs make builds reproducible.
  • Saying locking forbids dynamic versions (it pins them, doesn't ban them).
  • Recommending --refresh-dependencies as a reproducibility tool (it's the opposite).

context