gowalk-cicd 1.0.47 → 1.0.49
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 +25 -0
- package/README.md +55 -11
- package/action/.daemux-version +1 -1
- package/action/action.yml +34 -0
- package/action/prompts/generate_descriptions.prompt.yml +4 -1
- package/action/scripts/asc_metadata_applier.py +64 -0
- package/action/scripts/crashlytics_dsyms.py +182 -0
- package/action/scripts/test_asc_metadata_applier.py +146 -6
- package/action/scripts/test_crashlytics_dsyms.py +214 -0
- package/android-action/.daemux-version +1 -1
- package/android-action/action.yml +30 -0
- package/android-action/scripts/crashlytics_symbols.py +177 -0
- package/android-action/scripts/test_crashlytics_symbols.py +210 -0
- package/backend-action/.daemux-version +1 -1
- package/package.json +1 -1
- package/templates/deploy.yml +11 -2
package/CLAUDE.md
CHANGED
|
@@ -149,6 +149,31 @@ node /path/to/gowalk-cicd/bin/cli.mjs
|
|
|
149
149
|
must not discard the symbols of a build that is already live. Symbol
|
|
150
150
|
artifacts carry no `retention-days` so the repository's setting governs and
|
|
151
151
|
can be raised to cover a release's life.
|
|
152
|
+
- Crashlytics symbol upload is gated on the `firebase-app-id` action input,
|
|
153
|
+
which `deploy.yml` feeds from the `FIREBASE_APP_ID` repository variable on
|
|
154
|
+
both actions. Actions never read `vars.*` themselves — an unavailable
|
|
155
|
+
context inside a composite action invalidates every consumer's workflow —
|
|
156
|
+
so the variable crosses the workflow/action boundary as an input and the
|
|
157
|
+
feature reaches consumers only after the one-off `npx gowalk-cicd` that
|
|
158
|
+
refreshes `deploy.yml`. The variable holds one id per platform
|
|
159
|
+
(`1:<n>:android:<hash>`, `1:<n>:ios:<hash>`, comma/whitespace-separated);
|
|
160
|
+
each action picks its own and fails on anything that is not an id. Android
|
|
161
|
+
runs `firebase crashlytics:symbols:upload` (the only Android path Firebase
|
|
162
|
+
documents; needs Node + Java, no credentials) on the `--split-debug-info`
|
|
163
|
+
directory — `android-action/scripts/crashlytics_symbols.py`. iOS runs the
|
|
164
|
+
FirebaseCrashlytics pod's `upload-symbols` on the archive's dSYMs —
|
|
165
|
+
`action/scripts/crashlytics_dsyms.py` — because Flutter's `.symbols` are
|
|
166
|
+
ELF debug info the Firebase CLI's generators reject for Apple targets, and
|
|
167
|
+
`App.framework.dSYM` is the documented Apple path. Both run before the
|
|
168
|
+
store upload and fail closed — except an iOS id on an app with no
|
|
169
|
+
FirebaseCrashlytics `upload-symbols` tool, which is a warn-and-skip: an app
|
|
170
|
+
that does not embed the SDK has nothing to symbolicate. Logic belongs in
|
|
171
|
+
those scripts, where it is unit-tested, not inline in YAML. Supporting
|
|
172
|
+
invariants: the workflow's disk-space step must never remove
|
|
173
|
+
`/usr/local/lib/node_modules` (npm and npx live there); `firebase-tools` is
|
|
174
|
+
pinned exactly because the Android job holds the keystore and the Play
|
|
175
|
+
service account; the CLI runs from a temp cwd so its `.crashlytics/` debris
|
|
176
|
+
never lands in the checkout.
|
|
152
177
|
- The JDK is chosen by `scripts/select_jdk.py`, called from the workflow before
|
|
153
178
|
`setup-java`. The `JAVA_VERSION` repo variable always wins; otherwise Flutter
|
|
154
179
|
gets 17 and native Gradle gets 21, except that a positively-detected Kotlin
|
package/README.md
CHANGED
|
@@ -254,9 +254,11 @@ zip I/O error: No space left on device
|
|
|
254
254
|
```
|
|
255
255
|
|
|
256
256
|
The Android job removes the preinstalled toolchains a Flutter build never uses
|
|
257
|
-
(.NET, the Android NDK, GHC, PowerShell, Swift, Chromium
|
|
258
|
-
|
|
259
|
-
|
|
257
|
+
(.NET, the Android NDK, GHC, PowerShell, Swift, Chromium) and prunes Docker
|
|
258
|
+
images, reclaiming roughly 25 GB in a few seconds. It prints `df -h /` before
|
|
259
|
+
and after. Linux only; skipped in Bitrise mode. npm survives on purpose: the
|
|
260
|
+
[Crashlytics symbol upload](#crashlytics-symbol-upload-firebase_app_id) runs
|
|
261
|
+
the Firebase CLI through `npx`.
|
|
260
262
|
|
|
261
263
|
### Private git dependencies (Flutter)
|
|
262
264
|
|
|
@@ -493,14 +495,53 @@ flutter symbolize -i crash.txt -d app.android-arm64.symbols
|
|
|
493
495
|
```
|
|
494
496
|
|
|
495
497
|
A crash reporter never sees the artifact, and from the first obfuscated build
|
|
496
|
-
every Dart frame it shows is `***` until it has the symbols.
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
498
|
+
every Dart frame it shows is `***` until it has the symbols. For Crashlytics
|
|
499
|
+
the workflow uploads them itself once the repository variable
|
|
500
|
+
**`FIREBASE_APP_ID`** is set — see below. Sentry or others: upload the same
|
|
501
|
+
directory to that service from your own pipeline.
|
|
502
|
+
|
|
503
|
+
#### Crashlytics symbol upload (`FIREBASE_APP_ID`)
|
|
504
|
+
|
|
505
|
+
Set the repository variable `FIREBASE_APP_ID` to the app's Firebase App ID —
|
|
506
|
+
`1:<project number>:<platform>:<hash>`. An app that ships both platforms has
|
|
507
|
+
two ids (they differ in the third segment); list both, separated by a comma:
|
|
508
|
+
|
|
509
|
+
```
|
|
510
|
+
1:123456789012:android:0a1b2c3d4e5f6a7b,1:123456789012:ios:7b6a5f4e3d2c1b0a
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
Find them in `lib/firebase_options.dart` (`appId`), in
|
|
514
|
+
`android/app/google-services.json` (`mobilesdk_app_id`) and
|
|
515
|
+
`ios/Runner/GoogleService-Info.plist` (`GOOGLE_APP_ID`), or under *Project
|
|
516
|
+
settings → Your apps* in the Firebase console. No credentials are needed:
|
|
517
|
+
both upload tools authenticate by app id alone.
|
|
518
|
+
|
|
519
|
+
The variable reaches the actions through `deploy.yml`, which
|
|
520
|
+
[auto-update never touches](#deployyml-is-not-auto-updated) — an existing repo
|
|
521
|
+
must run `npx --yes gowalk-cicd` once before setting it does anything.
|
|
522
|
+
|
|
523
|
+
| | Android | iOS |
|
|
524
|
+
|---|---|---|
|
|
525
|
+
| 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` |
|
|
526
|
+
| 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 |
|
|
527
|
+
| 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 |
|
|
528
|
+
| 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 |
|
|
529
|
+
| Variable set for the other platform only | a `::warning::`, no upload | a `::notice::`, no upload |
|
|
530
|
+
|
|
531
|
+
Why the two platforms differ: Flutter's `.symbols` files are ELF debug info,
|
|
532
|
+
which the Firebase CLI's symbol generators accept for Android and reject for
|
|
533
|
+
Apple targets; Firebase documents dSYMs as the Apple path, and Flutter 3.12+
|
|
534
|
+
puts the Dart debug info into `App.framework.dSYM`. On iOS the explicit
|
|
535
|
+
upload is a belt-and-braces over the run-script phase — it also covers the
|
|
536
|
+
native `Runner.app.dSYM` and every plugin framework — so a phase that is
|
|
537
|
+
missing or failing can never leave a build unreadable.
|
|
538
|
+
|
|
539
|
+
A value that is not a Firebase App ID fails the deploy rather than being
|
|
540
|
+
skipped: a typo must not become a fleet that silently stops uploading symbols.
|
|
541
|
+
An iOS id on an app that does not embed Crashlytics (no `upload-symbols` tool
|
|
542
|
+
anywhere under the project) is a warning, not a failure — an app without the
|
|
543
|
+
SDK has no Crashlytics crashes to symbolicate. Android apps delivered through
|
|
544
|
+
Bitrise are built by Bitrise and are not covered.
|
|
504
545
|
|
|
505
546
|
Obfuscation renames identifiers, so code that depends on their spelling breaks
|
|
506
547
|
at runtime, not at build time. Do not rely on `runtimeType.toString()`,
|
|
@@ -849,6 +890,9 @@ Changes that need that manual run:
|
|
|
849
890
|
- **Flutter iOS obfuscation** — the `--obfuscate --split-debug-info` flags on
|
|
850
891
|
the iOS `flutter build ios --config-only` call and the step that retains
|
|
851
892
|
`ios-symbols-*`. See [Dart obfuscation](#dart-obfuscation-flutter-apps).
|
|
893
|
+
- **Crashlytics symbol upload** — `firebase-app-id: ${{ vars.FIREBASE_APP_ID }}`
|
|
894
|
+
passed to both actions. Until it is, setting the variable does nothing.
|
|
895
|
+
See [Crashlytics symbol upload](#crashlytics-symbol-upload-firebase_app_id).
|
|
852
896
|
|
|
853
897
|
### Opt out
|
|
854
898
|
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.49
|
package/action/action.yml
CHANGED
|
@@ -121,6 +121,17 @@ inputs:
|
|
|
121
121
|
description: "GitHub Models model id for AI metadata generation."
|
|
122
122
|
required: false
|
|
123
123
|
default: "openai/gpt-4o"
|
|
124
|
+
firebase-app-id:
|
|
125
|
+
description: >-
|
|
126
|
+
Firebase App ID(s) for the Crashlytics dSYM upload — the
|
|
127
|
+
`1:<project number>:ios:<hash>` value (GOOGLE_APP_ID in
|
|
128
|
+
GoogleService-Info.plist, or Project settings → Your apps in the Firebase
|
|
129
|
+
console). Several ids may be listed, separated by commas or whitespace;
|
|
130
|
+
the iOS one is used. Empty leaves dSYM upload to the app's own
|
|
131
|
+
Crashlytics run-script phase. The package workflow passes the
|
|
132
|
+
FIREBASE_APP_ID repository variable.
|
|
133
|
+
required: false
|
|
134
|
+
default: ""
|
|
124
135
|
# DESIGN DECISION: marketing-version-auto-bump trades unattended-CI
|
|
125
136
|
# smoothness against silent-semver-decisions visibility. Default
|
|
126
137
|
# 'rollover' lets CI keep the build moving when ASC's combined floor
|
|
@@ -705,6 +716,29 @@ runs:
|
|
|
705
716
|
-exportPath "$RUNNER_TEMP/export"
|
|
706
717
|
ls -la "$RUNNER_TEMP/export"
|
|
707
718
|
|
|
719
|
+
# Crashlytics symbolicates from the dSYMs in the archive — for a Flutter
|
|
720
|
+
# app that includes App.framework.dSYM, where the Dart frames of an
|
|
721
|
+
# obfuscated build come from, and the only Apple-platform path Firebase
|
|
722
|
+
# documents for such builds. The run-script phase `flutterfire configure`
|
|
723
|
+
# adds already uploads that dSYM during the archive; with firebase-app-id
|
|
724
|
+
# set (deploy.yml passes the FIREBASE_APP_ID repository variable) every
|
|
725
|
+
# dSYM in the archive is uploaded here as well, with the upload-symbols
|
|
726
|
+
# tool from the FirebaseCrashlytics pod, so a missing or broken phase
|
|
727
|
+
# cannot leave a build unreadable. Before the TestFlight upload on
|
|
728
|
+
# purpose: a failed upload fails the deploy instead of shipping crashes
|
|
729
|
+
# nobody can read. Logic and its tests live in scripts/crashlytics_dsyms.py.
|
|
730
|
+
- name: Upload dSYMs to Crashlytics
|
|
731
|
+
if: ${{ inputs.archive == 'true' && inputs.upload == 'true' && inputs.firebase-app-id != '' }}
|
|
732
|
+
shell: bash
|
|
733
|
+
env:
|
|
734
|
+
FIREBASE_APP_ID: ${{ inputs.firebase-app-id }}
|
|
735
|
+
PROJECT: ${{ inputs.project || env.CFG_PROJECT }}
|
|
736
|
+
WORKSPACE: ${{ inputs.workspace || env.CFG_WORKSPACE }}
|
|
737
|
+
run: |
|
|
738
|
+
python3 "${{ github.action_path }}/scripts/crashlytics_dsyms.py" \
|
|
739
|
+
--archive "$RUNNER_TEMP/app.xcarchive" \
|
|
740
|
+
--search-root "$(dirname "${WORKSPACE:-$PROJECT}")"
|
|
741
|
+
|
|
708
742
|
- name: Upload to TestFlight
|
|
709
743
|
if: ${{ inputs.archive == 'true' && inputs.upload == 'true' }}
|
|
710
744
|
shell: bash
|
|
@@ -12,8 +12,11 @@ messages:
|
|
|
12
12
|
long description, generate ASO-optimized, guideline-compliant descriptions.
|
|
13
13
|
|
|
14
14
|
STRICT RULES for description:
|
|
15
|
-
- description: 800-
|
|
15
|
+
- description: 800-3800 chars. First 3 lines = hook + value prop (visible before Read More).
|
|
16
16
|
Short paragraphs, line breaks, 3-5 bullet features, closing CTA.
|
|
17
|
+
- The delivery pipeline appends a two-line "Terms of Use: ... / Privacy Policy: ..."
|
|
18
|
+
footer after your text (that is why the ceiling is 3800, not 4000). Do NOT write
|
|
19
|
+
that footer, any legal link, or any URL yourself.
|
|
17
20
|
|
|
18
21
|
CRITICAL:
|
|
19
22
|
- You receive a JSON list of `locales_needing_description`. ONLY generate a
|
|
@@ -44,6 +44,15 @@ from metadata_constants import (
|
|
|
44
44
|
APP_INFO_RESOURCE = "appInfoLocalizations"
|
|
45
45
|
VERSION_RESOURCE = "appStoreVersionLocalizations"
|
|
46
46
|
|
|
47
|
+
# Every App Store description ends with a functional Terms of Use link and the
|
|
48
|
+
# app's own privacy-policy link (Apple's "no functional link to the Terms of
|
|
49
|
+
# Use" metadata rejection). Terms is Apple's standard EULA; the privacy URL is
|
|
50
|
+
# the locale's existing `privacyPolicyUrl`, which the panel sets and the
|
|
51
|
+
# detector reads back into `state.localizations[locale].fields`.
|
|
52
|
+
TERMS_URL = "https://www.apple.com/legal/internet-services/itunes/dev/stdeula/"
|
|
53
|
+
DESCRIPTION_LIMIT = CHAR_LIMITS["description"]
|
|
54
|
+
_FOOTER_SEPARATOR = "\n\n"
|
|
55
|
+
|
|
47
56
|
# Collapses any run of ASCII whitespace (including the raw control chars AI
|
|
48
57
|
# sometimes emits inside string literals: \n, \r, \t, and interior spaces)
|
|
49
58
|
# into a single space. Applied after non-strict JSON load so field values
|
|
@@ -122,6 +131,60 @@ def _validate_field(field: str, value: Any) -> str | None:
|
|
|
122
131
|
return trimmed
|
|
123
132
|
|
|
124
133
|
|
|
134
|
+
def footer_for(privacy_url: str) -> str:
|
|
135
|
+
"""The exact two-line footer appended to every generated description."""
|
|
136
|
+
return f"Terms of Use: {TERMS_URL}\nPrivacy Policy: {privacy_url}"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def with_footer(body: str, privacy_url: str) -> str:
|
|
140
|
+
"""Return ``body`` + one blank line + the footer, inside Apple's limit.
|
|
141
|
+
|
|
142
|
+
The body is shortened on a whitespace boundary when body + footer would
|
|
143
|
+
exceed the description limit; a hard cut happens only when the body has
|
|
144
|
+
no whitespace at or before the budget. The footer is never shortened.
|
|
145
|
+
"""
|
|
146
|
+
footer = footer_for(privacy_url)
|
|
147
|
+
budget = DESCRIPTION_LIMIT - len(_FOOTER_SEPARATOR + footer)
|
|
148
|
+
text = body.rstrip()
|
|
149
|
+
if len(text) > budget:
|
|
150
|
+
cut = max(text.rfind(ch, 0, budget + 1) for ch in (" ", "\n", "\t"))
|
|
151
|
+
text = text[:cut] if cut > 0 else text[:budget]
|
|
152
|
+
text = text.rstrip()
|
|
153
|
+
return text + _FOOTER_SEPARATOR + footer
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _describe_with_footer(
|
|
157
|
+
locale: str, loc_state: dict[str, Any], ver_writes: dict[str, str]
|
|
158
|
+
) -> None:
|
|
159
|
+
"""Attach the footer to a surviving generated description, in place.
|
|
160
|
+
|
|
161
|
+
Reads the locale's existing ``privacyPolicyUrl`` from the detector state.
|
|
162
|
+
An empty URL drops the description entirely (with a warning): a footer
|
|
163
|
+
with an empty privacy link would ship the exact defect this guards
|
|
164
|
+
against.
|
|
165
|
+
"""
|
|
166
|
+
if "description" not in ver_writes:
|
|
167
|
+
return
|
|
168
|
+
privacy = ((loc_state.get("fields") or {}).get("privacyPolicyUrl") or "").strip()
|
|
169
|
+
if not privacy:
|
|
170
|
+
warn(
|
|
171
|
+
f"{locale}: privacyPolicyUrl is empty; skipping generated description "
|
|
172
|
+
"(footer requires it)"
|
|
173
|
+
)
|
|
174
|
+
del ver_writes["description"]
|
|
175
|
+
return
|
|
176
|
+
complete = with_footer(ver_writes["description"], privacy)
|
|
177
|
+
footer = footer_for(privacy)
|
|
178
|
+
if len(complete) > DESCRIPTION_LIMIT or not complete.endswith(footer):
|
|
179
|
+
warn(
|
|
180
|
+
f"{locale}: description with footer is invalid "
|
|
181
|
+
f"(length {len(complete)}); dropping"
|
|
182
|
+
)
|
|
183
|
+
del ver_writes["description"]
|
|
184
|
+
return
|
|
185
|
+
ver_writes["description"] = complete
|
|
186
|
+
|
|
187
|
+
|
|
125
188
|
def _build_writes(
|
|
126
189
|
ai_locale: dict[str, Any],
|
|
127
190
|
empty_list: list[str],
|
|
@@ -248,6 +311,7 @@ def _apply_locale(
|
|
|
248
311
|
"""PATCH the writes for one locale. Returns count of fields written."""
|
|
249
312
|
app_writes = _build_writes(ai_locale, empty_list, APP_LEVEL_FIELDS)
|
|
250
313
|
ver_writes = _build_writes(ai_locale, empty_list, VERSION_LEVEL_FIELDS)
|
|
314
|
+
_describe_with_footer(locale, loc_state, ver_writes)
|
|
251
315
|
return (
|
|
252
316
|
_patch_group(token, APP_INFO_RESOURCE, locale,
|
|
253
317
|
loc_state.get("app_info_localization_id"), app_writes)
|
|
@@ -0,0 +1,182 @@
|
|
|
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.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import argparse
|
|
31
|
+
import os
|
|
32
|
+
import re
|
|
33
|
+
import subprocess
|
|
34
|
+
import sys
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
APP_ID_RE = re.compile(r"^(\d+):(\d+):(android|ios|web):([0-9A-Fa-f]+)$")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AppIdError(ValueError):
|
|
41
|
+
"""FIREBASE_APP_ID contains something that is not a Firebase App ID."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_app_ids(raw: str) -> list[tuple[str, str]]:
|
|
45
|
+
"""Split FIREBASE_APP_ID into (platform, app id) pairs; garbage is an error."""
|
|
46
|
+
ids: list[tuple[str, str]] = []
|
|
47
|
+
for token in re.split(r"[,\s]+", raw.strip()):
|
|
48
|
+
if not token:
|
|
49
|
+
continue
|
|
50
|
+
match = APP_ID_RE.match(token)
|
|
51
|
+
if match is None:
|
|
52
|
+
raise AppIdError(
|
|
53
|
+
f"{token!r} is not a Firebase App ID "
|
|
54
|
+
"(expected 1:<project number>:<android|ios|web>:<hash>)"
|
|
55
|
+
)
|
|
56
|
+
ids.append((match.group(3), token))
|
|
57
|
+
return ids
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _pick(ids: list[tuple[str, str]], platform: str) -> str | None:
|
|
61
|
+
return next((app_id for found_platform, app_id in ids if found_platform == platform), None)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def select_app_id(raw: str, platform: str) -> str | None:
|
|
65
|
+
return _pick(parse_app_ids(raw), platform)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def find_upload_symbols(search_root: Path, home: Path | None = None) -> Path | None:
|
|
69
|
+
"""Locate the FirebaseCrashlytics `upload-symbols` tool.
|
|
70
|
+
|
|
71
|
+
CocoaPods puts it at <ios dir>/Pods/FirebaseCrashlytics/upload-symbols; a
|
|
72
|
+
Swift Package Manager checkout keeps it under Xcode's DerivedData. Only a
|
|
73
|
+
tool whose parent directory names Crashlytics counts — the name alone is
|
|
74
|
+
too generic.
|
|
75
|
+
"""
|
|
76
|
+
direct = search_root / "Pods" / "FirebaseCrashlytics" / "upload-symbols"
|
|
77
|
+
if direct.is_file():
|
|
78
|
+
return direct
|
|
79
|
+
if search_root.is_dir():
|
|
80
|
+
# A project at the repo root makes search_root the whole checkout, so
|
|
81
|
+
# prune the trees that are big and can never hold the tool.
|
|
82
|
+
prune = {".git", "build", ".dart_tool", "node_modules"}
|
|
83
|
+
hits: list[Path] = []
|
|
84
|
+
for dirpath, dirnames, filenames in os.walk(search_root):
|
|
85
|
+
dirnames[:] = [d for d in dirnames if d not in prune]
|
|
86
|
+
if "upload-symbols" in filenames and Path(dirpath).name in ("FirebaseCrashlytics", "Crashlytics"):
|
|
87
|
+
candidate = Path(dirpath) / "upload-symbols"
|
|
88
|
+
if candidate.is_file():
|
|
89
|
+
hits.append(candidate)
|
|
90
|
+
if hits:
|
|
91
|
+
return sorted(hits)[0]
|
|
92
|
+
home = Path.home() if home is None else home
|
|
93
|
+
derived = home / "Library" / "Developer" / "Xcode" / "DerivedData"
|
|
94
|
+
for candidate in sorted(
|
|
95
|
+
derived.glob("*/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/upload-symbols")
|
|
96
|
+
):
|
|
97
|
+
if candidate.is_file():
|
|
98
|
+
return candidate
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def upload_command(tool: Path, app_id: str, dsyms_dir: Path) -> list[str]:
|
|
103
|
+
return [str(tool), "-ai", app_id, "-p", "ios", "--", str(dsyms_dir)]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
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
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def main(argv: list[str] | None = None, environ: dict[str, str] | None = None, run=None,
|
|
115
|
+
home: Path | None = None) -> int:
|
|
116
|
+
environ = os.environ if environ is None else environ
|
|
117
|
+
run = _default_run if run is None else run
|
|
118
|
+
|
|
119
|
+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
120
|
+
parser.add_argument("--archive", required=True, type=Path, help="the .xcarchive that was exported")
|
|
121
|
+
parser.add_argument("--search-root", required=True, type=Path,
|
|
122
|
+
help="directory holding the Xcode project/workspace (where Pods/ lives)")
|
|
123
|
+
args = parser.parse_args(argv)
|
|
124
|
+
|
|
125
|
+
raw = environ.get("FIREBASE_APP_ID", "")
|
|
126
|
+
try:
|
|
127
|
+
ids = parse_app_ids(raw)
|
|
128
|
+
except AppIdError as exc:
|
|
129
|
+
print(f"::error::FIREBASE_APP_ID: {exc}")
|
|
130
|
+
return 1
|
|
131
|
+
|
|
132
|
+
app_id = _pick(ids, "ios")
|
|
133
|
+
if app_id is None:
|
|
134
|
+
if ids:
|
|
135
|
+
print(
|
|
136
|
+
"::notice::FIREBASE_APP_ID names no iOS app (1:<project number>:ios:<hash>); "
|
|
137
|
+
"relying on the Crashlytics run-script phase for dSYM upload."
|
|
138
|
+
)
|
|
139
|
+
else:
|
|
140
|
+
print("FIREBASE_APP_ID unset; relying on the Crashlytics run-script phase for dSYM upload.")
|
|
141
|
+
return 0
|
|
142
|
+
|
|
143
|
+
dsyms_dir = args.archive / "dSYMs"
|
|
144
|
+
dsyms = sorted(dsyms_dir.glob("*.dSYM")) if dsyms_dir.is_dir() else []
|
|
145
|
+
if not dsyms:
|
|
146
|
+
print(f"::error::no dSYMs in {dsyms_dir}; nothing to upload to Crashlytics")
|
|
147
|
+
return 1
|
|
148
|
+
|
|
149
|
+
tool = find_upload_symbols(args.search_root, home)
|
|
150
|
+
if tool is None:
|
|
151
|
+
# No tool means the app does not link the Crashlytics SDK, so there are
|
|
152
|
+
# no Crashlytics crash reports to symbolicate — skipping loses nothing,
|
|
153
|
+
# while failing would block every release of a non-Crashlytics app
|
|
154
|
+
# whose FIREBASE_APP_ID happens to carry an iOS id.
|
|
155
|
+
print(
|
|
156
|
+
"::warning::FIREBASE_APP_ID names an iOS app but no FirebaseCrashlytics "
|
|
157
|
+
f"upload-symbols tool exists under {args.search_root} or Xcode's "
|
|
158
|
+
"DerivedData, so the app does not appear to embed Crashlytics; "
|
|
159
|
+
"skipping the dSYM upload. (`flutterfire configure` adds the "
|
|
160
|
+
"dependency and the run-script phase.)"
|
|
161
|
+
)
|
|
162
|
+
return 0
|
|
163
|
+
|
|
164
|
+
cmd = upload_command(tool, app_id, dsyms_dir)
|
|
165
|
+
print(f"Uploading {len(dsyms)} dSYM(s) to Crashlytics app {app_id}:")
|
|
166
|
+
for dsym in dsyms:
|
|
167
|
+
print(f" {dsym.name}")
|
|
168
|
+
print(" " + " ".join(cmd))
|
|
169
|
+
rc = run(cmd)
|
|
170
|
+
if rc != 0:
|
|
171
|
+
print(
|
|
172
|
+
f"::error::Crashlytics dSYM upload failed (exit {rc}). This step runs "
|
|
173
|
+
"before the TestFlight upload, so nothing shipped — fix the cause and "
|
|
174
|
+
"re-run the deploy."
|
|
175
|
+
)
|
|
176
|
+
return 1
|
|
177
|
+
print(f"Uploaded dSYMs to Crashlytics app {app_id}.")
|
|
178
|
+
return 0
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
if __name__ == "__main__":
|
|
182
|
+
sys.exit(main())
|
|
@@ -22,6 +22,10 @@ import asc_metadata_applier as mod # noqa: E402
|
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
TOKEN = "TEST_TOKEN"
|
|
25
|
+
# Every real listing the detector reads back carries the panel-set privacy
|
|
26
|
+
# URL; the applier appends the Terms/Privacy footer to generated descriptions
|
|
27
|
+
# from it, and SKIPS the description when it is empty.
|
|
28
|
+
PRIVACY = "https://docs.google.com/document/d/e/abc/pub"
|
|
25
29
|
|
|
26
30
|
|
|
27
31
|
def _state(empty_fields, localizations=None):
|
|
@@ -34,14 +38,18 @@ def _state(empty_fields, localizations=None):
|
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
|
|
37
|
-
def _loc(app_id="app-en", ver_id="ver-en"):
|
|
41
|
+
def _loc(app_id="app-en", ver_id="ver-en", privacy=PRIVACY):
|
|
38
42
|
return {
|
|
39
43
|
"app_info_localization_id": app_id,
|
|
40
44
|
"version_localization_id": ver_id,
|
|
41
|
-
"fields": {},
|
|
45
|
+
"fields": {"privacyPolicyUrl": privacy},
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
|
|
49
|
+
def _desc(body):
|
|
50
|
+
return mod.with_footer(body, PRIVACY)
|
|
51
|
+
|
|
52
|
+
|
|
45
53
|
class ValidateFieldTests(unittest.TestCase):
|
|
46
54
|
def test_returns_trimmed_value_under_limit(self):
|
|
47
55
|
self.assertEqual(mod._validate_field("name", " MyApp "), "MyApp")
|
|
@@ -268,7 +276,7 @@ class ApplyTests(unittest.TestCase):
|
|
|
268
276
|
self.assertEqual(total, 1)
|
|
269
277
|
self.assertEqual(mock_req.call_count, 1)
|
|
270
278
|
attrs = mock_req.call_args.kwargs["json_body"]["data"]["attributes"]
|
|
271
|
-
self.assertEqual(attrs, {"description": "Nice app."})
|
|
279
|
+
self.assertEqual(attrs, {"description": _desc("Nice app.")})
|
|
272
280
|
|
|
273
281
|
@mock.patch.object(mod, "request")
|
|
274
282
|
def test_double_gate_drops_field_not_in_empty_list(self, mock_req):
|
|
@@ -351,7 +359,7 @@ class MainTests(unittest.TestCase):
|
|
|
351
359
|
mock_req.call_args.args[1], "/appStoreVersionLocalizations/ver-en"
|
|
352
360
|
)
|
|
353
361
|
attrs = mock_req.call_args.kwargs["json_body"]["data"]["attributes"]
|
|
354
|
-
self.assertEqual(attrs, {"description": "Hi"})
|
|
362
|
+
self.assertEqual(attrs, {"description": _desc("Hi")})
|
|
355
363
|
|
|
356
364
|
@mock.patch.dict("os.environ", ENV_FAKE)
|
|
357
365
|
@mock.patch.object(mod, "make_jwt", return_value=TOKEN)
|
|
@@ -675,7 +683,7 @@ class ApplyWithFieldsFilterTests(unittest.TestCase):
|
|
|
675
683
|
self.assertEqual(locales, 1)
|
|
676
684
|
self.assertEqual(mock_req.call_count, 1)
|
|
677
685
|
attrs = mock_req.call_args.kwargs["json_body"]["data"]["attributes"]
|
|
678
|
-
self.assertEqual(attrs, {"description": "Desc"})
|
|
686
|
+
self.assertEqual(attrs, {"description": _desc("Desc")})
|
|
679
687
|
|
|
680
688
|
@mock.patch.object(mod, "request")
|
|
681
689
|
def test_filter_excludes_description(self, mock_req):
|
|
@@ -777,7 +785,7 @@ class MainFieldsFilterTests(unittest.TestCase):
|
|
|
777
785
|
self.assertEqual(rc, 0)
|
|
778
786
|
self.assertEqual(mock_req.call_count, 1)
|
|
779
787
|
attrs = mock_req.call_args.kwargs["json_body"]["data"]["attributes"]
|
|
780
|
-
self.assertEqual(attrs, {"description": "Desc"})
|
|
788
|
+
self.assertEqual(attrs, {"description": _desc("Desc")})
|
|
781
789
|
|
|
782
790
|
@mock.patch.dict("os.environ", ENV_FAKE)
|
|
783
791
|
@mock.patch.object(mod, "make_jwt", return_value=TOKEN)
|
|
@@ -831,6 +839,138 @@ class MainFieldsFilterTests(unittest.TestCase):
|
|
|
831
839
|
self.assertEqual(mock_req.call_count, 2)
|
|
832
840
|
|
|
833
841
|
|
|
842
|
+
class DescriptionFooterTests(unittest.TestCase):
|
|
843
|
+
"""Every generated description ends with the two-line Terms/Privacy footer
|
|
844
|
+
taken from the locale's existing privacyPolicyUrl; no URL, no description."""
|
|
845
|
+
|
|
846
|
+
def test_footer_text_is_exact(self):
|
|
847
|
+
self.assertEqual(
|
|
848
|
+
mod.footer_for("https://p.example/privacy"),
|
|
849
|
+
"Terms of Use: https://www.apple.com/legal/internet-services/itunes/dev/stdeula/"
|
|
850
|
+
"\nPrivacy Policy: https://p.example/privacy",
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
def test_footer_is_appended_verbatim_as_the_last_two_lines(self):
|
|
854
|
+
out = mod.with_footer("Great app.\n\n- fast\n- small", PRIVACY)
|
|
855
|
+
lines = out.split("\n")
|
|
856
|
+
self.assertEqual(lines[-3], "") # one blank line separates body and footer
|
|
857
|
+
self.assertEqual(lines[-2], f"Terms of Use: {mod.TERMS_URL}")
|
|
858
|
+
self.assertEqual(lines[-1], f"Privacy Policy: {PRIVACY}")
|
|
859
|
+
self.assertTrue(out.startswith("Great app.\n\n- fast\n- small"))
|
|
860
|
+
|
|
861
|
+
def test_short_body_is_not_shortened(self):
|
|
862
|
+
self.assertEqual(mod.with_footer("Hello", PRIVACY),
|
|
863
|
+
"Hello\n\n" + mod.footer_for(PRIVACY))
|
|
864
|
+
|
|
865
|
+
def test_long_body_is_shortened_on_a_whitespace_boundary(self):
|
|
866
|
+
body = " ".join(["word"] * 1200) # 5999 chars, spaces every 5th char
|
|
867
|
+
out = mod.with_footer(body, PRIVACY)
|
|
868
|
+
footer = mod.footer_for(PRIVACY)
|
|
869
|
+
self.assertLessEqual(len(out), mod.DESCRIPTION_LIMIT)
|
|
870
|
+
self.assertTrue(out.endswith("\n\n" + footer))
|
|
871
|
+
kept = out[: -len("\n\n" + footer)]
|
|
872
|
+
self.assertTrue(kept.endswith("word")) # cut between words, not inside
|
|
873
|
+
self.assertFalse(kept.endswith(" ")) # trailing whitespace stripped
|
|
874
|
+
# the budget is used, not wasted: at most one word's width short
|
|
875
|
+
self.assertGreater(len(out), mod.DESCRIPTION_LIMIT - 6)
|
|
876
|
+
|
|
877
|
+
def test_body_exactly_at_budget_keeps_every_character(self):
|
|
878
|
+
footer = mod.footer_for(PRIVACY)
|
|
879
|
+
budget = mod.DESCRIPTION_LIMIT - len("\n\n" + footer)
|
|
880
|
+
body = ("ab " * budget)[:budget].rstrip()
|
|
881
|
+
out = mod.with_footer(body, PRIVACY)
|
|
882
|
+
self.assertEqual(out, body + "\n\n" + footer)
|
|
883
|
+
self.assertLessEqual(len(out), mod.DESCRIPTION_LIMIT)
|
|
884
|
+
|
|
885
|
+
def test_body_without_whitespace_is_hard_cut(self):
|
|
886
|
+
out = mod.with_footer("x" * 5000, PRIVACY)
|
|
887
|
+
self.assertEqual(len(out), mod.DESCRIPTION_LIMIT)
|
|
888
|
+
self.assertTrue(out.endswith("\n\n" + mod.footer_for(PRIVACY)))
|
|
889
|
+
|
|
890
|
+
@mock.patch.object(mod, "request")
|
|
891
|
+
def test_locale_with_privacy_url_patches_description_with_footer(self, mock_req):
|
|
892
|
+
written = mod._apply_locale(
|
|
893
|
+
TOKEN, "en-US", _loc("app-en", "ver-en"),
|
|
894
|
+
{"description": "Hello"}, ["description"],
|
|
895
|
+
)
|
|
896
|
+
self.assertEqual(written, 1)
|
|
897
|
+
attrs = mock_req.call_args.kwargs["json_body"]["data"]["attributes"]
|
|
898
|
+
self.assertEqual(attrs, {"description": "Hello\n\n" + mod.footer_for(PRIVACY)})
|
|
899
|
+
|
|
900
|
+
@mock.patch.object(mod, "warn")
|
|
901
|
+
@mock.patch.object(mod, "request")
|
|
902
|
+
def test_empty_privacy_url_skips_description_but_not_other_fields(
|
|
903
|
+
self, mock_req, mock_warn):
|
|
904
|
+
written = mod._apply_locale(
|
|
905
|
+
TOKEN, "en-US", _loc("app-en", "ver-en", privacy=""),
|
|
906
|
+
{"name": "MyApp", "description": "Hello", "keywords": "k1,k2"},
|
|
907
|
+
["name", "description", "keywords"],
|
|
908
|
+
)
|
|
909
|
+
self.assertEqual(written, 2)
|
|
910
|
+
patched = [next(iter(c.kwargs["json_body"]["data"]["attributes"]))
|
|
911
|
+
for c in mock_req.call_args_list]
|
|
912
|
+
self.assertEqual(sorted(patched), ["keywords", "name"])
|
|
913
|
+
self.assertTrue(any("privacyPolicyUrl is empty" in c.args[0]
|
|
914
|
+
for c in mock_warn.call_args_list))
|
|
915
|
+
for call in mock_req.call_args_list:
|
|
916
|
+
self.assertNotIn("Terms of Use", json.dumps(call.kwargs["json_body"]))
|
|
917
|
+
|
|
918
|
+
@mock.patch.object(mod, "warn")
|
|
919
|
+
@mock.patch.object(mod, "request")
|
|
920
|
+
def test_missing_fields_block_skips_description(self, mock_req, mock_warn):
|
|
921
|
+
loc = {"app_info_localization_id": "app-en", "version_localization_id": "ver-en"}
|
|
922
|
+
written = mod._apply_locale(
|
|
923
|
+
TOKEN, "en-US", loc, {"description": "Hello"}, ["description"])
|
|
924
|
+
self.assertEqual(written, 0)
|
|
925
|
+
mock_req.assert_not_called()
|
|
926
|
+
self.assertEqual(mock_warn.call_count, 1)
|
|
927
|
+
|
|
928
|
+
@mock.patch.object(mod, "request")
|
|
929
|
+
def test_whitespace_only_privacy_url_counts_as_empty(self, mock_req):
|
|
930
|
+
written = mod._apply_locale(
|
|
931
|
+
TOKEN, "en-US", _loc("app-en", "ver-en", privacy=" "),
|
|
932
|
+
{"description": "Hello"}, ["description"])
|
|
933
|
+
self.assertEqual(written, 0)
|
|
934
|
+
mock_req.assert_not_called()
|
|
935
|
+
|
|
936
|
+
@mock.patch.object(mod, "request")
|
|
937
|
+
def test_run_without_description_is_unaffected(self, mock_req):
|
|
938
|
+
written = mod._apply_locale(
|
|
939
|
+
TOKEN, "en-US", _loc("app-en", "ver-en", privacy=""),
|
|
940
|
+
{"name": "MyApp", "keywords": "k1,k2"}, ["name", "keywords"])
|
|
941
|
+
self.assertEqual(written, 2)
|
|
942
|
+
self.assertEqual(mock_req.call_count, 2)
|
|
943
|
+
|
|
944
|
+
@mock.patch.object(mod, "request")
|
|
945
|
+
def test_generated_description_over_budget_ships_inside_the_limit(self, mock_req):
|
|
946
|
+
body = " ".join(["benefit"] * 480) # under 4000 on its own, over with footer
|
|
947
|
+
self.assertLessEqual(len(body), mod.DESCRIPTION_LIMIT)
|
|
948
|
+
written = mod._apply_locale(
|
|
949
|
+
TOKEN, "en-US", _loc("app-en", "ver-en"),
|
|
950
|
+
{"description": body}, ["description"])
|
|
951
|
+
self.assertEqual(written, 1)
|
|
952
|
+
value = mock_req.call_args.kwargs["json_body"]["data"]["attributes"]["description"]
|
|
953
|
+
self.assertLessEqual(len(value), mod.DESCRIPTION_LIMIT)
|
|
954
|
+
self.assertTrue(value.endswith(mod.footer_for(PRIVACY)))
|
|
955
|
+
|
|
956
|
+
@mock.patch.object(mod, "request")
|
|
957
|
+
def test_apply_end_to_end_carries_the_footer_per_locale(self, mock_req):
|
|
958
|
+
state = _state(
|
|
959
|
+
empty_fields={"en-US": ["description"], "ja": ["description"]},
|
|
960
|
+
localizations={
|
|
961
|
+
"en-US": _loc("app-en", "ver-en", privacy="https://p.example/en"),
|
|
962
|
+
"ja": _loc("app-ja", "ver-ja", privacy=""),
|
|
963
|
+
},
|
|
964
|
+
)
|
|
965
|
+
ai = {"localizations": {"en-US": {"description": "Hi"},
|
|
966
|
+
"ja": {"description": "こんにちは"}}}
|
|
967
|
+
total, locales = mod.apply(state, ai, TOKEN, fields_filter={"description"})
|
|
968
|
+
self.assertEqual((total, locales), (1, 1))
|
|
969
|
+
attrs = mock_req.call_args.kwargs["json_body"]["data"]["attributes"]
|
|
970
|
+
self.assertEqual(attrs["description"],
|
|
971
|
+
"Hi\n\n" + mod.footer_for("https://p.example/en"))
|
|
972
|
+
|
|
973
|
+
|
|
834
974
|
class NormalizeWhitespaceTests(unittest.TestCase):
|
|
835
975
|
def test_collapses_embedded_newlines(self):
|
|
836
976
|
self.assertEqual(mod._normalize_whitespace("a\nb"), "a b")
|