diff --git a/.github/workflows/ios-ship.yml b/.github/workflows/ios-ship.yml index 77db158f..4c767fb4 100644 --- a/.github/workflows/ios-ship.yml +++ b/.github/workflows/ios-ship.yml @@ -30,7 +30,9 @@ name: iOS TestFlight ship (GitHub-hosted macOS) # stay repository secrets. # * SENTRY_DSN and SENTRY_AUTH_TOKEN are best-effort: an inert Sentry beats a # red TestFlight ship. Infisical prod first, same-named GitHub secrets as -# fallback. +# fallback. SENTRY_DSN_CODECAPS (the DSN of Sentry project `codecaps`) +# replaces the shared fleet SENTRY_DSN for this app; a missing one falls +# back to the shared key and turns the run red. # Values are never printed. # # scripts/ios-appstore-gm-prepare.sh imports the Distribution identity (the job @@ -207,15 +209,48 @@ jobs: - name: Load Sentry ship secrets (Infisical, then repository secrets) uses: ./.github/actions/infisical-secrets with: - names: SENTRY_DSN SENTRY_AUTH_TOKEN SENTRY_AUTH_TOKEN_FULLSCOPE + names: SENTRY_DSN SENTRY_DSN_CODECAPS SENTRY_AUTH_TOKEN SENTRY_AUTH_TOKEN_FULLSCOPE project-id: ${{ secrets.INFISICAL_FLEET_OPS_PROJECT_ID }} client-id: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID || secrets.INFISICAL_CLIENT_ID }} client-secret: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET || secrets.INFISICAL_CLIENT_SECRET }} env: GH_FALLBACK_SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + GH_FALLBACK_SENTRY_DSN_CODECAPS: ${{ secrets.SENTRY_DSN_CODECAPS }} GH_FALLBACK_SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} GH_FALLBACK_SENTRY_AUTH_TOKEN_FULLSCOPE: ${{ secrets.SENTRY_AUTH_TOKEN_FULLSCOPE }} + # CodeCaps iOS reports into its own Sentry project, `codecaps` + # (4512207409840129), not into the project the shared fleet SENTRY_DSN + # names (`botfleet` since 2026-10-22). SENTRY_DSN_CODECAPS is that + # project's DSN; this step makes it the SENTRY_DSN the archive and the + # ship script see, so the events, the dSYMs and the Size Analysis build + # all land in `codecaps`. + # + # A missing SENTRY_DSN_CODECAPS falls back to the shared SENTRY_DSN, + # LOUDLY: an ::error:: annotation plus a note in RUNNER_TEMP that the + # Sentry status step reads, so the last step turns the run red. The + # note is never written into the checkout: the ship script refuses a + # dirty worktree, and an untracked file there would stop the ship. It never blocks the + # TestFlight upload (an inert or misrouted Sentry beats a missed ship), + # and it never stays green while crashes go to the wrong project. + # The value is masked by the fetch above and is never printed here. + - name: Use the CodeCaps Sentry DSN + run: | + set +o xtrace + set -euo pipefail + if [ -n "${SENTRY_DSN_CODECAPS:-}" ]; then + { + echo "SENTRY_DSN<>"$GITHUB_ENV" + echo "SENTRY_DSN now comes from SENTRY_DSN_CODECAPS (Sentry project codecaps)." + else + msg="SENTRY_DSN_CODECAPS is missing from Infisical Fleet-OPS prod / and from the repository secrets, so this build falls back to the shared fleet SENTRY_DSN and its crashes do not land in the codecaps project. Set SENTRY_DSN_CODECAPS to the DSN of Sentry project codecaps." + echo "::error title=Sentry DSN::${msg}" + printf '%s\n' "$msg" >>"$RUNNER_TEMP/ios-ship-sentry-dsn-missing" + fi + # Do not use `secrets.*` in `if:` -- GitHub's dispatch parser rejects it # (`Unrecognized named-value: secrets`) and the whole workflow dies at 0s. @@ -357,6 +392,15 @@ jobs: - name: Sentry artifact upload status if: always() run: | + dsn_note="$RUNNER_TEMP/ios-ship-sentry-dsn-missing" + dsn_missing=0 + if [ -f "$dsn_note" ]; then + dsn_missing=1 + echo "::error title=Sentry DSN::this build used the shared fleet SENTRY_DSN, not SENTRY_DSN_CODECAPS, so its crashes do not land in the codecaps project." + while IFS= read -r line; do + echo " ${line}" + done <"$dsn_note" + fi marker="$GITHUB_WORKSPACE/.ios-ship-sentry-failed" if [ -f "$marker" ]; then if [ "${{ steps.ship.outcome }}" = "success" ]; then @@ -369,8 +413,14 @@ jobs: done <"$marker" exit 1 fi + # Report the ship outcome before the DSN exit, so a failed ship + # is never hidden behind the Sentry DSN error. + if [ "${{ steps.ship.outcome }}" != "success" ]; then + echo "The ship step did not succeed (result: ${{ steps.ship.outcome }}), so the Sentry upload status is unknown. See the ship step output for the real cause." + fi + if [ "$dsn_missing" = "1" ]; then + exit 1 + fi if [ "${{ steps.ship.outcome }}" = "success" ]; then echo "Sentry dSYM and Size Analysis upload succeeded." - else - echo "The ship step did not succeed (result: ${{ steps.ship.outcome }}), so the Sentry upload status is unknown. See the ship step output for the real cause." fi diff --git a/INFISICAL.md b/INFISICAL.md index 3435b69b..3e038a13 100644 --- a/INFISICAL.md +++ b/INFISICAL.md @@ -30,7 +30,7 @@ The `prod` environment holds no CodeCaps keys yet: the two non-sensitive default **Explicitly out — build-time constants:** `SUFeedURL` / `SUPublicEDKey` (Sparkle, baked into the signed bundle — do not move these), provider API endpoints (`api.anthropic.com`, etc.), bundle identifiers, local credential file paths. -**Build-time CI secrets (Infisical prod, not runtime app keys):** `SENTRY_DSN` and `SENTRY_AUTH_TOKEN` live in the CodeCaps Infisical project for TestFlight / Sparkle ship jobs. The Mac and iOS apps never fetch them at runtime; `InfisicalSettings` does not grow a fourth named read. Ship workflows load them through `.github/actions/infisical-secrets` (GitHub repository secrets of the same name as fallback) and inject `SENTRY_DSN` into the archive as an xcodebuild / Info.plist build setting. `SentryTelemetry` skips init when the value is empty. `SENTRY_AUTH_TOKEN` is used only by `sentry-cli` on the runner for dSYM / Size Analysis upload (org `simple-with-us`, project `codecaps`). `SENTRY_FLEET_DSN` is a GitHub repository secret for `.github/workflows/sentry-ci-report.yml` (fleet-infra), not an Infisical app key. +**Build-time CI secrets (Infisical prod, not runtime app keys):** `SENTRY_DSN` and `SENTRY_AUTH_TOKEN` live in the Fleet-OPS Infisical project (prod `/`) for the TestFlight / Sparkle ship jobs; both `ios-ship.yml` and `mac-release.yml` read that project, and no ship job reads a CodeCaps Infisical project. The iOS TestFlight workflow (`ios-ship.yml`) also reads `SENTRY_DSN_CODECAPS` from Fleet-OPS prod `/`: the DSN of Sentry project `codecaps`, to be filled by admin, which replaces the shared fleet `SENTRY_DSN` for the iOS archive. A missing `SENTRY_DSN_CODECAPS` falls back to the shared key and turns the run red. The Mac and iOS apps never fetch them at runtime; `InfisicalSettings` does not grow a fourth named read. Ship workflows load them through `.github/actions/infisical-secrets` (GitHub repository secrets of the same name as fallback) and inject `SENTRY_DSN` into the archive as an xcodebuild / Info.plist build setting. `SentryTelemetry` skips init when the value is empty. `SENTRY_AUTH_TOKEN` is used only by `sentry-cli` on the runner for dSYM / Size Analysis upload (org `simple-with-us`, project `codecaps`). `SENTRY_FLEET_DSN` is a GitHub repository secret for `.github/workflows/sentry-ci-report.yml` (fleet-infra), not an Infisical app key. **iOS companion:** the companion cannot safely hold a client secret, so it never talks to Infisical. The Mac app owns the Infisical read; the companion keeps reading quota data through its existing API (`https://usage.jays.services/api/quota-windows` with its read token). No companion code changed for this rollout. diff --git a/docs/IOS-TESTFLIGHT.md b/docs/IOS-TESTFLIGHT.md index 860551ac..918b10a2 100644 --- a/docs/IOS-TESTFLIGHT.md +++ b/docs/IOS-TESTFLIGHT.md @@ -20,6 +20,7 @@ The ship script keeps its own rate gate and the same-HEAD skip. Never set `IOS_ - The same-named repository secrets are a stale copy that App Store Connect rejects with HTTP 401 (CC-163). The workflow does not read them and has no fallback to them. - `IOS_DIST_P12_BASE64` and `IOS_DIST_P12_PASSWORD` (the Apple Distribution identity) stay repository secrets. - `SENTRY_DSN` and `SENTRY_AUTH_TOKEN` are best-effort: Infisical first, same-named repository secrets as fallback. +- `SENTRY_DSN_CODECAPS` is the DSN of Sentry project `codecaps` (id 4512207409840129), read from Fleet-OPS Infisical prod `/` (repository secret of the same name as fallback). The workflow makes it the `SENTRY_DSN` the archive and the ship script see, so crashes, dSYMs and the Size Analysis build all land in `codecaps`. When it is missing the build falls back to the shared fleet `SENTRY_DSN` (project `botfleet` since 2026-10-22), and the run goes red with a `Sentry DSN` error; the TestFlight upload is never blocked. ## Versions And Notes diff --git a/scripts/ios-fleet/ship-testflight.sh b/scripts/ios-fleet/ship-testflight.sh index 8ebd0a3f..2e66a1ba 100755 --- a/scripts/ios-fleet/ship-testflight.sh +++ b/scripts/ios-fleet/ship-testflight.sh @@ -645,6 +645,8 @@ sentry_report_failure() { # exactly the "the dSYM landed in the wrong project" defect this lane exists to # fix (CC-200), and it would leave the run green. SENTRY_DSN_PROJECT_ERROR="" +SENTRY_API_BASE="${SENTRY_API_BASE:-https://sentry.io}" +SENTRY_API_BASE="${SENTRY_API_BASE%/}" sentry_dsn_project() { local raw="${SENTRY_DSN:-}" SENTRY_DSN_PROJECT_ERROR="" @@ -660,6 +662,26 @@ sentry_dsn_project() { esac # Numeric: ask the API for the slug. The token never enters the command # line: it is handed to curl on stdin, so a process listing cannot read it. + # + # The lookup goes to the Sentry web API (sentry.io), never to the DSN's own + # host: that host is the ingest endpoint (o.ingest.us.sentry.io) and + # answers 404 for every /api/0/ path. The endpoint is + # /api/0/projects/{org}/{project id or slug}/; the older + # /api/0/organizations/{org}/projects/{id}/ form does not exist and answered + # 404 for every input, which turned every CodeCaps ship red (2026-10-28). + # The DSN host is still read, but only for the numeric org id. + # Refuse a non-https API base before the Authorization header ever leaves this + # process: an http:// or attacker-steered SENTRY_API_BASE would send the + # full-scope token in cleartext or off-domain. + case "${SENTRY_API_BASE}" in + https://*) + ;; + *) + SENTRY_DSN_PROJECT_ERROR="SENTRY_API_BASE must be an https:// URL (got: ${SENTRY_API_BASE})" + printf '%s' "$SENTRY_DSN_PROJECT_ERROR" >"${LOG_DIR}/sentry-dsn-project.error" + return 1 + ;; + esac local host org slug body host="$(printf '%s' "$raw" | sed -E 's#^https?://[^@/]+@##; s#/.*$##')" if [[ -z "$host" ]]; then @@ -673,7 +695,7 @@ sentry_dsn_project() { fi for org in "$org_id" "simple-with-us"; do [[ -n "$org" ]] || continue - body="$(curl -fsS -H @- "https://${host}/api/0/organizations/${org}/projects/${last}/" \ + body="$(curl -fsS --connect-timeout 10 --max-time 30 -H @- "${SENTRY_API_BASE}/api/0/projects/${org}/${last}/" \ <<<"Authorization: Bearer ${SENTRY_API_TOKEN}" 2>/dev/null)" || body="" slug="$(printf '%s' "$body" | python3 -c 'import json,sys try: diff --git a/scripts/ios-ship-workflow.test.mjs b/scripts/ios-ship-workflow.test.mjs index 0d7d553e..ffcec26f 100644 --- a/scripts/ios-ship-workflow.test.mjs +++ b/scripts/ios-ship-workflow.test.mjs @@ -10,7 +10,8 @@ // Run: node --test scripts/ios-ship-workflow.test.mjs import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import test from "node:test"; @@ -400,6 +401,116 @@ test("the iOS lane uploads to the DSN project as well as the app's own", () => { assert.match(shipScript, /\$dsn_project" != "\$app_project"/); }); +// The DSN names a numeric project id, and sentry-cli needs the slug. The +// lookup must hit the Sentry web API's project endpoint, never the DSN's +// ingest host and never the nonexistent /organizations/{org}/projects/{id}/ +// form, which answered 404 for every input and turned every ship red +// (2026-10-28). The function runs here against a stub curl that answers +// only the real endpoint. +function runDsnProject(dsn) { + const dir = mkdtempSync(join(tmpdir(), "dsn-project-")); + const fn = shipScript.match(/^SENTRY_DSN_PROJECT_ERROR=""\n[\s\S]*?^sentry_dsn_project\(\) \{\n[\s\S]*?^\}\n/m); + assert.ok(fn, "ship-testflight.sh defines sentry_dsn_project"); + writeFileSync(join(dir, "fn.sh"), fn[0]); + const stub = join(dir, "curl"); + writeFileSync( + stub, + [ + "#!/usr/bin/env bash", + 'url="${@: -1}"', + 'printf "%s\\n" "$url" >>"$STUB_LOG"', + 'case "$url" in', + ' https://sentry.io/api/0/projects/*/4512207409840129/) echo \'{"slug":"codecaps"}\' ;;', + " *) exit 22 ;;", + "esac", + "", + ].join("\n"), + ); + chmodSync(stub, 0o755); + const log = join(dir, "urls.log"); + writeFileSync(log, ""); + let out = ""; + let ok = true; + try { + out = execFileSync( + "bash", + ["-c", 'source "$DIR/fn.sh"; sentry_dsn_project'], + { + env: { + PATH: `${dir}:${process.env.PATH}`, + DIR: dir, + LOG_DIR: dir, + STUB_LOG: log, + SENTRY_DSN: dsn, + SENTRY_API_TOKEN: "test-token-not-real", + }, + encoding: "utf8", + }, + ); + } catch { + ok = false; + } + return { ok, out, urls: readFileSync(log, "utf8").trim().split("\n").filter(Boolean) }; +} + +test("the DSN project id is resolved through sentry.io /api/0/projects/{org}/{id}/", () => { + const dsn = "https://placeholder@o4511650476326912.ingest.us.sentry.io/4512207409840129"; + const r = runDsnProject(dsn); + assert.ok(r.ok, "a resolvable id succeeds"); + assert.equal(r.out, "codecaps"); + assert.equal(r.urls[0], "https://sentry.io/api/0/projects/4511650476326912/4512207409840129/"); + for (const u of r.urls) { + assert.doesNotMatch(u, /ingest/, "the API is never asked on the ingest host"); + assert.doesNotMatch(u, /\/organizations\/[^/]+\/projects\//); + } +}); + +test("an unresolvable DSN project id fails with the reason and tries the fleet org", () => { + const r = runDsnProject("https://placeholder@o4511650476326912.ingest.us.sentry.io/999"); + assert.equal(r.ok, false); + assert.deepEqual(r.urls, [ + "https://sentry.io/api/0/projects/4511650476326912/999/", + "https://sentry.io/api/0/projects/simple-with-us/999/", + ]); +}); + +test("a slug DSN tail is used as it stands, with no API call", () => { + const r = runDsnProject("https://placeholder@o1.ingest.us.sentry.io/codecaps"); + assert.ok(r.ok); + assert.equal(r.out, "codecaps"); + assert.deepEqual(r.urls, []); +}); + +test("CodeCaps iOS ships with its own Sentry DSN, and a missing one is loud", () => { + // SENTRY_DSN_CODECAPS (Sentry project codecaps) is fetched with the other + // Sentry names, from Infisical first and the same-named repository secret + // second, and it replaces the shared fleet SENTRY_DSN before the archive. + assert.match(code, /names: [^\n]*\bSENTRY_DSN_CODECAPS\b/); + assert.match(code, /GH_FALLBACK_SENTRY_DSN_CODECAPS: \$\{\{ secrets\.SENTRY_DSN_CODECAPS \}\}/); + const steps = code.split(/- name:/); + const pick = steps.findIndex((s) => /Use the CodeCaps Sentry DSN/.test(s)); + const load = steps.findIndex((s) => /Load Sentry ship secrets/.test(s)); + const ship = steps.findIndex((s) => /Ship CodeCaps to TestFlight/.test(s)); + assert.ok(load > 0 && pick > load && ship > pick, "the DSN is picked after the fetch and before the ship"); + const step = steps[pick]; + assert.match(step, /SENTRY_DSN<>"\$GITHUB_ENV"/); + // Missing: the shared key stays, but the run goes red through a note the + // Sentry status step reads, and the ship itself is not blocked. The note + // lives in RUNNER_TEMP, never in the checkout: the ship script refuses a + // dirty worktree, so an untracked file there would stop the ship. + assert.match(step, /::error title=Sentry DSN::/); + assert.match(step, /"\$RUNNER_TEMP\/ios-ship-sentry-dsn-missing"/); + assert.doesNotMatch(step, /GITHUB_WORKSPACE/); + assert.doesNotMatch(step, /exit 1/); + const status = steps.find((s) => /Sentry artifact upload status/.test(s)); + assert.match(status, /\$RUNNER_TEMP\/ios-ship-sentry-dsn-missing/); + assert.match(status, /dsn_missing" = "1" \]; then\s+exit 1/); + // The DSN value is never echoed. + assert.doesNotMatch(step, /echo[^\n]*\$\{?SENTRY_DSN_CODECAPS\}?"?\s*$/m); +}); + test("a failed Sentry upload is an error annotation and a red run", () => { assert.match(shipScript, /::error title=Sentry upload::/); assert.match(shipScript, /sentry_report_failure/);