skip to content

How would you design a conditional-signing setup for a library published from CI, balancing local developer builds, snapshot CI, and signed release CI?

level: seniorimportance: should knowfreq 30%

answer

  1. three contexts: local / snapshot CI / release CI
  2. one predicate covers all three
  3. key only in the release job (smallest blast radius)
  4. in-memory key from CI secrets for release
  5. guard: fail fast + clear when key missing

basics

~20 s

Make 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 s

Treat 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 lines
kotlin
signing {
    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

for a junior

Recognize that only release publishes should require a key and CI provides it via secrets.

for a middle

Map the three contexts to the single predicate and explain why snapshots/local don't need a key.

for a senior

Design key provisioning to minimize blast radius and add a fail-fast guard for missing keys.

for a principal

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.

context