gowalk-cicd 1.0.57 → 1.0.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -98,6 +98,12 @@ node /path/to/gowalk-cicd/bin/cli.mjs
98
98
  Detect and warn, tell the user to remove lines manually.
99
99
  - Store API calls belong in GitHub Actions. Local tests may build/sign but must
100
100
  never connect to App Store Connect or Google Play.
101
+ - Every Apple/Google store API call, OAuth exchange and binary upload requires its
102
+ assigned `APPLE_STORE_PROXY_URL` / `GOOGLE_STORE_PROXY_URL` repository secret.
103
+ Python clients enforce explicit proxies; Play upload steps set the proxy for the
104
+ pinned Google client. Missing configuration must stop before a provider call.
105
+ Apple IPA uploads use `action/scripts/upload_build.py`, not native upload tools.
106
+ It retains provider IDs/checksums and verifies matching IPA identity before resume.
101
107
  - Signing material should use encrypted Actions secrets. `deploy.yml`
102
108
  materializes `ASC_KEY_P8` + its ID/issuer, the Apple distribution identity
103
109
  (`IOS_DISTRIBUTION_P12_BASE64`, `IOS_DISTRIBUTION_CERT_META_BASE64`,
@@ -155,31 +161,19 @@ node /path/to/gowalk-cicd/bin/cli.mjs
155
161
  must not discard the symbols of a build that is already live. Symbol
156
162
  artifacts carry no `retention-days` so the repository's setting governs and
157
163
  can be raised to cover a release's life.
158
- - Crashlytics symbol upload is gated on the `firebase-app-id` action input,
159
- which `deploy.yml` feeds from the `FIREBASE_APP_ID` repository variable on
160
- both actions. Actions never read `vars.*` themselves an unavailable
161
- context inside a composite action invalidates every consumer's workflow
162
- so the variable crosses the workflow/action boundary as an input and the
163
- feature reaches consumers only after the one-off `npx gowalk-cicd` that
164
- refreshes `deploy.yml`. The variable holds one id per platform
165
- (`1:<n>:android:<hash>`, `1:<n>:ios:<hash>`, comma/whitespace-separated);
166
- each action picks its own and fails on anything that is not an id. Android
167
- runs `firebase crashlytics:symbols:upload` (the only Android path Firebase
168
- documents; needs Node + Java, no credentials) on the `--split-debug-info`
169
- directory `android-action/scripts/crashlytics_symbols.py`. iOS runs the
170
- FirebaseCrashlytics pod's `upload-symbols` on the archive's dSYMs
171
- `action/scripts/crashlytics_dsyms.py` — because Flutter's `.symbols` are
172
- ELF debug info the Firebase CLI's generators reject for Apple targets, and
173
- `App.framework.dSYM` is the documented Apple path. Both run before the
174
- store upload and fail closed — except an iOS id on an app with no
175
- FirebaseCrashlytics `upload-symbols` tool, which is a warn-and-skip: an app
176
- that does not embed the SDK has nothing to symbolicate. Logic belongs in
177
- those scripts, where it is unit-tested, not inline in YAML. Supporting
178
- invariants: the workflow's disk-space step must never remove
179
- `/usr/local/lib/node_modules` (npm and npx live there); `firebase-tools` is
180
- pinned exactly because the Android job holds the keystore and the Play
181
- service account; the CLI runs from a temp cwd so its `.crashlytics/` debris
182
- never lands in the checkout.
164
+ - Firebase Crashlytics traffic follows the Google account proxy. Android builds and
165
+ the pinned Firebase CLI receive the explicit proxy environment; Crashlytics Gradle
166
+ 2.9.2+ and the pinned buildtools support it (generic JVM networking does not).
167
+ Native iOS upload-symbols uses NSURLSession, so it is never executed. Before
168
+ archiving, dedicated Crashlytics upload phases in the ephemeral Xcode projects
169
+ are deferred; mixed/unidentified upload phases refuse the archive for repair.
170
+ CI retains the archive's dSYMs as a ZIP with UUIDs, source SHA and SHA-256, then
171
+ emits `firebase_symbols_pending` (`gowalk-cicd/firebase-symbols-pending.v1`).
172
+ The app session downloads that exact artifact and uploads it through its
173
+ account-pinned Firebase console, reading processing back before completing the
174
+ delivery request. This requires no human handoff. A green CI run alone is not
175
+ evidence that the deferred symbols reached Firebase. Android retains its Dart
176
+ symbols and uploads them before the store bundle, using the configured proxy.
183
177
  - The iOS action reads every script and prompt from the snapshot it takes of
184
178
  itself in its first step (`$SWIFT_APP_ACTION`, under `RUNNER_TEMP`), never
185
179
  from `${{ github.action_path }}` after that step. The plugin self-update
@@ -188,14 +182,15 @@ node /path/to/gowalk-cicd/bin/cli.mjs
188
182
  release that fixes it; the snapshot is what keeps that run on one version.
189
183
  `test_action_step_order.py` pins both. A new script reference in
190
184
  `action/action.yml` therefore uses `"$SWIFT_APP_ACTION/scripts/..."`.
191
- - Two situations end a green job with work for the panel, and each is a typed
185
+ - Three situations end a green job with work for the panel, and each is a typed
192
186
  check-run annotation (`::notice title=<name>::<one-line JSON with a versioned
193
187
  schema>`), never prose in a `::warning::`: `play_review_pending`
194
188
  (`templates/deploy.yml`, Play refused auto-submit and the bundle landed
195
189
  unsubmitted) and `store_version_locked` (`action/action.yml`, the
196
190
  `TESTFLIGHT_ONLY` decision of `manage_marketing_version.py`: an App Store
197
191
  version is under review, so the build shipped to TestFlight under that
198
- version and metadata was skipped). The README table lists the schemas; add a
192
+ version and metadata was skipped), and `firebase_symbols_pending` (the dSYM
193
+ archive awaits the session's proxied console upload). The README lists the schemas; add a
199
194
  field by bumping the version.
200
195
  - The JDK is chosen by `scripts/select_jdk.py`, called from the workflow before
201
196
  `setup-java`. The `JAVA_VERSION` repo variable always wins; otherwise Flutter
package/README.md CHANGED
@@ -279,6 +279,7 @@ the situation and whose **message** is one line of JSON carrying a versioned
279
279
  | --- | --- | --- |
280
280
  | `play_review_pending` | `gowalk-cicd/play-review-pending.v1` | `package`, `track`, `version_code` |
281
281
  | `store_version_locked` | `gowalk-cicd/store-version-locked.v1` | `version`, `state`, `build_number` |
282
+ | `firebase_symbols_pending` | `gowalk-cicd/firebase-symbols-pending.v1` | `platform`, `app_id`, `source_sha`, `artifact`, `file`, `sha256`, `status` |
282
283
 
283
284
  Add a field by bumping the schema version; never change the meaning of an
284
285
  existing one.
@@ -329,7 +330,7 @@ The Android job removes the preinstalled toolchains a Flutter build never uses
329
330
  (.NET, the Android NDK, GHC, PowerShell, Swift, Chromium) and prunes Docker
330
331
  images, reclaiming roughly 25 GB in a few seconds. It prints `df -h /` before
331
332
  and after. Linux only; skipped in Bitrise mode. npm survives on purpose: the
332
- [Crashlytics symbol upload](#crashlytics-symbol-upload-firebase_app_id) runs
333
+ [Crashlytics symbol upload](#crashlytics-symbol-delivery-firebase_app_id) runs
333
334
  the Firebase CLI through `npx`.
334
335
 
335
336
  ### Private git dependencies (Flutter)
@@ -572,77 +573,41 @@ the workflow uploads them itself once the repository variable
572
573
  **`FIREBASE_APP_ID`** is set — see below. Sentry or others: upload the same
573
574
  directory to that service from your own pipeline.
574
575
 
575
- #### Crashlytics symbol upload (`FIREBASE_APP_ID`)
576
+ #### Crashlytics symbol delivery (`FIREBASE_APP_ID`)
576
577
 
577
- Set the repository variable `FIREBASE_APP_ID` to the app's Firebase App ID
578
- `1:<project number>:<platform>:<hash>`. An app that ships both platforms has
579
- two ids (they differ in the third segment); list both, separated by a comma:
578
+ Set the repository variable to the Firebase App IDs for the shipping platforms
579
+ (`1:<project>:ios:<hash>` and/or `1:<project>:android:<hash>`). The workflow passes it
580
+ as the `firebase-app-id` input. Configure the account's `GOOGLE_STORE_PROXY_URL`
581
+ repository secret before any provider operation.
580
582
 
581
- ```
582
- 1:123456789012:android:0a1b2c3d4e5f6a7b,1:123456789012:ios:7b6a5f4e3d2c1b0a
583
- ```
583
+ Android builds and the pinned Firebase CLI receive the proxy explicitly, including
584
+ Crashlytics mapping/native-symbol uploads. Crashlytics Gradle 2.9.2+ supports proxy
585
+ environment variables; older versions must be updated before use. Dart symbols are
586
+ retained as artifacts and uploaded through that transport before the bundle ships.
584
587
 
585
- Find them in `lib/firebase_options.dart` (`appId`), in
586
- `android/app/google-services.json` (`mobilesdk_app_id`) and
587
- `ios/Runner/GoogleService-Info.plist` (`GOOGLE_APP_ID`), or under *Project
588
- settings Your apps* in the Firebase console. No credentials are needed:
589
- both upload tools authenticate by app id alone.
588
+ Native iOS `upload-symbols` is refused because it does not use this environment
589
+ transport. CI defers dedicated native upload phases in its ephemeral projects and
590
+ retains `ios-crashlytics-symbols-<run>-<attempt>` with `ios-dsyms.zip` and a manifest
591
+ containing UUIDs, source SHA and SHA-256. The typed `firebase_symbols_pending`
592
+ annotation uses schema `gowalk-cicd/firebase-symbols-pending.v1` and names the exact
593
+ artifact. The autonomous app session must upload it through the account-pinned
594
+ Firebase console's Crashlytics dSYMs tab and read back processing; no human action
595
+ is required. A completed CI run does not mean this deferred upload is complete.
596
+ [Firebase documents the ZIP upload interface](https://firebase.google.com/docs/crashlytics/ios/get-deobfuscated-reports).
590
597
 
591
- The variable reaches the actions through `deploy.yml`, which
592
- [auto-update never touches](#deployyml-is-not-auto-updated) — an existing repo
593
- must run `npx --yes gowalk-cicd` once before setting it does anything.
594
-
595
- | | Android | iOS |
596
- |---|---|---|
597
- | What is uploaded | the `--split-debug-info` directory, with the command Firebase documents for Flutter: `firebase crashlytics:symbols:upload --app=<android id> <dir>` (Firebase CLI through `npx`, major-pinned) | every dSYM in the archive — `App.framework.dSYM` carries the Dart frames — with the `upload-symbols` tool from the FirebaseCrashlytics pod: `upload-symbols -ai <ios id> -p ios -- <archive>/dSYMs` |
598
- | When | in the Android action, right after the build and before the bundle goes to Play | in the iOS action, right after the IPA export and before the TestFlight upload |
599
- | If it fails | the deploy fails; nothing ships with crashes nobody can read. The symbols are still in the `android-symbols-*` artifact for a manual upload | same — the failure lands before the TestFlight upload, so nothing shipped; fix the cause and re-run the deploy |
600
- | Variable unset | a `::warning::` when the app depends on `firebase_crashlytics` (every Dart frame will be `***`) | nothing — the run-script phase that `flutterfire configure` adds already uploads `App.framework.dSYM`, and the workflow installs the flutterfire CLI it needs |
601
- | Variable set for the other platform only | a `::warning::`, no upload | a `::notice::`, no upload |
602
-
603
- Why the two platforms differ: Flutter's `.symbols` files are ELF debug info,
604
- which the Firebase CLI's symbol generators accept for Android and reject for
605
- Apple targets; Firebase documents dSYMs as the Apple path, and Flutter 3.12+
606
- puts the Dart debug info into `App.framework.dSYM`. On iOS the explicit
607
- upload is a belt-and-braces over the run-script phase — it also covers the
608
- native `Runner.app.dSYM` and every plugin framework — so a phase that is
609
- missing or failing can never leave a build unreadable.
610
-
611
- A value that is not a Firebase App ID fails the deploy rather than being
612
- skipped: a typo must not become a fleet that silently stops uploading symbols.
613
- An iOS id on an app that does not embed Crashlytics (no `upload-symbols` tool
614
- anywhere under the project) is a warning, not a failure — an app without the
615
- SDK has no Crashlytics crashes to symbolicate. Android apps delivered through
616
- Bitrise are built by Bitrise and are not covered.
617
-
618
- Obfuscation renames identifiers, so code that depends on their spelling breaks
619
- at runtime, not at build time. Do not rely on `runtimeType.toString()`,
620
- `Type.toString()`, or on matching class or function names in stack traces
621
- (`Foo` becomes `Lk`, and a raw trace is a row of `***`) — compare types with
622
- `is` instead. Enum values keep their names today (`Color.red.toString()` is
623
- still `Color.red`), but `flutter build --help` lists `Enum.toString()` among
624
- the methods that may return obfuscated results, so prefer `Enum.name` when the
625
- identifier matters.
626
-
627
- Where the flags are applied: Android passes them straight to
628
- `flutter build appbundle`. iOS is less obvious — `deploy.yml` runs
629
- `flutter build ios --config-only` with the flags, which writes
630
- `DART_OBFUSCATION=true` and `SPLIT_DEBUG_INFO=<dir>` into
631
- `ios/Flutter/Generated.xcconfig`, and the Flutter build phase inside the
632
- `xcodebuild archive` that follows compiles against those settings. The symbol
633
- directory lives under the runner's temp directory on both platforms, never in
634
- the checkout.
635
-
636
- Because the iOS half lives in `deploy.yml`, which
637
- [auto-update never touches](#deployyml-is-not-auto-updated), an existing
638
- Flutter repo gets Android obfuscation on its next auto-update but iOS
639
- obfuscation only after a one-off `npx --yes gowalk-cicd`. The Android action
640
- retains its own symbols precisely so that this half-updated state is safe.
641
598
 
642
599
  ### iOS delivery
643
600
 
644
601
  The iOS composite action runs on `macos-15` and:
645
602
 
603
+ All Apple and Google store requests require an account-pinned proxy, including OAuth,
604
+ metadata and binary transfer. Provision `APPLE_STORE_PROXY_URL` and
605
+ `GOOGLE_STORE_PROXY_URL` as encrypted repository secrets before deployment. A missing
606
+ proxy refuses provider calls; there is no direct fallback. Refresh existing workflow
607
+ files with the released installer while preserving application-specific build inputs.
608
+ The Apple uploader retains `apple-upload-<run>-<attempt>` receipts with IPA SHA-256,
609
+ upload ID and processing readback, and resumes matching bytes after interruption.
610
+
646
611
  1. **Auto-detects** your `.xcodeproj` / `.xcworkspace`, scheme, bundle ID,
647
612
  and `team_id` (from the ASC API key). No `ci.config.yaml` required —
648
613
  override via action inputs only if auto-detection fails.
@@ -659,7 +624,7 @@ The iOS composite action runs on `macos-15` and:
659
624
  `CI-<bundle_id>`. Patches the `.pbxproj` to use Manual signing against those
660
625
  profiles.
661
626
  6. **Archives** with `xcodebuild archive`, exports the IPA, and uploads via
662
- `xcrun altool`.
627
+ Apple's build-upload REST API through `APPLE_STORE_PROXY_URL`.
663
628
  7. **Sets "What's New"** on every declared localization (reads
664
629
  `fastlane/metadata/ios/<locale>/release_notes.txt` if present, or from the
665
630
  `app-store-whats-new` input).
@@ -967,7 +932,7 @@ Changes that need that manual run:
967
932
  `ios-symbols-*`. See [Dart obfuscation](#dart-obfuscation-flutter-apps).
968
933
  - **Crashlytics symbol upload** — `firebase-app-id: ${{ vars.FIREBASE_APP_ID }}`
969
934
  passed to both actions. Until it is, setting the variable does nothing.
970
- See [Crashlytics symbol upload](#crashlytics-symbol-upload-firebase_app_id).
935
+ See [Crashlytics symbol upload](#crashlytics-symbol-delivery-firebase_app_id).
971
936
 
972
937
  ### Opt out
973
938
 
@@ -1 +1 @@
1
- 1.0.57
1
+ 1.0.59
package/action/action.yml CHANGED
@@ -345,7 +345,7 @@ runs:
345
345
  test
346
346
  fi
347
347
 
348
- - name: Stage ASC API key for xcrun / altool
348
+ - name: Stage ASC API key for authenticated REST uploads
349
349
  if: ${{ inputs.archive == 'true' }}
350
350
  shell: bash
351
351
  run: |
@@ -353,7 +353,7 @@ runs:
353
353
  # prints env: blocks on step-group expansion, which leaks secrets.
354
354
  # read_config.py already validated creds/AuthKey_<KEY_ID>_Issuer_<UUID>.p8
355
355
  # exists and exported ASC_KEY_P8_PATH; we copy the file on disk and
356
- # stage it where xcrun/altool expect it (~/.appstoreconnect/private_keys).
356
+ # stage it for the authenticated REST clients only.
357
357
  : "${ASC_KEY_ID:?ASC_KEY_ID env var is required}"
358
358
  : "${ASC_ISSUER_ID:?ASC_ISSUER_ID env var is required}"
359
359
  : "${ASC_KEY_P8_PATH:?ASC_KEY_P8_PATH env var is required (set by read_config.py)}"
@@ -364,9 +364,6 @@ runs:
364
364
  mkdir -p "$RUNNER_TEMP/asc"
365
365
  cp "$ASC_KEY_P8_PATH" "$RUNNER_TEMP/asc/AuthKey.p8"
366
366
  chmod 600 "$RUNNER_TEMP/asc/AuthKey.p8"
367
- mkdir -p ~/.appstoreconnect/private_keys
368
- cp "$RUNNER_TEMP/asc/AuthKey.p8" \
369
- ~/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8
370
367
  echo "ASC_KEY_PATH=$RUNNER_TEMP/asc/AuthKey.p8" >> "$GITHUB_ENV"
371
368
 
372
369
  - name: Resolve marketing version (project source of truth)
@@ -696,6 +693,16 @@ runs:
696
693
  || /usr/libexec/PlistBuddy -c "Add :ITSAppUsesNonExemptEncryption bool $VALUE" "$INFOPLIST_PATH"
697
694
  echo "Set ITSAppUsesNonExemptEncryption=$VALUE in $INFOPLIST_PATH"
698
695
 
696
+ - name: Defer native Firebase upload phases to the proxied console
697
+ if: ${{ inputs.archive == 'true' }}
698
+ shell: bash
699
+ env:
700
+ PROJECT: ${{ inputs.project || env.CFG_PROJECT }}
701
+ WORKSPACE: ${{ inputs.workspace || env.CFG_WORKSPACE }}
702
+ run: |
703
+ python3 "$SWIFT_APP_ACTION/scripts/prepare_crashlytics_build.py" \
704
+ --search-root "$(dirname "${WORKSPACE:-$PROJECT}")"
705
+
699
706
  - name: Archive
700
707
  if: ${{ inputs.archive == 'true' }}
701
708
  shell: bash
@@ -790,47 +797,46 @@ runs:
790
797
  # set (deploy.yml passes the FIREBASE_APP_ID repository variable) every
791
798
  # dSYM in the archive is uploaded here as well, with the upload-symbols
792
799
  # tool from the FirebaseCrashlytics pod, so a missing or broken phase
793
- # cannot leave a build unreadable. Before the TestFlight upload on
794
- # purpose: a failed upload fails the deploy instead of shipping crashes
795
- # nobody can read. Logic and its tests live in scripts/crashlytics_dsyms.py.
796
- - name: Upload dSYMs to Crashlytics
797
- if: ${{ inputs.archive == 'true' && inputs.upload == 'true' && inputs.firebase-app-id != '' }}
800
+ # cannot lose the exact build's symbols. The autonomous session completes
801
+ # the upload through the proxied Firebase console from this typed artifact.
802
+ - name: Retain dSYMs for the proxied Firebase console
803
+ if: ${{ inputs.archive == 'true' && inputs.upload == 'true' }}
798
804
  shell: bash
799
805
  env:
800
806
  FIREBASE_APP_ID: ${{ inputs.firebase-app-id }}
801
807
  PROJECT: ${{ inputs.project || env.CFG_PROJECT }}
802
808
  WORKSPACE: ${{ inputs.workspace || env.CFG_WORKSPACE }}
803
809
  run: |
804
- python3 "$SWIFT_APP_ACTION/scripts/crashlytics_dsyms.py" \
810
+ python3 "$SWIFT_APP_ACTION/scripts/prepare_crashlytics_dsyms.py" \
805
811
  --archive "$RUNNER_TEMP/app.xcarchive" \
806
- --search-root "$(dirname "${WORKSPACE:-$PROJECT}")"
812
+ --search-root "$(dirname "${WORKSPACE:-$PROJECT}")" \
813
+ --output "$RUNNER_TEMP/firebase-symbols"
814
+
815
+ - name: Preserve the Firebase console upload artifact
816
+ if: ${{ always() && inputs.archive == 'true' }}
817
+ uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
818
+ with:
819
+ name: ios-crashlytics-symbols-${{ github.run_id }}-${{ github.run_attempt }}
820
+ path: ${{ runner.temp }}/firebase-symbols/
821
+ if-no-files-found: ignore
807
822
 
808
823
  - name: Upload to TestFlight
809
824
  if: ${{ inputs.archive == 'true' && inputs.upload == 'true' }}
810
825
  shell: bash
826
+ env:
827
+ APP_STORE_APPLE_ID: ${{ inputs.app-store-apple-id || env.CFG_APP_STORE_APPLE_ID }}
811
828
  run: |
812
829
  IPA=$(ls "$RUNNER_TEMP"/export/*.ipa | head -1)
813
- echo "Uploading $IPA"
814
- LOG="$RUNNER_TEMP/altool.log"
815
- set +e
816
- xcrun altool --upload-app \
817
- --type ios \
818
- --file "$IPA" \
819
- --apiKey "$ASC_KEY_ID" \
820
- --apiIssuer "$ASC_ISSUER_ID" \
821
- --output-format normal 2>&1 | tee "$LOG"
822
- ALTOOL_RC=${PIPESTATUS[0]}
823
- set -e
824
- # altool occasionally exits 0 even on failure; verify the final outcome marker.
825
- # Apple's altool prints transient "ERROR:" lines for retried network blips
826
- # before emitting "UPLOAD SUCCEEDED" — don't false-positive on those.
827
- if [ "$ALTOOL_RC" -ne 0 ] \
828
- || grep -qE '^Failed to upload|ERROR ITMS-' "$LOG" \
829
- || ! grep -qE 'UPLOAD SUCCEEDED|No errors uploading archive' "$LOG"; then
830
- echo "altool reported an upload failure (rc=$ALTOOL_RC)"
831
- exit 1
832
- fi
833
- echo "Upload succeeded"
830
+ python3 "$SWIFT_APP_ACTION/scripts/upload_build.py" "$IPA" \
831
+ --report "$RUNNER_TEMP/apple-build-upload.json"
832
+
833
+ - name: Retain Apple upload receipt
834
+ if: ${{ always() && inputs.archive == 'true' && inputs.upload == 'true' }}
835
+ uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
836
+ with:
837
+ name: apple-upload-${{ github.run_id }}-${{ github.run_attempt }}
838
+ path: ${{ runner.temp }}/apple-build-upload.json
839
+ if-no-files-found: ignore
834
840
 
835
841
  # Both App Store metadata writers are skipped while an App Store version
836
842
  # is locked (STORE_VERSION_LOCKED, the TESTFLIGHT_ONLY decision): there is
@@ -0,0 +1,28 @@
1
+ """Explicit account-pinned transport for CI; missing proxy never means direct egress."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from urllib.parse import urlsplit
6
+
7
+ import requests
8
+
9
+
10
+ def proxies() -> dict[str, str]:
11
+ value = os.environ.get("APPLE_STORE_PROXY_URL", "").strip()
12
+ try:
13
+ parsed = urlsplit(value)
14
+ valid = parsed.scheme in {"http", "https"} and parsed.hostname and parsed.port
15
+ except ValueError:
16
+ valid = False
17
+ if not valid:
18
+ raise SystemExit("APPLE_STORE_PROXY_URL must contain the account-pinned proxy; direct requests are refused")
19
+ return {"http": value, "https": value}
20
+
21
+
22
+ def request(method: str, url: str, **kwargs) -> requests.Response:
23
+ # An explicit map also wins over NO_PROXY and any unrelated runner proxy.
24
+ try:
25
+ return requests.request(method, url, proxies=proxies(), **kwargs)
26
+ except requests.RequestException:
27
+ # Proxy errors can embed credentials and signed upload URLs.
28
+ raise RuntimeError("Apple request failed through the configured proxy") from None
@@ -16,6 +16,8 @@ from typing import Any
16
16
  import jwt
17
17
  import requests
18
18
 
19
+ from apple_store_proxy import proxies
20
+
19
21
 
20
22
  ASC_BASE = "https://api.appstoreconnect.apple.com/v1"
21
23
 
@@ -162,11 +164,11 @@ def _retry_network_error(
162
164
  if attempt >= total - 1:
163
165
  raise SystemExit(
164
166
  f"ASC {method} {path} network error after {total} "
165
- f"attempts: {exc!r}"
167
+ "attempts through the configured proxy"
166
168
  )
167
169
  delay = backoffs[attempt]
168
170
  print(
169
- f"ASC {method} {path} network error ({exc!r}); "
171
+ f"ASC {method} {path} network error through the configured proxy; "
170
172
  f"retrying in {delay}s ({attempt + 1}/{total - 1})",
171
173
  file=sys.stderr,
172
174
  )
@@ -222,7 +224,7 @@ def request(
222
224
  try:
223
225
  resp = requests.request(
224
226
  method, url, headers=headers, params=params,
225
- json=json_body, timeout=timeout,
227
+ json=json_body, timeout=timeout, proxies=proxies(),
226
228
  )
227
229
  except requests.RequestException as exc:
228
230
  _retry_network_error(method, path, attempt, total, backoffs, exc)
@@ -1,28 +1,9 @@
1
1
  #!/usr/bin/env python3
2
- """Upload every dSYM in the archive to Firebase Crashlytics.
3
-
4
- Crashlytics symbolicates Apple crashes from dSYMs. For a Flutter app that
5
- includes App.framework.dSYM, which is where the Dart frames come from — and
6
- the only Apple-platform path Firebase documents for Flutter's obfuscated,
7
- --split-debug-info builds (the .symbols files those flags write are ELF
8
- debug info that the Firebase CLI's symbol generators reject). The run-script
9
- phase `flutterfire configure` adds already uploads that one dSYM during the
10
- archive; this script uploads all of them (App.framework, the app binary,
11
- plugin frameworks) with the `upload-symbols` tool that ships in the
12
- FirebaseCrashlytics pod, so a missing or broken phase cannot leave a build
13
- unreadable, and native frames get their symbols too.
14
-
15
- upload-symbols -ai <FIREBASE_APP_ID> -p ios -- <archive>/dSYMs
16
-
17
- No credentials: the tool authenticates by app id alone.
18
-
19
- Gating: FIREBASE_APP_ID holds the app's Firebase App ID(s),
20
- `1:<project number>:<platform>:<hash>`, comma- or whitespace-separated when an
21
- app ships both platforms; the iOS one is used and its absence is a notice, not
22
- a failure (the run-script phase is the documented default). With an iOS id
23
- present, a missing tool or a failed upload fails the step — the action runs it
24
- before the TestFlight upload, so a deploy never goes out with crashes nobody
25
- can read.
2
+ """Shared Crashlytics app/tool discovery and the retired native uploader interface.
3
+
4
+ CI uses prepare_crashlytics_dsyms.py to retain an archive and a typed console-upload
5
+ receipt. Native upload-symbols uses NSURLSession without the task proxy environment,
6
+ so executing it from this legacy interface is refused. No direct fallback is allowed.
26
7
  """
27
8
 
28
9
  from __future__ import annotations
@@ -104,11 +85,8 @@ def upload_command(tool: Path, app_id: str, dsyms_dir: Path) -> list[str]:
104
85
 
105
86
 
106
87
  def _default_run(cmd: list[str]) -> int:
107
- try:
108
- return subprocess.run(cmd, check=False).returncode
109
- except OSError as exc:
110
- print(f"::error::could not run {cmd[0]}: {exc}")
111
- return 126
88
+ raise SystemExit("Native Crashlytics upload is refused; use prepare_crashlytics_dsyms.py "
89
+ "and complete the upload through the account-proxied console")
112
90
 
113
91
 
114
92
  def main(argv: list[str] | None = None, environ: dict[str, str] | None = None, run=None,
@@ -134,10 +112,10 @@ def main(argv: list[str] | None = None, environ: dict[str, str] | None = None, r
134
112
  if ids:
135
113
  print(
136
114
  "::notice::FIREBASE_APP_ID names no iOS app (1:<project number>:ios:<hash>); "
137
- "relying on the Crashlytics run-script phase for dSYM upload."
115
+ "native upload is disabled; use the proxied console delivery."
138
116
  )
139
117
  else:
140
- print("FIREBASE_APP_ID unset; relying on the Crashlytics run-script phase for dSYM upload.")
118
+ print("FIREBASE_APP_ID unset; native upload is disabled; configure proxied console delivery.")
141
119
  return 0
142
120
 
143
121
  dsyms_dir = args.archive / "dSYMs"
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env python3
2
+ """Defer dedicated native Crashlytics upload phases to the proxied console delivery."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from pathlib import Path
8
+ import plistlib
9
+ import re
10
+ import shlex
11
+ import subprocess
12
+
13
+ MARKER = "echo 'Crashlytics dSYMs are retained by CI for the account-proxied console upload'"
14
+ UPLOADS = ("upload-crashlytics-symbols", "upload-symbols", "Crashlytics/run")
15
+
16
+
17
+ def dedicated(script: str) -> bool:
18
+ lines = [line.strip() for line in script.replace("\\\n", " ").splitlines()
19
+ if line.strip() and not line.lstrip().startswith("#")]
20
+ commands = [line for line in lines if not re.fullmatch(r'(?:export )?PATH=[^;&|<>`]+', line)]
21
+ if len(commands) != 1 or any(token in script for token in ("$(", "`")):
22
+ return False
23
+ lexer = shlex.shlex(commands[0], posix=True, punctuation_chars=";&|<>")
24
+ lexer.whitespace_split = True
25
+ tokens = list(lexer)
26
+ if not tokens or any(set(token) <= set(";&|<>") for token in tokens):
27
+ return False
28
+ return (tokens[0].endswith(("upload-symbols", "Crashlytics/run"))
29
+ or (tokens[0] == "flutterfire" and tokens[1:2] == ["upload-crashlytics-symbols"]))
30
+
31
+
32
+ def rewrite(document: dict) -> list[str]:
33
+ changed = []
34
+ for identity, phase in document.get("objects", {}).items():
35
+ if phase.get("isa") != "PBXShellScriptBuildPhase":
36
+ continue
37
+ script = phase.get("shellScript", "")
38
+ active = "\n".join(line for line in script.splitlines() if not line.lstrip().startswith("#"))
39
+ if not any(token in active for token in UPLOADS):
40
+ continue
41
+ if "crashlytics" not in str(phase.get("name", "")).lower() or not dedicated(script):
42
+ raise ValueError("Crashlytics upload is in a mixed build phase; separate it before archiving")
43
+ phase["shellScript"] = MARKER
44
+ changed.append(identity)
45
+ return changed
46
+
47
+
48
+ def prepare(root: Path) -> int:
49
+ root = root.resolve()
50
+ projects = sorted(root.rglob("project.pbxproj"))
51
+ prepared = []
52
+ for project in projects:
53
+ if project.is_symlink() or root not in project.resolve().parents:
54
+ raise ValueError("Xcode project leaves the CI workspace")
55
+ raw = subprocess.check_output(["plutil", "-convert", "json", "-o", "-", str(project)], timeout=30)
56
+ document = json.loads(raw)
57
+ changed = rewrite(document)
58
+ if changed:
59
+ prepared.append((project, document, changed))
60
+ # Validate every project before changing one; a mixed phase cannot produce
61
+ # a partially rewritten workspace that a caller accidentally archives.
62
+ for project, document, changed in prepared:
63
+ temporary = project.with_suffix(".proxy-new")
64
+ try:
65
+ temporary.write_bytes(plistlib.dumps(document, sort_keys=False))
66
+ temporary.replace(project)
67
+ finally:
68
+ temporary.unlink(missing_ok=True)
69
+ print(f"Deferred {len(changed)} Crashlytics upload phase(s) in {project.relative_to(root)}")
70
+ return sum(len(changed) for _, _, changed in prepared)
71
+
72
+
73
+ if __name__ == "__main__":
74
+ parser = argparse.ArgumentParser(description=__doc__)
75
+ parser.add_argument("--search-root", required=True, type=Path)
76
+ args = parser.parse_args()
77
+ prepare(args.search_root)
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env python3
2
+ """Retain iOS dSYMs and a typed receipt for automatic upload through the proxied console."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import hashlib
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ import subprocess
11
+ import zipfile
12
+
13
+ from crashlytics_dsyms import find_upload_symbols, select_app_id
14
+
15
+ SCHEMA = "gowalk-cicd/firebase-symbols-pending.v1"
16
+
17
+
18
+ def prepare(archive: Path, output: Path, app_id: str) -> dict:
19
+ root = (archive / "dSYMs").resolve()
20
+ bundles = sorted(root.glob("*.dSYM"))
21
+ files = sorted(path for bundle in bundles for path in bundle.rglob("*") if path.is_file())
22
+ if not files or any(path.is_symlink() or root not in path.resolve().parents for path in files):
23
+ raise ValueError("Archive must contain dSYM files owned by this build")
24
+ output.mkdir(parents=True, exist_ok=True)
25
+ target = output / "ios-dsyms.zip"
26
+ with zipfile.ZipFile(target, "w", compression=zipfile.ZIP_DEFLATED) as zipped:
27
+ for path in files:
28
+ zipped.write(path, path.relative_to(root))
29
+ with target.open("rb") as stream:
30
+ digest = hashlib.file_digest(stream, "sha256").hexdigest()
31
+ uuids = subprocess.check_output(["dwarfdump", "--uuid", *(str(bundle) for bundle in bundles)],
32
+ text=True, timeout=60).splitlines()
33
+ artifact = f"ios-crashlytics-symbols-{os.environ['GITHUB_RUN_ID']}-{os.environ['GITHUB_RUN_ATTEMPT']}"
34
+ receipt = {"schema": SCHEMA, "platform": "ios", "app_id": app_id,
35
+ "source_sha": os.environ["GITHUB_SHA"], "artifact": artifact,
36
+ "file": target.name, "sha256": digest, "status": "pending_console_upload"}
37
+ (output / "firebase-symbols.json").write_text(json.dumps({**receipt, "uuids": uuids}, indent=2))
38
+ return receipt
39
+
40
+
41
+ def main() -> None:
42
+ parser = argparse.ArgumentParser(description=__doc__)
43
+ parser.add_argument("--archive", required=True, type=Path)
44
+ parser.add_argument("--search-root", required=True, type=Path)
45
+ parser.add_argument("--output", required=True, type=Path)
46
+ args = parser.parse_args()
47
+ app_id = select_app_id(os.environ.get("FIREBASE_APP_ID", ""), "ios")
48
+ tool = find_upload_symbols(args.search_root)
49
+ if not tool:
50
+ print("No Crashlytics SDK upload tool found; no Firebase symbol delivery is required.")
51
+ return
52
+ if not app_id:
53
+ raise SystemExit("The app embeds Crashlytics but FIREBASE_APP_ID names no iOS app")
54
+ receipt = prepare(args.archive, args.output, app_id)
55
+ print("::notice title=firebase_symbols_pending::" + json.dumps(receipt, separators=(",", ":")))
56
+
57
+
58
+ if __name__ == "__main__":
59
+ main()
@@ -47,7 +47,7 @@ class TestActionStepOrder(unittest.TestCase):
47
47
  order["Commit + push refreshed app-ci"])
48
48
  # ... and before every other step that talks to a store or signs.
49
49
  for later in ("Resolve credentials + auto-detect Xcode project",
50
- "Stage ASC API key for xcrun / altool",
50
+ "Stage ASC API key for authenticated REST uploads",
51
51
  "Prepare signing (cert + keychain + per-target profiles + pbxproj)",
52
52
  "Archive"):
53
53
  self.assertLess(order["Commit + push refreshed app-ci"], order[later], later)