From 294cbb56d04bc82f0da4ea4e39c54be29ccf6574 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:20:50 -0500 Subject: [PATCH 1/4] [GB-MONITOR] Fix the Sentry DSN project lookup and give CodeCaps iOS its own DSN sentry_dsn_project() asked https:///api/0/organizations/{org}/projects/{id}/, an endpoint that does not exist (404 for every input), so every CodeCaps iOS ship went red at the Sentry status step even though the uploads to codecaps worked. It now asks https://sentry.io/api/0/projects/{org}/{id}/. ios-ship.yml fetches SENTRY_DSN_CODECAPS (Sentry project codecaps) and makes it the SENTRY_DSN for the archive. A missing one falls back to the shared fleet DSN with an ::error:: and a red run; the TestFlight upload is never blocked. --- .github/workflows/ios-ship.yml | 37 ++++++++- INFISICAL.md | 2 +- docs/IOS-TESTFLIGHT.md | 1 + scripts/ios-fleet/ship-testflight.sh | 11 ++- scripts/ios-ship-workflow.test.mjs | 107 ++++++++++++++++++++++++++- 5 files changed, 153 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ios-ship.yml b/.github/workflows/ios-ship.yml index 77db158f..e9bf720e 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,46 @@ 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 line in the Sentry failure + # marker, so the last step turns the run red. 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" >>"$GITHUB_WORKSPACE/.ios-ship-sentry-failed" + fi + # Do not use `secrets.*` in `if:` -- GitHub's dispatch parser rejects it # (`Unrecognized named-value: secrets`) and the whole workflow dies at 0s. diff --git a/INFISICAL.md b/INFISICAL.md index 3435b69b..aa913fc5 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 CodeCaps Infisical project for TestFlight / Sparkle ship jobs. The iOS TestFlight workflow (`ios-ship.yml`) reads them from Fleet-OPS prod `/`, together with `SENTRY_DSN_CODECAPS`: 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 f3a69805..cc7d66c8 100755 --- a/scripts/ios-fleet/ship-testflight.sh +++ b/scripts/ios-fleet/ship-testflight.sh @@ -625,6 +625,7 @@ 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_dsn_project() { local raw="${SENTRY_DSN:-}" SENTRY_DSN_PROJECT_ERROR="" @@ -640,6 +641,14 @@ 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. local host org slug body host="$(printf '%s' "$raw" | sed -E 's#^https?://[^@/]+@##; s#/.*$##')" if [[ -z "$host" ]]; then @@ -653,7 +662,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 -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..4f2d593d 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,110 @@ 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 the same + // marker the Sentry status step reads, and the ship itself is not blocked. + assert.match(step, /::error title=Sentry DSN::/); + assert.match(step, /\.ios-ship-sentry-failed/); + assert.doesNotMatch(step, /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/); From 33a110303c0b8d5fc3d6ac27316c5ae6f56e59b7 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:26:30 -0500 Subject: [PATCH 2/4] [GB-MONITOR] Keep the missing-DSN note out of the checkout The ship script refuses a dirty worktree, so a note written to the repo root before the ship step would have stopped the ship. The note now goes to RUNNER_TEMP and the Sentry status step reads it (Seer finding on #336). --- .github/workflows/ios-ship.yml | 20 +++++++++++++++++--- scripts/ios-ship-workflow.test.mjs | 12 +++++++++--- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ios-ship.yml b/.github/workflows/ios-ship.yml index e9bf720e..8049ec88 100644 --- a/.github/workflows/ios-ship.yml +++ b/.github/workflows/ios-ship.yml @@ -227,8 +227,10 @@ jobs: # all land in `codecaps`. # # A missing SENTRY_DSN_CODECAPS falls back to the shared SENTRY_DSN, - # LOUDLY: an ::error:: annotation plus a line in the Sentry failure - # marker, so the last step turns the run red. It never blocks the + # 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. @@ -246,7 +248,7 @@ jobs: 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" >>"$GITHUB_WORKSPACE/.ios-ship-sentry-failed" + printf '%s\n' "$msg" >>"$RUNNER_TEMP/ios-ship-sentry-dsn-missing" fi # Do not use `secrets.*` in `if:` -- GitHub's dispatch parser rejects it @@ -390,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 @@ -402,6 +413,9 @@ jobs: done <"$marker" exit 1 fi + if [ "$dsn_missing" = "1" ]; then + exit 1 + fi if [ "${{ steps.ship.outcome }}" = "success" ]; then echo "Sentry dSYM and Size Analysis upload succeeded." else diff --git a/scripts/ios-ship-workflow.test.mjs b/scripts/ios-ship-workflow.test.mjs index 4f2d593d..ffcec26f 100644 --- a/scripts/ios-ship-workflow.test.mjs +++ b/scripts/ios-ship-workflow.test.mjs @@ -496,11 +496,17 @@ test("CodeCaps iOS ships with its own Sentry DSN, and a missing one is loud", () assert.match(step, /SENTRY_DSN<>"\$GITHUB_ENV"/); - // Missing: the shared key stays, but the run goes red through the same - // marker the Sentry status step reads, and the ship itself is not blocked. + // 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, /\.ios-ship-sentry-failed/); + 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); }); From 9032f081de8ccece241bd1fbf4e13086a7bae702 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:25:40 -0500 Subject: [PATCH 3/4] [GB-MONITOR] Report the ship outcome before the DSN exit; name Fleet-OPS as the only Sentry vault in INFISICAL.md --- .github/workflows/ios-ship.yml | 7 +++++-- INFISICAL.md | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ios-ship.yml b/.github/workflows/ios-ship.yml index 8049ec88..4c767fb4 100644 --- a/.github/workflows/ios-ship.yml +++ b/.github/workflows/ios-ship.yml @@ -413,11 +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 aa913fc5..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 iOS TestFlight workflow (`ios-ship.yml`) reads them from Fleet-OPS prod `/`, together with `SENTRY_DSN_CODECAPS`: 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. +**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. From f61a95e03a154de150b90d7b51549799d0cb5105 Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:53:27 -0500 Subject: [PATCH 4/4] =?UTF-8?q?[GB-MONITOR]=20Harden=20SENTRY=5FAPI=5FBASE?= =?UTF-8?q?=20before=20the=20DSN=20project=20lookup=C2=A0=20Strip=20a=20tr?= =?UTF-8?q?ailing=20slash,=20reject=20non-https=20bases=20so=20the=20API?= =?UTF-8?q?=20token=20never=20leaves=20over=20cleartext=20or=20off-domain,?= =?UTF-8?q?=20and=20give=20the=20project-slug=20curl=20connect/max=20timeo?= =?UTF-8?q?uts=20so=20a=20hung=20Sentry=20API=20fails=20fast.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/ios-fleet/ship-testflight.sh | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/scripts/ios-fleet/ship-testflight.sh b/scripts/ios-fleet/ship-testflight.sh index cc7d66c8..4f50414b 100755 --- a/scripts/ios-fleet/ship-testflight.sh +++ b/scripts/ios-fleet/ship-testflight.sh @@ -626,6 +626,7 @@ sentry_report_failure() { # 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="" @@ -649,6 +650,18 @@ sentry_dsn_project() { # /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 @@ -662,7 +675,7 @@ sentry_dsn_project() { fi for org in "$org_id" "simple-with-us"; do [[ -n "$org" ]] || continue - body="$(curl -fsS -H @- "${SENTRY_API_BASE}/api/0/projects/${org}/${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: