diff --git a/.github/signing-selftest/build.gradle.kts b/.github/signing-selftest/build.gradle.kts new file mode 100644 index 0000000..6603086 --- /dev/null +++ b/.github/signing-selftest/build.gradle.kts @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2026 Bernard Ladenthin +// +// SPDX-License-Identifier: Apache-2.0 + +// Throwaway signing project used ONLY by the `verify-signing-key-gradle` +// preflight job in .github/workflows/publish.yml. It signs a tiny throwaway Zip +// through Gradle's `signing` plugin + `useInMemoryPgpKeys` (BouncyCastle) — the +// exact path any Gradle-based publish (e.g. an Android AAR) uses to sign release +// artifacts, and a stricter parser of the armored key than the `gpg` CLI. The +// job asserts only that the detached .asc was produced; no secret is ever +// printed (the key/passphrase arrive via env). Kept identical across the sibling +// repos so a future Gradle publish is pre-validated ("prepared for Gradle"). +plugins { + base + signing +} + +val signingKey = System.getenv("MAVEN_GPG_PRIVATE_KEY") +val signingPassphrase = System.getenv("MAVEN_GPG_PASSPHRASE") +// Optional signing (sub)key id (e.g. 07D2D767). When set, Gradle selects that +// key instead of the primary — required when the key's signing capability lives +// on a subkey (gpg auto-selects it, but the 2-arg useInMemoryPgpKeys picks the +// primary, whose secret BouncyCastle may not be able to unlock -> null +// PGPPrivateKey). Driven by the GPG_KEY_ID env secret. +val signingKeyId = System.getenv("MAVEN_GPG_KEY_ID") +require(!signingKey.isNullOrBlank()) { "MAVEN_GPG_PRIVATE_KEY is empty" } + +val makeArtifact = tasks.register("makeArtifact") { + archiveBaseName.set("signing-selftest") + destinationDirectory.set(layout.buildDirectory) + from(layout.projectDirectory.file("settings.gradle.kts")) +} + +signing { + if (!signingKeyId.isNullOrBlank()) { + useInMemoryPgpKeys(signingKeyId, signingKey, signingPassphrase ?: "") + } else { + useInMemoryPgpKeys(signingKey, signingPassphrase ?: "") + } + sign(makeArtifact.get()) +} diff --git a/.github/signing-selftest/settings.gradle.kts b/.github/signing-selftest/settings.gradle.kts new file mode 100644 index 0000000..2167b8c --- /dev/null +++ b/.github/signing-selftest/settings.gradle.kts @@ -0,0 +1,5 @@ +// SPDX-FileCopyrightText: 2026 Bernard Ladenthin +// +// SPDX-License-Identifier: Apache-2.0 + +rootProject.name = "signing-selftest" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d8b304c..c307851 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -144,6 +144,67 @@ jobs: echo "RESULT: OK — key imports, is not expired, is signing-capable, and the passphrase successfully unlocked it to produce a VALID signature. maven-gpg-plugin will be able to sign with this key/passphrase." + # --------------------------------------------------------------------------- + # GPG signing-key preflight — GRADLE / BouncyCastle path. + # Companion to the `verify-signing-key` (gpg) job above: that one mirrors + # maven-gpg-plugin (how the Maven artifacts are signed); this one drives + # Gradle's `signing` plugin + `useInMemoryPgpKeys` (BouncyCastle) — the path any + # Gradle-based publish (e.g. an Android AAR) uses to sign. BouncyCastle is a + # STRICTER parser of the armored key than gpg, so it catches key/format problems + # gpg tolerates (e.g. the primary-vs-signing-subkey null-PGPPrivateKey issue). + # It signs a throwaway project (.github/signing-selftest/) — no repo build is + # involved — so this job is IDENTICAL across the sibling repos and validates the + # release key via the Gradle path even in repos that do not publish via Gradle + # yet ("prepared for Gradle"). Standalone (no `needs:`), parallel at pipeline + # start, `environment: maven-central` so it reads the same secret the publish + # uses. Red-by-design where the secret is not delivered (see the gpg job's note). + # + # SECURITY: prints no secret material. Key/passphrase reach Gradle only via env + # (read by System.getenv at runtime); `set -x` is never enabled; the passphrase + # is `::add-mask::`ed; Gradle runs with `--stacktrace` only. Only the produced + # `.asc` (exit code) is asserted. Uses Gradle 8.14.3. + # --------------------------------------------------------------------------- + verify-signing-key-gradle: + name: Verify GPG signing key — Gradle/BouncyCastle path (no secrets printed) + runs-on: ubuntu-latest + environment: maven-central + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-java@v5 + with: + java-version: '21' + distribution: temurin + - uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: "8.14.3" + - name: Sign a throwaway artifact via useInMemoryPgpKeys (BouncyCastle) + shell: bash + env: + MAVEN_GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + MAVEN_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + MAVEN_GPG_KEY_ID: ${{ secrets.GPG_KEY_ID }} + run: | + set -euo pipefail # NOTE: deliberately NO `set -x` — it would echo the passphrase. + + if [ -z "${MAVEN_GPG_PRIVATE_KEY:-}" ]; then + echo "::error::MAVEN_GPG_PRIVATE_KEY is empty for this run. The maven-central environment did not deliver the secret to this ref (fork PR / other branch). Nothing to verify." + exit 1 + fi + if [ -n "${MAVEN_GPG_PASSPHRASE:-}" ]; then echo "::add-mask::${MAVEN_GPG_PASSPHRASE}"; fi + + PROJ=".github/signing-selftest" + echo "== Sign a throwaway artifact through Gradle's useInMemoryPgpKeys (BouncyCastle) ==" + gradle --no-daemon -p "$PROJ" signMakeArtifact --stacktrace + + ASC="$PROJ/build/signing-selftest.zip.asc" + if [ -f "$ASC" ]; then + echo " Detached signature produced: $(wc -c < "$ASC") armored bytes" + echo "RESULT: OK — Gradle's useInMemoryPgpKeys accepted the armored key + passphrase and produced a signature." + else + echo "::error::Gradle signing produced no .asc — useInMemoryPgpKeys could not build a usable signatory from MAVEN_GPG_PRIVATE_KEY / MAVEN_GPG_PASSPHRASE." + exit 1 + fi + code-style: name: Code style (spotless) + package graph needs: startgate