From 6badffe1548cacd3b044c764da9af261d254cf38 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 08:08:14 +0000 Subject: [PATCH 1/2] ci: add opt-in GPG signing-key preflight job (no secrets printed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a standalone `verify-signing-key` job to publish.yml that reproduces what maven-gpg-plugin does at deploy time, so a bad/expired key or wrong passphrase is caught in ~20s instead of failing the publish stage. - No `needs:` — runs in parallel at the very start of the pipeline. - `environment: maven-central` — reads the SAME GPG_PRIVATE_KEY / GPG_PASSPHRASE secret the publish jobs use. - Gated to workflow_dispatch + a new `verify_signing_key` checkbox, so it never runs on normal PR/push CI and only exercises the environment gate on demand. - Prints only PUBLIC key metadata (key id, fingerprint, owner UID, algorithm, created/expiry); validates the passphrase via a throwaway sign+verify roundtrip. Passphrase is passed on fd 3 (never argv/logs), `set -x` is never enabled, and the passphrase is `::add-mask::`ed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018pwn51YPBbtiuUyiLRrfrG --- .github/workflows/publish.yml | 116 ++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1768daa..3026584 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,6 +14,10 @@ on: description: "Deploy to Maven Central (snapshot if -SNAPSHOT, release if a vX.Y.Z tag)" type: boolean default: false + verify_signing_key: + description: "Verify ONLY the GPG signing key: import it, print PUBLIC metadata (owner/expiry/fingerprint) and run a passphrase-unlock sign+verify self-test. Prints NO secret material and does not publish." + type: boolean + default: false permissions: contents: read @@ -31,6 +35,118 @@ jobs: steps: - run: echo "Start gate elapsed — proceeding with pipeline." + # --------------------------------------------------------------------------- + # GPG signing-key preflight (opt-in, standalone, no `needs:` — runs in parallel + # at the very start). Reproduces what maven-gpg-plugin does at deploy time so a + # bad/expired key or wrong passphrase is caught in ~20s instead of failing the + # publish stage. Declares `environment: maven-central` so it reads the SAME + # GPG_PRIVATE_KEY / GPG_PASSPHRASE secret the publish jobs use. + # + # SECURITY: this job NEVER prints secret material. It imports the key into an + # ephemeral keyring, prints only PUBLIC key metadata (key id, fingerprint, + # owner UID, algorithm, created/expiry — all of which live on public + # keyservers), and validates the passphrase by producing + verifying a + # throwaway signature. The passphrase is passed on fd 3 (never argv, never a + # log line), `set -x` is deliberately never enabled, and the passphrase is + # additionally `::add-mask::`ed. Gated to `workflow_dispatch` + the + # `verify_signing_key` checkbox so it never runs on normal PR/push CI (and so + # the `maven-central` environment gate is only exercised on demand). + # --------------------------------------------------------------------------- + verify-signing-key: + name: Verify GPG signing key (no secrets printed) + if: ${{ github.event_name == 'workflow_dispatch' && inputs.verify_signing_key }} + runs-on: ubuntu-latest + environment: maven-central + steps: + - name: Import key + run sign/verify self-test (prints only PUBLIC metadata) + shell: bash + env: + GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + run: | + set -euo pipefail # NOTE: deliberately NO `set -x` — it would echo the passphrase. + + if [ -z "${GPG_PRIVATE_KEY:-}" ]; then + echo "::error::GPG_PRIVATE_KEY is empty for this run. Either the secret is not set, or it is scoped to a different environment/branch than 'maven-central' on this ref. Nothing to verify." + exit 1 + fi + # Defensive: even though we never print it, register the passphrase as a + # masked value so any accidental echo downstream is redacted. + if [ -n "${GPG_PASSPHRASE:-}" ]; then echo "::add-mask::${GPG_PASSPHRASE}"; fi + + echo "gpg: $(gpg --version | head -n1)" + + # Ephemeral, private keyring; removed on exit. + export GNUPGHOME="$(mktemp -d)" + chmod 700 "$GNUPGHOME" + cleanup() { gpgconf --kill gpg-agent >/dev/null 2>&1 || true; rm -rf "$GNUPGHOME"; } + trap cleanup EXIT + + echo "== Import private key into an ephemeral keyring (key via stdin, never argv) ==" + printf '%s\n' "$GPG_PRIVATE_KEY" | gpg --batch --import + + COLONS="$(gpg --list-secret-keys --with-colons --fixed-list-mode)" + SECCOUNT="$(printf '%s\n' "$COLONS" | awk -F: '$1=="sec"{n++} END{print n+0}')" + echo "Secret keys imported: $SECCOUNT" + if [ "$SECCOUNT" -lt 1 ]; then + echo "::error::No secret key was imported — GPG_PRIVATE_KEY is not a valid armored secret key (check that the secret contains the full -----BEGIN PGP PRIVATE KEY BLOCK----- with intact newlines)." + exit 1 + fi + + KEYID="$(printf '%s\n' "$COLONS" | awk -F: '$1=="sec"{print $5; exit}')" + ALGO="$(printf '%s\n' "$COLONS" | awk -F: '$1=="sec"{print $4; exit}')" + CREATED="$(printf '%s\n' "$COLONS"| awk -F: '$1=="sec"{print $6; exit}')" + EXPIRES="$(printf '%s\n' "$COLONS"| awk -F: '$1=="sec"{print $7; exit}')" + FPR="$(printf '%s\n' "$COLONS" | awk -F: '$1=="fpr"{print $10; exit}')" + + echo "== PUBLIC key metadata ==" + echo " Key ID (long): $KEYID" + echo " Fingerprint: $FPR" + echo " Pubkey algo id: $ALGO" + echo " Created (UTC): $(date -u -d "@$CREATED" 2>/dev/null || echo "$CREATED")" + echo " Owner UID(s):" + printf '%s\n' "$COLONS" | awk -F: '$1=="uid"{print " - " $10}' + + # --- Expiration gate --- + NOW="$(date -u +%s)" + if [ -n "$EXPIRES" ]; then + echo " Expires (UTC): $(date -u -d "@$EXPIRES" 2>/dev/null || echo "$EXPIRES")" + if [ "$EXPIRES" -le "$NOW" ]; then + echo "::error::Signing key is EXPIRED — Maven Central will reject its signatures. Extend the key's expiry and update the GPG_PRIVATE_KEY secret." + exit 1 + fi + echo " Days to expiry: $(( (EXPIRES - NOW) / 86400 ))" + if [ "$(( (EXPIRES - NOW) / 86400 ))" -lt 30 ]; then + echo "::warning::Signing key expires in under 30 days — plan to rotate it." + fi + else + echo " Expires (UTC): never" + fi + + # --- Signing-capability gate --- + if printf '%s\n' "$COLONS" | awk -F: '($1=="sec"||$1=="ssb"){print $12}' | grep -q 's'; then + echo " Signing capability: present" + else + echo "::error::No signing-capable (sub)key found — this key cannot produce release signatures." + exit 1 + fi + + # --- Passphrase unlock + sign + verify roundtrip (the exact failure mode) --- + # Passphrase on fd 3 only. Payload is a throwaway nonce; only the signature's + # validity (exit codes) matters — no secret is ever emitted. + echo "== Passphrase unlock + detached-sign + verify self-test ==" + WORK="$(mktemp -d)" + printf '%s' "ai-index signing-selftest" > "$WORK/payload.txt" + gpg --batch --yes --pinentry-mode loopback --passphrase-fd 3 \ + --local-user "$KEYID" \ + --detach-sign --armor --output "$WORK/payload.txt.asc" "$WORK/payload.txt" \ + 3<<<"${GPG_PASSPHRASE:-}" + echo " Signature produced: $(wc -c < "$WORK/payload.txt.asc") armored bytes" + gpg --batch --verify "$WORK/payload.txt.asc" "$WORK/payload.txt" + rm -rf "$WORK" + + 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." + code-style: name: Code style (spotless) + package graph needs: startgate From ae982736ecdb63f1b2c91103b7dbc646cf81157f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 08:26:13 +0000 Subject: [PATCH 2/2] ci: make GPG signing-key preflight run on every trigger (no opt-in) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the workflow_dispatch checkbox and the if-gate so verify-signing-key runs unconditionally in parallel at the start of every pipeline. A red on refs where the secret is not delivered (fork PRs, other contributors' branches) is the intended signal — "this ref cannot sign a release" — not a regression. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018pwn51YPBbtiuUyiLRrfrG --- .github/workflows/publish.yml | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3026584..d8b304c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,10 +14,6 @@ on: description: "Deploy to Maven Central (snapshot if -SNAPSHOT, release if a vX.Y.Z tag)" type: boolean default: false - verify_signing_key: - description: "Verify ONLY the GPG signing key: import it, print PUBLIC metadata (owner/expiry/fingerprint) and run a passphrase-unlock sign+verify self-test. Prints NO secret material and does not publish." - type: boolean - default: false permissions: contents: read @@ -36,11 +32,15 @@ jobs: - run: echo "Start gate elapsed — proceeding with pipeline." # --------------------------------------------------------------------------- - # GPG signing-key preflight (opt-in, standalone, no `needs:` — runs in parallel - # at the very start). Reproduces what maven-gpg-plugin does at deploy time so a - # bad/expired key or wrong passphrase is caught in ~20s instead of failing the - # publish stage. Declares `environment: maven-central` so it reads the SAME - # GPG_PRIVATE_KEY / GPG_PASSPHRASE secret the publish jobs use. + # GPG signing-key preflight (standalone, no `needs:` — runs in parallel at the + # very start on every trigger). Reproduces what maven-gpg-plugin does at deploy + # time so a bad/expired key or wrong passphrase is caught in ~20s instead of + # failing the publish stage. Declares `environment: maven-central` so it reads + # the SAME GPG_PRIVATE_KEY / GPG_PASSPHRASE secret the publish jobs use. + # + # It is EXPECTED to go RED on refs where the secret is not delivered — fork PRs + # and other contributors' branches (secrets are withheld there). That red is + # the intended signal: "this ref cannot sign a release", not a regression. # # SECURITY: this job NEVER prints secret material. It imports the key into an # ephemeral keyring, prints only PUBLIC key metadata (key id, fingerprint, @@ -48,13 +48,10 @@ jobs: # keyservers), and validates the passphrase by producing + verifying a # throwaway signature. The passphrase is passed on fd 3 (never argv, never a # log line), `set -x` is deliberately never enabled, and the passphrase is - # additionally `::add-mask::`ed. Gated to `workflow_dispatch` + the - # `verify_signing_key` checkbox so it never runs on normal PR/push CI (and so - # the `maven-central` environment gate is only exercised on demand). + # additionally `::add-mask::`ed. # --------------------------------------------------------------------------- verify-signing-key: name: Verify GPG signing key (no secrets printed) - if: ${{ github.event_name == 'workflow_dispatch' && inputs.verify_signing_key }} runs-on: ubuntu-latest environment: maven-central steps: