diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1768daa..d8b304c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -31,6 +31,119 @@ jobs: steps: - run: echo "Start gate elapsed — proceeding with pipeline." + # --------------------------------------------------------------------------- + # 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, + # 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. + # --------------------------------------------------------------------------- + verify-signing-key: + name: Verify GPG signing key (no secrets printed) + 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