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,163 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tests for ``mmv.fetch_versions`` (a thin re-export of
4
+ ``asc_version_fetch.fetch_versions``).
5
+
6
+ Covers pagination, the explicit ``filter[platform]=IOS`` query param,
7
+ the ``limit=200`` (ASC max) request shape, and the per-record stderr
8
+ logging used by the CI debug trace. These were factored out of
9
+ ``test_mmv_floor_check.py`` to keep that module under the 400-line
10
+ cap while preserving every assertion.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import io
16
+ import sys
17
+ import unittest
18
+ from contextlib import redirect_stderr
19
+ from pathlib import Path
20
+ from unittest import mock
21
+
22
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
23
+
24
+ import manage_marketing_version as mmv # noqa: E402
25
+ import asc_version_fetch # noqa: E402
26
+
27
+ from tests_common import _FakeResponse # noqa: E402
28
+
29
+
30
+ def _asv_record(
31
+ vid: str, version: str, state: str, created: str,
32
+ ) -> dict:
33
+ """Build a /v1/apps/{id}/appStoreVersions data entry."""
34
+ return {
35
+ "id": vid,
36
+ "attributes": {
37
+ "versionString": version,
38
+ "appStoreState": state,
39
+ "platform": "IOS",
40
+ "createdDate": created,
41
+ },
42
+ }
43
+
44
+
45
+ class FetchVersionsPaginationTests(unittest.TestCase):
46
+ """fetch_versions MUST follow links.next to exhaustion."""
47
+
48
+ def test_fetch_is_paginated(self):
49
+ page1 = {
50
+ "data": [
51
+ _asv_record("id-1.0", "1.0", "READY_FOR_SALE",
52
+ "2025-01-01T00:00:00Z"),
53
+ _asv_record("id-1.0.1", "1.0.1", "PREPARE_FOR_SUBMISSION",
54
+ "2025-02-01T00:00:00Z"),
55
+ ],
56
+ "links": {
57
+ "next": "https://api.appstoreconnect.apple.com/v1/apps/111/"
58
+ "appStoreVersions?cursor=PAGE2",
59
+ },
60
+ }
61
+ page2 = {
62
+ "data": [
63
+ _asv_record("id-1.0.4", "1.0.4", "READY_FOR_SALE",
64
+ "2025-04-01T00:00:00Z"),
65
+ ],
66
+ "links": {},
67
+ }
68
+ responses = [_FakeResponse(page1), _FakeResponse(page2)]
69
+
70
+ def fake_request(method, path, token, *, params=None, **_kw):
71
+ return responses.pop(0)
72
+
73
+ with mock.patch.object(asc_version_fetch, "request", side_effect=fake_request):
74
+ got = mmv.fetch_versions("111", "tok")
75
+
76
+ versions = {v["versionString"] for v in got}
77
+ self.assertIn("1.0.4", versions)
78
+ self.assertIn("1.0", versions)
79
+ self.assertIn("1.0.1", versions)
80
+ self.assertEqual(len(responses), 0, "both pages must be consumed")
81
+
82
+
83
+ class FetchVersionsPlatformFilterTests(unittest.TestCase):
84
+ """fetch_versions MUST pass filter[platform]=IOS."""
85
+
86
+ def test_platform_filter_applied(self):
87
+ seen_params: list[dict | None] = []
88
+
89
+ def fake_request(method, path, token, *, params=None, **_kw):
90
+ seen_params.append(params)
91
+ return _FakeResponse({"data": [], "links": {}})
92
+
93
+ with mock.patch.object(asc_version_fetch, "request", side_effect=fake_request):
94
+ mmv.fetch_versions("111", "tok")
95
+
96
+ self.assertTrue(seen_params)
97
+ first = seen_params[0] or {}
98
+ self.assertEqual(first.get("filter[platform]"), "IOS")
99
+
100
+
101
+ class FetchVersionsLimitTests(unittest.TestCase):
102
+ """fetch_versions MUST request limit=200 (ASC max)."""
103
+
104
+ def test_limit_200_requested(self):
105
+ seen_params: list[dict | None] = []
106
+
107
+ def fake_request(method, path, token, *, params=None, **_kw):
108
+ seen_params.append(params)
109
+ return _FakeResponse({"data": [], "links": {}})
110
+
111
+ with mock.patch.object(asc_version_fetch, "request", side_effect=fake_request):
112
+ mmv.fetch_versions("111", "tok")
113
+
114
+ first = seen_params[0] or {}
115
+ self.assertEqual(first.get("limit"), 200)
116
+
117
+
118
+ class FetchVersionsLoggingTests(unittest.TestCase):
119
+ """Every fetched record MUST be logged to stderr."""
120
+
121
+ def test_every_record_logged(self):
122
+ page1 = {
123
+ "data": [
124
+ {
125
+ "id": "id-1.0.4",
126
+ "attributes": {
127
+ "versionString": "1.0.4",
128
+ "appStoreState": "READY_FOR_SALE",
129
+ "platform": "IOS",
130
+ "createdDate": "2025-04-01T00:00:00Z",
131
+ },
132
+ },
133
+ {
134
+ "id": "id-1.0.1",
135
+ "attributes": {
136
+ "versionString": "1.0.1",
137
+ "appStoreState": "PREPARE_FOR_SUBMISSION",
138
+ "platform": "IOS",
139
+ "createdDate": "2025-02-01T00:00:00Z",
140
+ },
141
+ },
142
+ ],
143
+ "links": {},
144
+ }
145
+
146
+ def fake_request(method, path, token, *, params=None, **_kw):
147
+ return _FakeResponse(page1)
148
+
149
+ buf = io.StringIO()
150
+ with mock.patch.object(asc_version_fetch, "request", side_effect=fake_request), \
151
+ redirect_stderr(buf):
152
+ mmv.fetch_versions("111", "tok")
153
+
154
+ stderr = buf.getvalue()
155
+ self.assertIn("1.0.4", stderr)
156
+ self.assertIn("READY_FOR_SALE", stderr)
157
+ self.assertIn("id-1.0.4", stderr)
158
+ self.assertIn("1.0.1", stderr)
159
+ self.assertIn("PREPARE_FOR_SUBMISSION", stderr)
160
+
161
+
162
+ if __name__ == "__main__":
163
+ unittest.main()
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tests for the independent narrow per-state ASC ground-truth floor query.
4
+
5
+ Extracted from ``test_mmv_floor_check.py`` (round 11) so each test
6
+ file stays under the 10-functions-per-file cap. Counting nested
7
+ ``fake_request`` closures, the parent file was at 13 defs; pulling
8
+ ``GroundTruthFloorTests`` out drops both files below the cap.
9
+
10
+ Covers ``mmv.get_ground_truth_floor`` -- the defence-in-depth narrow
11
+ query the floor check uses to validate the main fetch's combined
12
+ floor. Each test pins the per-state filter behaviour so a buggy main
13
+ fetch can't silently mask a higher ship-blocking row.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import sys
19
+ import unittest
20
+ from pathlib import Path
21
+ from unittest import mock
22
+
23
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
24
+
25
+ import asc_version_fetch # noqa: E402
26
+ import manage_marketing_version as mmv # noqa: E402
27
+
28
+ from tests_common import _FakeResponse # noqa: E402
29
+
30
+
31
+ class GroundTruthFloorTests(unittest.TestCase):
32
+ """Independent narrow per-state query is the self-correcting floor."""
33
+
34
+ def test_ground_truth_floor_finds_ready_for_sale(self):
35
+ def fake_request(method, path, token, *, params=None, **_kw):
36
+ state_filter = (params or {}).get("filter[appStoreState]", "")
37
+ if "READY_FOR_SALE" in state_filter:
38
+ return _FakeResponse({
39
+ "data": [{
40
+ "id": "id-1.0.4",
41
+ "attributes": {
42
+ "versionString": "1.0.4",
43
+ "appStoreState": "READY_FOR_SALE",
44
+ "platform": "IOS",
45
+ "createdDate": "2025-04-01T00:00:00Z",
46
+ },
47
+ }],
48
+ "links": {},
49
+ })
50
+ return _FakeResponse({"data": [], "links": {}})
51
+
52
+ with mock.patch.object(asc_version_fetch, "request", side_effect=fake_request):
53
+ floor = mmv.get_ground_truth_floor("111", "tok")
54
+
55
+ self.assertEqual(floor, "1.0.4")
56
+
57
+ def test_ground_truth_floor_considers_all_ship_blocking_states(self):
58
+ def fake_request(method, path, token, *, params=None, **_kw):
59
+ state_filter = (params or {}).get("filter[appStoreState]", "")
60
+ if "PENDING_APPLE_RELEASE" in state_filter:
61
+ return _FakeResponse({
62
+ "data": [{
63
+ "id": "id-1.0.6",
64
+ "attributes": {
65
+ "versionString": "1.0.6",
66
+ "appStoreState": "PENDING_APPLE_RELEASE",
67
+ "platform": "IOS",
68
+ "createdDate": "2025-06-01T00:00:00Z",
69
+ },
70
+ }],
71
+ "links": {},
72
+ })
73
+ if "READY_FOR_SALE" in state_filter:
74
+ return _FakeResponse({
75
+ "data": [{
76
+ "id": "id-1.0.4",
77
+ "attributes": {
78
+ "versionString": "1.0.4",
79
+ "appStoreState": "READY_FOR_SALE",
80
+ "platform": "IOS",
81
+ "createdDate": "2025-04-01T00:00:00Z",
82
+ },
83
+ }],
84
+ "links": {},
85
+ })
86
+ return _FakeResponse({"data": [], "links": {}})
87
+
88
+ with mock.patch.object(asc_version_fetch, "request", side_effect=fake_request):
89
+ floor = mmv.get_ground_truth_floor("111", "tok")
90
+
91
+ self.assertEqual(floor, "1.0.6")
92
+
93
+ def test_ground_truth_floor_returns_none_when_no_ship_blocking(self):
94
+ def fake_request(method, path, token, *, params=None, **_kw):
95
+ return _FakeResponse({"data": [], "links": {}})
96
+
97
+ with mock.patch.object(asc_version_fetch, "request", side_effect=fake_request):
98
+ floor = mmv.get_ground_truth_floor("111", "tok")
99
+
100
+ self.assertIsNone(floor)
101
+
102
+
103
+ if __name__ == "__main__":
104
+ unittest.main()
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tests for ``manage_marketing_version``'s validation + main() entrypoint.
4
+
5
+ After round-8 split, this file holds only the input-validation and
6
+ ``main()`` end-to-end scenarios plus the round-12 fail-closed
7
+ unknown-ASC-state rejection. Sibling test files cover the rest:
8
+
9
+ * ``test_manage_marketing_version_match.py`` -- row-matching at the
10
+ target versionString (REUSE / terminal-collision reject /
11
+ in-review reject / DEVELOPER_REJECTED reuse).
12
+ * ``test_manage_marketing_version_floor.py`` -- floor-vs-target
13
+ compare scenarios.
14
+ * ``test_manage_marketing_version_autoroll.py`` -- round-7 auto-roll
15
+ lock-in (terminal collisions auto-roll; in-review rejects).
16
+
17
+ The original snowballing-bump bug guarded against: when the project
18
+ sat at MARKETING_VERSION=1.0.0 across CI runs, the old code computed
19
+ ``next = combined_floor + patch`` -- the marketing version drifted
20
+ upward in TestFlight (1.0.0 -> 1.0.7 -> ...) without ever round-tripping
21
+ back to the repo. Each new run started from 1.0.0 and got bumped again,
22
+ server-side only.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import io
28
+ import json
29
+ import os
30
+ import sys
31
+ import unittest
32
+ from contextlib import redirect_stderr
33
+ from pathlib import Path
34
+ from unittest import mock
35
+
36
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
37
+
38
+ import manage_marketing_version as mmv # noqa: E402
39
+
40
+ from tests_common import ( # noqa: E402
41
+ _Env,
42
+ _assert_decide_exits,
43
+ _assert_mentions_build_setting_source,
44
+ _v,
45
+ )
46
+
47
+
48
+ class ValidationAndMainTests(unittest.TestCase):
49
+ """Semver validation, end-to-end main(), and missing-env scenarios."""
50
+
51
+ def test_target_invalid_semver_exits_1(self):
52
+ """Scenario 7: target 'foo' (invalid semver) -> SystemExit(1)."""
53
+ stderr = _assert_decide_exits(
54
+ self, "foo", [], ground_truth=None, expected_code=1,
55
+ )
56
+ self.assertIn("MARKETING_VERSION", stderr)
57
+ _assert_mentions_build_setting_source(self, stderr)
58
+
59
+ def test_main_end_to_end_with_marketing_version_env(self):
60
+ """Scenario 8: xcodegen-style end-to-end via main()."""
61
+ out_buf = io.StringIO()
62
+ err_buf = io.StringIO()
63
+ env_overrides = {
64
+ "ASC_KEY_ID": "kid",
65
+ "ASC_ISSUER_ID": "iid",
66
+ "ASC_KEY_PATH": "/tmp/ignored.p8",
67
+ "APP_STORE_APPLE_ID": "111",
68
+ "MARKETING_VERSION": "1.0.0",
69
+ }
70
+ empty_breakdown = {"appStoreVersions": None,
71
+ "preReleaseVersions": None,
72
+ "buildsViaPreRelease": None}
73
+ with mock.patch.dict(os.environ, env_overrides, clear=False), \
74
+ mock.patch.object(mmv, "make_jwt", return_value="tok"), \
75
+ mock.patch.object(mmv, "fetch_versions", return_value=[]), \
76
+ mock.patch.object(mmv, "get_ground_truth_floor", return_value=None), \
77
+ mock.patch.object(
78
+ mmv, "get_combined_floor",
79
+ return_value=(None, empty_breakdown)), \
80
+ mock.patch.object(mmv, "create_or_reuse_with_stale",
81
+ return_value="created-100"), \
82
+ mock.patch("sys.stdout", out_buf), \
83
+ redirect_stderr(err_buf):
84
+ mmv.main()
85
+ decision = json.loads(out_buf.getvalue().strip())
86
+ self.assertEqual(decision["versionString"], "1.0.0")
87
+ self.assertEqual(decision["decision"], "CREATE")
88
+ self.assertEqual(decision["appStoreVersionId"], "created-100")
89
+
90
+ def test_missing_marketing_version_env_exits_1(self):
91
+ """Scenario 9: MARKETING_VERSION missing -> SystemExit(1)."""
92
+ buf = io.StringIO()
93
+ env_overrides = {
94
+ "ASC_KEY_ID": "kid",
95
+ "ASC_ISSUER_ID": "iid",
96
+ "ASC_KEY_PATH": "/tmp/ignored.p8",
97
+ "APP_STORE_APPLE_ID": "111",
98
+ }
99
+ with mock.patch.dict(os.environ, env_overrides, clear=False), \
100
+ _Env("MARKETING_VERSION", None), \
101
+ mock.patch.object(mmv, "make_jwt", return_value="tok"), \
102
+ mock.patch.object(mmv, "fetch_versions", return_value=[]), \
103
+ redirect_stderr(buf):
104
+ with self.assertRaises(SystemExit) as ctx:
105
+ mmv.main()
106
+ self.assertEqual(ctx.exception.code, 1)
107
+ stderr = buf.getvalue()
108
+ self.assertIn("MARKETING_VERSION", stderr)
109
+
110
+ def test_target_matches_unknown_state_exits_2_fail_closed(self):
111
+ """Round 12: row exists at target with an ASC state in NONE of
112
+ our allowlists (TERMINAL / IN_REVIEW / BLOCKING / REUSABLE) ->
113
+ SystemExit(2) rather than silently CREATEing into a 409.
114
+
115
+ The earlier classifier returned ``"none"`` for unknown states,
116
+ so the script proceeded to CREATE -- which 409s against the
117
+ existing row on every CI run. Failing closed surfaces the
118
+ unrecognized state to the human so they can update the
119
+ classifier or bump MARKETING_VERSION manually."""
120
+ unknown = _v("1.0.0", "TOTALLY_NEW_APPLE_STATE", vid="ed-unk-100")
121
+ stderr = _assert_decide_exits(
122
+ self, "1.0.0", [unknown],
123
+ ground_truth="1.0.0", combined_floor="1.0.0",
124
+ )
125
+ self.assertIn("1.0.0", stderr)
126
+ self.assertIn("TOTALLY_NEW_APPLE_STATE", stderr)
127
+ self.assertIn("unrecognized state", stderr)
128
+ self.assertIn("ed-unk-100", stderr)
129
+ self.assertIn("Update mmv classifier", stderr)
130
+ _assert_mentions_build_setting_source(self, stderr)
131
+
132
+
133
+ if __name__ == "__main__":
134
+ unittest.main()
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tests for the auto-roll-vs-in-review policy in
4
+ ``manage_marketing_version.decide_for_version``.
5
+
6
+ Round-7 codex finding lock-in: auto-roll fires for TERMINAL collisions
7
+ (READY_FOR_SALE etc.) but NOT for in-review collisions
8
+ (WAITING_FOR_REVIEW / IN_REVIEW). In-review must always reject so the
9
+ developer can resolve the ambiguity manually -- Apple is actively
10
+ reviewing the row, and advancing past it would race the review
11
+ process.
12
+
13
+ Extracted from ``test_manage_marketing_version.py`` so each test file
14
+ stays under the 10-functions-per-file cap (round 8).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import io
20
+ import os
21
+ import sys
22
+ import unittest
23
+ from contextlib import redirect_stderr
24
+ from pathlib import Path
25
+ from unittest import mock
26
+
27
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
28
+
29
+ import manage_marketing_version as mmv # noqa: E402
30
+
31
+ from tests_common import _v # noqa: E402
32
+
33
+
34
+ class AutoRollTerminalVsInReviewTests(unittest.TestCase):
35
+ """auto-roll fires only for terminal collisions; in-review rejects."""
36
+
37
+ def _run(self, asc_state, policy):
38
+ """Drive ``decide_for_version`` with a single floor row at 1.0.5
39
+ in ``asc_state`` and the given auto-bump ``policy``. Returns
40
+ ``(result_or_none, exit_code_or_none, stderr)``."""
41
+ floor = "1.0.5"
42
+ per_source = {
43
+ "appStoreVersions": floor,
44
+ "preReleaseVersions": None,
45
+ "buildsViaPreRelease": None,
46
+ }
47
+ with mock.patch.object(
48
+ mmv, "create_or_reuse_with_stale", return_value="rolled-id"), \
49
+ mock.patch.object(
50
+ mmv, "get_combined_floor",
51
+ return_value=(floor, per_source)), \
52
+ mock.patch.object(
53
+ mmv, "get_ground_truth_floor", return_value=floor), \
54
+ mock.patch(
55
+ "version_utils.write_marketing_version",
56
+ return_value=True), \
57
+ mock.patch.dict(
58
+ os.environ,
59
+ {
60
+ "MARKETING_VERSION_AUTO_BUMP": policy,
61
+ # Round-9 persistence-context gate: auto-bump
62
+ # only fires on push to the default branch.
63
+ # Pin those envs here so the auto-roll tests
64
+ # exercise the actual auto-bump code path
65
+ # (rather than the persistence-context refusal).
66
+ "GITHUB_EVENT_NAME": "push",
67
+ "GITHUB_REF": "refs/heads/main",
68
+ "GITHUB_DEFAULT_BRANCH": "main",
69
+ },
70
+ clear=False):
71
+ versions = [_v(floor, asc_state)]
72
+ buf = io.StringIO()
73
+ with redirect_stderr(buf):
74
+ try:
75
+ result = mmv.decide_for_version(
76
+ floor, versions, app_id="111", token="tok",
77
+ )
78
+ return result, None, buf.getvalue()
79
+ except SystemExit as exc:
80
+ return None, exc.code, buf.getvalue()
81
+
82
+ def test_ready_for_sale_with_autobump_creates_next_patch(self):
83
+ result, exit_code, _ = self._run("READY_FOR_SALE", "patch")
84
+ self.assertIsNone(exit_code, "auto-roll must succeed for terminal")
85
+ self.assertEqual(result["decision"], "CREATE")
86
+ self.assertEqual(result["versionString"], "1.0.6")
87
+
88
+ def test_pending_apple_release_with_autobump_creates_next_patch(self):
89
+ result, exit_code, _ = self._run("PENDING_APPLE_RELEASE", "patch")
90
+ self.assertIsNone(exit_code)
91
+ self.assertEqual(result["decision"], "CREATE")
92
+ self.assertEqual(result["versionString"], "1.0.6")
93
+
94
+ def test_processing_for_app_store_with_autobump_creates_next_patch(self):
95
+ result, exit_code, _ = self._run("PROCESSING_FOR_APP_STORE", "patch")
96
+ self.assertIsNone(exit_code)
97
+ self.assertEqual(result["decision"], "CREATE")
98
+ self.assertEqual(result["versionString"], "1.0.6")
99
+
100
+ def test_waiting_for_review_with_autobump_rejects_loudly(self):
101
+ """Round-7: in-review must NEVER auto-roll regardless of policy."""
102
+ result, exit_code, stderr = self._run("WAITING_FOR_REVIEW", "patch")
103
+ self.assertIsNone(result)
104
+ self.assertEqual(exit_code, 2)
105
+ self.assertIn("currently in App Review", stderr)
106
+
107
+ def test_in_review_with_autobump_rejects_loudly(self):
108
+ result, exit_code, stderr = self._run("IN_REVIEW", "patch")
109
+ self.assertIsNone(result)
110
+ self.assertEqual(exit_code, 2)
111
+ self.assertIn("currently in App Review", stderr)
112
+
113
+ def test_pending_developer_release_with_autobump_rejects_loudly(self):
114
+ """Round-11: PENDING_DEVELOPER_RELEASE means Apple approved
115
+ the build and is waiting on the developer's manual release
116
+ click. Auto-rolling past it would bypass the developer's
117
+ release decision -- silently shipping the next patch while
118
+ the approved build sits unreleased. Must reject loudly so a
119
+ human resolves the ambiguity."""
120
+ result, exit_code, stderr = self._run(
121
+ "PENDING_DEVELOPER_RELEASE", "patch",
122
+ )
123
+ self.assertIsNone(result)
124
+ self.assertEqual(exit_code, 2)
125
+ self.assertIn("PENDING_DEVELOPER_RELEASE", stderr)
126
+ self.assertIn("manual release", stderr)
127
+
128
+ def test_terminal_with_policy_none_rejects(self):
129
+ result, exit_code, stderr = self._run("READY_FOR_SALE", "none")
130
+ self.assertIsNone(result)
131
+ self.assertEqual(exit_code, 2)
132
+ self.assertIn("READY_FOR_SALE", stderr)
133
+
134
+ def test_in_review_with_policy_none_rejects(self):
135
+ result, exit_code, stderr = self._run("WAITING_FOR_REVIEW", "none")
136
+ self.assertIsNone(result)
137
+ self.assertEqual(exit_code, 2)
138
+ self.assertIn("currently in App Review", stderr)
139
+
140
+ def test_editable_state_reuses_unchanged(self):
141
+ """Sanity: PREPARE_FOR_SUBMISSION at target REUSEs unchanged."""
142
+ result, exit_code, _ = self._run("PREPARE_FOR_SUBMISSION", "patch")
143
+ self.assertIsNone(exit_code)
144
+ self.assertEqual(result["decision"], "REUSE")
145
+ self.assertEqual(result["versionString"], "1.0.5")
146
+
147
+
148
+ if __name__ == "__main__":
149
+ unittest.main()