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,265 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Floor-check helpers for manage_marketing_version.
|
|
4
|
+
|
|
5
|
+
The combined floor is the max versionString across:
|
|
6
|
+
- /appStoreVersions (caller-provided, ship-blocking states only)
|
|
7
|
+
- /preReleaseVersions (TestFlight trains)
|
|
8
|
+
- /builds->preReleaseVersion (hidden trains visible only via builds)
|
|
9
|
+
|
|
10
|
+
Plus an independent narrow per-state ground-truth query as defence-in-depth
|
|
11
|
+
against truncated/buggy main fetches.
|
|
12
|
+
|
|
13
|
+
This module owns the semver primitives, the floor-resolving wrappers
|
|
14
|
+
that tests patch (``fetch_versions``, ``get_ground_truth_floor``,
|
|
15
|
+
``get_combined_floor``), and the strict/non-strict assertion that
|
|
16
|
+
``decide_for_version`` runs before REUSE/CREATE branching.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import sys
|
|
24
|
+
|
|
25
|
+
import asc_version_fetch
|
|
26
|
+
import version_utils
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
SEM_RE = re.compile(r"^(\d+)\.(\d+)(?:\.(\d+))?$")
|
|
30
|
+
|
|
31
|
+
# DESIGN DECISION: auto-bump policy is read from the env var so the action
|
|
32
|
+
# step can wire it from `inputs.marketing-version-auto-bump` without a
|
|
33
|
+
# CLI-flag re-plumb of every helper. Default 'rollover' matches the action
|
|
34
|
+
# input default -- patch with carry at .9 (1.0.9 -> 1.1.0) is the more
|
|
35
|
+
# natural semver progression for unattended CI. ``'patch'`` is preserved
|
|
36
|
+
# unchanged for consumers pinning the historical (unbounded) behavior.
|
|
37
|
+
# ``'none'`` preserves the historical fail-the-build path.
|
|
38
|
+
_AUTO_BUMP_ENV = "MARKETING_VERSION_AUTO_BUMP"
|
|
39
|
+
|
|
40
|
+
# DESIGN DECISION: auto-bump must only fire when the resulting bumped
|
|
41
|
+
# value can be committed back to the repo in the same run. The
|
|
42
|
+
# commit-back step in action.yml is gated on `event=push` AND
|
|
43
|
+
# `ref=refs/heads/<default_branch>`. Auto-bumping on any other event
|
|
44
|
+
# (workflow_dispatch, pull_request, push to feature branches) would
|
|
45
|
+
# upload an IPA at the bumped version while git stays at the old
|
|
46
|
+
# value, causing the next run to recompute against stale state and
|
|
47
|
+
# either re-bump or drift.
|
|
48
|
+
#
|
|
49
|
+
# These envs are set by the action.yml step from
|
|
50
|
+
# ``${{ github.event_name }}`` and ``${{ github.ref }}`` /
|
|
51
|
+
# ``${{ github.event.repository.default_branch }}``. Empty / unset
|
|
52
|
+
# values are treated as "context unknown -> refuse to auto-bump"
|
|
53
|
+
# (defensive: when run outside the GitHub Actions context, e.g. local
|
|
54
|
+
# dev or a third-party orchestrator, the safe default is to require a
|
|
55
|
+
# human bump).
|
|
56
|
+
_EVENT_ENV = "GITHUB_EVENT_NAME"
|
|
57
|
+
_REF_ENV = "GITHUB_REF"
|
|
58
|
+
_DEFAULT_BRANCH_ENV = "GITHUB_DEFAULT_BRANCH"
|
|
59
|
+
|
|
60
|
+
# These constants and helpers are intentionally public (no leading
|
|
61
|
+
# underscore) because manage_marketing_version.py imports them across the
|
|
62
|
+
# module boundary. Module-internal helpers below keep the leading
|
|
63
|
+
# underscore to mark them as private.
|
|
64
|
+
MIGRATION_HINT = (
|
|
65
|
+
"See .github/actions/swift-app/MIGRATION.md for the "
|
|
66
|
+
"migration procedure."
|
|
67
|
+
)
|
|
68
|
+
BUILD_SETTING_SOURCES = (
|
|
69
|
+
"project.yml [xcodegen], the .xcodeproj's MARKETING_VERSION xcconfig, "
|
|
70
|
+
"or Info.plist's CFBundleShortVersionString"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def semver_tuple(version_string: str) -> tuple[int, int, int]:
|
|
75
|
+
"""Parse 'M.m[.p]' -> (M, m, p). Non-semver returns (-1,-1,-1) so it
|
|
76
|
+
sorts below any real version."""
|
|
77
|
+
m = SEM_RE.match(version_string or "")
|
|
78
|
+
if not m:
|
|
79
|
+
return (-1, -1, -1)
|
|
80
|
+
return (int(m.group(1)), int(m.group(2)), int(m.group(3) or "0"))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def bump_patch_for_message(floor: str) -> str:
|
|
84
|
+
"""Suggest a next-patch value to surface in error text. Best-effort."""
|
|
85
|
+
m = SEM_RE.match(floor or "")
|
|
86
|
+
if not m:
|
|
87
|
+
return f"{floor} (bump above this)"
|
|
88
|
+
major, minor = int(m.group(1)), int(m.group(2))
|
|
89
|
+
patch = int(m.group(3) or "0")
|
|
90
|
+
return f"{major}.{minor}.{patch + 1}"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def fetch_versions(app_id: str, token: str) -> list[dict]:
|
|
94
|
+
return asc_version_fetch.fetch_versions(app_id, token)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_ground_truth_floor(app_id: str, token: str) -> str | None:
|
|
98
|
+
return asc_version_fetch.get_ground_truth_floor(app_id, token, semver_tuple)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_combined_floor(
|
|
102
|
+
app_id: str, token: str, appstore_versions: list[dict],
|
|
103
|
+
) -> tuple[str | None, dict[str, str | None]]:
|
|
104
|
+
"""Wrapper around asc_version_fetch.get_combined_floor that forwards
|
|
105
|
+
both the floor AND the per-source breakdown so the cross-check error
|
|
106
|
+
message can name the real source contributing the binding floor (not
|
|
107
|
+
a synthesized one)."""
|
|
108
|
+
return asc_version_fetch.get_combined_floor(
|
|
109
|
+
app_id, token, semver_tuple, appstore_versions=appstore_versions,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _resolve_floor(app_id: str, token: str, appstore_versions: list[dict]):
|
|
114
|
+
"""Return (floor, per_source). floor is None when every ASC source is
|
|
115
|
+
empty (true first-release case). Late-binds the seam wrappers via the
|
|
116
|
+
parent ``manage_marketing_version`` module so test patches at
|
|
117
|
+
``mmv.get_combined_floor`` / ``mmv.get_ground_truth_floor`` win."""
|
|
118
|
+
import manage_marketing_version as mmv
|
|
119
|
+
combined, per_source = mmv.get_combined_floor(
|
|
120
|
+
app_id, token, appstore_versions,
|
|
121
|
+
)
|
|
122
|
+
narrow = mmv.get_ground_truth_floor(app_id, token)
|
|
123
|
+
candidates = [c for c in (combined, narrow) if c]
|
|
124
|
+
floor = max(candidates, key=semver_tuple) if candidates else None
|
|
125
|
+
return floor, per_source
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _floor_error(target: str, floor: str, per_source: dict, *, strict: bool) -> None:
|
|
129
|
+
relation = "strictly greater than" if strict else "greater than or equal to"
|
|
130
|
+
print(
|
|
131
|
+
f"::error::MARKETING_VERSION {target} in your project's build "
|
|
132
|
+
f"settings (typically " + BUILD_SETTING_SOURCES + f") must be "
|
|
133
|
+
f"{relation} the App Store Connect floor {floor}. Bump it (e.g. "
|
|
134
|
+
f"to {bump_patch_for_message(floor)}), commit, and rerun. "
|
|
135
|
+
f"Sources contributing to floor: "
|
|
136
|
+
f"appStoreVersions={per_source.get('appStoreVersions') or '<none>'}, "
|
|
137
|
+
f"preReleaseVersions={per_source.get('preReleaseVersions') or '<none>'}, "
|
|
138
|
+
f"buildsViaPreRelease={per_source.get('buildsViaPreRelease') or '<none>'}. "
|
|
139
|
+
+ MIGRATION_HINT,
|
|
140
|
+
file=sys.stderr,
|
|
141
|
+
)
|
|
142
|
+
raise SystemExit(2)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _persist_context_ok(policy: str) -> bool:
|
|
146
|
+
"""True when this run can persist a project-file bump back to the
|
|
147
|
+
default branch via the action's commit-back step. The commit-back
|
|
148
|
+
step is gated on ``event=push`` AND ``ref=refs/heads/<default>``;
|
|
149
|
+
any other context (workflow_dispatch / pull_request / push to a
|
|
150
|
+
feature branch) would upload an IPA at the bumped version while
|
|
151
|
+
git stays at the old value, drifting the project from ASC.
|
|
152
|
+
|
|
153
|
+
On refusal, emits the ``::warning::`` describing why this run
|
|
154
|
+
cannot persist the bump (event/ref/default_branch values from the
|
|
155
|
+
environment). The warning names the active policy (rollover /
|
|
156
|
+
patch / minor) so consumers reading CI logs can see which mode
|
|
157
|
+
was attempted. Side-effecting the warning here -- rather than at
|
|
158
|
+
the caller -- keeps ``maybe_auto_bump`` under the per-function LOC
|
|
159
|
+
cap without splitting the env-read + warning into two helpers
|
|
160
|
+
(which would also push the file over the per-file functions cap)."""
|
|
161
|
+
event = (os.environ.get(_EVENT_ENV) or "").strip()
|
|
162
|
+
ref = (os.environ.get(_REF_ENV) or "").strip()
|
|
163
|
+
default_branch = (os.environ.get(_DEFAULT_BRANCH_ENV) or "").strip()
|
|
164
|
+
ok = (
|
|
165
|
+
event == "push"
|
|
166
|
+
and bool(default_branch)
|
|
167
|
+
and ref == f"refs/heads/{default_branch}"
|
|
168
|
+
)
|
|
169
|
+
if ok:
|
|
170
|
+
return True
|
|
171
|
+
print(
|
|
172
|
+
f"::warning::auto-bump={policy} is enabled, but this run "
|
|
173
|
+
f"cannot persist the bump (event={event or '<unset>'}, "
|
|
174
|
+
f"ref={ref or '<unset>'}, "
|
|
175
|
+
f"default_branch={default_branch or '<unset>'}). The "
|
|
176
|
+
f"action's commit-back step only runs on push to "
|
|
177
|
+
f"refs/heads/{default_branch or '<unset>'}; auto-bumping on "
|
|
178
|
+
f"any other event would upload an IPA at the bumped version "
|
|
179
|
+
f"while git stays at the old value. Falling back to fail-on-"
|
|
180
|
+
f"floor; either push to the default branch with auto-bump "
|
|
181
|
+
f"enabled, or manually bump MARKETING_VERSION and rerun.",
|
|
182
|
+
file=sys.stderr,
|
|
183
|
+
)
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def maybe_auto_bump(target: str, floor: str) -> str | None:
|
|
188
|
+
"""Return the bumped version when auto-bump is enabled, the run
|
|
189
|
+
can persist the bump, and writes succeed. Returns None when policy
|
|
190
|
+
is 'none', the persistence context disallows commit-back, or the
|
|
191
|
+
project-file write failed -- the caller falls back to the historical
|
|
192
|
+
``_floor_error`` path.
|
|
193
|
+
|
|
194
|
+
Public seam so ``manage_marketing_version`` can fire the auto-roll
|
|
195
|
+
when target == a terminal-state floor row (READY_FOR_SALE auto-roll)
|
|
196
|
+
-- not just when target < floor (the historical floor-violation
|
|
197
|
+
case).
|
|
198
|
+
|
|
199
|
+
Side effects: writes the new version into the project file (pbxproj
|
|
200
|
+
/ xcconfig / Info.plist / project.yml depending on resolution) and
|
|
201
|
+
updates ``os.environ['MARKETING_VERSION']`` so downstream steps see
|
|
202
|
+
the bumped value. ``version_utils.write_marketing_version`` ALSO
|
|
203
|
+
runs ``git add -f`` on the touched path so the auto-bump is staged
|
|
204
|
+
in the index before any later step (e.g. prepare_signing) mutates
|
|
205
|
+
the same file."""
|
|
206
|
+
policy = (os.environ.get(_AUTO_BUMP_ENV) or "rollover").strip().lower()
|
|
207
|
+
if policy == "none":
|
|
208
|
+
return None
|
|
209
|
+
if policy not in ("rollover", "patch", "minor"):
|
|
210
|
+
print(
|
|
211
|
+
f"::warning::Unknown {_AUTO_BUMP_ENV}={policy!r}; "
|
|
212
|
+
f"falling back to fail-on-floor behavior",
|
|
213
|
+
file=sys.stderr,
|
|
214
|
+
)
|
|
215
|
+
return None
|
|
216
|
+
if not _persist_context_ok(policy):
|
|
217
|
+
return None
|
|
218
|
+
new_version = version_utils.compute_next_version(target, floor, policy)
|
|
219
|
+
if not version_utils.write_marketing_version(new_version):
|
|
220
|
+
print(
|
|
221
|
+
f"::warning::auto-bump could not locate a writable "
|
|
222
|
+
f"MARKETING_VERSION source (pbxproj or Info.plist); "
|
|
223
|
+
f"falling back to fail-on-floor behavior",
|
|
224
|
+
file=sys.stderr,
|
|
225
|
+
)
|
|
226
|
+
return None
|
|
227
|
+
os.environ["MARKETING_VERSION"] = new_version
|
|
228
|
+
print(
|
|
229
|
+
f"::notice::mmv: auto-bumped {target} -> {new_version} "
|
|
230
|
+
f"(policy={policy}, ASC floor was {floor})",
|
|
231
|
+
file=sys.stderr,
|
|
232
|
+
)
|
|
233
|
+
return new_version
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def assert_target_meets_floor(
|
|
237
|
+
target: str, app_id: str, token: str,
|
|
238
|
+
*, appstore_versions: list[dict], strict: bool,
|
|
239
|
+
) -> str:
|
|
240
|
+
"""Reject when the floor outranks `target`. ``strict=True`` requires
|
|
241
|
+
target > floor (CREATE: a new row must not collide with anything
|
|
242
|
+
shipped/uploaded). ``strict=False`` allows target == floor (REUSE:
|
|
243
|
+
the editable matching target IS a floor contributor, so equality is
|
|
244
|
+
expected and fine).
|
|
245
|
+
|
|
246
|
+
Returns the effective target (which may differ from the input when
|
|
247
|
+
auto-bump fires on the non-strict pre-check). Callers MUST use the
|
|
248
|
+
returned value for any subsequent floor-relative reasoning, otherwise
|
|
249
|
+
they'd compare a stale target against the post-bump state.
|
|
250
|
+
"""
|
|
251
|
+
floor, per_source = _resolve_floor(app_id, token, appstore_versions)
|
|
252
|
+
if floor is None:
|
|
253
|
+
return target
|
|
254
|
+
target_t, floor_t = semver_tuple(target), semver_tuple(floor)
|
|
255
|
+
ok = target_t > floor_t if strict else target_t >= floor_t
|
|
256
|
+
if ok:
|
|
257
|
+
relation = ">" if strict else ">="
|
|
258
|
+
print(f"[decision] floor_check OK: target={target} {relation} "
|
|
259
|
+
f"floor={floor}", file=sys.stderr)
|
|
260
|
+
return target
|
|
261
|
+
bumped = maybe_auto_bump(target, floor)
|
|
262
|
+
if bumped is not None:
|
|
263
|
+
return bumped
|
|
264
|
+
_floor_error(target, floor, per_source, strict=strict)
|
|
265
|
+
raise AssertionError("unreachable") # _floor_error raises SystemExit
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Query App Store Connect for the highest-numbered build across every
|
|
4
|
+
source that can surface an in-flight upload, and print ``<highest + 1>``
|
|
5
|
+
to stdout.
|
|
6
|
+
|
|
7
|
+
Why multiple sources
|
|
8
|
+
--------------------
|
|
9
|
+
Apple's ``/builds`` endpoint normally lists every build regardless of
|
|
10
|
+
``processingState`` (PROCESSING, VALID, INVALID, EXPIRED), but there is
|
|
11
|
+
a window right after altool completes where a freshly-uploaded build
|
|
12
|
+
only shows up reliably under its ``preReleaseVersion`` relationship.
|
|
13
|
+
Missing that build is catastrophic: the script would return a value
|
|
14
|
+
altool already rejected on the previous run, causing the next upload
|
|
15
|
+
to fail with::
|
|
16
|
+
|
|
17
|
+
The bundle version must be higher than the previously uploaded
|
|
18
|
+
version: 'N'.
|
|
19
|
+
|
|
20
|
+
So we union build versions from BOTH ``/builds`` and
|
|
21
|
+
``/preReleaseVersions?include=builds`` before taking the max.
|
|
22
|
+
|
|
23
|
+
Environment
|
|
24
|
+
-----------
|
|
25
|
+
ASC_KEY_ID API key ID
|
|
26
|
+
ASC_ISSUER_ID API issuer ID
|
|
27
|
+
ASC_KEY_PATH Path to the .p8 key file
|
|
28
|
+
APP_STORE_APPLE_ID App Store numeric app id
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
import subprocess
|
|
36
|
+
import sys
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
from asc_common import get_json, make_jwt
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def env(name: str) -> str:
|
|
43
|
+
val = os.environ.get(name)
|
|
44
|
+
if not val:
|
|
45
|
+
if name == "APP_STORE_APPLE_ID":
|
|
46
|
+
raise SystemExit(
|
|
47
|
+
"::error::APP_STORE_APPLE_ID is empty. This usually means "
|
|
48
|
+
"auto-detect could not resolve PRODUCT_BUNDLE_IDENTIFIER from "
|
|
49
|
+
"your Xcode project (check the auto-detect: ... log lines in the "
|
|
50
|
+
"'Resolve credentials + auto-detect' step above). Fix options: "
|
|
51
|
+
"(1) commit your .xcodeproj or add an xcodegen project.yml; "
|
|
52
|
+
"(2) pass `app-store-apple-id:` explicitly via the action's `with:` block."
|
|
53
|
+
)
|
|
54
|
+
raise SystemExit(f"missing env var: {name}")
|
|
55
|
+
return val
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _build_version_int(build: dict) -> int | None:
|
|
59
|
+
try:
|
|
60
|
+
return int(build.get("attributes", {}).get("version"))
|
|
61
|
+
except (TypeError, ValueError):
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _log_newest(builds: list[dict], limit: int = 3) -> None:
|
|
66
|
+
"""Print the newest few builds (by uploadedDate) to stderr for debugging."""
|
|
67
|
+
def _uploaded(b: dict) -> str:
|
|
68
|
+
return (b.get("attributes") or {}).get("uploadedDate") or ""
|
|
69
|
+
|
|
70
|
+
newest = sorted(builds, key=_uploaded, reverse=True)[:limit]
|
|
71
|
+
for b in newest:
|
|
72
|
+
attrs = b.get("attributes") or {}
|
|
73
|
+
print(
|
|
74
|
+
f"ASC build: v={attrs.get('version')!r} "
|
|
75
|
+
f"state={attrs.get('processingState')!r} "
|
|
76
|
+
f"uploaded={attrs.get('uploadedDate')!r}",
|
|
77
|
+
file=sys.stderr,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def fetch_build_versions(app_id: str, token: str) -> set[int]:
|
|
82
|
+
"""Versions from GET /builds?filter[app]=... (all processingStates)."""
|
|
83
|
+
data = get_json(
|
|
84
|
+
"/builds",
|
|
85
|
+
token,
|
|
86
|
+
params={
|
|
87
|
+
"filter[app]": app_id,
|
|
88
|
+
"sort": "-version",
|
|
89
|
+
"limit": "200",
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
builds = data.get("data", []) or []
|
|
93
|
+
print(f"ASC /builds: scanning {len(builds)} builds", file=sys.stderr)
|
|
94
|
+
_log_newest(builds)
|
|
95
|
+
return {n for n in map(_build_version_int, builds) if n is not None}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def fetch_prerelease_versions(app_id: str, token: str) -> set[int]:
|
|
99
|
+
"""Versions reached via /preReleaseVersions?include=builds.
|
|
100
|
+
|
|
101
|
+
Fresh uploads occasionally appear in the ``included`` build records
|
|
102
|
+
here before ``/builds`` catches up; harvest both the relationship
|
|
103
|
+
edges and the included build resources.
|
|
104
|
+
"""
|
|
105
|
+
data = get_json(
|
|
106
|
+
"/preReleaseVersions",
|
|
107
|
+
token,
|
|
108
|
+
params={
|
|
109
|
+
"filter[app]": app_id,
|
|
110
|
+
"include": "builds",
|
|
111
|
+
"limit": "200",
|
|
112
|
+
},
|
|
113
|
+
)
|
|
114
|
+
included = data.get("included", []) or []
|
|
115
|
+
builds = [r for r in included if r.get("type") == "builds"]
|
|
116
|
+
print(
|
|
117
|
+
f"ASC /preReleaseVersions: scanning {len(builds)} included builds",
|
|
118
|
+
file=sys.stderr,
|
|
119
|
+
)
|
|
120
|
+
_log_newest(builds)
|
|
121
|
+
return {n for n in map(_build_version_int, builds) if n is not None}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _read_pbxproj_build_number() -> int | None:
|
|
125
|
+
"""Best-effort local fallback: read CURRENT_PROJECT_VERSION from pbxproj.
|
|
126
|
+
|
|
127
|
+
Returns the maximum integer CURRENT_PROJECT_VERSION found across all
|
|
128
|
+
XCBuildConfiguration blocks of the first discovered .xcodeproj at the
|
|
129
|
+
current working directory. Returns None if nothing usable is found
|
|
130
|
+
(no project, parse failure, all values are non-numeric / interpolated).
|
|
131
|
+
"""
|
|
132
|
+
try:
|
|
133
|
+
projects = sorted(Path(".").glob("*.xcodeproj"))
|
|
134
|
+
if not projects:
|
|
135
|
+
return None
|
|
136
|
+
pbx = projects[0] / "project.pbxproj"
|
|
137
|
+
if not pbx.exists():
|
|
138
|
+
return None
|
|
139
|
+
out = subprocess.check_output(
|
|
140
|
+
["plutil", "-convert", "json", "-o", "-", str(pbx)]
|
|
141
|
+
)
|
|
142
|
+
objects = (json.loads(out).get("objects") or {})
|
|
143
|
+
except (subprocess.CalledProcessError, json.JSONDecodeError, OSError):
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
values: set[int] = set()
|
|
147
|
+
for obj in objects.values():
|
|
148
|
+
if not isinstance(obj, dict):
|
|
149
|
+
continue
|
|
150
|
+
settings = obj.get("buildSettings") or {}
|
|
151
|
+
raw = settings.get("CURRENT_PROJECT_VERSION")
|
|
152
|
+
if raw is None:
|
|
153
|
+
continue
|
|
154
|
+
try:
|
|
155
|
+
values.add(int(str(raw).strip()))
|
|
156
|
+
except ValueError:
|
|
157
|
+
continue
|
|
158
|
+
return max(values) if values else None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def resolve_next_build_number(app_id: str, token: str) -> int:
|
|
162
|
+
"""Aggregate every known source of uploaded build versions."""
|
|
163
|
+
versions = fetch_build_versions(app_id, token)
|
|
164
|
+
versions |= fetch_prerelease_versions(app_id, token)
|
|
165
|
+
highest = max(versions, default=0)
|
|
166
|
+
print(f"ASC highest build version seen: {highest}", file=sys.stderr)
|
|
167
|
+
return highest + 1
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def main() -> None:
|
|
171
|
+
app_id = env("APP_STORE_APPLE_ID")
|
|
172
|
+
try:
|
|
173
|
+
token = make_jwt(env("ASC_KEY_ID"), env("ASC_ISSUER_ID"), env("ASC_KEY_PATH"))
|
|
174
|
+
print(resolve_next_build_number(app_id, token))
|
|
175
|
+
return
|
|
176
|
+
except SystemExit:
|
|
177
|
+
raise
|
|
178
|
+
except Exception as exc: # noqa: BLE001 - last-resort safety net
|
|
179
|
+
print(
|
|
180
|
+
f"::warning::ASC build lookup failed ({exc!r}); "
|
|
181
|
+
"falling back to pbxproj CURRENT_PROJECT_VERSION + 1",
|
|
182
|
+
file=sys.stderr,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
local = _read_pbxproj_build_number()
|
|
186
|
+
if local is None:
|
|
187
|
+
raise SystemExit(
|
|
188
|
+
"::error::could not resolve next build number from ASC or pbxproj; "
|
|
189
|
+
"fix ASC credentials or commit a CURRENT_PROJECT_VERSION in the Xcode project"
|
|
190
|
+
)
|
|
191
|
+
print(local + 1)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
if __name__ == "__main__":
|
|
195
|
+
main()
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Xcode ``project.pbxproj`` helpers.
|
|
4
|
+
|
|
5
|
+
Parses native targets and patches their XCBuildConfiguration buildSettings
|
|
6
|
+
blocks with manual signing values, while keeping the file's original
|
|
7
|
+
OpenStep format (comments, ordering, the !$*UTF8*$! header) intact. SwiftPM
|
|
8
|
+
and other non-native targets are ignored so their build settings stay on
|
|
9
|
+
whatever defaults Xcode / SwiftPM picked.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
import subprocess
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
PROFILE_PREFIX = "CI-"
|
|
21
|
+
|
|
22
|
+
# Product types that must be signed with a provisioning profile. App
|
|
23
|
+
# extensions share the common ``com.apple.product-type.app-extension``
|
|
24
|
+
# prefix (Message Filter, Widgets, NetworkExtension, WatchKit, etc).
|
|
25
|
+
_SIGNABLE_PRODUCT_TYPES = {
|
|
26
|
+
"com.apple.product-type.application",
|
|
27
|
+
"com.apple.product-type.application.on-demand-install-capable",
|
|
28
|
+
"com.apple.product-type.application.watchapp2",
|
|
29
|
+
"com.apple.product-type.watchkit2-extension",
|
|
30
|
+
}
|
|
31
|
+
_SIGNABLE_PRODUCT_PREFIXES = (
|
|
32
|
+
"com.apple.product-type.app-extension",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
_SIGNING_KEYS = {
|
|
36
|
+
"CODE_SIGN_STYLE": "Manual",
|
|
37
|
+
"CODE_SIGN_IDENTITY": '"Apple Distribution"',
|
|
38
|
+
"CODE_SIGNING_ALLOWED": "YES",
|
|
39
|
+
"CODE_SIGNING_REQUIRED": "YES",
|
|
40
|
+
}
|
|
41
|
+
_STRIP_KEYS = ("PROVISIONING_PROFILE",)
|
|
42
|
+
|
|
43
|
+
_SETTING_LINE = re.compile(r"^(\s*)([A-Z_][A-Z0-9_]*)\s*=\s*(.+);\s*$")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# --------------------------------------------------------------------------- #
|
|
47
|
+
# Parsing #
|
|
48
|
+
# --------------------------------------------------------------------------- #
|
|
49
|
+
|
|
50
|
+
def _load_pbxproj(project_path: str) -> dict:
|
|
51
|
+
pbx = Path(project_path) / "project.pbxproj"
|
|
52
|
+
out = subprocess.check_output(
|
|
53
|
+
["plutil", "-convert", "json", "-o", "-", str(pbx)]
|
|
54
|
+
)
|
|
55
|
+
return json.loads(out)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _is_signable_product_type(product_type: str) -> bool:
|
|
59
|
+
if product_type in _SIGNABLE_PRODUCT_TYPES:
|
|
60
|
+
return True
|
|
61
|
+
return any(product_type.startswith(p) for p in _SIGNABLE_PRODUCT_PREFIXES)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _bundle_id_from_configs(objects: dict, config_ids: list[str]) -> str:
|
|
65
|
+
"""Return the first non-empty bundle id across the given configs."""
|
|
66
|
+
for cid in config_ids:
|
|
67
|
+
cfg = objects.get(cid) or {}
|
|
68
|
+
settings = cfg.get("buildSettings") or {}
|
|
69
|
+
bid = (settings.get("PRODUCT_BUNDLE_IDENTIFIER") or "").strip()
|
|
70
|
+
if bid and "$(" not in bid and "${" not in bid:
|
|
71
|
+
return bid
|
|
72
|
+
return ""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def discover_signable_targets(project_path: str) -> list[dict]:
|
|
76
|
+
"""Return one entry per signable native target.
|
|
77
|
+
|
|
78
|
+
Each entry: ``{"name": str, "bundle_id": str, "config_ids": [str,...]}``
|
|
79
|
+
where ``config_ids`` is the list of XCBuildConfiguration UUIDs whose
|
|
80
|
+
``buildSettings`` dict we need to patch (typically Debug + Release).
|
|
81
|
+
"""
|
|
82
|
+
pbx = _load_pbxproj(project_path)
|
|
83
|
+
objects = pbx["objects"]
|
|
84
|
+
targets: list[dict] = []
|
|
85
|
+
for obj in objects.values():
|
|
86
|
+
if obj.get("isa") != "PBXNativeTarget":
|
|
87
|
+
continue
|
|
88
|
+
if not _is_signable_product_type(obj.get("productType") or ""):
|
|
89
|
+
continue
|
|
90
|
+
name = obj.get("name") or "<unknown>"
|
|
91
|
+
config_list = objects.get(obj.get("buildConfigurationList")) or {}
|
|
92
|
+
config_ids = list(config_list.get("buildConfigurations") or [])
|
|
93
|
+
bundle_id = _bundle_id_from_configs(objects, config_ids)
|
|
94
|
+
if not bundle_id:
|
|
95
|
+
print(f"skip target {name!r}: no PRODUCT_BUNDLE_IDENTIFIER")
|
|
96
|
+
continue
|
|
97
|
+
targets.append(
|
|
98
|
+
{"name": name, "bundle_id": bundle_id, "config_ids": config_ids}
|
|
99
|
+
)
|
|
100
|
+
if not targets:
|
|
101
|
+
raise SystemExit(
|
|
102
|
+
f"discover_signable_targets: no signable targets found in "
|
|
103
|
+
f"{project_path}"
|
|
104
|
+
)
|
|
105
|
+
return targets
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# --------------------------------------------------------------------------- #
|
|
109
|
+
# Mutation #
|
|
110
|
+
# --------------------------------------------------------------------------- #
|
|
111
|
+
|
|
112
|
+
def patch_project_signing(
|
|
113
|
+
project_path: str,
|
|
114
|
+
targets: list[dict],
|
|
115
|
+
team_id: str,
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Set manual signing + per-target profile name for every target.
|
|
118
|
+
|
|
119
|
+
Edits the pbxproj as text so formatting (comments, ordering, header)
|
|
120
|
+
survives. Scope is strictly the XCBuildConfiguration blocks referenced
|
|
121
|
+
by the ``targets`` list. SwiftPM / resource-bundle configs stay put.
|
|
122
|
+
"""
|
|
123
|
+
pbx_path = Path(project_path) / "project.pbxproj"
|
|
124
|
+
text = pbx_path.read_text(encoding="utf-8")
|
|
125
|
+
for target in targets:
|
|
126
|
+
profile_name = f"{PROFILE_PREFIX}{target['bundle_id']}"
|
|
127
|
+
for cid in target["config_ids"]:
|
|
128
|
+
text = _apply_signing_to_config(text, cid, team_id, profile_name)
|
|
129
|
+
print(
|
|
130
|
+
f"Patched {target['name']!r} -> {profile_name} "
|
|
131
|
+
f"(configs={len(target['config_ids'])})"
|
|
132
|
+
)
|
|
133
|
+
pbx_path.write_text(text, encoding="utf-8")
|
|
134
|
+
subprocess.check_call(["plutil", "-lint", str(pbx_path)])
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _apply_signing_to_config(
|
|
138
|
+
text: str, config_id: str, team_id: str, profile_name: str
|
|
139
|
+
) -> str:
|
|
140
|
+
start = text.find(f"\t\t{config_id} ")
|
|
141
|
+
if start < 0:
|
|
142
|
+
start = text.find(f"\t\t{config_id}\t")
|
|
143
|
+
if start < 0:
|
|
144
|
+
start = text.find(f"{config_id} = {{")
|
|
145
|
+
if start < 0:
|
|
146
|
+
raise SystemExit(
|
|
147
|
+
f"patch_project_signing: config {config_id} not found"
|
|
148
|
+
)
|
|
149
|
+
key = "buildSettings = {"
|
|
150
|
+
s_idx = text.find(key, start)
|
|
151
|
+
if s_idx < 0:
|
|
152
|
+
raise SystemExit(
|
|
153
|
+
f"patch_project_signing: buildSettings not found for {config_id}"
|
|
154
|
+
)
|
|
155
|
+
end = _match_brace(text, s_idx + len(key) - 1)
|
|
156
|
+
if end < 0:
|
|
157
|
+
raise SystemExit(
|
|
158
|
+
f"patch_project_signing: unbalanced buildSettings for {config_id}"
|
|
159
|
+
)
|
|
160
|
+
inner = text[s_idx + len(key): end]
|
|
161
|
+
patched = _patch_inner_settings(inner, team_id, profile_name)
|
|
162
|
+
return text[: s_idx + len(key)] + patched + text[end:]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _match_brace(text: str, open_idx: int) -> int:
|
|
166
|
+
"""Return the index of the ``}`` that closes the ``{`` at ``open_idx``."""
|
|
167
|
+
depth = 0
|
|
168
|
+
i = open_idx
|
|
169
|
+
while i < len(text):
|
|
170
|
+
ch = text[i]
|
|
171
|
+
if ch == "{":
|
|
172
|
+
depth += 1
|
|
173
|
+
elif ch == "}":
|
|
174
|
+
depth -= 1
|
|
175
|
+
if depth == 0:
|
|
176
|
+
return i
|
|
177
|
+
i += 1
|
|
178
|
+
return -1
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _patch_inner_settings(
|
|
182
|
+
inner: str, team_id: str, profile_name: str
|
|
183
|
+
) -> str:
|
|
184
|
+
"""Rewrite build-setting lines inside one buildSettings block."""
|
|
185
|
+
values = dict(_SIGNING_KEYS)
|
|
186
|
+
values["DEVELOPMENT_TEAM"] = team_id
|
|
187
|
+
values["PROVISIONING_PROFILE_SPECIFIER"] = f'"{profile_name}"'
|
|
188
|
+
|
|
189
|
+
lines = inner.splitlines(keepends=True)
|
|
190
|
+
emitted: set[str] = set()
|
|
191
|
+
out: list[str] = []
|
|
192
|
+
for line in lines:
|
|
193
|
+
m = _SETTING_LINE.match(line)
|
|
194
|
+
if not m:
|
|
195
|
+
out.append(line)
|
|
196
|
+
continue
|
|
197
|
+
indent, key_name = m.group(1), m.group(2)
|
|
198
|
+
if key_name in _STRIP_KEYS:
|
|
199
|
+
continue
|
|
200
|
+
if key_name in values:
|
|
201
|
+
out.append(f"{indent}{key_name} = {values[key_name]};\n")
|
|
202
|
+
emitted.add(key_name)
|
|
203
|
+
continue
|
|
204
|
+
out.append(line)
|
|
205
|
+
|
|
206
|
+
missing = [k for k in values if k not in emitted]
|
|
207
|
+
if missing:
|
|
208
|
+
indent = "\t\t\t\t"
|
|
209
|
+
for line in lines:
|
|
210
|
+
m = _SETTING_LINE.match(line)
|
|
211
|
+
if m:
|
|
212
|
+
indent = m.group(1)
|
|
213
|
+
break
|
|
214
|
+
tail = out[-1] if out else ""
|
|
215
|
+
new_lines = [f"{indent}{k} = {values[k]};\n" for k in missing]
|
|
216
|
+
if tail.strip() == "":
|
|
217
|
+
out = out[:-1] + new_lines + [tail]
|
|
218
|
+
else:
|
|
219
|
+
out = out + new_lines
|
|
220
|
+
return "".join(out)
|