Skip to content

Commit ff5891c

Browse files
bundoleeclaude
andcommitted
ci: add release preflight to verify deploy credentials before the build
Objective: A release only touches its deploy credentials in the very last steps, after a ~30-40 min build. So an expired npm token, a revoked GitHub PAT, or a mis-scoped Maven login is not discovered until the build has already finished — wasting the wait and risking a half-published release where some registries got the artifact and others did not. Approach: Add a preflight that actually authenticates each credential up front and gates the release on it. It makes a real authenticated call per target (npm whoami, Maven Central Portal, a GPG sign+verify, a GitHub repo push-permission check, and a PyPI OIDC token mint) and collects all results before failing, so one run tells you every credential that is broken — not just the first. It lives in its own reusable workflow (preflight.yml) so it both gates release.yml via `needs` AND can be run on its own from the Actions tab to check credentials in ~1 min without triggering a build. Secrets are handled defensively: read only from the environment, fed to curl via a stdin config (never argv, so nothing leaks through /proc/<pid>/cmdline), derived values are ::add-mask::'d, and the GPG private key is imported into an ephemeral keyring that is killed and wiped on exit. Credentials are passed to the reusable workflow explicitly (not `secrets: inherit`) so preflight only ever sees the six secrets it uses. Evidence: Ran the script locally against the live endpoints with deliberately wrong credentials, and scanned all output for secret material. Before: credential failures surfaced only after the full build, at deploy time. After: preflight fails in ~1 min and names every broken credential; release does not start. | Scenario | Expected | Actual | |----------|----------|--------| | No secrets set | all FAIL, clean exit 1 | 5/5 FAIL, exit 1, no crash | | Wrong Maven creds (live call) | HTTP 401, labeled bad creds | "HTTP 401 - bad credentials" | | Maven pass with " \ : and spaces | credential sent intact | decoded credential byte-identical | | Wrong GitHub PAT (live call) | no push access | "no push access to .../opendataloader.org" | | Bad GPG key | import fails, not a false pass | "GPG signing (import)" FAIL | | CI + RUNNER_TEMP unset | refuse (do not write key to /tmp) | hard-fail before import | | Leak scan (stdout+stderr+summary) | zero secret material | no raw or base64 credential found | shellcheck and actionlint both report clean on all three files. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2bd7466 commit ff5891c

3 files changed

Lines changed: 324 additions & 0 deletions

File tree

.github/workflows/preflight.yml

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
name: Preflight
2+
3+
# Verify every deploy credential actually authenticates. Reused by release.yml
4+
# (as a gate before the ~30-40 min build) and runnable on its own from the
5+
# Actions tab (Run workflow) to check credentials in ~1 min without a build.
6+
on:
7+
# When called by release.yml, secrets are passed explicitly (least privilege
8+
# — NOT `secrets: inherit`, which would hand preflight every repo secret).
9+
workflow_call:
10+
secrets:
11+
NPM_TOKEN:
12+
required: true
13+
MAVEN_CENTRAL_USERNAME:
14+
required: true
15+
MAVEN_CENTRAL_PASSWORD:
16+
required: true
17+
MAVEN_GPG_KEY:
18+
required: true
19+
MAVEN_GPG_PASSPHRASE:
20+
required: true
21+
HOMEPAGE_SYNC_TOKEN:
22+
required: true
23+
# When run on its own from the Actions tab, the job reads repo secrets directly.
24+
workflow_dispatch:
25+
26+
jobs:
27+
preflight:
28+
runs-on: ubuntu-latest
29+
permissions:
30+
contents: read # checkout + GitHub repo read (homepage-sync PAT check)
31+
id-token: write # mint OIDC token for the PyPI check
32+
steps:
33+
- name: Checkout code
34+
uses: actions/checkout@v7
35+
36+
- name: Set up Node.js
37+
uses: actions/setup-node@v6
38+
with:
39+
node-version: '20'
40+
registry-url: 'https://registry.npmjs.org'
41+
42+
- name: Verify deploy credentials
43+
run: ./scripts/preflight.sh
44+
env:
45+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
46+
MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }}
47+
MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }}
48+
MAVEN_GPG_KEY: ${{ secrets.MAVEN_GPG_KEY }}
49+
MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }}
50+
HOMEPAGE_SYNC_TOKEN: ${{ secrets.HOMEPAGE_SYNC_TOKEN }}

.github/workflows/release.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,29 @@ on:
77
workflow_dispatch:
88

