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,170 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tests for ``decide_for_version`` floor-vs-target scenarios.
4
+
5
+ Covers what happens when the project's MARKETING_VERSION sits below /
6
+ above / equal to the ASC combined floor (max of appStoreVersions +
7
+ preReleaseVersions + buildsViaPreRelease):
8
+
9
+ * floor strictly above target with editable at target -> SystemExit(2)
10
+ (must NOT silently downgrade)
11
+ * floor equals target with no row at target -> SystemExit(2)
12
+ (CREATE would collide)
13
+ * floor None (true first-release) -> CREATE proceeds
14
+ * target below floor where floor came from a higher editable ->
15
+ SystemExit(2)
16
+ * target strictly above the floor -> CREATE
17
+ * target equal to a terminal floor row -> SystemExit(2)
18
+ (the auto-roll variant of this lives in
19
+ test_manage_marketing_version_autoroll.py)
20
+ * target below a terminal row -> SystemExit(2)
21
+
22
+ Match-at-target scenarios live in
23
+ ``test_manage_marketing_version_match.py``. main() / env-validation
24
+ scenarios stay in ``test_manage_marketing_version.py``. Split out in
25
+ round 8 to keep each test file under the 10-functions-per-file cap.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import os
31
+ import sys
32
+ import unittest
33
+ from pathlib import Path
34
+ from unittest import mock
35
+
36
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
37
+
38
+ from tests_common import ( # noqa: E402
39
+ _assert_decide_exits,
40
+ _assert_mentions_build_setting_source,
41
+ _run_decide_for_version,
42
+ _v,
43
+ )
44
+
45
+
46
+ def setUpModule() -> None:
47
+ """Pin auto-bump to 'none' for the whole floor-tests module.
48
+
49
+ The floor-vs-target tests assert the historical fail-on-floor /
50
+ historical reject behavior. With auto-bump enabled (the default
51
+ 'patch'), the round-7 auto-roll would CREATE the next patch
52
+ instead of rejecting on terminal collisions, which is a
53
+ different code path covered by
54
+ ``test_manage_marketing_version_autoroll.py``.
55
+
56
+ Pinning the env via setUpModule (rather than per-test) keeps each
57
+ test pure -- they exercise floor reasoning, not policy gating."""
58
+ global _ENV_PATCH
59
+ _ENV_PATCH = mock.patch.dict(
60
+ os.environ, {"MARKETING_VERSION_AUTO_BUMP": "none"}, clear=False,
61
+ )
62
+ _ENV_PATCH.start()
63
+
64
+
65
+ def tearDownModule() -> None:
66
+ _ENV_PATCH.stop()
67
+
68
+
69
+ class FloorVsTargetTests(unittest.TestCase):
70
+ """``decide_for_version`` floor-vs-target compare scenarios."""
71
+
72
+ def test_reuse_blocked_when_floor_strictly_above_target(self):
73
+ """Issue 3 regression: editable matches target but floor came
74
+ from a HIGHER source. REUSE must NOT silently downgrade."""
75
+ editable = _v("1.0.0", "PREPARE_FOR_SUBMISSION", vid="ed-100")
76
+ per_source = {
77
+ "appStoreVersions": "2.0.0",
78
+ "preReleaseVersions": "2.0.0",
79
+ "buildsViaPreRelease": None,
80
+ }
81
+ stderr = _assert_decide_exits(
82
+ self, "1.0.0", [editable],
83
+ ground_truth="2.0.0", combined_floor="2.0.0",
84
+ per_source=per_source,
85
+ )
86
+ self.assertIn("1.0.0", stderr)
87
+ self.assertIn("2.0.0", stderr)
88
+ _assert_mentions_build_setting_source(self, stderr)
89
+
90
+ def test_create_blocked_when_floor_equals_target_no_match(self):
91
+ """No editable at target but floor equals target -- CREATE
92
+ blocked (would collide with whatever pinned the floor)."""
93
+ per_source = {
94
+ "appStoreVersions": None,
95
+ "preReleaseVersions": "1.0.0",
96
+ "buildsViaPreRelease": None,
97
+ }
98
+ stderr = _assert_decide_exits(
99
+ self, "1.0.0", [],
100
+ ground_truth=None, combined_floor="1.0.0",
101
+ per_source=per_source,
102
+ )
103
+ self.assertIn("strictly greater than", stderr)
104
+ self.assertIn("1.0.0", stderr)
105
+ _assert_mentions_build_setting_source(self, stderr)
106
+
107
+ def test_create_proceeds_when_floor_none_and_target_present(self):
108
+ """First-release scenario: ASC empty, target 1.0.0 -> CREATE 1.0.0."""
109
+ result, stderr, calls = _run_decide_for_version(
110
+ "1.0.0", versions=[],
111
+ ground_truth=None, combined_floor=None,
112
+ )
113
+ self.assertEqual(result["decision"], "CREATE")
114
+ self.assertEqual(result["versionString"], "1.0.0")
115
+ self.assertEqual(len(calls), 1)
116
+ self.assertEqual(calls[0].args[1], "1.0.0")
117
+ self.assertIn("decision=CREATE", stderr)
118
+
119
+ def test_target_below_floor_with_editable_at_floor_exits_2(self):
120
+ """Scenario 3: target 1.0.0 + editable PFS at 1.0.6 with combined
121
+ floor 1.0.6 -> SystemExit(2) with build-setting source mention."""
122
+ editable = _v("1.0.6", "PREPARE_FOR_SUBMISSION", vid="ed-106")
123
+ stderr = _assert_decide_exits(
124
+ self, "1.0.0", [editable], ground_truth="1.0.6",
125
+ )
126
+ self.assertIn("1.0.0", stderr)
127
+ self.assertIn("1.0.6", stderr)
128
+ _assert_mentions_build_setting_source(self, stderr)
129
+
130
+ def test_target_strictly_above_floor_creates(self):
131
+ """Scenario 4: target 1.0.5 + appStoreVersions [1.0.4 RFS] -> CREATE 1.0.5."""
132
+ versions = [_v("1.0.4", "READY_FOR_SALE", vid="rfs-104")]
133
+ result, stderr, calls = _run_decide_for_version(
134
+ "1.0.5", versions=versions, ground_truth="1.0.4",
135
+ )
136
+ self.assertEqual(result["decision"], "CREATE")
137
+ self.assertEqual(result["versionString"], "1.0.5")
138
+ self.assertEqual(len(calls), 1)
139
+ self.assertEqual(calls[0].args[1], "1.0.5")
140
+ self.assertIn("decision=CREATE", stderr)
141
+
142
+ def test_target_equal_to_terminal_exits_2(self):
143
+ """Scenario 5: target 1.0.4 + appStoreVersions [1.0.4 RFS] ->
144
+ SystemExit(2). When auto-bump policy='none' (the default in this
145
+ test fixture's _run_decide_for_version helper), no auto-roll
146
+ fires, so the historical reject path applies.
147
+
148
+ The auto-roll variant (same scenario but auto-bump=patch ->
149
+ CREATE 1.0.5) lives in
150
+ test_manage_marketing_version_autoroll.py."""
151
+ versions = [_v("1.0.4", "READY_FOR_SALE", vid="rfs-104")]
152
+ stderr = _assert_decide_exits(
153
+ self, "1.0.4", versions, ground_truth="1.0.4",
154
+ )
155
+ self.assertIn("1.0.4", stderr)
156
+ _assert_mentions_build_setting_source(self, stderr)
157
+
158
+ def test_target_below_terminal_exits_2(self):
159
+ """Scenario 6: target 1.0.3 + appStoreVersions [1.0.4 RFS] -> SystemExit(2)."""
160
+ versions = [_v("1.0.4", "READY_FOR_SALE", vid="rfs-104")]
161
+ stderr = _assert_decide_exits(
162
+ self, "1.0.3", versions, ground_truth="1.0.4",
163
+ )
164
+ self.assertIn("1.0.3", stderr)
165
+ self.assertIn("1.0.4", stderr)
166
+ _assert_mentions_build_setting_source(self, stderr)
167
+
168
+
169
+ if __name__ == "__main__":
170
+ unittest.main()
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tests for ``decide_for_version`` row-matching scenarios.
4
+
5
+ Covers what happens when the project's MARKETING_VERSION matches (or
6
+ doesn't match) an existing ASC row:
7
+
8
+ * empty ASC -> CREATE
9
+ * editable PFS at target -> REUSE
10
+ * steady-state TestFlight (target == combined_floor with editable
11
+ match) -> REUSE
12
+ * terminal / in-review collision at target -> SystemExit(2)
13
+ (the auto-roll lock-in test for terminal-but-autobump-enabled lives
14
+ in test_manage_marketing_version_autoroll.py)
15
+ * DEVELOPER_REJECTED at target -> REUSE (developer pulled it from
16
+ review; safe to mutate)
17
+
18
+ Floor-vs-target scenarios live in
19
+ ``test_manage_marketing_version_floor.py``. main() / env-validation
20
+ scenarios stay in ``test_manage_marketing_version.py``. Split out in
21
+ round 8 to keep each test file under the 10-functions-per-file cap.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ import sys
28
+ import unittest
29
+ from pathlib import Path
30
+ from unittest import mock
31
+
32
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
33
+
34
+ from tests_common import ( # noqa: E402
35
+ _assert_decide_exits,
36
+ _assert_mentions_build_setting_source,
37
+ _run_decide_for_version,
38
+ _v,
39
+ )
40
+
41
+
42
+ def setUpModule() -> None:
43
+ """Pin auto-bump to 'none' for the whole match-tests module.
44
+
45
+ The terminal-at-target / in-review-at-target tests assert the
46
+ historical SystemExit(2) reject behavior. With auto-bump enabled
47
+ (the default 'patch'), the round-7 auto-roll would CREATE the
48
+ next patch on TERMINAL collisions, which is a different code path
49
+ covered by ``test_manage_marketing_version_autoroll.py``."""
50
+ global _ENV_PATCH
51
+ _ENV_PATCH = mock.patch.dict(
52
+ os.environ, {"MARKETING_VERSION_AUTO_BUMP": "none"}, clear=False,
53
+ )
54
+ _ENV_PATCH.start()
55
+
56
+
57
+ def tearDownModule() -> None:
58
+ _ENV_PATCH.stop()
59
+
60
+
61
+ class MatchAtTargetTests(unittest.TestCase):
62
+ """``decide_for_version`` row-matching at the target versionString."""
63
+
64
+ def test_target_1_0_0_empty_asc_creates(self):
65
+ """Scenario 1: target 1.0.0 + ASC empty -> CREATE."""
66
+ result, stderr, calls = _run_decide_for_version(
67
+ "1.0.0", versions=[], ground_truth=None,
68
+ )
69
+ self.assertEqual(result["decision"], "CREATE")
70
+ self.assertEqual(result["versionString"], "1.0.0")
71
+ self.assertEqual(result["appStoreVersionId"], "new-created-id")
72
+ self.assertEqual(len(calls), 1)
73
+ self.assertEqual(calls[0].args[1], "1.0.0")
74
+ self.assertIn("decision=CREATE", stderr)
75
+
76
+ def test_target_matches_existing_editable_reuses(self):
77
+ """Scenario 2: target 1.0.0 + editable PFS at 1.0.0 -> REUSE id."""
78
+ editable = _v("1.0.0", "PREPARE_FOR_SUBMISSION", vid="ed-100")
79
+ result, stderr, calls = _run_decide_for_version(
80
+ "1.0.0", versions=[editable], ground_truth=None,
81
+ )
82
+ self.assertEqual(result["decision"], "REUSE")
83
+ self.assertEqual(result["versionString"], "1.0.0")
84
+ self.assertEqual(result["appStoreVersionId"], "ed-100")
85
+ self.assertEqual(calls, [])
86
+ self.assertIn("decision=REUSE", stderr)
87
+ self.assertIn("matches MARKETING_VERSION", stderr)
88
+
89
+ def test_reuse_when_target_equals_combined_floor_steady_state(self):
90
+ """Steady-state TestFlight iteration: project still sits at 1.0.0,
91
+ ASC has the editable PFS row at 1.0.0 AND the prior TestFlight
92
+ upload registered the same version under preReleaseVersions
93
+ (combined_floor=1.0.0). REUSE must succeed -- the cross-check is
94
+ not allowed to compare the target to itself."""
95
+ editable = _v("1.0.0", "PREPARE_FOR_SUBMISSION", vid="ed-100")
96
+ per_source = {
97
+ "appStoreVersions": "1.0.0",
98
+ "preReleaseVersions": "1.0.0",
99
+ "buildsViaPreRelease": "1.0.0",
100
+ }
101
+ result, stderr, calls = _run_decide_for_version(
102
+ "1.0.0", versions=[editable],
103
+ ground_truth="1.0.0", combined_floor="1.0.0",
104
+ per_source=per_source,
105
+ )
106
+ self.assertEqual(result["decision"], "REUSE")
107
+ self.assertEqual(result["appStoreVersionId"], "ed-100")
108
+ self.assertEqual(calls, [])
109
+ self.assertNotIn("must be strictly greater", stderr)
110
+ self.assertIn("decision=REUSE", stderr)
111
+
112
+ def test_target_matches_terminal_at_target_exits_2(self):
113
+ """Same versionString already shipped (READY_FOR_SALE) at exactly
114
+ target -> SystemExit(2)."""
115
+ terminal = _v("1.0.0", "READY_FOR_SALE", vid="rfs-100")
116
+ stderr = _assert_decide_exits(
117
+ self, "1.0.0", [terminal],
118
+ ground_truth="1.0.0", combined_floor="1.0.0",
119
+ )
120
+ self.assertIn("1.0.0", stderr)
121
+ self.assertIn("READY_FOR_SALE", stderr)
122
+ self.assertIn("fresh marketing version", stderr)
123
+ _assert_mentions_build_setting_source(self, stderr)
124
+
125
+ def test_target_matches_editable_and_terminal_exits_2(self):
126
+ """Issue 8 regression guard: ASC normally enforces a single row
127
+ per versionString, but if both editable PFS and terminal RFS
128
+ co-exist at target, REUSE'ing the editable would silently
129
+ proceed past a row the user actually shipped."""
130
+ editable = _v("1.0.0", "PREPARE_FOR_SUBMISSION", vid="ed-100")
131
+ terminal = _v("1.0.0", "READY_FOR_SALE", vid="rfs-100")
132
+ stderr = _assert_decide_exits(
133
+ self, "1.0.0", [editable, terminal],
134
+ ground_truth="1.0.0", combined_floor="1.0.0",
135
+ )
136
+ self.assertIn("1.0.0", stderr)
137
+ self.assertIn("READY_FOR_SALE", stderr)
138
+ self.assertIn("editable row at the same versionString", stderr)
139
+ self.assertIn("fresh marketing version", stderr)
140
+ _assert_mentions_build_setting_source(self, stderr)
141
+
142
+ def test_target_matches_waiting_for_review_exits_2(self):
143
+ """Issue 13: REUSE must NOT attach a TestFlight build to a
144
+ version Apple is reviewing. WAITING_FOR_REVIEW is editable in
145
+ ASC's broad sense, but mutating it would interfere with App
146
+ Review. SystemExit(2) with "review" wording."""
147
+ in_review = _v("1.0.0", "WAITING_FOR_REVIEW", vid="ed-wfr-100")
148
+ stderr = _assert_decide_exits(
149
+ self, "1.0.0", [in_review],
150
+ ground_truth="1.0.0", combined_floor="1.0.0",
151
+ )
152
+ self.assertIn("1.0.0", stderr)
153
+ self.assertIn("WAITING_FOR_REVIEW", stderr)
154
+ self.assertIn("ed-wfr-100", stderr)
155
+ self.assertIn("review", stderr.lower())
156
+ _assert_mentions_build_setting_source(self, stderr)
157
+
158
+ def test_target_matches_in_review_exits_2(self):
159
+ """Issue 13: IN_REVIEW match is also blocked, same as
160
+ WAITING_FOR_REVIEW. Apple is actively reviewing the row."""
161
+ in_review = _v("1.0.0", "IN_REVIEW", vid="ed-ir-100")
162
+ stderr = _assert_decide_exits(
163
+ self, "1.0.0", [in_review],
164
+ ground_truth="1.0.0", combined_floor="1.0.0",
165
+ )
166
+ self.assertIn("1.0.0", stderr)
167
+ self.assertIn("IN_REVIEW", stderr)
168
+ self.assertIn("ed-ir-100", stderr)
169
+ self.assertIn("review", stderr.lower())
170
+ _assert_mentions_build_setting_source(self, stderr)
171
+
172
+ def test_target_matches_developer_rejected_reuses(self):
173
+ """Issue 13: DEVELOPER_REJECTED is in REUSABLE_STATES — the
174
+ developer explicitly took the row out of review, so REUSE is
175
+ the correct steady-state behaviour."""
176
+ rejected = _v("1.0.0", "DEVELOPER_REJECTED", vid="ed-dr-100")
177
+ result, stderr, calls = _run_decide_for_version(
178
+ "1.0.0", versions=[rejected], ground_truth=None,
179
+ )
180
+ self.assertEqual(result["decision"], "REUSE")
181
+ self.assertEqual(result["appStoreVersionId"], "ed-dr-100")
182
+ self.assertEqual(calls, [])
183
+ self.assertIn("decision=REUSE", stderr)
184
+
185
+
186
+ if __name__ == "__main__":
187
+ unittest.main()
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tests for the POST /appStoreVersions 409-classifier path.
4
+
5
+ The single-source-of-truth fix means CI must NEVER bump MARKETING_VERSION
6
+ on its own; every 409 from POST that is not an actual race-recovered
7
+ REUSE-by-fetch must surface the underlying ASC error and exit non-zero.
8
+ This file pins:
9
+
10
+ - 409 RELATIONSHIP.INVALID / unknown-code / version-collision under the
11
+ no-bump default -> SystemExit(4) without bump.
12
+ - The pre-flight request body is logged before POST so failures point
13
+ at the offending payload immediately.
14
+ - Failure response bodies log untruncated (full ASC errors).
15
+ - 409 race-recovery via fetch_versions reconcile (REUSE-by-fetch).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import io
21
+ import json
22
+ import sys
23
+ import unittest
24
+ from contextlib import redirect_stderr
25
+ from pathlib import Path
26
+ from unittest import mock
27
+
28
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
29
+
30
+ import manage_marketing_version as mmv # noqa: E402
31
+ import asc_build_history # noqa: E402
32
+ import asc_version_create # noqa: E402
33
+
34
+ from tests_common import _v # noqa: E402
35
+
36
+
37
+ def _patch_empty_fetches():
38
+ """Common patch set for create_or_reuse 409-classifier tests: empty
39
+ fetch_versions + empty preReleaseVersions/builds so the floor never
40
+ blocks before the 409 path runs."""
41
+ return (
42
+ mock.patch.object(mmv, "fetch_versions", return_value=[]),
43
+ mock.patch.object(
44
+ asc_build_history, "fetch_prerelease_versions", return_value=[],
45
+ ),
46
+ mock.patch.object(
47
+ asc_build_history, "fetch_builds_prerelease_versions",
48
+ return_value=[],
49
+ ),
50
+ )
51
+
52
+
53
+ class Status409ClassifierTests(unittest.TestCase):
54
+ """Guards: 409 RELATIONSHIP.INVALID/etc. must fail fast (NOT bump)."""
55
+
56
+ def _conflict(self, code: str, pointer: str | None = None,
57
+ detail: str = "conflict") -> mock.Mock:
58
+ err: dict = {"status": "409", "code": code, "detail": detail}
59
+ if pointer:
60
+ err["source"] = {"pointer": pointer}
61
+ resp = mock.Mock()
62
+ resp.status_code = 409
63
+ resp.headers = {"X-ASC-Request-Id": "abc-123"}
64
+ resp.text = json.dumps({"errors": [err]})
65
+ return resp
66
+
67
+ def test_create_version_payload_shape(self):
68
+ captured: dict = {}
69
+
70
+ def fake_request(method, path, token, **kwargs):
71
+ captured["method"] = method
72
+ captured["path"] = path
73
+ captured["json_body"] = kwargs.get("json_body")
74
+ out = mock.Mock()
75
+ out.status_code = 201
76
+ out.json.return_value = {"data": {"id": "ok"}}
77
+ return out
78
+
79
+ with mock.patch.object(asc_version_create, "request", side_effect=fake_request):
80
+ asc_version_create.create_version("6755308530", "1.0.6", "tok")
81
+
82
+ self.assertEqual(captured["method"], "POST")
83
+ self.assertEqual(captured["path"], "/appStoreVersions")
84
+ body = captured["json_body"]
85
+ data = body["data"]
86
+ self.assertEqual(data["type"], "appStoreVersions")
87
+ self.assertEqual(data["attributes"]["platform"], "IOS")
88
+ self.assertEqual(data["attributes"]["versionString"], "1.0.6")
89
+ app_rel = data["relationships"]["app"]["data"]
90
+ self.assertEqual(app_rel["type"], "apps")
91
+ self.assertEqual(app_rel["id"], "6755308530")
92
+ self.assertIsInstance(app_rel["id"], str)
93
+
94
+ def test_request_body_logged_before_post(self):
95
+ buf = io.StringIO()
96
+ logged_before_post: list[bool] = []
97
+
98
+ def fake_request(method, path, token, **kwargs):
99
+ logged_before_post.append(
100
+ "[create-version] request body" in buf.getvalue()
101
+ )
102
+ out = mock.Mock()
103
+ out.status_code = 201
104
+ out.json.return_value = {"data": {"id": "ok"}}
105
+ return out
106
+
107
+ with mock.patch.object(asc_version_create, "request", side_effect=fake_request), \
108
+ redirect_stderr(buf):
109
+ asc_version_create.create_version("111", "1.0.6", "tok")
110
+
111
+ self.assertTrue(logged_before_post and logged_before_post[0])
112
+ stderr = buf.getvalue()
113
+ self.assertIn("[create-version] request body", stderr)
114
+ self.assertIn("1.0.6", stderr)
115
+ self.assertIn("appStoreVersions", stderr)
116
+
117
+ def test_response_body_logged_on_failure_untruncated(self):
118
+ big_detail = "x" * 2500
119
+ resp = self._conflict(
120
+ "ENTITY_ERROR.RELATIONSHIP.INVALID",
121
+ pointer="/data/relationships/app",
122
+ detail=big_detail,
123
+ )
124
+ buf = io.StringIO()
125
+ fv, fpr, fbpr = _patch_empty_fetches()
126
+ with mock.patch.object(mmv, "create_version", return_value=resp), \
127
+ fv, fpr, fbpr, redirect_stderr(buf):
128
+ with self.assertRaises(SystemExit):
129
+ mmv.create_or_reuse("111", "1.0.6", "tok")
130
+
131
+ stderr = buf.getvalue()
132
+ self.assertIn(big_detail, stderr)
133
+ self.assertIn("/data/relationships/app", stderr)
134
+
135
+ def test_409_relationship_invalid_fails_fast(self):
136
+ """409 RELATIONSHIP.INVALID -> SystemExit(4); never reaches bump_fn."""
137
+ resp = self._conflict(
138
+ "ENTITY_ERROR.RELATIONSHIP.INVALID",
139
+ pointer="/data/relationships/app",
140
+ )
141
+ attempts: list[str] = []
142
+
143
+ def fake_create(app_id, version, token):
144
+ attempts.append(version)
145
+ return resp
146
+
147
+ buf = io.StringIO()
148
+ fv, fpr, fbpr = _patch_empty_fetches()
149
+ with mock.patch.object(mmv, "create_version", side_effect=fake_create), \
150
+ fv, fpr, fbpr, redirect_stderr(buf):
151
+ with self.assertRaises(SystemExit) as ctx:
152
+ mmv.create_or_reuse("111", "1.0.6", "tok")
153
+
154
+ self.assertEqual(attempts, ["1.0.6"])
155
+ self.assertEqual(ctx.exception.code, 4)
156
+ stderr = buf.getvalue()
157
+ self.assertIn("RELATIONSHIP.INVALID", stderr)
158
+ self.assertIn("/data/relationships/app", stderr)
159
+
160
+ def test_409_unknown_code_fails_fast_with_diagnostic(self):
161
+ resp = self._conflict(
162
+ "ENTITY_ERROR.SOMETHING_NEW",
163
+ pointer="/data/attributes/platform",
164
+ detail="unexpected thing",
165
+ )
166
+ attempts: list[str] = []
167
+
168
+ def fake_create(app_id, version, token):
169
+ attempts.append(version)
170
+ return resp
171
+
172
+ buf = io.StringIO()
173
+ fv, fpr, fbpr = _patch_empty_fetches()
174
+ with mock.patch.object(mmv, "create_version", side_effect=fake_create), \
175
+ fv, fpr, fbpr, redirect_stderr(buf):
176
+ with self.assertRaises(SystemExit):
177
+ mmv.create_or_reuse("111", "1.0.6", "tok")
178
+
179
+ self.assertEqual(attempts, ["1.0.6"])
180
+ stderr = buf.getvalue()
181
+ self.assertIn("ENTITY_ERROR.SOMETHING_NEW", stderr)
182
+
183
+ def test_409_version_collision_no_bump_exits_4(self):
184
+ """Even a real version-collision 409 must exit 4 now."""
185
+ collision_resp = self._conflict(
186
+ "CONFLICT.VERSION_EXISTS",
187
+ detail="A version with this versionString already exists.",
188
+ )
189
+ attempts: list[str] = []
190
+
191
+ def fake_create(app_id, version, token):
192
+ attempts.append(version)
193
+ return collision_resp
194
+
195
+ buf = io.StringIO()
196
+ fv, fpr, fbpr = _patch_empty_fetches()
197
+ with mock.patch.object(mmv, "create_version", side_effect=fake_create), \
198
+ fv, fpr, fbpr, redirect_stderr(buf):
199
+ with self.assertRaises(SystemExit) as ctx:
200
+ mmv.create_or_reuse("111", "1.0.6", "tok")
201
+
202
+ self.assertEqual(attempts, ["1.0.6"])
203
+ self.assertEqual(ctx.exception.code, 4)
204
+ stderr = buf.getvalue()
205
+ self.assertIn("hidden", stderr.lower())
206
+
207
+ def test_409_reuses_existing_version_by_versionString(self):
208
+ """If POST returns 409 and fetch_versions surfaces a matching id,
209
+ create_or_reuse returns that id (REUSE-by-fetch path)."""
210
+ fake_resp = mock.Mock()
211
+ fake_resp.status_code = 409
212
+ fake_resp.headers = {}
213
+ fake_resp.text = ""
214
+
215
+ with mock.patch.object(mmv, "create_version", return_value=fake_resp), \
216
+ mock.patch.object(
217
+ mmv, "fetch_versions",
218
+ return_value=[_v("1.2.3", "PREPARE_FOR_SUBMISSION", vid="race-id")],
219
+ ), \
220
+ mock.patch.object(
221
+ asc_build_history, "fetch_prerelease_versions",
222
+ return_value=[]), \
223
+ mock.patch.object(
224
+ asc_build_history, "fetch_builds_prerelease_versions",
225
+ return_value=[]):
226
+ got = mmv.create_or_reuse("111", "1.2.3", "tok")
227
+ self.assertEqual(got, "race-id")
228
+
229
+
230
+ if __name__ == "__main__":
231
+ unittest.main()