Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 54 additions & 4 deletions .github/workflows/ios-ship.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment thread
jaywedgeworth22 marked this conversation as resolved.
Resolved
Comment thread
jaywedgeworth22 marked this conversation as resolved.
Resolved
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<<SENTRY_DSN_CODECAPS_EOF"
printf '%s\n' "$SENTRY_DSN_CODECAPS"
Comment thread
jaywedgeworth22 marked this conversation as resolved.
Resolved
echo "SENTRY_DSN_CODECAPS_EOF"
} >>"$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
Comment thread
jaywedgeworth22 marked this conversation as resolved.
Resolved

# Do not use `secrets.*` in `if:` -- GitHub's dispatch parser rejects it
# (`Unrecognized named-value: secrets`) and the whole workflow dies at 0s.

Expand Down Expand Up @@ -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
Expand All @@ -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
Comment thread
jaywedgeworth22 marked this conversation as resolved.
Resolved
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
2 changes: 1 addition & 1 deletion INFISICAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions docs/IOS-TESTFLIGHT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
24 changes: 23 additions & 1 deletion scripts/ios-fleet/ship-testflight.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Comment thread
jaywedgeworth22 marked this conversation as resolved.
Resolved
Comment thread
jaywedgeworth22 marked this conversation as resolved.
Resolved
SENTRY_API_BASE="${SENTRY_API_BASE%/}"
sentry_dsn_project() {
local raw="${SENTRY_DSN:-}"
SENTRY_DSN_PROJECT_ERROR=""
Expand All @@ -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<org id>.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
Expand All @@ -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:
Expand Down
113 changes: 112 additions & 1 deletion scripts/ios-ship-workflow.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<</);
assert.match(step, /"\$SENTRY_DSN_CODECAPS"/);
assert.match(step, />>"\$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/);
Expand Down
Loading