How would you design a conditional-signing setup for a library published from CI, balancing local developer builds, snapshot CI, and signed release CI?
answer
- three contexts: local / snapshot CI / release CI
- one predicate covers all three
- key only in the release job (smallest blast radius)
- in-memory key from CI secrets for release
- guard: fail fast + clear when key missing
basics
~20 sMake isRequired true only for release publishes (hasTask('publish') && !SNAPSHOT). Provide the signing key/passphrase only to the release CI job via secrets; leave local and snapshot builds keyless so they pass with unsigned (or skipped) artifacts.
solid answer
~50 sTreat signing as a three-context problem. (1) **Local dev builds**: no key, `isRequired` resolves false because there's no `publish` in the graph — `./gradlew build` just works. (2) **Snapshot CI**: publishes `x-SNAPSHOT`; `isRequired` is false because of the `-SNAPSHOT` suffix, so even though `publish` is in the graph it won't demand a key — keep the release key out of this job entirely. (3) **Release CI**: builds a final version and runs `publish`; `isRequired` is true, and you inject the GPG key + passphrase via CI secrets (in-memory keys are cleanest for CI). The single predicate `isRequired = gradle.taskGraph.hasTask("publish") && !version.toString().endsWith("-SNAPSHOT")` encodes all three. Layer in a guard so a release build with no key fails *fast and clearly*, and never echo the secret in logs. This minimizes how widely the sensitive key is exposed while guaranteeing every Central release is signed.
code
kotlin · 13 linessigning {
isRequired = gradle.taskGraph.hasTask("publish") &&
!version.toString().endsWith("-SNAPSHOT")
sign(publishing.publications)
}
tasks.withType<Sign>().configureEach {
doFirst {
if (isRequired) require(project.findProperty("signingInMemoryKey") != null) {
"Release publish requires a signing key; none provided."
}
}
}go deeper
Recognize that only release publishes should require a key and CI provides it via secrets.
Map the three contexts to the single predicate and explain why snapshots/local don't need a key.
Design key provisioning to minimize blast radius and add a fail-fast guard for missing keys.
Treat it as secret-governance: scope the release key to one audited job, enforce signed releases org-wide via convention plugins, and document the policy.
## The three execution contexts | Context | Version | `publish` in graph? | `isRequired` | Key present? | |---|---|---|---|---| | Local dev build | any | no | false | no | | Snapshot CI | `1.4.0-SNAPSHOT` | yes | **false** (SNAPSHOT) | no | | Release CI | `1.4.0` | yes | **true** | **yes** (via secrets) | The single predicate covers all three: ```kotlin signing { isRequired = gradle.taskGraph.hasTask("publish") && !version.toString().endsWith("-SNAPSHOT") sign(publishing.publications) } ``` ## Key provisioning per context The security goal is to expose the release signing key to as few jobs as possible. So: - **Local & snapshot CI**: provide *no* signing key. Because `isRequired` is false there, builds and snapshot publishes succeed unsigned without error. - **Release CI only**: inject the key and passphrase from the CI secret store. For CI, in-memory keys are the standard mechanism — you set the ASCII-armored key and passphrase as environment-backed Gradle properties so nothing touches disk. (The mechanics of in-memory vs. GPG keyrings are a sibling topic; here the point is *only the release job gets them*.) ## Fail fast on misconfiguration If `isRequired` is true but no key was injected, the build will fail at the `Sign` task — which is what you want, but the error can be cryptic. Add an early, explicit guard so the failure is obvious: ```kotlin tasks.withType<Sign>().configureEach { onlyIf { isRequired } doFirst { require(project.findProperty("signingInMemoryKey") != null) { "Release publish requires a signing key but none was provided to this CI job." } } } ``` ## Why not just always require signing? Because that forces the release key into the snapshot pipeline (frequent, broad blast radius) and breaks every developer's `build`. Conditional signing keeps the sensitive secret scoped to the single, infrequent, audited release job — a defense-in-depth win — while still *guaranteeing* releases are signed. ## Pitfalls to avoid - Reading the task graph in a way that breaks the configuration cache; prefer the lazily-resolved signing idiom. - Logging the passphrase or key (never `println` secrets). - Forgetting that `isRequired = false` still signs if a key *is* present — so a stray key in snapshot CI would unexpectedly sign snapshots (harmless but surprising).
- Why scope the release key to only the release CI job instead of all jobs?It minimizes blast radius: the most sensitive secret is exposed to the fewest, least-frequent, most-audited jobs. Snapshot CI and local builds never need it because conditional signing doesn't require signatures there.
- What happens if a release build runs with isRequired=true but no key was injected?The Sign task fails the build — which is correct. Adding an explicit early guard turns a cryptic signing error into a clear 'no signing key provided' message.
- Could a stray key in snapshot CI sign snapshots even though isRequired is false?Yes. isRequired=false only avoids *failing* when signing is impossible; if a key is present, signing still runs. It's harmless but a reason to keep keys out of non-release jobs.
saying these in an interview costs you the question
- Proposing 'always require signing' — it breaks dev builds and spreads the key into snapshot CI.
- Suggesting you log or print the passphrase to verify it.
- Assuming isRequired=false guarantees unsigned output regardless of key presence.