99
jobs:
10+
# =================================================================
11+
# 0. PREFLIGHT — verify deploy credentials authenticate BEFORE the
12+
# long build, so an expired token / missing scope fails in ~1 min
13+
# instead of after ~30-40 min (and avoids a partial publish).
14+
# Defined in preflight.yml so it can also be run on its own from
15+
# the Actions tab (Run workflow) to check credentials without a build.
16+
# =================================================================
17+
preflight:
18+
uses: ./.github/workflows/preflight.yml
19+
permissions:
20+
contents: read
21+
id-token: write
22+
# Explicit pass-through (least privilege) — only the 6 secrets preflight uses.
23+
secrets:
24+
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
25+
MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }}
26+
MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }}
27+
MAVEN_GPG_KEY: ${{ secrets.MAVEN_GPG_KEY }}
28+
MAVEN_GPG_PASSPHRASE: ${{ secrets.MAVEN_GPG_PASSPHRASE }}
29+
HOMEPAGE_SYNC_TOKEN: ${{ secrets.HOMEPAGE_SYNC_TOKEN }}
30+
1031
release:
32+
needs: preflight
1133
runs-on: ubuntu-latest
1234
env:
1335
VERSION: '0.0.0'

scripts/preflight.sh

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
#!/bin/bash
2+
3+
# Preflight: verify that every deploy credential actually authenticates BEFORE
4+
# the ~30-40 min build runs. release.yml only touches these credentials in its
5+
# final steps, so an expired token or missing scope would otherwise fail late
6+
# (and risk a partial publish). This gates the release job via `needs`.
7+
#
8+
# Checks (auth-only — no publish, no dry-run):
9+
# npm : `npm whoami` with NODE_AUTH_TOKEN
10+
# maven : Sonatype Central Portal /published (200 vs 401)
11+
# gpg : import key + sign+verify a dummy file with the passphrase
12+
# github : repo read + push permission for the homepage-sync PAT
13+
# pypi : mint a GitHub Actions OIDC token for audience=pypi
14+
#
15+
# Secrets are read from the environment ONLY (never script args — they leak via
16+
# `ps`). curl auth is fed via `--config /dev/stdin` (a here-doc), NOT `-H` on the
17+
# command line, so tokens never appear in curl's argv / /proc/<pid>/cmdline.
18+
# Nothing secret is ever printed; only PASS/FAIL labels and HTTP status codes.
19+
#
20+
# Usage: ./scripts/preflight.sh (env vars injected by the workflow)
21+
22+
set -euo pipefail
23+
24+
# --- coordinates (kept as named vars to avoid drift) ------------------------
25+
MAVEN_NS="org.opendataloader"
26+
MAVEN_NAME="opendataloader-pdf-core"
27+
MAVEN_PROBE_VERSION="0.0.0" # any value; we only read 200-vs-401
28+
NPM_REGISTRY="https://registry.npmjs.org"
29+
GH_REPO="opendataloader-project/opendataloader.org"
30+
PYPI_AUDIENCE="pypi"
31+
32+
# --- result accumulator -----------------------------------------------------
33+
RESULTS=()
34+
FAILED=0
35+
36+
pass() { RESULTS+=("PASS | $1"); echo "PASS | $1"; }
37+
fail() { RESULTS+=("FAIL | $1"); echo "FAIL | $1" >&2; FAILED=1; }
38+
39+
# Assert an env var is set and non-empty without ever printing its value.
40+
require_env() {
41+
local name="$1"
42+
if [ -z "${!name:-}" ]; then
43+
echo "Error: required secret \$$name is unset or empty." >&2
44+
return 1
45+
fi
46+
}
47+
48+
# --- 1. npm -----------------------------------------------------------------
49+
check_npm() {
50+
local label="npm (NPM_TOKEN)"
51+
require_env NODE_AUTH_TOKEN || { fail "$label"; return; }
52+
# npm reads NODE_AUTH_TOKEN from the env via the registry auth config that
53+
# setup-node writes; whoami is a pure authenticated call, no publish.
54+
if npm whoami --registry="$NPM_REGISTRY" >/dev/null 2>&1; then
55+
pass "$label"
56+
else
57+
fail "$label"
58+
fi
59+
}
60+
61+
# --- 2. Maven Central (Sonatype Central Portal) -----------------------------
62+
check_maven() {
63+
local label="Maven Central (MAVEN_CENTRAL_USERNAME/PASSWORD)"
64+
require_env MAVEN_CENTRAL_USERNAME || { fail "$label"; return; }
65+
require_env MAVEN_CENTRAL_PASSWORD || { fail "$label"; return; }
66+
67+
local url="https://central.sonatype.com/api/v1/publisher/published?namespace=${MAVEN_NS}&name=${MAVEN_NAME}&version=${MAVEN_PROBE_VERSION}"
68+
69+
# Build the Basic credential in the shell (base64 handles ANY bytes safely),
70+
# then pass it as an Authorization header via a curl config read from stdin so
71+
# the token never lands in argv. We do NOT put user:pass in curl's `user =`:
72+
# curl's config quoting mangles values containing " \ or whitespace, which
73+
# would send the wrong credential and misreport a valid one as a 401. The
74+
# base64 blob is [A-Za-z0-9+/=] only, so it is safe inside the quoted header.
75+
local basic
76+
basic="$(printf '%s:%s' "$MAVEN_CENTRAL_USERNAME" "$MAVEN_CENTRAL_PASSWORD" | base64 | tr -d '\n')"
77+
echo "::add-mask::$basic"
78+
79+
# On transport failure curl still prints "000" to stdout AND exits non-zero,
80+
# so use the exit status (not a "|| echo 000" that would append a 2nd "000").
81+
local code
82+
code="$(curl -sS -o /dev/null -w '%{http_code}' --config /dev/stdin "$url" <<EOF || true
83+
header = "Authorization: Basic ${basic}"
84+
EOF
85+
)"
86+
[ -z "$code" ] && code="000"
87+
88+
# 200 => authenticated (published true/false is irrelevant).
89+
# 401/403 => bad creds. 5xx/000 => Sonatype outage or network blip, NOT a
90+
# credential problem — label it so an infra hiccup isn't misread as expiry.
91+
if [ "$code" = "200" ]; then
92+
pass "$label"
93+
elif [ "$code" = "401" ] || [ "$code" = "403" ]; then
94+
fail "$label (HTTP $code — bad credentials)"
95+
else
96+
fail "$label (HTTP $code — Sonatype unreachable? not necessarily a credential fault)"
97+
fi
98+
}
99+
100+
# --- 3. GPG -----------------------------------------------------------------
101+
check_gpg() {
102+
local label="GPG signing (MAVEN_GPG_KEY/PASSPHRASE)"
103+
require_env MAVEN_GPG_KEY || { fail "$label"; return; }
104+
require_env MAVEN_GPG_PASSPHRASE || { fail "$label"; return; }
105+
106+
# Isolated ephemeral keyring so we never touch the runner's default homedir.
107+
local gpg_home="$TMP/gnupg"
108+
mkdir -p "$gpg_home"
109+
chmod 700 "$gpg_home"
110+
111+
if ! printf '%s' "$MAVEN_GPG_KEY" | gpg --homedir "$gpg_home" --batch --import >/dev/null 2>&1; then
112+
fail "$label (import)"
113+
return
114+
fi
115+
116+
local dummy="$TMP/preflight-sign.txt"
117+
echo "opendataloader-pdf preflight" > "$dummy"
118+
119+
# Passphrase via stdin (--passphrase-fd 0), never on the command line.
120+
if ! printf '%s' "$MAVEN_GPG_PASSPHRASE" \
121+
| gpg --homedir "$gpg_home" --batch --yes --pinentry-mode loopback \
122+
--passphrase-fd 0 --detach-sign --armor -o "$dummy.asc" "$dummy" >/dev/null 2>&1; then
123+
fail "$label (sign)"
124+
return
125+
fi
126+
127+
if gpg --homedir "$gpg_home" --batch --verify "$dummy.asc" "$dummy" >/dev/null 2>&1; then
128+
pass "$label"
129+
else
130+
fail "$label (verify)"
131+
fi
132+
}
133+
134+
# --- 4. GitHub PAT (homepage sync) ------------------------------------------
135+
check_github() {
136+
local label="GitHub PAT (HOMEPAGE_SYNC_TOKEN)"
137+
require_env HOMEPAGE_SYNC_TOKEN || { fail "$label"; return; }
138+
139+
# Token via --header on a stdin config (out of argv). Body carries no secret
140+
# (repo metadata); safe to capture. The `permissions` object only appears on
141+
# authenticated requests, and .permissions.push is the write-access signal the
142+
# docs-sync step needs.
143+
local body
144+
body="$(curl -sS --config /dev/stdin \
145+
-H "Accept: application/vnd.github+json" \
146+
"https://api.github.com/repos/${GH_REPO}" 2>/dev/null <<EOF || echo '{}'
147+
header = "Authorization: Bearer ${HOMEPAGE_SYNC_TOKEN}"
148+
EOF
149+
)"
150+
151+
if echo "$body" | jq -e '.permissions.push == true' >/dev/null 2>&1; then
152+
pass "$label"
153+
else
154+
fail "$label (no push access to ${GH_REPO})"
155+
fi
156+
}
157+
158+
# --- 5. PyPI (OIDC issuance) ------------------------------------------------
159+
check_pypi() {
160+
local label="PyPI OIDC (id-token: write)"
161+
require_env ACTIONS_ID_TOKEN_REQUEST_URL || { fail "$label (no id-token permission)"; return; }
162+
require_env ACTIONS_ID_TOKEN_REQUEST_TOKEN || { fail "$label (no id-token permission)"; return; }
163+
164+
# Mint an OIDC token for audience=pypi. Proves id-token: write works and the
165+
# runner can issue the exact token the PyPI publish step relies on. Request
166+
# token via stdin config (out of argv); the minted value is piped straight
167+
# into jq, masked, and discarded — never printed.
168+
local value
169+
value="$(curl -sS --config /dev/stdin \
170+
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${PYPI_AUDIENCE}" 2>/dev/null <<EOF | jq -r '.value // empty' 2>/dev/null || echo ""
171+
header = "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}"
172+
EOF
173+
)"
174+
175+
if [ -n "$value" ]; then
176+
echo "::add-mask::$value"
177+
pass "$label"
178+
else
179+
fail "$label"
180+
fi
181+
}
182+
183+
# --- summary ----------------------------------------------------------------
184+
write_summary() {
185+
local summary="${GITHUB_STEP_SUMMARY:-}"
186+
[ -z "$summary" ] && return 0
187+
{
188+
echo "## Release preflight — credential checks"
189+
echo ""
190+
echo "| Status | Check |"
191+
echo "|--------|-------|"
192+
local line status rest
193+
for line in "${RESULTS[@]}"; do
194+
status="${line%% | *}"
195+
rest="${line#* | }"
196+
if [ "$status" = "PASS" ]; then
197+
echo "| ✅ | $rest |"
198+
else
199+
echo "| ❌ | $rest |"
200+
fi
201+
done
202+
echo ""
203+
if [ "$FAILED" -eq 0 ]; then
204+
echo "**All credentials authenticated. Release may proceed.**"
205+
else
206+
echo "**One or more credentials failed. Release is blocked.**"
207+
fi
208+
} >> "$summary"
209+
}
210+
211+
# --- run all (collect-then-fail: never short-circuit on first failure) ------
212+
main() {
213+
# $TMP holds the ephemeral GPG keyring + dummy files; cleaned on exit.
214+
# In CI, require RUNNER_TEMP so the imported private signing key never lands
215+
# under world-writable /tmp; the /tmp fallback is for local testing only.
216+
local temp_base="${RUNNER_TEMP:-}"
217+
if [ -z "$temp_base" ]; then
218+
if [ "${CI:-}" = "true" ]; then
219+
echo "Error: RUNNER_TEMP is unset in CI; refusing to write the GPG key under /tmp." >&2
220+
exit 1
221+
fi
222+
temp_base="/tmp"
223+
fi
224+
if ! TMP="$(mktemp -d "${temp_base}/preflight.XXXXXX")"; then
225+
echo "Error: could not create a temp dir for preflight (${temp_base} unwritable?)." >&2
226+
exit 1
227+
fi
228+
# Kill any gpg-agent spawned under $TMP (holds the passphrase in memory) before
229+
# wiping the dir, so no keyring/agent state outlives the run.
230+
trap 'gpgconf --homedir "$TMP/gnupg" --kill all >/dev/null 2>&1 || true; rm -rf "$TMP"' EXIT
231+
232+
echo "Running release preflight credential checks..."
233+
echo "------------------------------------------------"
234+
235+
check_npm || true
236+
check_maven || true
237+
check_gpg || true
238+
check_github || true
239+
check_pypi || true
240+
241+
echo "------------------------------------------------"
242+
write_summary
243+
244+
if [ "$FAILED" -eq 0 ]; then
245+
echo "Preflight OK: all deploy credentials authenticated."
246+
else
247+
echo "Preflight FAILED: fix the credentials above before releasing." >&2
248+
fi
249+
exit "$FAILED"
250+
}
251+
252+
main

0 commit comments

Comments
 (0)