gowalk-cicd 1.0.48 → 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/action/.daemux-version +1 -1
- package/action/prompts/generate_descriptions.prompt.yml +4 -1
- package/action/scripts/asc_metadata_applier.py +64 -0
- package/action/scripts/test_asc_metadata_applier.py +146 -6
- package/android-action/.daemux-version +1 -1
- package/backend-action/.daemux-version +1 -1
- package/package.json +1 -1
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.49
|
|
@@ -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)
|
|
@@ -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")
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.49
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.49
|