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