gowalk-cicd 1.0.48 → 1.0.50

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
@@ -108,6 +108,9 @@ node /path/to/gowalk-cicd/bin/cli.mjs
108
108
  - The mobile workflow's feature-branch-only `backend-preview` job bootstraps a
109
109
  new app before GitHub registers `deploy-backend.yml` on the default branch.
110
110
  Default-branch deploys continue through the separate backend workflow.
111
+ - Domain-backed backend deploys fail closed on certificate issuance and the
112
+ public HTTPS health probe. `select_certbot_account.sh` chooses one existing
113
+ ACME account deterministically so multi-account hosts stay non-interactive.
111
114
  - The Android action serves two build systems. `android_config.project_kind()`
112
115
  is the only place that decides which; every downstream step branches on the
113
116
  `project_kind` output rather than re-sniffing the repo. Flutter wins the tie
package/README.md CHANGED
@@ -73,6 +73,8 @@ The backend workflow runs on a GitHub-hosted runner and, on a push touching
73
73
  it rsyncs the backend dir to `/opt/gowalk-backends/<app>/`, runs
74
74
  `docker compose up -d --build`, auto-detects the published `127.0.0.1:<port>`,
75
75
  wires an nginx vhost + Let's Encrypt cert for the API domain, and health-checks.
76
+ Hosts with several existing Let's Encrypt accounts select one deterministically,
77
+ and a domain deploy fails unless its public HTTPS certificate and health route validate.
76
78
 
77
79
  Requirements on the consumer repo:
78
80
 
@@ -1 +1 @@
1
- 1.0.48
1
+ 1.0.50
@@ -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)
@@ -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.48
1
+ 1.0.50
@@ -1 +1 @@
1
- 1.0.48
1
+ 1.0.50
@@ -122,9 +122,12 @@ runs:
122
122
  rsync -az -e "$SSH" "$COMPOSE_FILE" \
123
123
  "${{ inputs.ssh-user }}@${{ inputs.host }}:$DEST/docker-compose.yml"
124
124
  fi
125
- # ship the remote deploy script (kept out of the app checkout)
126
- rsync -az -e "$SSH" "${{ github.action_path }}/remote_deploy.sh" \
127
- "${{ inputs.ssh-user }}@${{ inputs.host }}:$DEST/.remote_deploy.sh"
125
+ # Ship remote helpers after the application mirror. Both are package
126
+ # owned and kept out of the consumer's source tree.
127
+ rsync -az -e "$SSH" \
128
+ "${{ github.action_path }}/remote_deploy.sh" \
129
+ "${{ github.action_path }}/select_certbot_account.sh" \
130
+ "${{ inputs.ssh-user }}@${{ inputs.host }}:$DEST/"
128
131
 
129
132
  - name: Deploy on the host
130
133
  shell: bash
@@ -140,7 +143,7 @@ runs:
140
143
  "umask 077; cat > '$DEST/.runtime.env'"
141
144
  fi
142
145
  $SSH "${{ inputs.ssh-user }}@${{ inputs.host }}" \
143
- "bash '$DEST/.remote_deploy.sh' \
146
+ "bash '$DEST/remote_deploy.sh' \
144
147
  '${{ inputs.app-name }}' '${{ inputs.api-domain }}' \
145
148
  '${{ inputs.health-path }}' '${{ inputs.cert-email }}'"
146
149
 
@@ -10,6 +10,7 @@ HEALTH="${3:-/health}"
10
10
  CERT_EMAIL="${4:-admin@gowalk.com}"
11
11
  DIR="/opt/gowalk-backends/$APP"
12
12
  cd "$DIR"
13
+ source "$DIR/select_certbot_account.sh"
13
14
 
14
15
  log() { echo "[deploy $APP] $*"; }
15
16
 
@@ -95,16 +96,34 @@ fi
95
96
  # ── Let's Encrypt cert (idempotent; certbot skips if current)
96
97
  if ! certbot certificates 2>/dev/null | grep -q "Domains: $DOMAIN\b"; then
97
98
  log "requesting LE cert for $DOMAIN"
98
- certbot --nginx -d "$DOMAIN" --non-interactive --agree-tos \
99
- --email "$CERT_EMAIL" --redirect || log "warn: certbot failed (DNS not pointed yet?)"
99
+ CERTBOT_ACCOUNT="$(select_certbot_account)"
100
+ CERTBOT_ARGS=()
101
+ if [ -n "$CERTBOT_ACCOUNT" ]; then
102
+ log "using an existing Let's Encrypt account"
103
+ CERTBOT_ARGS+=(--account "$CERTBOT_ACCOUNT")
104
+ fi
105
+ if ! certbot --nginx -d "$DOMAIN" --non-interactive --agree-tos \
106
+ --email "$CERT_EMAIL" --redirect "${CERTBOT_ARGS[@]}"; then
107
+ log "ERROR: certbot failed for $DOMAIN"
108
+ exit 1
109
+ fi
100
110
  else
101
111
  log "cert for $DOMAIN already present"
102
112
  fi
103
113
 
104
- # ── public health-check (best effort — cert/DNS may still be propagating)
105
- if curl -fsSk "https://$DOMAIN$HEALTH" >/dev/null 2>&1; then
106
- log "public https://$DOMAIN$HEALTH healthy"
107
- else
108
- log "warn: public URL not answering yet (DNS/cert propagation) — container is up on :$PORT"
114
+ # ── public health-check. This validates DNS, the certificate, nginx routing,
115
+ # and the application together; a green deploy must mean the public API works.
116
+ PUBLIC_HEALTHY=""
117
+ for i in $(seq 1 15); do
118
+ if curl -fsS "https://$DOMAIN$HEALTH" >/dev/null 2>&1; then
119
+ log "public https://$DOMAIN$HEALTH healthy (after ${i}x2s)"
120
+ PUBLIC_HEALTHY=1
121
+ break
122
+ fi
123
+ sleep 2
124
+ done
125
+ if [ -z "$PUBLIC_HEALTHY" ]; then
126
+ log "ERROR: public https://$DOMAIN$HEALTH failed TLS or health validation"
127
+ exit 1
109
128
  fi
110
129
  log "done"
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # Print the stable id of one existing Certbot account. Hosts may contain more
4
+ # than one registered account; non-interactive Certbot otherwise stops to ask
5
+ # which account should authorize a new certificate.
6
+ select_certbot_account() {
7
+ local accounts_dir="${1:-/etc/letsencrypt/accounts}"
8
+ local registration
9
+
10
+ registration="$(
11
+ find "$accounts_dir" -type f -name regr.json -print 2>/dev/null \
12
+ | LC_ALL=C sort \
13
+ | head -1
14
+ )"
15
+ [ -n "$registration" ] || return 0
16
+ basename "$(dirname "$registration")"
17
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.48",
3
+ "version": "1.0.50",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {