Your shared HTTP cache started serving wrong outputs after a developer's machine pushed bad entries. How do you prevent this going forward, including at the credential/server level?
answer
- config = advisory, server = enforced
- read-only token for devs, write token in CI
- purge poisoned keys
- fix non-reproducible task inputs
- @PathSensitive, reproducible jars
basics
~20 sMake developers pull read-only (isPush = false) and reserve push for trusted CI. Enforce it at the server with separate credentials: developers get a read-only token, only the CI seed job holds a write-capable token. Then purge poisoned entries.
solid answer
~40 sTwo layers of defense. **In Gradle config**, set `isPush = false` for non-CI builds so developers never write. But config alone is advisory — anyone could flip the flag locally — so enforce it **server-side**: most build-cache backends (the Gradle Enterprise/Develocity cache node, or a reverse-proxied HTTP store) support separate read and write credentials. Developers receive a read-only token; the write token lives only in the CI secret store, available solely to the seed job. ```kotlin remote<HttpBuildCache> { url = uri("https://cache.example.com/cache/") isPush = isCi credentials { username = providers.gradleProperty("cacheUser").orNull password = providers.gradleProperty("cachePass").orNull } } ``` Gradle injects read or write creds depending on environment. After locking it down, **purge the poisoned keys** from the store so they stop being served, and verify task **reproducibility** so future pushes are deterministic.
code
kotlin · 9 linesremote<HttpBuildCache> {
url = uri("https://cache.example.com/cache/")
isPush = providers.environmentVariable("CI").isPresent
credentials {
// CI injects a write token; devs get a read-only one
username = providers.gradleProperty("cacheUser").orNull
password = providers.gradleProperty("cachePass").orNull
}
}go deeper
Knows developers should be read-only and CI should push.
Configures isPush per environment and read-only dev access.
Adds server-side credential separation, purging, and reproducibility auditing as defense in depth.
Owns the cache as a governed trust boundary: token lifecycle, write-permission allocation, incident runbook, and reproducibility standards across teams.
## How poisoning happens The remote cache is keyed by a hash of task inputs. If two builds produce the *same* key but *different* outputs, whichever pushed first wins, and everyone else downloads that output. Poisoning occurs when a machine pushes an output produced under conditions that aren't captured by the inputs hash — a non-reproducible task (timestamps, absolute paths, system locale), a dirty working tree, or a tampered dependency. The entry looks legitimate to every puller. ## Layer 1 — Gradle-side policy Set `isPush = false` for developer builds and `true` only on the trusted seed job, ideally gated on `startParameter`/a CI flag. This is the right *intent*, but it is **advisory**: it lives in `settings.gradle.kts`, which a developer can edit locally, or they can pass overriding flags. So it must be backed by enforcement. ## Layer 2 — server-side credential separation Make pushing physically impossible without a write credential: - Configure the cache backend to require **authentication** and to distinguish **read** from **write** permissions (Develocity's cache node and most HTTP cache proxies support this). - Issue a **read-only token** to developers (and to PR CI jobs). Distribute it via the normal config channels. - Keep a **write-capable token** only in the CI secret manager, exposed solely to the designated seed job. In Gradle you wire credentials through the `credentials {}` block, sourcing them from Gradle properties / env so each environment supplies the right token: ```kotlin remote<HttpBuildCache> { url = uri("https://cache.example.com/cache/") isEnabled = true isPush = isCi credentials { username = providers.gradleProperty("cacheUser").orNull password = providers.gradleProperty("cachePass").orNull } } ``` Now even if a developer flips `isPush = true`, the server rejects the write because their token lacks permission. ## Layer 3 — reproducibility hygiene Server locks prevent *unauthorized* writes, but the seed job itself can still push bad entries if its tasks aren't deterministic. Audit `@CacheableTask` inputs: normalize file paths (`@PathSensitive`), strip timestamps from jars (`isReproducibleFileOrder = true`, `isPreserveFileTimestamps = false`), pin the toolchain, and use Develocity build scans / `--scan` or the `org.gradle.caching.debug` log to confirm matching keys across builds. ## Remediation of an existing incident 1. **Flip the lockdown** (read-only dev tokens, write token to CI only). 2. **Purge** the poisoned keys from the store (admin API/UI) so they stop being served. 3. **Find the root cause** — which task was non-reproducible — and fix its inputs. 4. **Verify** by re-seeding from a clean CI build and confirming hits resolve correctly. The principle: treat the cache as a trust boundary. Gradle config expresses intent; the server enforces it; reproducibility keeps even trusted pushes correct.
- Why isn't setting isPush = false in settings.gradle.kts sufficient by itself?It is advisory config a developer can edit or override locally. Real enforcement requires the server to reject writes from read-only credentials.
- After locking writes down, why must you still audit task reproducibility?The trusted seed job can still push wrong entries if its tasks aren't deterministic (timestamps, absolute paths). Reproducibility keeps even authorized pushes correct.
saying these in an interview costs you the question
- Treating isPush = false as a hard security control rather than advisory config.
- Forgetting to purge already-poisoned keys, so wrong outputs keep being served after the fix.