← back to Nineoh Guide
finish-tk12.sh
173 lines
#!/usr/bin/env bash
# finish-tk12.sh — TURNKEY driver for TK-12: nineoh "Unofficial 90210 Guide" → App Store / TestFlight
#
# ONE command, run the MOMENT Steve has an App Store Connect API key:
# bash finish-tk12.sh <path-to-.p8> <Key ID> <Issuer ID>
#
# What it does, in order:
# (a) routes the Key ID / Issuer ID via the secrets-manager (last-4 only, NEVER echoes the key),
# and installs the .p8 into a GITIGNORED local credentials dir EAS can read;
# (b) wires eas.json submit.production.ios (ascApiKeyPath / ascApiKeyId / ascApiKeyIssuerId)
# + confirms the production build profile is distribution:store;
# (c) runs eas build --platform ios --profile production (headless, ASC key = no prompts);
# (d) then PRINTS (does NOT run) the exact eas submit command and STOPS,
# so Steve gives the final TestFlight go himself.
#
# SAFE + IDEMPOTENT: re-running with the same args re-wires eas.json to the same values
# (a no-op) and re-copies the .p8. It never submits, never creates Apple credentials
# interactively, never pushes to a remote. A production build consumes 1 EAS build slot
# ($0 on the free tier / ~$1-2 of credit) — that is the only paid side effect, and it is
# the explicitly-approved step B.
#
# HARD RAILS honored: no App Store submit fired; the ASC key value is never printed or
# committed; the .p8 lives only in a gitignored dir; no remote push.
set -euo pipefail
# ----- locate self / project -------------------------------------------------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$SCRIPT_DIR" # ~/Projects/nineoh-guide
APP_DIR="$PROJECT_ROOT/apps/mobile"
EAS_JSON="$APP_DIR/eas.json"
CRED_DIR="$PROJECT_ROOT/.credentials" # gitignored — never committed
SECRETS_CLI="$HOME/Projects/secrets-manager/cli.js"
bold() { printf '\033[1m%s\033[0m\n' "$*"; }
die() { printf '\033[31mERROR:\033[0m %s\n' "$*" >&2; exit 1; }
last4() { local s="$1"; printf '…%s' "${s: -4}"; }
# ----- arg validation --------------------------------------------------------
if [[ $# -ne 3 ]]; then
cat >&2 <<EOF
usage: bash finish-tk12.sh <path-to-.p8> <Key ID> <Issuer ID>
<path-to-.p8> the App Store Connect API key file you downloaded ONCE
(e.g. ~/Downloads/AuthKey_ABC123XYZ.p8)
<Key ID> the Key ID shown next to the key in App Store Connect
<Issuer ID> the Issuer ID at the top of the Integrations → App Store Connect API page
EOF
exit 2
fi
P8_SRC="$1"; KEY_ID="$2"; ISSUER_ID="$3"
[[ -f "$P8_SRC" ]] || die "no .p8 file at: $P8_SRC"
[[ "$P8_SRC" == *.p8 ]] || die "expected a .p8 file, got: $P8_SRC"
[[ -n "$KEY_ID" ]] || die "empty Key ID"
[[ -n "$ISSUER_ID" ]] || die "empty Issuer ID"
[[ -f "$EAS_JSON" ]] || die "eas.json not found at $EAS_JSON — wrong project?"
command -v node >/dev/null 2>&1 || die "node not found on PATH"
bold "TK-12 turnkey driver — nineoh Unofficial 90210 Guide → App Store/TestFlight"
echo " project : $PROJECT_ROOT"
echo " ASC key : Key ID $(last4 "$KEY_ID") · Issuer ID $(last4 "$ISSUER_ID") · .p8 $(basename "$P8_SRC")"
echo " (key values are never printed in full or committed — last-4 only)"
echo
# ----- (a) install .p8 into gitignored creds dir + route the string IDs -------
bold "[a] Installing .p8 + routing Key ID / Issuer ID"
mkdir -p "$CRED_DIR"
chmod 700 "$CRED_DIR"
P8_DEST="$CRED_DIR/asc-api-key.p8"
cp "$P8_SRC" "$P8_DEST"
chmod 600 "$P8_DEST"
echo " .p8 -> $P8_DEST (gitignored, chmod 600)"
# Belt-and-suspenders: make sure the creds dir can never be committed.
GI_ROOT="$PROJECT_ROOT/.gitignore"
if [[ -f "$GI_ROOT" ]] && ! grep -qxF ".credentials/" "$GI_ROOT"; then
printf '\n# TK-12: App Store Connect API key material — never commit\n.credentials/\n*.p8\n' >> "$GI_ROOT"
echo " added .credentials/ + *.p8 to $GI_ROOT"
fi
# Route the two string IDs via secrets-manager (idempotent add). NEVER routes the .p8 body.
if [[ -f "$SECRETS_CLI" ]]; then
node "$SECRETS_CLI" add NINEOH_ASC_API_KEY_ID "$KEY_ID" >/dev/null 2>&1 \
&& echo " routed NINEOH_ASC_API_KEY_ID ($(last4 "$KEY_ID")) via secrets-manager" \
|| echo " (secrets add for Key ID returned non-zero — continuing; eas.json is the source of truth)"
node "$SECRETS_CLI" add NINEOH_ASC_API_ISSUER_ID "$ISSUER_ID" >/dev/null 2>&1 \
&& echo " routed NINEOH_ASC_API_ISSUER_ID ($(last4 "$ISSUER_ID")) via secrets-manager" \
|| echo " (secrets add for Issuer ID returned non-zero — continuing; eas.json is the source of truth)"
else
echo " (secrets-manager cli not found at $SECRETS_CLI — skipping key routing; eas.json still wired below)"
fi
echo
# ----- (b) wire eas.json + confirm build profile -----------------------------
bold "[b] Wiring eas.json submit.production.ios + confirming production build profile"
# Use a relative ascApiKeyPath so eas.json stays portable and carries no absolute home path.
# eas.json lives in apps/mobile; the creds dir is at project root → ../../.credentials/...
ASC_KEY_PATH_REL="../../.credentials/asc-api-key.p8"
# Rewrite eas.json deterministically in Node (no jq dependency). Idempotent — sets the three
# ASC fields, drops the SET-AT-SUBMIT ascAppId sentinel so eas auto-detects/creates the record,
# and asserts production.distribution === "store".
KEY_ID="$KEY_ID" ISSUER_ID="$ISSUER_ID" ASC_KEY_PATH_REL="$ASC_KEY_PATH_REL" EAS_JSON="$EAS_JSON" \
node <<'NODE'
const fs = require('fs');
const p = process.env.EAS_JSON;
const j = JSON.parse(fs.readFileSync(p, 'utf8'));
j.submit = j.submit || {};
j.submit.production = j.submit.production || {};
const ios = j.submit.production.ios = j.submit.production.ios || {};
ios.ascApiKeyPath = process.env.ASC_KEY_PATH_REL;
ios.ascApiKeyId = process.env.KEY_ID;
ios.ascApiKeyIssuerId = process.env.ISSUER_ID;
// Drop the non-numeric sentinel so `eas submit` auto-detects by bundle id / creates the ASC
// app record on first run. (Leaving "SET-AT-SUBMIT" in place makes eas submit error confusingly.)
if (ios.ascAppId === 'SET-AT-SUBMIT') delete ios.ascAppId;
// Assert / repair the production BUILD profile is a store build.
j.build = j.build || {};
j.build.production = j.build.production || {};
if (j.build.production.distribution !== 'store') {
j.build.production.distribution = 'store';
console.log(' (repaired build.production.distribution -> "store")');
}
fs.writeFileSync(p, JSON.stringify(j, null, 2) + '\n');
console.log(' eas.json wired: ascApiKeyPath / ascApiKeyId / ascApiKeyIssuerId set; ascAppId sentinel cleared');
console.log(' build.production.distribution = ' + j.build.production.distribution);
NODE
echo
# ----- (c) production build ---------------------------------------------------
bold "[c] Running production build (1 EAS slot, \$0 free-tier / ~\$1-2 credit)"
echo " cd $APP_DIR && npx eas-cli build --platform ios --profile production --non-interactive"
echo
cd "$APP_DIR"
# --non-interactive is safe now: the ASC API key removes every Apple prompt for the store build.
npx eas-cli build --platform ios --profile production --non-interactive
echo
bold "[c] Production build finished (see the build URL above)."
echo
# ----- (d) PRINT the submit command and STOP ---------------------------------
bold "[d] ⛔ HOLDING at TestFlight submit — this is Steve's final go-live call."
cat <<EOF
The store build is done and signed. The App Store submit is GATED (go-live) and is
NOT run by this script. To send the build to TestFlight, run this yourself:
cd $APP_DIR
npx eas-cli submit --platform ios --profile production --latest
Notes:
• First submit auto-creates the App Store Connect app record and prints a numeric
ascAppId — paste it back into $EAS_JSON (submit.production.ios.ascAppId) for reuse.
• Build appears in TestFlight ~5-30 min after Apple finishes processing.
• ⚠️ App Review risk (Guideline 4.1 Copycats / 5.2 IP): "Unofficial 90210 Guide"
references a trademarked property. The app already ships the UNOFFICIAL badge +
disclaimers, but Apple can still reject at its discretion — treat that as a real
possibility, not an edge case.
TK-12 turnkey run complete: key routed, eas.json wired, production build fired, held at submit.
EOF