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,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()
|