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 +3 -0
- package/README.md +2 -0
- 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/backend-action/action.yml +7 -4
- package/backend-action/remote_deploy.sh +26 -7
- package/backend-action/select_certbot_account.sh +17 -0
- package/package.json +1 -1
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
|
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
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-
|
|
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.50
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
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
|
-
#
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
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
|
-
|
|
99
|
-
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
+
}
|