Files
opencode-mobile/.github/workflows/publish-app-store.yml
engineer ace8c19816 feat(analytics): add consent-gated activation-funnel analytics via PostHog
Installs are up 615% but 7-day retention is ~0% and we had no analytics SDK
to see where users drop off. Adds a thin PostHog wrapper (src/lib/analytics.ts)
that tracks app_opened, connection_form_submitted, connection_attempted,
connection_succeeded/failed (with a coarse error_class, e.g. the known 401
auth bug), message_sent, and response_received.

PostHog was chosen over Aptabase for its GMS-free JS-only RN SDK (fine for
the F-Droid/no-Firebase build), EU-hosted/self-host option, and generous
free tier. Analytics shares the exact same consent flag as Sentry
(telemetry.ts now gates both) so zero network calls happen without explicit
opt-in.

Requires a new EXPO_PUBLIC_POSTHOG_KEY CI secret (wired into build.yml,
publish-fdroid.yml, publish-play-store.yml, and documented in
publish-app-store.yml alongside the existing Sentry secrets).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJKAQ6HAikWGQK7PGZ5Y4E
2026-07-16 15:48:16 -07:00

234 lines
11 KiB
YAML

# Publish OpenCode for iOS to TestFlight with EAS Build + EAS Submit.
#
# This workflow FAILS FAST (non-zero exit) instead of "succeeding by skipping":
# a release with missing credentials or unfilled identifiers is a hard error, so a
# green run always means a real build was produced and submitted.
#
# ── HUMAN GATE (one-time, after Apple Developer Program enrollment) ──────────────
# Complete ALL of the following before releasing. Do NOT invent any of these IDs.
#
# 1. Link the app to an Expo project and add its UUID as the GitHub Actions
# repository variable EAS_PROJECT_ID (see eas.json.README.md), then fill and
# commit the eas.json placeholders:
# submit.production.ios.ascAppId REPLACE_WITH_APP_STORE_CONNECT_APP_ID → numeric App Store Connect App ID
# submit.production.ios.appleTeamId REPLACE_WITH_APPLE_TEAM_ID → 10-char Apple Team ID
#
# 2. Add GitHub Actions secrets (Settings → Secrets and variables → Actions):
# EXPO_TOKEN Expo access token (expo.dev → Account settings → Access tokens)
# APPLE_APP_STORE_CONNECT_API_KEY_ID ASC API Key ID (App Store Connect → Users and Access → Integrations → App Store Connect API)
# APPLE_APP_STORE_CONNECT_ISSUER_ID ASC API Issuer ID (same page)
# APPLE_APP_STORE_CONNECT_API_KEY base64 of the .p8 key file: `base64 -i AuthKey_XXXX.p8` (downloadable once)
#
# 3. Bootstrap iOS signing credentials on EAS once (creates the distribution cert +
# provisioning profile so CI never needs to prompt):
# eas login && eas build --platform ios --profile production
#
# Optional crash reporting + analytics belong in the EAS `production` environment
# because the iOS bundle is built on a remote EAS worker. Configure
# EXPO_PUBLIC_SENTRY_DSN, SENTRY_AUTH_TOKEN, SENTRY_ORG, SENTRY_PROJECT, and
# EXPO_PUBLIC_POSTHOG_KEY (PostHog project API key) in Expo before releasing.
#
# Build number is managed remotely by EAS (eas.json: cli.appVersionSource=remote,
# build.production.ios.autoIncrement=buildNumber). The workflow only injects the
# EAS project linkage into its temporary runner copy of app.json.
name: Publish to App Store (TestFlight)
on:
# One release ⇒ one build. Triggering only on `release: published` avoids the
# duplicate build that a combined release+tag trigger would create.
release:
types: [published]
workflow_dispatch:
# Serialize runs per release so a re-trigger cannot start a duplicate concurrent
# build/submit. cancel-in-progress:false never kills an in-flight submission.
concurrency:
group: publish-app-store-${{ github.event.release.tag_name || github.ref_name }}
cancel-in-progress: false
permissions:
contents: read
jobs:
testflight:
name: EAS Build and submit to TestFlight
runs-on: ubuntu-latest
timeout-minutes: 90
env:
EAS_CLI_VERSION: "21.0.0"
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
EAS_PROJECT_ID: ${{ vars.EAS_PROJECT_ID }}
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 20
cache: npm
- name: Preflight — verify credentials and identifiers (fail fast)
env:
ASC_KEY_ID: ${{ secrets.APPLE_APP_STORE_CONNECT_API_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.APPLE_APP_STORE_CONNECT_ISSUER_ID }}
ASC_KEY_B64: ${{ secrets.APPLE_APP_STORE_CONNECT_API_KEY }}
run: |
set -euo pipefail
fail=0
need() {
if [ -z "${2:-}" ]; then
echo "::error::Missing required secret: $1"
fail=1
fi
}
need "EXPO_TOKEN" "${EXPO_TOKEN:-}"
need "APPLE_APP_STORE_CONNECT_API_KEY_ID" "${ASC_KEY_ID:-}"
need "APPLE_APP_STORE_CONNECT_ISSUER_ID" "${ASC_ISSUER_ID:-}"
need "APPLE_APP_STORE_CONNECT_API_KEY" "${ASC_KEY_B64:-}"
if [ -z "${EAS_PROJECT_ID:-}" ]; then
echo "::error::Missing required repository variable: EAS_PROJECT_ID"
fail=1
elif ! printf '%s' "$EAS_PROJECT_ID" | grep -Eq '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'; then
echo "::error::EAS_PROJECT_ID must be an Expo project UUID"
fail=1
fi
asc_app_id=$(node -p "require('./eas.json').submit.production.ios.ascAppId || ''")
team_id=$(node -p "require('./eas.json').submit.production.ios.appleTeamId || ''")
case "$asc_app_id" in
""|REPLACE_*) echo "::error::eas.json submit.production.ios.ascAppId is unset or still a placeholder"; fail=1 ;;
*[!0-9]*) echo "::error::eas.json submit.production.ios.ascAppId must contain only digits"; fail=1 ;;
esac
case "$team_id" in
""|REPLACE_*) echo "::error::eas.json submit.production.ios.appleTeamId is unset or still a placeholder"; fail=1 ;;
esac
if ! printf '%s' "$team_id" | grep -Eq '^[A-Z0-9]{10}$'; then
echo "::error::eas.json submit.production.ios.appleTeamId must be a 10-character Apple Team ID"
fail=1
fi
if [ -n "${ASC_KEY_ID:-}" ] && ! printf '%s' "$ASC_KEY_ID" | grep -Eq '^[A-Z0-9]{10}$'; then
echo "::error::APPLE_APP_STORE_CONNECT_API_KEY_ID must be a 10-character key ID"
fail=1
fi
if [ -n "${ASC_ISSUER_ID:-}" ] && ! printf '%s' "$ASC_ISSUER_ID" | grep -Eq '^[0-9a-fA-F-]{36}$'; then
echo "::error::APPLE_APP_STORE_CONNECT_ISSUER_ID must be a UUID"
fail=1
fi
if [ "$fail" -ne 0 ]; then
echo "::error::BLOCKED: complete the one-time human-gated setup in this workflow's header (GitHub secrets + eas.json identifiers) before releasing. No build was started."
exit 1
fi
echo "Preflight OK — all credentials and identifiers present."
- name: Install EAS CLI (exact pin)
run: npm install -g eas-cli@"$EAS_CLI_VERSION"
- name: Install dependencies (deterministic)
run: npm ci --legacy-peer-deps
- name: Configure EAS project linkage
run: |
set -euo pipefail
node -e "
const fs = require('fs');
const j = require('./app.json');
j.expo.extra = { ...j.expo.extra, eas: { ...j.expo.extra?.eas, projectId: process.env.EAS_PROJECT_ID } };
fs.writeFileSync('app.json', JSON.stringify(j, null, 2) + '\n');
"
test "$(node -p "require('./app.json').expo.extra.eas.projectId")" = "$EAS_PROJECT_ID"
echo "Linked build to Expo project $EAS_PROJECT_ID."
- name: Configure App Store Connect API key
env:
ASC_KEY_ID: ${{ secrets.APPLE_APP_STORE_CONNECT_API_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.APPLE_APP_STORE_CONNECT_ISSUER_ID }}
ASC_KEY_B64: ${{ secrets.APPLE_APP_STORE_CONNECT_API_KEY }}
run: |
set -euo pipefail
key_path="$RUNNER_TEMP/asc_api_key.p8"
printf '%s' "$ASC_KEY_B64" | base64 -d > "$key_path"
if ! head -n1 "$key_path" | grep -q "BEGIN PRIVATE KEY"; then
echo "::error::APPLE_APP_STORE_CONNECT_API_KEY did not base64-decode to a valid .p8 private key."
exit 1
fi
chmod 600 "$key_path"
export ASC_KEY_PATH="$key_path"
# Expose ASC credentials to EAS Build for non-interactive signing management.
{
echo "EXPO_ASC_API_KEY_PATH=$key_path"
echo "EXPO_ASC_KEY_ID=$ASC_KEY_ID"
echo "EXPO_ASC_ISSUER_ID=$ASC_ISSUER_ID"
echo "EXPO_APPLE_TEAM_ID=$(node -p "require('./eas.json').submit.production.ios.appleTeamId")"
echo "EXPO_APPLE_TEAM_TYPE=COMPANY_OR_ORGANIZATION"
} >> "$GITHUB_ENV"
# EAS Submit reads the ASC key only from the eas.json submit profile (all three
# fields required). Inject them here so no real key IDs are committed to the repo.
node -e "
const fs = require('fs');
const j = require('./eas.json');
j.submit.production.ios.ascApiKeyPath = process.env.ASC_KEY_PATH;
j.submit.production.ios.ascApiKeyId = process.env.ASC_KEY_ID;
j.submit.production.ios.ascApiKeyIssuerId = process.env.ASC_ISSUER_ID;
fs.writeFileSync('eas.json', JSON.stringify(j, null, 2) + '\n');
"
echo "ASC API key configured for EAS Build and EAS Submit."
- name: EAS Build (iOS, wait for completion)
id: build
run: |
set -uo pipefail
set +e
eas build \
--platform ios \
--profile production \
--non-interactive \
--json > eas-build-output.json
rc=$?
set -e
if [ "$rc" -ne 0 ]; then
echo "::error::eas build failed (exit $rc). See the eas-ios-build-metadata artifact."
exit "$rc"
fi
# `eas build --json` prints a JSON ARRAY of completed builds. Select the exact
# iOS build id deterministically — never rely on an ambiguous "latest".
build_id=$(node -e "
const a = JSON.parse(require('fs').readFileSync('eas-build-output.json', 'utf8'));
if (!Array.isArray(a)) { console.error('Expected a JSON array from eas build --json'); process.exit(1); }
const ios = a.filter((b) => String(b.platform).toUpperCase() === 'IOS');
if (ios.length !== 1) { console.error('Expected exactly one iOS build, got ' + ios.length); process.exit(1); }
const b = ios[0];
if (b.status && String(b.status).toUpperCase() !== 'FINISHED') { console.error('iOS build did not finish: ' + b.status); process.exit(1); }
if (!b.id) { console.error('Build object has no id'); process.exit(1); }
process.stdout.write(b.id);
")
echo "build_id=$build_id" >> "$GITHUB_OUTPUT"
echo "Selected EAS iOS build id: $build_id"
- name: Upload EAS build metadata
if: always()
uses: actions/upload-artifact@v7
with:
name: eas-ios-build-metadata
path: eas-build-output.json
retention-days: 30
if-no-files-found: ignore
- name: Submit exact build to TestFlight
run: |
set -euo pipefail
eas submit \
--platform ios \
--profile production \
--id "${{ steps.build.outputs.build_id }}" \
--non-interactive
- name: TestFlight release notes (informational)
if: always()
run: |
notes="distribution/whatsnew-ios/release-notes-en-US.txt"
if [ -f "$notes" ]; then
echo "TestFlight 'What to Test' notes for this release:"
cat "$notes"
else
echo "No release notes file found at $notes"
fi