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 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, global node modules)
258
- and prunes Docker images, reclaiming roughly 25 GB in a few seconds. It prints
259
- `df -h /` before and after. Linux only; skipped in Bitrise mode.
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. If the app uses
497
- Crashlytics, Sentry or similar, upload the same directory to that service — for
498
- Crashlytics, `firebase crashlytics:symbols:upload --app=<FIREBASE_APP_ID>
499
- <dir>` with Firebase CLI 11.9 or newer, once per platform with that platform's
500
- app id (`mobilesdk_app_id` in `google-services.json`, `GOOGLE_APP_ID` in
501
- `GoogleService-Info.plist`). This package does not do that upload for you: the
502
- existing Crashlytics run-script phase on iOS uploads only the native dSYMs, and
503
- App Store Connect's own symbol upload covers only those as well.
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
 
@@ -1 +1 @@
1
- 1.0.47
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-4000 chars. First 3 lines = hook + value prop (visible before Read More).
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")