gowalk-cicd 1.0.0
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 +94 -0
- package/README.md +417 -0
- package/action/.daemux-version +1 -0
- package/action/MIGRATION.md +232 -0
- package/action/action.yml +973 -0
- package/action/prompts/generate_dependent_fields.prompt.yml +78 -0
- package/action/prompts/generate_descriptions.prompt.yml +59 -0
- package/action/scripts/app_context_scanner.py +239 -0
- package/action/scripts/asc_build_history.py +284 -0
- package/action/scripts/asc_common.py +246 -0
- package/action/scripts/asc_metadata_applier.py +368 -0
- package/action/scripts/asc_metadata_detector.py +250 -0
- package/action/scripts/asc_version_create.py +365 -0
- package/action/scripts/asc_version_fetch.py +274 -0
- package/action/scripts/asc_version_reuse.py +177 -0
- package/action/scripts/auto_detect.py +391 -0
- package/action/scripts/autoupdate_check.sh +118 -0
- package/action/scripts/cert_factory.py +190 -0
- package/action/scripts/cfg_io.py +27 -0
- package/action/scripts/cfg_resolve.py +147 -0
- package/action/scripts/commit_bot_changes.sh +82 -0
- package/action/scripts/creds_store.py +328 -0
- package/action/scripts/keychain.py +103 -0
- package/action/scripts/lookup_app_id.py +51 -0
- package/action/scripts/manage_marketing_version.py +337 -0
- package/action/scripts/metadata_constants.py +102 -0
- package/action/scripts/mmv_decide_create.py +181 -0
- package/action/scripts/mmv_floor_check.py +265 -0
- package/action/scripts/next_build_number.py +195 -0
- package/action/scripts/pbxproj_editor.py +220 -0
- package/action/scripts/prepare_signing.py +223 -0
- package/action/scripts/profile_io.py +64 -0
- package/action/scripts/profile_manager.py +307 -0
- package/action/scripts/read_config.py +315 -0
- package/action/scripts/resolve_marketing_version.py +173 -0
- package/action/scripts/set_app_store_whats_new.py +317 -0
- package/action/scripts/team_resolver.py +130 -0
- package/action/scripts/test_app_context_scanner.py +167 -0
- package/action/scripts/test_asc_build_history.py +221 -0
- package/action/scripts/test_asc_builds_prerelease.py +219 -0
- package/action/scripts/test_asc_common_timeouts.py +121 -0
- package/action/scripts/test_asc_metadata_applier.py +850 -0
- package/action/scripts/test_asc_metadata_detector.py +445 -0
- package/action/scripts/test_auto_detect.py +432 -0
- package/action/scripts/test_cfg_resolve_credentials.py +48 -0
- package/action/scripts/test_compute_next_version.py +95 -0
- package/action/scripts/test_compute_next_version_patch_backcompat.py +41 -0
- package/action/scripts/test_compute_next_version_strictness.py +67 -0
- package/action/scripts/test_fetch_versions.py +163 -0
- package/action/scripts/test_ground_truth_floor.py +104 -0
- package/action/scripts/test_manage_marketing_version.py +134 -0
- package/action/scripts/test_manage_marketing_version_autoroll.py +149 -0
- package/action/scripts/test_manage_marketing_version_floor.py +170 -0
- package/action/scripts/test_manage_marketing_version_match.py +187 -0
- package/action/scripts/test_mmv_409_classifier.py +231 -0
- package/action/scripts/test_mmv_autobump_persist_gate.py +131 -0
- package/action/scripts/test_mmv_decide_create.py +119 -0
- package/action/scripts/test_mmv_floor_check.py +158 -0
- package/action/scripts/test_mmv_floor_crosscheck.py +132 -0
- package/action/scripts/test_mmv_helpers.py +57 -0
- package/action/scripts/test_next_build_number.py +174 -0
- package/action/scripts/test_read_config_auto_detect.py +257 -0
- package/action/scripts/test_resolve_marketing_version.py +298 -0
- package/action/scripts/test_reuse_stale_editable.py +182 -0
- package/action/scripts/test_set_app_store_whats_new.py +71 -0
- package/action/scripts/test_team_fallback_wiring.py +157 -0
- package/action/scripts/test_team_resolver.py +249 -0
- package/action/scripts/tests_common.py +167 -0
- package/action/scripts/version_utils.py +373 -0
- package/android-action/.daemux-version +1 -0
- package/android-action/action.yml +92 -0
- package/android-action/scripts/android_config.py +130 -0
- package/android-action/scripts/bitrise_deploy.py +160 -0
- package/android-action/scripts/find_bundle.py +25 -0
- package/android-action/scripts/play_preflight.py +69 -0
- package/android-action/scripts/resolve_android.py +70 -0
- package/android-action/scripts/sign_bundle.py +70 -0
- package/android-action/scripts/test_android_config.py +97 -0
- package/android-action/scripts/test_bitrise_deploy.py +124 -0
- package/android-action/scripts/test_sign_bundle.py +82 -0
- package/bin/cli.mjs +63 -0
- package/package.json +57 -0
- package/src/install.mjs +208 -0
- package/templates/deploy.yml +150 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Resolve the App Store Connect numeric app id for a bundle id.
|
|
4
|
+
|
|
5
|
+
Environment:
|
|
6
|
+
ASC_KEY_ID - API key ID
|
|
7
|
+
ASC_ISSUER_ID - API issuer ID
|
|
8
|
+
ASC_KEY_PATH - Path to the .p8 key file
|
|
9
|
+
BUNDLE_ID - Application bundle identifier
|
|
10
|
+
|
|
11
|
+
Prints the app id to stdout on success. On 0 or >1 matches, exits 1 with a
|
|
12
|
+
guidance message on stderr.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
|
|
19
|
+
from asc_common import get_json, make_jwt
|
|
20
|
+
from cfg_io import fail
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def env(name: str) -> str:
|
|
24
|
+
val = os.environ.get(name)
|
|
25
|
+
if not val:
|
|
26
|
+
fail(f"missing env var: {name}")
|
|
27
|
+
return val
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def main() -> None:
|
|
31
|
+
bundle_id = env("BUNDLE_ID")
|
|
32
|
+
token = make_jwt(env("ASC_KEY_ID"), env("ASC_ISSUER_ID"), env("ASC_KEY_PATH"))
|
|
33
|
+
data = get_json("/apps", token, params={"filter[bundleId]": bundle_id, "limit": "10"})
|
|
34
|
+
apps = data.get("data", [])
|
|
35
|
+
if not apps:
|
|
36
|
+
fail(
|
|
37
|
+
f"No ASC app found for bundleId={bundle_id}. "
|
|
38
|
+
"Run `fastlane create_app_ios` once manually to register the app, "
|
|
39
|
+
"or set app.app_store_apple_id in ci.config.yaml."
|
|
40
|
+
)
|
|
41
|
+
if len(apps) > 1:
|
|
42
|
+
ids = ", ".join(a.get("id", "?") for a in apps)
|
|
43
|
+
fail(
|
|
44
|
+
f"Multiple ASC apps match bundleId={bundle_id} (ids: {ids}). "
|
|
45
|
+
"Set app.app_store_apple_id in ci.config.yaml to disambiguate."
|
|
46
|
+
)
|
|
47
|
+
print(apps[0]["id"])
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
main()
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Resolve the App Store Connect version slot for this run.
|
|
4
|
+
|
|
5
|
+
The project's MARKETING_VERSION build setting is the single source of truth
|
|
6
|
+
for the marketing version. CI does not bump it. This script finds-or-creates
|
|
7
|
+
the App Store Connect (ASC) row matching that exact version, and only the
|
|
8
|
+
build number is bumped automatically (see next_build_number.py).
|
|
9
|
+
|
|
10
|
+
Algorithm:
|
|
11
|
+
1. Read MARKETING_VERSION from env (set by action.yml from
|
|
12
|
+
xcodebuild -showBuildSettings or PlistBuddy).
|
|
13
|
+
2. Floor-check (non-strict): target >= combined floor across
|
|
14
|
+
appStoreVersions + preReleaseVersions + builds->preReleaseVersion.
|
|
15
|
+
3. If the target row already exists in /appStoreVersions:
|
|
16
|
+
- any non-editable match (terminal/blocking) -> SystemExit(2);
|
|
17
|
+
the slot is taken by a shipped or in-review row.
|
|
18
|
+
- editable-only match -> REUSE its id (steady-state TestFlight
|
|
19
|
+
iteration at unchanged MARKETING_VERSION).
|
|
20
|
+
4. CREATE path: floor-check (strict, target > floor); PATCH-rename any
|
|
21
|
+
stale editable to target; or POST a new row.
|
|
22
|
+
|
|
23
|
+
This module is intentionally thin: floor logic lives in
|
|
24
|
+
``mmv_floor_check``; CREATE wiring (POST/PATCH-rename, stale-editable
|
|
25
|
+
detection) lives in ``mmv_decide_create``. Both submodules re-bind back
|
|
26
|
+
through this one so test patches at ``mmv.<seam>`` keep winning.
|
|
27
|
+
|
|
28
|
+
Env: ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH, APP_STORE_APPLE_ID,
|
|
29
|
+
MARKETING_VERSION (project source of truth, set by action.yml).
|
|
30
|
+
Stdout: {"decision":"REUSE|CREATE","versionString":"...","appStoreVersionId":"..."}
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import json
|
|
36
|
+
import os
|
|
37
|
+
import sys
|
|
38
|
+
|
|
39
|
+
from asc_common import (
|
|
40
|
+
BLOCKING_STATES,
|
|
41
|
+
IN_REVIEW_STATES,
|
|
42
|
+
REUSABLE_STATES,
|
|
43
|
+
TERMINAL_STATES,
|
|
44
|
+
make_jwt,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
from mmv_floor_check import (
|
|
48
|
+
BUILD_SETTING_SOURCES,
|
|
49
|
+
MIGRATION_HINT,
|
|
50
|
+
SEM_RE,
|
|
51
|
+
assert_target_meets_floor,
|
|
52
|
+
bump_patch_for_message,
|
|
53
|
+
fetch_versions,
|
|
54
|
+
get_combined_floor,
|
|
55
|
+
get_ground_truth_floor,
|
|
56
|
+
maybe_auto_bump,
|
|
57
|
+
semver_tuple,
|
|
58
|
+
)
|
|
59
|
+
from mmv_decide_create import (
|
|
60
|
+
create_or_reuse,
|
|
61
|
+
create_or_reuse_with_stale,
|
|
62
|
+
create_version,
|
|
63
|
+
decide_create,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Re-export the public seams so tests that patch ``mmv.<name>`` keep
|
|
67
|
+
# working. The submodules look these up through this module at call
|
|
68
|
+
# time, so a patch on ``mmv.fetch_versions`` propagates correctly.
|
|
69
|
+
__all__ = (
|
|
70
|
+
"SEM_RE",
|
|
71
|
+
"create_or_reuse",
|
|
72
|
+
"create_version",
|
|
73
|
+
"decide_for_version",
|
|
74
|
+
"env",
|
|
75
|
+
"fetch_versions",
|
|
76
|
+
"get_combined_floor",
|
|
77
|
+
"get_ground_truth_floor",
|
|
78
|
+
"main",
|
|
79
|
+
"make_jwt",
|
|
80
|
+
"semver_tuple",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
_APP_ID_MISSING_HINT = (
|
|
85
|
+
"::error::APP_STORE_APPLE_ID is empty. This usually means auto-detect "
|
|
86
|
+
"could not resolve PRODUCT_BUNDLE_IDENTIFIER from your Xcode project "
|
|
87
|
+
"(check the auto-detect: ... log lines in the 'Resolve credentials + "
|
|
88
|
+
"auto-detect' step above). Fix options: (1) commit your .xcodeproj or "
|
|
89
|
+
"add an xcodegen project.yml; (2) pass `app-store-apple-id:` explicitly "
|
|
90
|
+
"via the action's `with:` block."
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
_MARKETING_VERSION_MISSING_HINT = (
|
|
94
|
+
"::error::MARKETING_VERSION env var is missing or invalid. The "
|
|
95
|
+
"action.yml step that reads it from xcodebuild -showBuildSettings "
|
|
96
|
+
"(or PlistBuddy fallback) must set it before this script runs. "
|
|
97
|
+
+ MIGRATION_HINT
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _log(msg: str) -> None:
|
|
102
|
+
print(f"[decision] {msg}", file=sys.stderr)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _result(decision: str, version: str, vid: str, state: str = "") -> dict:
|
|
106
|
+
out = {
|
|
107
|
+
"decision": decision,
|
|
108
|
+
"versionString": version,
|
|
109
|
+
"appStoreVersionId": vid,
|
|
110
|
+
}
|
|
111
|
+
if state:
|
|
112
|
+
out["state"] = state
|
|
113
|
+
return out
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def env(name: str) -> str:
|
|
117
|
+
val = os.environ.get(name)
|
|
118
|
+
if not val:
|
|
119
|
+
if name == "APP_STORE_APPLE_ID":
|
|
120
|
+
msg = _APP_ID_MISSING_HINT
|
|
121
|
+
elif name == "MARKETING_VERSION":
|
|
122
|
+
msg = _MARKETING_VERSION_MISSING_HINT
|
|
123
|
+
else:
|
|
124
|
+
msg = f"::error::Missing required env var: {name}"
|
|
125
|
+
print(msg, file=sys.stderr)
|
|
126
|
+
raise SystemExit(1)
|
|
127
|
+
return val
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _validate_target(target_version: str) -> None:
|
|
131
|
+
"""SystemExit(1) when target is not a parseable semver."""
|
|
132
|
+
if SEM_RE.match(target_version or ""):
|
|
133
|
+
return
|
|
134
|
+
print(
|
|
135
|
+
f"::error::MARKETING_VERSION {target_version!r} is not a "
|
|
136
|
+
f"valid semantic version (expected MAJOR.MINOR[.PATCH]). "
|
|
137
|
+
f"Set MARKETING_VERSION in " + BUILD_SETTING_SOURCES + ". "
|
|
138
|
+
+ MIGRATION_HINT,
|
|
139
|
+
file=sys.stderr,
|
|
140
|
+
)
|
|
141
|
+
raise SystemExit(1)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _classify_match_at_target(
|
|
145
|
+
versions: list[dict], target: str,
|
|
146
|
+
) -> tuple[str, dict | None, bool]:
|
|
147
|
+
"""Classify rows at ``target`` -> ``(status, match, also_editable)``.
|
|
148
|
+
|
|
149
|
+
Status values: ``"none"`` (no match -> CREATE), ``"reuse"`` (editable
|
|
150
|
+
-> REUSE its id), ``"terminal"`` (settled, slot locked -- caller may
|
|
151
|
+
auto-roll), ``"in_review"`` (under Apple Review or awaiting dev
|
|
152
|
+
release click -- caller MUST reject), ``"unknown"`` (matched but
|
|
153
|
+
state is in NO allowlist -- caller MUST reject; round 12 fail-closed
|
|
154
|
+
so an unfamiliar ASC state can't silently 409 on CREATE). The state
|
|
155
|
+
taxonomy lives in ``asc_common.py`` next to the constant set
|
|
156
|
+
definitions; positive allowlists (round 11) replaced the historical
|
|
157
|
+
``not in EDITABLE_STATES`` test that misclassified
|
|
158
|
+
PENDING_DEVELOPER_RELEASE as terminal."""
|
|
159
|
+
matches = [v for v in versions if v.get("versionString") == target]
|
|
160
|
+
if not matches:
|
|
161
|
+
return "none", None, False
|
|
162
|
+
in_review = [
|
|
163
|
+
v for v in matches
|
|
164
|
+
if v.get("state") in IN_REVIEW_STATES
|
|
165
|
+
or v.get("state") in BLOCKING_STATES
|
|
166
|
+
]
|
|
167
|
+
if in_review:
|
|
168
|
+
return "in_review", in_review[0], False
|
|
169
|
+
terminal = [v for v in matches if v.get("state") in TERMINAL_STATES]
|
|
170
|
+
if terminal:
|
|
171
|
+
return "terminal", terminal[0], len(matches) > 1
|
|
172
|
+
reusable = [v for v in matches if v.get("state") in REUSABLE_STATES]
|
|
173
|
+
if reusable:
|
|
174
|
+
return "reuse", reusable[0], False
|
|
175
|
+
# Round 12: matches exist but no state is recognized -- fail closed.
|
|
176
|
+
return "unknown", matches[0], len(matches) > 1
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _reject_match(
|
|
180
|
+
target: str, match: dict, *,
|
|
181
|
+
also_editable: bool = False, unknown: bool = False,
|
|
182
|
+
) -> None:
|
|
183
|
+
"""Reject a non-reusable match at ``target``. When ``unknown`` is
|
|
184
|
+
True the matched row's ASC state is in none of our allowlists --
|
|
185
|
+
bail rather than guess (round 12: silently CREATEing would 409;
|
|
186
|
+
silently REUSEing could interfere with whatever Apple is doing
|
|
187
|
+
with the row). Otherwise this is the historical terminal-collision
|
|
188
|
+
rejection path."""
|
|
189
|
+
state, vid = match.get("state", ""), match.get("id", "")
|
|
190
|
+
if unknown:
|
|
191
|
+
print(
|
|
192
|
+
f"::error::MARKETING_VERSION {target} exists in App Store "
|
|
193
|
+
f"Connect in unrecognized state {state!r} (id={vid}); "
|
|
194
|
+
f"bailing rather than guessing whether to REUSE or CREATE. "
|
|
195
|
+
f"Update mmv classifier (asc_common.TERMINAL_STATES / "
|
|
196
|
+
f"IN_REVIEW_STATES / BLOCKING_STATES / REUSABLE_STATES) to "
|
|
197
|
+
f"cover {state!r}, or bump MARKETING_VERSION manually to a "
|
|
198
|
+
f"fresh value in your project's build settings ("
|
|
199
|
+
+ BUILD_SETTING_SOURCES + "). " + MIGRATION_HINT,
|
|
200
|
+
file=sys.stderr,
|
|
201
|
+
)
|
|
202
|
+
raise SystemExit(2)
|
|
203
|
+
suffix = (
|
|
204
|
+
" (an editable row at the same versionString also exists)"
|
|
205
|
+
if also_editable else ""
|
|
206
|
+
)
|
|
207
|
+
print(
|
|
208
|
+
f"::error::MARKETING_VERSION {target} already exists in App "
|
|
209
|
+
f"Store Connect in state {state} (id={vid}){suffix}. REUSE/CREATE "
|
|
210
|
+
f"both require a fresh marketing version. Bump MARKETING_VERSION "
|
|
211
|
+
f"in your project's build settings (" + BUILD_SETTING_SOURCES +
|
|
212
|
+
"). " + MIGRATION_HINT,
|
|
213
|
+
file=sys.stderr,
|
|
214
|
+
)
|
|
215
|
+
raise SystemExit(2)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _reject_in_review_match(target: str, match: dict) -> None:
|
|
219
|
+
"""Issue 13: REUSE/CREATE must NOT touch a row Apple is reviewing
|
|
220
|
+
OR a row awaiting the developer's manual release click
|
|
221
|
+
(PENDING_DEVELOPER_RELEASE). Both states require human action;
|
|
222
|
+
auto-rolling past them would either race App Review or bypass the
|
|
223
|
+
developer's release decision."""
|
|
224
|
+
state, vid = match.get("state", ""), match.get("id", "")
|
|
225
|
+
if state == "PENDING_DEVELOPER_RELEASE":
|
|
226
|
+
situation = (
|
|
227
|
+
"is approved by App Review and awaiting your manual release. "
|
|
228
|
+
"Either release the existing build via App Store Connect, or"
|
|
229
|
+
)
|
|
230
|
+
else:
|
|
231
|
+
situation = (
|
|
232
|
+
"is currently in App Review and cannot be modified. Either "
|
|
233
|
+
"wait for review to complete, or"
|
|
234
|
+
)
|
|
235
|
+
print(
|
|
236
|
+
f"::error::MARKETING_VERSION {target} exists in App Store Connect "
|
|
237
|
+
f"in state {state} (id={vid}). The version {situation} "
|
|
238
|
+
f"bump MARKETING_VERSION to a fresh value in your project's "
|
|
239
|
+
f"build settings (" + BUILD_SETTING_SOURCES + "). "
|
|
240
|
+
+ MIGRATION_HINT,
|
|
241
|
+
file=sys.stderr,
|
|
242
|
+
)
|
|
243
|
+
raise SystemExit(2)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _auto_roll_past_locked(
|
|
247
|
+
target_version: str, versions: list[dict],
|
|
248
|
+
) -> tuple[str, str, dict | None, bool]:
|
|
249
|
+
"""Try to auto-roll past a TERMINAL row at target.
|
|
250
|
+
|
|
251
|
+
Classifies the row(s) at target. When the match is terminal
|
|
252
|
+
(READY_FOR_SALE / PROCESSING_FOR_APP_STORE / PENDING_APPLE_RELEASE
|
|
253
|
+
etc.), fires ``maybe_auto_bump`` to advance the project's
|
|
254
|
+
MARKETING_VERSION to the next patch and re-classifies against the
|
|
255
|
+
rolled value. When auto-bump is disabled (policy='none') or the
|
|
256
|
+
write target is unreachable, returns the original locked
|
|
257
|
+
classification so the caller can surface the historical
|
|
258
|
+
SystemExit(2) rejection.
|
|
259
|
+
|
|
260
|
+
DESIGN DECISION: in-review collisions (WAITING_FOR_REVIEW /
|
|
261
|
+
IN_REVIEW / PENDING_DEVELOPER_RELEASE) are deliberately NOT
|
|
262
|
+
auto-rolled. Apple is either actively reviewing the row at
|
|
263
|
+
``target`` or has approved it and is waiting on the developer's
|
|
264
|
+
manual release click; advancing the project to a new version would
|
|
265
|
+
either submit a competing build while review is in flight or
|
|
266
|
+
bypass the developer's release decision -- both states require
|
|
267
|
+
human action. The historical SystemExit(2) rejection at these
|
|
268
|
+
states surfaces the conflict loudly so a human can resolve it.
|
|
269
|
+
|
|
270
|
+
Returns ``(effective_target, status, match, also_editable)``."""
|
|
271
|
+
status, match, also_editable = _classify_match_at_target(
|
|
272
|
+
versions, target_version,
|
|
273
|
+
)
|
|
274
|
+
if status != "terminal":
|
|
275
|
+
return target_version, status, match, also_editable
|
|
276
|
+
rolled = maybe_auto_bump(target_version, target_version)
|
|
277
|
+
if rolled is None or rolled == target_version:
|
|
278
|
+
return target_version, status, match, also_editable
|
|
279
|
+
_log(f"auto-roll: {target_version} is {match.get('state', '')}, "
|
|
280
|
+
f"advancing project to {rolled}")
|
|
281
|
+
new_status, new_match, new_also = _classify_match_at_target(
|
|
282
|
+
versions, rolled,
|
|
283
|
+
)
|
|
284
|
+
return rolled, new_status, new_match, new_also
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def decide_for_version(
|
|
288
|
+
target_version: str, versions: list[dict],
|
|
289
|
+
app_id: str, token: str,
|
|
290
|
+
) -> dict:
|
|
291
|
+
"""Find-or-create the ASC row at exactly ``target_version``.
|
|
292
|
+
|
|
293
|
+
Validates semver, runs the non-strict floor check (auto-bump
|
|
294
|
+
rewrites MARKETING_VERSION when below the ASC floor; the returned
|
|
295
|
+
effective target is what downstream uses), then classifies any
|
|
296
|
+
row(s) at target. Terminal collisions auto-roll to the next patch
|
|
297
|
+
(or reject when policy='none'); in-review and unknown-state
|
|
298
|
+
collisions reject loudly (see ``_classify_match_at_target`` for
|
|
299
|
+
the full taxonomy). No match -> CREATE; reusable editable -> REUSE."""
|
|
300
|
+
_validate_target(target_version)
|
|
301
|
+
target_version = assert_target_meets_floor(
|
|
302
|
+
target_version, app_id, token,
|
|
303
|
+
appstore_versions=versions, strict=False,
|
|
304
|
+
)
|
|
305
|
+
target_version, status, match, also_editable = _auto_roll_past_locked(
|
|
306
|
+
target_version, versions,
|
|
307
|
+
)
|
|
308
|
+
if status == "terminal":
|
|
309
|
+
_reject_match(target_version, match, also_editable=also_editable)
|
|
310
|
+
if status == "in_review":
|
|
311
|
+
_reject_in_review_match(target_version, match)
|
|
312
|
+
if status == "unknown":
|
|
313
|
+
_reject_match(target_version, match, unknown=True)
|
|
314
|
+
if status == "none":
|
|
315
|
+
return decide_create(target_version, versions, app_id, token)
|
|
316
|
+
vid = match.get("id", "")
|
|
317
|
+
state = match.get("state", "")
|
|
318
|
+
_log(f"decision=REUSE versionString={target_version} "
|
|
319
|
+
f"(matches MARKETING_VERSION; state={state}, id={vid})")
|
|
320
|
+
return _result("REUSE", target_version, vid, state)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def main() -> None:
|
|
324
|
+
key_id = env("ASC_KEY_ID")
|
|
325
|
+
issuer_id = env("ASC_ISSUER_ID")
|
|
326
|
+
key_path = env("ASC_KEY_PATH")
|
|
327
|
+
app_id = env("APP_STORE_APPLE_ID")
|
|
328
|
+
target_version = env("MARKETING_VERSION")
|
|
329
|
+
|
|
330
|
+
token = make_jwt(key_id, issuer_id, key_path)
|
|
331
|
+
versions = fetch_versions(app_id, token)
|
|
332
|
+
result = decide_for_version(target_version, versions, app_id, token)
|
|
333
|
+
print(json.dumps(result))
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
if __name__ == "__main__":
|
|
337
|
+
main()
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Shared constants and tiny helpers for AI-driven App Store metadata automation.
|
|
4
|
+
|
|
5
|
+
Used by asc_metadata_detector.py, asc_metadata_applier.py, app_context_scanner.py
|
|
6
|
+
and their tests. Keep this file small and dependency-free -- it is the contract
|
|
7
|
+
between the read-side (detector), write-side (applier), and context scanner.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def warn(msg: str) -> None:
|
|
17
|
+
"""Emit a GitHub-Actions-formatted warning on stderr."""
|
|
18
|
+
print(f"::warning::{msg}", file=sys.stderr)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def log(msg: str) -> None:
|
|
22
|
+
"""Emit a plain stderr log line (not parsed by GitHub Actions)."""
|
|
23
|
+
print(msg, file=sys.stderr)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def require_env(name: str) -> str:
|
|
27
|
+
"""Return a non-empty env var or raise SystemExit with a GHA error line."""
|
|
28
|
+
value = os.environ.get(name, "").strip()
|
|
29
|
+
if not value:
|
|
30
|
+
raise SystemExit(f"::error::{name} env var is required")
|
|
31
|
+
return value
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Fields owned by appInfoLocalizations (the "app-level" metadata resource).
|
|
35
|
+
# These persist across version submissions -- editing them requires an
|
|
36
|
+
# editable appInfo state (see EDITABLE_APP_INFO_STATES below).
|
|
37
|
+
APP_LEVEL_FIELDS = ("name", "subtitle")
|
|
38
|
+
|
|
39
|
+
# Fields owned by appStoreVersionLocalizations (per-version metadata).
|
|
40
|
+
# Edited through the version slot resolved by manage_marketing_version.py.
|
|
41
|
+
VERSION_LEVEL_FIELDS = (
|
|
42
|
+
"description",
|
|
43
|
+
"keywords",
|
|
44
|
+
"promotionalText",
|
|
45
|
+
"whatsNew",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# URL-typed metadata fields, grouped by owning resource. Never generated by AI,
|
|
49
|
+
# never PATCHed by the applier, never reported by the detector as "empty".
|
|
50
|
+
APP_LEVEL_URL_FIELDS = ("privacyPolicyUrl", "privacyChoicesUrl")
|
|
51
|
+
VERSION_LEVEL_URL_FIELDS = ("supportUrl", "marketingUrl")
|
|
52
|
+
|
|
53
|
+
# Unioned skip set used by both detector and applier as a single gate.
|
|
54
|
+
SKIP_URL_FIELDS = frozenset(APP_LEVEL_URL_FIELDS + VERSION_LEVEL_URL_FIELDS)
|
|
55
|
+
|
|
56
|
+
# Full per-resource field lists (editable + URL) as fetched from the ASC API.
|
|
57
|
+
APP_INFO_LOC_FIELDS = APP_LEVEL_FIELDS + APP_LEVEL_URL_FIELDS
|
|
58
|
+
VERSION_LOC_FIELDS = VERSION_LEVEL_FIELDS + VERSION_LEVEL_URL_FIELDS
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# Apple allows editing appInfoLocalizations only while the parent appInfo
|
|
62
|
+
# record is in one of these states. Outside of these, the applier must
|
|
63
|
+
# no-op (the API would reject the PATCH).
|
|
64
|
+
#
|
|
65
|
+
# Mirrors asc_common.EDITABLE_STATES but narrowed -- IN_REVIEW /
|
|
66
|
+
# WAITING_FOR_REVIEW lock the appInfo resource even though they permit
|
|
67
|
+
# some version-level edits.
|
|
68
|
+
EDITABLE_APP_INFO_STATES = frozenset({
|
|
69
|
+
"PREPARE_FOR_SUBMISSION",
|
|
70
|
+
"REJECTED",
|
|
71
|
+
"METADATA_REJECTED",
|
|
72
|
+
"DEVELOPER_REJECTED",
|
|
73
|
+
"WAITING_FOR_REVIEW",
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# App Store version states that accept PATCHes on appStoreVersionLocalizations
|
|
78
|
+
# (description, keywords, promotionalText, whatsNew). Outside of these, Apple's
|
|
79
|
+
# API returns 409 Conflict. Narrower than asc_common.EDITABLE_STATES -- we
|
|
80
|
+
# exclude IN_REVIEW and INVALID_BINARY which reject localization edits.
|
|
81
|
+
EDITABLE_VERSION_STATES = frozenset({
|
|
82
|
+
"PREPARE_FOR_SUBMISSION",
|
|
83
|
+
"DEVELOPER_REJECTED",
|
|
84
|
+
"METADATA_REJECTED",
|
|
85
|
+
"REJECTED",
|
|
86
|
+
"WAITING_FOR_REVIEW",
|
|
87
|
+
"READY_FOR_REVIEW",
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# Per-field character caps enforced by Apple's App Store submission review.
|
|
92
|
+
# IMPORTANT: these are CHARACTER counts (len(str)) not BYTE counts. Apple's
|
|
93
|
+
# UI and API both measure by character, which matters for CJK locales
|
|
94
|
+
# where one character is often 3+ UTF-8 bytes. Validate with len(value).
|
|
95
|
+
CHAR_LIMITS = {
|
|
96
|
+
"name": 30,
|
|
97
|
+
"subtitle": 30,
|
|
98
|
+
"keywords": 100,
|
|
99
|
+
"description": 4000,
|
|
100
|
+
"promotionalText": 170,
|
|
101
|
+
"whatsNew": 4000,
|
|
102
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
CREATE-path helpers for manage_marketing_version.
|
|
4
|
+
|
|
5
|
+
This module owns the wiring that turns a target versionString into either
|
|
6
|
+
a fresh /appStoreVersions POST or a PATCH-rename of a stale editable. The
|
|
7
|
+
``_no_bump`` sentinel disarms ``asc_version_create.create_or_reuse``'s
|
|
8
|
+
bump-retry loop because the project's MARKETING_VERSION is the single
|
|
9
|
+
source of truth -- CI must never invent a new versionString.
|
|
10
|
+
|
|
11
|
+
The ``_via_module`` helper provides a late-binding seam back through
|
|
12
|
+
``manage_marketing_version`` so test patches at ``mmv.fetch_versions`` /
|
|
13
|
+
``mmv.create_version`` keep winning even though the call originates here.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
from asc_common import EDITABLE_STATES, REUSABLE_STATES
|
|
21
|
+
import asc_build_history
|
|
22
|
+
import asc_version_create
|
|
23
|
+
from mmv_floor_check import assert_target_meets_floor, semver_tuple
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _no_bump(current: str) -> str:
|
|
27
|
+
"""Default bump_fn: never bump. CI must not invent a marketing
|
|
28
|
+
version because the project's build setting is authoritative."""
|
|
29
|
+
print(
|
|
30
|
+
f"::error::POST /appStoreVersions returned 409 for {current}; "
|
|
31
|
+
f"ASC reports this versionString already exists but it was not "
|
|
32
|
+
f"visible in /appStoreVersions/preReleaseVersions/builds. "
|
|
33
|
+
f"Inspect the app in App Store Connect manually.",
|
|
34
|
+
file=sys.stderr,
|
|
35
|
+
)
|
|
36
|
+
raise SystemExit(4)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _via_module(name: str):
|
|
40
|
+
"""Late-binding seam for tests that patch attributes on
|
|
41
|
+
``manage_marketing_version``. Returns a wrapper that defers attribute
|
|
42
|
+
lookup to call time so ``mock.patch.object(mmv, name)`` keeps winning."""
|
|
43
|
+
def _call(*args, **kwargs):
|
|
44
|
+
import manage_marketing_version
|
|
45
|
+
return getattr(manage_marketing_version, name)(*args, **kwargs)
|
|
46
|
+
return _call
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def create_version(app_id: str, version: str, token: str):
|
|
50
|
+
return asc_version_create.create_version(app_id, version, token)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def create_or_reuse(
|
|
54
|
+
app_id: str, version: str, token: str,
|
|
55
|
+
*, bump_fn=None,
|
|
56
|
+
) -> str:
|
|
57
|
+
"""POST a new version at exactly `version`. Caller passes a `bump_fn`
|
|
58
|
+
that disarms the bump-retry path by raising; this script never wants
|
|
59
|
+
a server-side bump because the project's MARKETING_VERSION is the
|
|
60
|
+
single source of truth."""
|
|
61
|
+
return create_or_reuse_with_stale(
|
|
62
|
+
app_id, version, token, bump_fn=bump_fn,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def create_or_reuse_with_stale(
|
|
67
|
+
app_id: str, version: str, token: str,
|
|
68
|
+
*, bump_fn=None, stale_editable_id: str | None = None,
|
|
69
|
+
) -> str:
|
|
70
|
+
"""``create_or_reuse`` plus the optional stale-editable PATCH-rename
|
|
71
|
+
seam so ``decide_for_version`` can promote a leftover editable row at
|
|
72
|
+
a different versionString to the project's freshly-bumped target."""
|
|
73
|
+
return asc_version_create.create_or_reuse(
|
|
74
|
+
app_id, version, token,
|
|
75
|
+
bump_fn=bump_fn or _no_bump,
|
|
76
|
+
fetch_versions=_via_module("fetch_versions"),
|
|
77
|
+
fetch_prerelease_versions=asc_build_history.fetch_prerelease_versions,
|
|
78
|
+
fetch_builds_prerelease_versions=(
|
|
79
|
+
asc_build_history.fetch_builds_prerelease_versions
|
|
80
|
+
),
|
|
81
|
+
post_fn=_via_module("create_version"),
|
|
82
|
+
stale_editable_id=stale_editable_id,
|
|
83
|
+
patch_fn=asc_version_create.patch_version,
|
|
84
|
+
delete_fn=asc_version_create.delete_version,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _refuse_downgrade(target: str, version_string: str, vid: str, state: str) -> None:
|
|
89
|
+
"""Emit a SystemExit(2) error when an editable exists at versionString >= target.
|
|
90
|
+
|
|
91
|
+
PATCH-renaming a higher-or-equal editable would silently downgrade an
|
|
92
|
+
in-progress draft. The CREATE-path floor check does NOT catch this
|
|
93
|
+
because editable rows do not contribute to combined_floor."""
|
|
94
|
+
print(
|
|
95
|
+
f"::error::Cannot CREATE MARKETING_VERSION {target}: an "
|
|
96
|
+
f"editable App Store version exists at {version_string} "
|
|
97
|
+
f"(id={vid}, state={state}). Renaming a "
|
|
98
|
+
f"higher or equal-version editable would downgrade an "
|
|
99
|
+
f"in-progress draft. Either bump MARKETING_VERSION to "
|
|
100
|
+
f">= {version_string} in your project's build settings "
|
|
101
|
+
f"(project.yml [xcodegen], the .xcodeproj's "
|
|
102
|
+
f"MARKETING_VERSION xcconfig, or Info.plist's "
|
|
103
|
+
f"CFBundleShortVersionString), or manually delete or "
|
|
104
|
+
f"rename the existing draft in App Store Connect. "
|
|
105
|
+
f"See .github/actions/swift-app/MIGRATION.md "
|
|
106
|
+
f"for the migration procedure.",
|
|
107
|
+
file=sys.stderr,
|
|
108
|
+
)
|
|
109
|
+
raise SystemExit(2)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _stale_editable_id(versions: list[dict], target: str) -> str | None:
|
|
113
|
+
"""Return the id of a REUSABLE row at a versionString strictly LESS
|
|
114
|
+
than target (a leftover lower-version draft to PATCH-rename onto the
|
|
115
|
+
project's freshly-bumped MARKETING_VERSION). Returns None when no
|
|
116
|
+
rename is needed.
|
|
117
|
+
|
|
118
|
+
SystemExit(2) when an editable exists at a versionString >= target:
|
|
119
|
+
PATCH-renaming it would silently downgrade an in-progress draft.
|
|
120
|
+
The CREATE-path floor check does NOT catch this because editable
|
|
121
|
+
rows do not contribute to combined_floor.
|
|
122
|
+
|
|
123
|
+
Issue 13: rows currently under Apple Review (WAITING_FOR_REVIEW /
|
|
124
|
+
IN_REVIEW) are EDITABLE in ASC's broad sense but mutating them would
|
|
125
|
+
interfere with the review process. They are still subject to the
|
|
126
|
+
>= target downgrade check (Issue 10 still applies), but they are
|
|
127
|
+
NOT candidates for PATCH-rename when version < target — the CREATE
|
|
128
|
+
path falls through to a plain POST instead, leaving the in-review
|
|
129
|
+
row alone."""
|
|
130
|
+
target_t = semver_tuple(target)
|
|
131
|
+
candidates: list[str] = []
|
|
132
|
+
for v in versions:
|
|
133
|
+
state = v.get("state")
|
|
134
|
+
if state not in EDITABLE_STATES:
|
|
135
|
+
continue
|
|
136
|
+
version_string = v.get("versionString") or ""
|
|
137
|
+
if version_string == target:
|
|
138
|
+
continue
|
|
139
|
+
vid = v.get("id")
|
|
140
|
+
if not vid:
|
|
141
|
+
continue
|
|
142
|
+
if semver_tuple(version_string) >= target_t:
|
|
143
|
+
_refuse_downgrade(target, version_string, vid, state)
|
|
144
|
+
# In-review rows (WAITING_FOR_REVIEW / IN_REVIEW) are editable
|
|
145
|
+
# but not safe to rename — leave them alone and POST a fresh row.
|
|
146
|
+
if state in REUSABLE_STATES:
|
|
147
|
+
candidates.append(vid)
|
|
148
|
+
return candidates[0] if candidates else None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def decide_create(
|
|
152
|
+
target_version: str, versions: list[dict],
|
|
153
|
+
app_id: str, token: str,
|
|
154
|
+
) -> dict:
|
|
155
|
+
"""CREATE path: enforce strict floor, PATCH-rename any stale editable,
|
|
156
|
+
then POST. SystemExit(2) when target is not strictly above the floor.
|
|
157
|
+
|
|
158
|
+
Late-binds ``mmv.create_or_reuse_with_stale`` and ``mmv._result`` so
|
|
159
|
+
test patches at the parent module win."""
|
|
160
|
+
import manage_marketing_version as mmv
|
|
161
|
+
# The strict floor-check may also auto-bump (e.g. on a CREATE-path
|
|
162
|
+
# call where the project is below the floor and the non-strict
|
|
163
|
+
# pre-check did not fire because the prior caller skipped it). Use
|
|
164
|
+
# the returned value for all subsequent reasoning.
|
|
165
|
+
target_version = assert_target_meets_floor(
|
|
166
|
+
target_version, app_id, token,
|
|
167
|
+
appstore_versions=versions, strict=True,
|
|
168
|
+
)
|
|
169
|
+
stale_id = _stale_editable_id(versions, target_version)
|
|
170
|
+
new_id = mmv.create_or_reuse_with_stale(
|
|
171
|
+
app_id, target_version, token, stale_editable_id=stale_id,
|
|
172
|
+
)
|
|
173
|
+
if stale_id:
|
|
174
|
+
msg = (f"decision=CREATE versionString={target_version} "
|
|
175
|
+
f"(PATCH-rename of stale editable id={stale_id}) "
|
|
176
|
+
f"-> appStoreVersion id={new_id}")
|
|
177
|
+
else:
|
|
178
|
+
msg = (f"decision=CREATE versionString={target_version} "
|
|
179
|
+
f"-> appStoreVersion id={new_id}")
|
|
180
|
+
print(f"[decision] {msg}", file=sys.stderr)
|
|
181
|
+
return mmv._result("CREATE", target_version, new_id)
|