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.
Files changed (84) hide show
  1. package/CLAUDE.md +94 -0
  2. package/README.md +417 -0
  3. package/action/.daemux-version +1 -0
  4. package/action/MIGRATION.md +232 -0
  5. package/action/action.yml +973 -0
  6. package/action/prompts/generate_dependent_fields.prompt.yml +78 -0
  7. package/action/prompts/generate_descriptions.prompt.yml +59 -0
  8. package/action/scripts/app_context_scanner.py +239 -0
  9. package/action/scripts/asc_build_history.py +284 -0
  10. package/action/scripts/asc_common.py +246 -0
  11. package/action/scripts/asc_metadata_applier.py +368 -0
  12. package/action/scripts/asc_metadata_detector.py +250 -0
  13. package/action/scripts/asc_version_create.py +365 -0
  14. package/action/scripts/asc_version_fetch.py +274 -0
  15. package/action/scripts/asc_version_reuse.py +177 -0
  16. package/action/scripts/auto_detect.py +391 -0
  17. package/action/scripts/autoupdate_check.sh +118 -0
  18. package/action/scripts/cert_factory.py +190 -0
  19. package/action/scripts/cfg_io.py +27 -0
  20. package/action/scripts/cfg_resolve.py +147 -0
  21. package/action/scripts/commit_bot_changes.sh +82 -0
  22. package/action/scripts/creds_store.py +328 -0
  23. package/action/scripts/keychain.py +103 -0
  24. package/action/scripts/lookup_app_id.py +51 -0
  25. package/action/scripts/manage_marketing_version.py +337 -0
  26. package/action/scripts/metadata_constants.py +102 -0
  27. package/action/scripts/mmv_decide_create.py +181 -0
  28. package/action/scripts/mmv_floor_check.py +265 -0
  29. package/action/scripts/next_build_number.py +195 -0
  30. package/action/scripts/pbxproj_editor.py +220 -0
  31. package/action/scripts/prepare_signing.py +223 -0
  32. package/action/scripts/profile_io.py +64 -0
  33. package/action/scripts/profile_manager.py +307 -0
  34. package/action/scripts/read_config.py +315 -0
  35. package/action/scripts/resolve_marketing_version.py +173 -0
  36. package/action/scripts/set_app_store_whats_new.py +317 -0
  37. package/action/scripts/team_resolver.py +130 -0
  38. package/action/scripts/test_app_context_scanner.py +167 -0
  39. package/action/scripts/test_asc_build_history.py +221 -0
  40. package/action/scripts/test_asc_builds_prerelease.py +219 -0
  41. package/action/scripts/test_asc_common_timeouts.py +121 -0
  42. package/action/scripts/test_asc_metadata_applier.py +850 -0
  43. package/action/scripts/test_asc_metadata_detector.py +445 -0
  44. package/action/scripts/test_auto_detect.py +432 -0
  45. package/action/scripts/test_cfg_resolve_credentials.py +48 -0
  46. package/action/scripts/test_compute_next_version.py +95 -0
  47. package/action/scripts/test_compute_next_version_patch_backcompat.py +41 -0
  48. package/action/scripts/test_compute_next_version_strictness.py +67 -0
  49. package/action/scripts/test_fetch_versions.py +163 -0
  50. package/action/scripts/test_ground_truth_floor.py +104 -0
  51. package/action/scripts/test_manage_marketing_version.py +134 -0
  52. package/action/scripts/test_manage_marketing_version_autoroll.py +149 -0
  53. package/action/scripts/test_manage_marketing_version_floor.py +170 -0
  54. package/action/scripts/test_manage_marketing_version_match.py +187 -0
  55. package/action/scripts/test_mmv_409_classifier.py +231 -0
  56. package/action/scripts/test_mmv_autobump_persist_gate.py +131 -0
  57. package/action/scripts/test_mmv_decide_create.py +119 -0
  58. package/action/scripts/test_mmv_floor_check.py +158 -0
  59. package/action/scripts/test_mmv_floor_crosscheck.py +132 -0
  60. package/action/scripts/test_mmv_helpers.py +57 -0
  61. package/action/scripts/test_next_build_number.py +174 -0
  62. package/action/scripts/test_read_config_auto_detect.py +257 -0
  63. package/action/scripts/test_resolve_marketing_version.py +298 -0
  64. package/action/scripts/test_reuse_stale_editable.py +182 -0
  65. package/action/scripts/test_set_app_store_whats_new.py +71 -0
  66. package/action/scripts/test_team_fallback_wiring.py +157 -0
  67. package/action/scripts/test_team_resolver.py +249 -0
  68. package/action/scripts/tests_common.py +167 -0
  69. package/action/scripts/version_utils.py +373 -0
  70. package/android-action/.daemux-version +1 -0
  71. package/android-action/action.yml +92 -0
  72. package/android-action/scripts/android_config.py +130 -0
  73. package/android-action/scripts/bitrise_deploy.py +160 -0
  74. package/android-action/scripts/find_bundle.py +25 -0
  75. package/android-action/scripts/play_preflight.py +69 -0
  76. package/android-action/scripts/resolve_android.py +70 -0
  77. package/android-action/scripts/sign_bundle.py +70 -0
  78. package/android-action/scripts/test_android_config.py +97 -0
  79. package/android-action/scripts/test_bitrise_deploy.py +124 -0
  80. package/android-action/scripts/test_sign_bundle.py +82 -0
  81. package/bin/cli.mjs +63 -0
  82. package/package.json +57 -0
  83. package/src/install.mjs +208 -0
  84. package/templates/deploy.yml +150 -0
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tests the integration wiring for auto-derived team_id:
4
+
5
+ 1. ``prepare_signing.main()`` no longer fails with "missing env var: TEAM_ID"
6
+ when TEAM_ID is empty; it falls back to the team Apple assigned to the
7
+ provisioning profile (already available via provision_all_bundles).
8
+
9
+ 2. ``cfg_resolve.derive_team_if_empty`` calls team_resolver.derive_team_id
10
+ only when input+config yield an empty team, and emits a labeled source
11
+ (``derived_from_asc_key``).
12
+
13
+ All ASC network calls are mocked.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import sys
20
+ import unittest
21
+ from pathlib import Path
22
+ from unittest import mock
23
+
24
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
25
+
26
+ import cfg_resolve # noqa: E402
27
+ import prepare_signing # noqa: E402
28
+
29
+
30
+ class PrepareSigningTeamIdOptionalTests(unittest.TestCase):
31
+ """prepare_signing.py: TEAM_ID env var must be optional now."""
32
+
33
+ def test_main_runs_without_team_id_when_profiles_return_team(self):
34
+ # Arrange: stub every external side-effect so main() can run end-to-end
35
+ # without a real Xcode project or ASC API.
36
+ env_patch = {
37
+ "ASC_KEY_ID": "KID",
38
+ "ASC_ISSUER_ID": "ISS",
39
+ "ASC_KEY_PATH": "/tmp/fake.p8",
40
+ "RUNNER_TEMP": "/tmp",
41
+ "PROJECT": "/tmp/fake.xcodeproj",
42
+ # TEAM_ID deliberately omitted — this is the bug we're fixing.
43
+ }
44
+ with mock.patch.dict(os.environ, env_patch, clear=True):
45
+ with mock.patch.object(prepare_signing, "make_jwt", return_value="T"):
46
+ with mock.patch.object(
47
+ prepare_signing,
48
+ "discover_signable_targets",
49
+ return_value=[
50
+ {
51
+ "name": "App",
52
+ "bundle_id": "com.example.app",
53
+ "config_ids": ["C1"],
54
+ }
55
+ ],
56
+ ):
57
+ with mock.patch.object(
58
+ prepare_signing,
59
+ "_load_or_create_cert",
60
+ return_value=("CERTID", b"FAKEP12", "password", False),
61
+ ):
62
+ with mock.patch.object(
63
+ prepare_signing,
64
+ "provision_all_bundles",
65
+ return_value=(
66
+ [("com.example.app", "CI-com.example.app", "UUID")],
67
+ "DERIVED123", # effective_team from profile plist
68
+ ),
69
+ ):
70
+ with mock.patch.object(
71
+ prepare_signing, "patch_project_signing"
72
+ ):
73
+ with mock.patch.object(
74
+ prepare_signing, "setup_keychain"
75
+ ):
76
+ # Act: must not raise SystemExit.
77
+ prepare_signing.main()
78
+
79
+ def test_main_fails_with_actionable_message_when_nothing_yields_team(self):
80
+ env_patch = {
81
+ "ASC_KEY_ID": "KID",
82
+ "ASC_ISSUER_ID": "ISS",
83
+ "ASC_KEY_PATH": "/tmp/fake.p8",
84
+ "RUNNER_TEMP": "/tmp",
85
+ "PROJECT": "/tmp/fake.xcodeproj",
86
+ }
87
+ with mock.patch.dict(os.environ, env_patch, clear=True):
88
+ with mock.patch.object(prepare_signing, "make_jwt", return_value="T"):
89
+ with mock.patch.object(
90
+ prepare_signing,
91
+ "discover_signable_targets",
92
+ return_value=[
93
+ {"name": "A", "bundle_id": "b", "config_ids": ["C"]}
94
+ ],
95
+ ):
96
+ with mock.patch.object(
97
+ prepare_signing,
98
+ "_load_or_create_cert",
99
+ return_value=("CID", b"P12", "password", False),
100
+ ):
101
+ with mock.patch.object(
102
+ prepare_signing,
103
+ "provision_all_bundles",
104
+ # Profile with no TeamIdentifier — impossible in
105
+ # practice but we still need a graceful message.
106
+ return_value=([("b", "CI-b", "UUID")], ""),
107
+ ):
108
+ with mock.patch.object(
109
+ prepare_signing, "patch_project_signing"
110
+ ):
111
+ with self.assertRaises(SystemExit) as ctx:
112
+ prepare_signing.main()
113
+ self.assertIn("team_id", str(ctx.exception).lower())
114
+
115
+
116
+ class CfgResolveDeriveTeamTests(unittest.TestCase):
117
+ """cfg_resolve: derive_team_if_empty wires team_resolver into resolve_app."""
118
+
119
+ def test_derive_not_called_when_team_from_config(self):
120
+ """If config already supplies team_id, no API call is made."""
121
+ with mock.patch("cfg_resolve.derive_team_id") as m_derive:
122
+ result = cfg_resolve.derive_team_if_empty(
123
+ team_val="ABC123",
124
+ team_src="config",
125
+ creds={"key_id": "K", "issuer_id": "I", "key_path": "/p"},
126
+ )
127
+ self.assertEqual(result, ("ABC123", "config"))
128
+ m_derive.assert_not_called()
129
+
130
+ def test_derive_called_when_team_empty_emits_labeled_source(self):
131
+ """When team is empty, derive via ASC and label source."""
132
+ with mock.patch("cfg_resolve.make_jwt", return_value="T"):
133
+ with mock.patch(
134
+ "cfg_resolve.derive_team_id", return_value="DERIVED7X8"
135
+ ) as m_derive:
136
+ result = cfg_resolve.derive_team_if_empty(
137
+ team_val="",
138
+ team_src="empty",
139
+ creds={"key_id": "K", "issuer_id": "I", "key_path": "/p"},
140
+ )
141
+ self.assertEqual(result, ("DERIVED7X8", "derived_from_asc_key"))
142
+ m_derive.assert_called_once_with("T")
143
+
144
+ def test_derive_keeps_empty_when_api_cant_determine(self):
145
+ """If ASC lookup returns empty, keep empty — prepare_signing falls back."""
146
+ with mock.patch("cfg_resolve.make_jwt", return_value="T"):
147
+ with mock.patch("cfg_resolve.derive_team_id", return_value=""):
148
+ result = cfg_resolve.derive_team_if_empty(
149
+ team_val="",
150
+ team_src="empty",
151
+ creds={"key_id": "K", "issuer_id": "I", "key_path": "/p"},
152
+ )
153
+ self.assertEqual(result, ("", "empty"))
154
+
155
+
156
+ if __name__ == "__main__":
157
+ unittest.main()
@@ -0,0 +1,249 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tests for team_resolver.derive_team_id — the ASC-API-based Apple developer
4
+ team identifier derivation used when ``team_id`` is omitted from
5
+ ``ci.config.yaml``.
6
+
7
+ Runs offline: every ASC API interaction is stubbed via unittest.mock.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import datetime
14
+ import plistlib
15
+ import subprocess
16
+ import sys
17
+ import unittest
18
+ from pathlib import Path
19
+ from unittest import mock
20
+
21
+ from cryptography import x509
22
+ from cryptography.hazmat.primitives import hashes, serialization
23
+ from cryptography.hazmat.primitives.asymmetric import rsa
24
+ from cryptography.x509.oid import NameOID
25
+
26
+ # Make sibling scripts importable regardless of the pytest invocation cwd.
27
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
28
+
29
+ import team_resolver # noqa: E402 (import-after-sys.path modification)
30
+
31
+
32
+ def _make_dist_cert_der(team_id: str) -> bytes:
33
+ """Generate a throwaway DER-encoded Apple-Distribution-style cert with OU=<team_id>."""
34
+ key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
35
+ subject = issuer = x509.Name(
36
+ [
37
+ x509.NameAttribute(
38
+ NameOID.COMMON_NAME,
39
+ f"Apple Distribution: Test Team ({team_id})",
40
+ ),
41
+ x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, team_id),
42
+ x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Test Team"),
43
+ x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
44
+ ]
45
+ )
46
+ now = datetime.datetime.now(datetime.timezone.utc)
47
+ cert = (
48
+ x509.CertificateBuilder()
49
+ .subject_name(subject)
50
+ .issuer_name(issuer)
51
+ .public_key(key.public_key())
52
+ .serial_number(x509.random_serial_number())
53
+ .not_valid_before(now)
54
+ .not_valid_after(now + datetime.timedelta(days=365))
55
+ .sign(key, hashes.SHA256())
56
+ )
57
+ return cert.public_bytes(serialization.Encoding.DER)
58
+
59
+
60
+ def _make_profile_content(team_id: str) -> bytes:
61
+ """Build a fake mobileprovision payload (plain plist, not CMS-signed).
62
+
63
+ team_resolver falls back to ``plistlib.loads`` directly when the payload
64
+ isn't a signed blob, so a raw plist is sufficient for the test path.
65
+ """
66
+ plist_dict = {
67
+ "AppIDName": "Test",
68
+ "TeamIdentifier": [team_id],
69
+ "Name": "CI-com.example.test",
70
+ "UUID": "00000000-0000-0000-0000-000000000000",
71
+ }
72
+ return plistlib.dumps(plist_dict)
73
+
74
+
75
+ class DeriveTeamIdFromCertificateTests(unittest.TestCase):
76
+ """Happy path: ASC returns at least one Distribution cert."""
77
+
78
+ def test_derives_team_from_cert_ou_when_cert_exists(self):
79
+ team_id = "QYVMNH6654"
80
+ cert_der = _make_dist_cert_der(team_id)
81
+ cert_b64 = base64.b64encode(cert_der).decode()
82
+ asc_response = {
83
+ "data": [
84
+ {
85
+ "id": "CERT123",
86
+ "attributes": {
87
+ "certificateType": "DISTRIBUTION",
88
+ "certificateContent": cert_b64,
89
+ },
90
+ }
91
+ ]
92
+ }
93
+ with mock.patch.object(
94
+ team_resolver, "get_json", return_value=asc_response
95
+ ) as m_get:
96
+ derived = team_resolver.derive_team_id("jwt-token")
97
+ self.assertEqual(derived, team_id)
98
+ m_get.assert_any_call(
99
+ "/certificates",
100
+ "jwt-token",
101
+ params={"limit": "1", "sort": "-id"},
102
+ )
103
+
104
+ def test_returns_team_even_when_cert_type_is_development(self):
105
+ team_id = "VD3WNXP8BH"
106
+ cert_der = _make_dist_cert_der(team_id)
107
+ cert_b64 = base64.b64encode(cert_der).decode()
108
+ asc_response = {
109
+ "data": [
110
+ {
111
+ "id": "DEVCERT",
112
+ "attributes": {
113
+ "certificateType": "DEVELOPMENT",
114
+ "certificateContent": cert_b64,
115
+ },
116
+ }
117
+ ]
118
+ }
119
+ with mock.patch.object(team_resolver, "get_json", return_value=asc_response):
120
+ derived = team_resolver.derive_team_id("jwt-token")
121
+ self.assertEqual(derived, team_id)
122
+
123
+
124
+ class DeriveTeamIdFromProfileFallbackTests(unittest.TestCase):
125
+ """Fallback when no certs exist: use an existing provisioning profile's plist."""
126
+
127
+ def test_falls_back_to_profile_when_no_certs(self):
128
+ team_id = "ABC1234567"
129
+ profile_b64 = base64.b64encode(_make_profile_content(team_id)).decode()
130
+ cert_response = {"data": []}
131
+ profile_response = {
132
+ "data": [
133
+ {
134
+ "id": "PROF123",
135
+ "attributes": {
136
+ "name": "CI-com.example.test",
137
+ "profileContent": profile_b64,
138
+ },
139
+ }
140
+ ]
141
+ }
142
+
143
+ def fake_get_json(path, token, *, params=None):
144
+ if path.startswith("/certificates"):
145
+ return cert_response
146
+ if path.startswith("/profiles"):
147
+ return profile_response
148
+ raise AssertionError(f"unexpected path {path}")
149
+
150
+ with mock.patch.object(team_resolver, "get_json", side_effect=fake_get_json):
151
+ derived = team_resolver.derive_team_id("jwt-token")
152
+ self.assertEqual(derived, team_id)
153
+
154
+ def test_handles_cms_signed_profile_via_security_tool(self):
155
+ """Realistic profiles are CMS-signed; plistlib.loads raises.
156
+
157
+ team_resolver must shell out to ``security cms -D`` to decode them.
158
+ """
159
+ team_id = "TEAM987654"
160
+ # Produce a blob that plistlib.loads can't parse directly.
161
+ fake_cms_blob = b"\x30\x82\x01\x00" + b"NOT-A-PLIST" * 20
162
+ profile_response = {
163
+ "data": [
164
+ {
165
+ "id": "P1",
166
+ "attributes": {
167
+ "name": "CI-com.example.test",
168
+ "profileContent": base64.b64encode(fake_cms_blob).decode(),
169
+ },
170
+ }
171
+ ]
172
+ }
173
+
174
+ def fake_get_json(path, token, *, params=None):
175
+ if path.startswith("/certificates"):
176
+ return {"data": []}
177
+ return profile_response
178
+
179
+ decoded_plist = plistlib.dumps({"TeamIdentifier": [team_id]})
180
+
181
+ def fake_check_output(args, *a, **kw):
182
+ # args[0] == "security", args[1:3] == ("cms", "-D")
183
+ self.assertEqual(args[0], "security")
184
+ self.assertIn("cms", args)
185
+ return decoded_plist
186
+
187
+ with mock.patch.object(team_resolver, "get_json", side_effect=fake_get_json):
188
+ with mock.patch.object(
189
+ subprocess, "check_output", side_effect=fake_check_output
190
+ ):
191
+ derived = team_resolver.derive_team_id("jwt-token")
192
+ self.assertEqual(derived, team_id)
193
+
194
+
195
+ class DeriveTeamIdFailureTests(unittest.TestCase):
196
+ """Absolute failure path — no certs AND no profiles."""
197
+
198
+ def test_returns_empty_when_neither_cert_nor_profile_found(self):
199
+ def fake_get_json(path, token, *, params=None):
200
+ return {"data": []}
201
+
202
+ with mock.patch.object(team_resolver, "get_json", side_effect=fake_get_json):
203
+ derived = team_resolver.derive_team_id("jwt-token")
204
+ self.assertEqual(derived, "")
205
+
206
+ def test_returns_empty_on_certificate_without_ou(self):
207
+ """Malformed cert with no OU attribute — must not crash, must return ''."""
208
+ key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
209
+ subject = x509.Name(
210
+ [x509.NameAttribute(NameOID.COMMON_NAME, "Malformed")]
211
+ )
212
+ now = datetime.datetime.now(datetime.timezone.utc)
213
+ cert = (
214
+ x509.CertificateBuilder()
215
+ .subject_name(subject)
216
+ .issuer_name(subject)
217
+ .public_key(key.public_key())
218
+ .serial_number(1)
219
+ .not_valid_before(now)
220
+ .not_valid_after(now + datetime.timedelta(days=30))
221
+ .sign(key, hashes.SHA256())
222
+ )
223
+ cert_b64 = base64.b64encode(
224
+ cert.public_bytes(serialization.Encoding.DER)
225
+ ).decode()
226
+ cert_response = {
227
+ "data": [
228
+ {
229
+ "id": "BAD",
230
+ "attributes": {
231
+ "certificateType": "DISTRIBUTION",
232
+ "certificateContent": cert_b64,
233
+ },
234
+ }
235
+ ]
236
+ }
237
+
238
+ def fake_get_json(path, token, *, params=None):
239
+ if path.startswith("/certificates"):
240
+ return cert_response
241
+ return {"data": []}
242
+
243
+ with mock.patch.object(team_resolver, "get_json", side_effect=fake_get_json):
244
+ derived = team_resolver.derive_team_id("jwt-token")
245
+ self.assertEqual(derived, "")
246
+
247
+
248
+ if __name__ == "__main__":
249
+ unittest.main()
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Shared test helpers for the manage_marketing_version test suite.
4
+
5
+ Imports the production module via `manage_marketing_version` so test
6
+ patches at the `mmv` namespace propagate through helper code paths
7
+ (``mmv.fetch_versions``, ``mmv.get_combined_floor``, etc. are seam
8
+ re-exports the helpers exercise late-bound).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import io
14
+ import os
15
+ import sys
16
+ from contextlib import redirect_stderr
17
+ from pathlib import Path
18
+ from unittest import mock
19
+
20
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
21
+
22
+ import manage_marketing_version as mmv # noqa: E402
23
+
24
+
25
+ def _v(version: str, state: str, vid: str = "", created: str = "") -> dict:
26
+ """Build a minimal versions-list entry matching fetch_versions() shape."""
27
+ return {
28
+ "versionString": version,
29
+ "state": state,
30
+ "id": vid or f"id-{version}-{state}",
31
+ "createdDate": created,
32
+ }
33
+
34
+
35
+ class _Env:
36
+ """Scoped override of an env var so tests don't leak state."""
37
+
38
+ def __init__(self, name: str, value: str | None) -> None:
39
+ self._name = name
40
+ self._value = value
41
+ self._prev: str | None = None
42
+ self._had: bool = False
43
+
44
+ def __enter__(self) -> "_Env":
45
+ self._had = self._name in os.environ
46
+ self._prev = os.environ.get(self._name)
47
+ if self._value is None:
48
+ os.environ.pop(self._name, None)
49
+ else:
50
+ os.environ[self._name] = self._value
51
+ return self
52
+
53
+ def __exit__(self, *_exc) -> None:
54
+ if self._had:
55
+ os.environ[self._name] = self._prev or ""
56
+ else:
57
+ os.environ.pop(self._name, None)
58
+
59
+
60
+ _BUILD_SETTING_TOKENS = (
61
+ "project.yml", "Info.plist", "xcconfig",
62
+ "MARKETING_VERSION", "CFBundleShortVersionString",
63
+ )
64
+
65
+
66
+ def _assert_mentions_build_setting_source(test, stderr: str) -> None:
67
+ """The cross-check error message MUST name at least one build-setting
68
+ source so the user knows what to edit in their project."""
69
+ test.assertTrue(
70
+ any(tok in stderr for tok in _BUILD_SETTING_TOKENS),
71
+ f"error must reference a build-setting source; got {stderr!r}",
72
+ )
73
+
74
+
75
+ def _patch_floor(
76
+ ground_truth: str | None,
77
+ combined_floor: str | None = None,
78
+ *,
79
+ per_source: dict[str, str | None] | None = None,
80
+ ):
81
+ """Context-manager bundle for the two mmv-level floor seams. Tests use
82
+ this to control what _assert_above_ground_truth sees without reaching
83
+ into asc_version_fetch.
84
+
85
+ `per_source` overrides the per-source breakdown surfaced in the
86
+ error message; defaults to attributing the floor to appStoreVersions
87
+ only so legacy tests stay green."""
88
+ cf = combined_floor if combined_floor is not None else ground_truth
89
+ breakdown = per_source if per_source is not None else {
90
+ "appStoreVersions": cf,
91
+ "preReleaseVersions": None,
92
+ "buildsViaPreRelease": None,
93
+ }
94
+ return (
95
+ mock.patch.object(mmv, "get_ground_truth_floor", return_value=ground_truth),
96
+ mock.patch.object(mmv, "get_combined_floor", return_value=(cf, breakdown)),
97
+ )
98
+
99
+
100
+ def _run_decide_for_version(
101
+ target_version: str,
102
+ versions: list[dict],
103
+ *,
104
+ new_id: str = "new-created-id",
105
+ ground_truth: str | None = None,
106
+ combined_floor: str | None = None,
107
+ per_source: dict[str, str | None] | None = None,
108
+ ):
109
+ """
110
+ Invoke decide_for_version() with create_or_reuse stubbed to return
111
+ `new_id`. Returns (result, stderr_text, create_calls).
112
+
113
+ `combined_floor` controls what mmv.get_combined_floor returns; if None,
114
+ `ground_truth` doubles as the combined value too. `per_source` lets the
115
+ test inject a real breakdown so message-content assertions can verify
116
+ the binding-source name actually surfaces (the production path).
117
+ """
118
+ buf = io.StringIO()
119
+ gt_patch, cf_patch = _patch_floor(
120
+ ground_truth, combined_floor, per_source=per_source,
121
+ )
122
+ with mock.patch.object(
123
+ mmv, "create_or_reuse_with_stale", return_value=new_id) as cor, \
124
+ gt_patch, cf_patch, \
125
+ redirect_stderr(buf):
126
+ result = mmv.decide_for_version(
127
+ target_version, versions, app_id="111", token="tok"
128
+ )
129
+ return result, buf.getvalue(), cor.call_args_list
130
+
131
+
132
+ def _assert_decide_exits(
133
+ test, target: str, versions: list[dict],
134
+ *, ground_truth: str | None, combined_floor: str | None = None,
135
+ per_source: dict[str, str | None] | None = None,
136
+ expected_code: int = 2,
137
+ ) -> str:
138
+ """Run decide_for_version expecting SystemExit. Returns the stderr text
139
+ so the caller can make message-content assertions."""
140
+ buf = io.StringIO()
141
+ gt_patch, cf_patch = _patch_floor(
142
+ ground_truth, combined_floor, per_source=per_source,
143
+ )
144
+ with gt_patch, cf_patch, redirect_stderr(buf):
145
+ with test.assertRaises(SystemExit) as ctx:
146
+ mmv.decide_for_version(target, versions, app_id="111", token="tok")
147
+ test.assertEqual(ctx.exception.code, expected_code)
148
+ return buf.getvalue()
149
+
150
+
151
+ class _FakeResponse:
152
+ """Minimal stand-in for requests.Response."""
153
+
154
+ def __init__(self, payload: dict, status_code: int = 200) -> None:
155
+ self._payload = payload
156
+ self.status_code = status_code
157
+ self.text = ""
158
+
159
+ def json(self) -> dict:
160
+ return self._payload
161
+
162
+
163
+ def _mk_prerelease_entry(version: str, pid: str = "") -> dict:
164
+ return {
165
+ "id": pid or f"pr-{version}",
166
+ "attributes": {"version": version, "platform": "IOS"},
167
+ }