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,131 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tests for the persistence-context gate on auto-bump (round-9 lock-in).
4
+
5
+ ``mmv_floor_check.maybe_auto_bump`` only fires when the run can also
6
+ commit the bumped value back to the default branch. The action's
7
+ commit-back step is gated on ``event=push`` AND
8
+ ``ref=refs/heads/<default>``; auto-bumping in any other context
9
+ (workflow_dispatch / pull_request / push to a feature branch) would
10
+ upload an IPA at the bumped version while git stays at the old
11
+ value, causing the next run to recompute against stale state.
12
+
13
+ These tests pin the persistence-context envs explicitly to prove the
14
+ gate's exact triggers / refusals. They are intentionally separate
15
+ from ``test_manage_marketing_version_autoroll.py`` (which exercises
16
+ the auto-roll DECISION matrix at terminal vs in-review) so each test
17
+ file stays under the 10-functions-per-file cap.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import io
23
+ import os
24
+ import sys
25
+ import unittest
26
+ from contextlib import redirect_stderr
27
+ from pathlib import Path
28
+ from unittest import mock
29
+
30
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
31
+
32
+ import mmv_floor_check # noqa: E402
33
+
34
+
35
+ class PersistenceContextGateTests(unittest.TestCase):
36
+ """``maybe_auto_bump`` must refuse when the run cannot commit back."""
37
+
38
+ def _run(self, env_overrides: dict) -> tuple[object, str]:
39
+ """Drive ``maybe_auto_bump`` with the given env overrides; mocks
40
+ ``write_marketing_version`` so the only thing under test is the
41
+ policy + persistence-context gating. Returns (result, stderr)."""
42
+ env = {"MARKETING_VERSION_AUTO_BUMP": "patch"}
43
+ env.update(env_overrides)
44
+ buf = io.StringIO()
45
+ with mock.patch.dict(os.environ, env, clear=False), \
46
+ mock.patch(
47
+ "version_utils.write_marketing_version",
48
+ return_value=True), \
49
+ redirect_stderr(buf):
50
+ result = mmv_floor_check.maybe_auto_bump("1.0.5", "1.0.5")
51
+ return result, buf.getvalue()
52
+
53
+ def test_push_to_default_branch_fires_autobump(self):
54
+ result, _ = self._run({
55
+ "GITHUB_EVENT_NAME": "push",
56
+ "GITHUB_REF": "refs/heads/main",
57
+ "GITHUB_DEFAULT_BRANCH": "main",
58
+ })
59
+ self.assertEqual(result, "1.0.6")
60
+
61
+ def test_workflow_dispatch_refuses_autobump(self):
62
+ result, stderr = self._run({
63
+ "GITHUB_EVENT_NAME": "workflow_dispatch",
64
+ "GITHUB_REF": "refs/heads/main",
65
+ "GITHUB_DEFAULT_BRANCH": "main",
66
+ })
67
+ self.assertIsNone(result)
68
+ self.assertIn("cannot persist the bump", stderr)
69
+ self.assertIn("workflow_dispatch", stderr)
70
+
71
+ def test_push_to_feature_branch_refuses_autobump(self):
72
+ result, stderr = self._run({
73
+ "GITHUB_EVENT_NAME": "push",
74
+ "GITHUB_REF": "refs/heads/feature-x",
75
+ "GITHUB_DEFAULT_BRANCH": "main",
76
+ })
77
+ self.assertIsNone(result)
78
+ self.assertIn("cannot persist the bump", stderr)
79
+ self.assertIn("feature-x", stderr)
80
+
81
+ def test_pull_request_refuses_autobump(self):
82
+ result, stderr = self._run({
83
+ "GITHUB_EVENT_NAME": "pull_request",
84
+ "GITHUB_REF": "refs/pull/42/merge",
85
+ "GITHUB_DEFAULT_BRANCH": "main",
86
+ })
87
+ self.assertIsNone(result)
88
+ self.assertIn("cannot persist the bump", stderr)
89
+
90
+ def test_missing_default_branch_env_refuses_autobump(self):
91
+ """Defensive: empty default-branch env -> refuse."""
92
+ result, stderr = self._run({
93
+ "GITHUB_EVENT_NAME": "push",
94
+ "GITHUB_REF": "refs/heads/main",
95
+ "GITHUB_DEFAULT_BRANCH": "",
96
+ })
97
+ self.assertIsNone(result)
98
+ self.assertIn("cannot persist the bump", stderr)
99
+
100
+ def test_alternative_default_branch_master_works(self):
101
+ """Sanity: a repo whose default branch is `master` still bumps."""
102
+ result, _ = self._run({
103
+ "GITHUB_EVENT_NAME": "push",
104
+ "GITHUB_REF": "refs/heads/master",
105
+ "GITHUB_DEFAULT_BRANCH": "master",
106
+ })
107
+ self.assertEqual(result, "1.0.6")
108
+
109
+ def test_policy_none_short_circuits_before_persist_check(self):
110
+ """Order-of-checks lock-in: policy=none returns None silently
111
+ without surfacing the persistence-context warning."""
112
+ env = {
113
+ "MARKETING_VERSION_AUTO_BUMP": "none",
114
+ "GITHUB_EVENT_NAME": "workflow_dispatch",
115
+ "GITHUB_REF": "refs/heads/main",
116
+ "GITHUB_DEFAULT_BRANCH": "main",
117
+ }
118
+ buf = io.StringIO()
119
+ with mock.patch.dict(os.environ, env, clear=False), \
120
+ mock.patch(
121
+ "version_utils.write_marketing_version",
122
+ return_value=True), \
123
+ redirect_stderr(buf):
124
+ result = mmv_floor_check.maybe_auto_bump("1.0.5", "1.0.5")
125
+ self.assertIsNone(result)
126
+ # Persistence-context warning must NOT fire when policy=none.
127
+ self.assertNotIn("cannot persist the bump", buf.getvalue())
128
+
129
+
130
+ if __name__ == "__main__":
131
+ unittest.main()
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tests for mmv_decide_create.py — the orchestrator decisions made via
4
+ ``_stale_editable_id`` and ``decide_create``.
5
+
6
+ Covers:
7
+ - StaleEditableSelectionTests: ``_stale_editable_id`` decides the
8
+ kwarg passed into ``create_or_reuse_with_stale``; lower-version
9
+ editables get PATCH-renamed; equal/higher-version editables fail
10
+ fast (Issue 10) so a future draft cannot be silently downgraded.
11
+ - In-review rows (Issue 13) are skipped as PATCH-rename candidates;
12
+ the CREATE path falls through to a plain POST.
13
+
14
+ The asc_version_create.reuse_stale_editable seam (PATCH /
15
+ DELETE+POST fallback / exit-5 path / logging) lives in
16
+ ``test_reuse_stale_editable.py``. The 409 classifier path (POST
17
+ returns 409, never bumps, fails fast) lives in
18
+ ``test_mmv_409_classifier.py``.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import sys
24
+ import unittest
25
+ from pathlib import Path
26
+
27
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
28
+
29
+ from tests_common import ( # noqa: E402
30
+ _assert_decide_exits,
31
+ _run_decide_for_version,
32
+ _v,
33
+ )
34
+
35
+
36
+ class StaleEditableSelectionTests(unittest.TestCase):
37
+ """``_stale_editable_id`` decides the kwarg that ``decide_create``
38
+ passes into ``create_or_reuse_with_stale``. Lower-version editables
39
+ get PATCH-renamed to target; equal/higher-version editables fail
40
+ fast (Issue 10) so a future draft cannot be silently downgraded."""
41
+
42
+ def test_create_patch_renames_stale_editable_to_target(self):
43
+ """target=1.1.0 with editable PFS at 1.0.5 -> CREATE via PATCH-rename;
44
+ stale_editable_id kwarg must be the lower editable's id."""
45
+ editable = _v("1.0.5", "PREPARE_FOR_SUBMISSION", vid="ed-105")
46
+ result, stderr, calls = _run_decide_for_version(
47
+ "1.1.0", versions=[editable], ground_truth="1.0.5",
48
+ )
49
+ self.assertEqual(result["decision"], "CREATE")
50
+ self.assertEqual(result["versionString"], "1.1.0")
51
+ self.assertEqual(len(calls), 1)
52
+ self.assertEqual(calls[0].kwargs.get("stale_editable_id"), "ed-105")
53
+ self.assertIn("PATCH-rename of stale editable id=ed-105", stderr)
54
+
55
+ def test_create_no_stale_editable_no_patch(self):
56
+ """target=1.1.0 with no editable at all -> CREATE via plain POST;
57
+ stale_editable_id kwarg must be None."""
58
+ result, stderr, calls = _run_decide_for_version(
59
+ "1.1.0", versions=[], ground_truth=None,
60
+ )
61
+ self.assertEqual(result["decision"], "CREATE")
62
+ self.assertEqual(len(calls), 1)
63
+ self.assertIsNone(calls[0].kwargs.get("stale_editable_id"))
64
+ self.assertNotIn("PATCH-rename", stderr)
65
+
66
+ def test_create_blocked_by_higher_editable_pfs(self):
67
+ """Issue 10: a future draft at 1.2.0 (PFS) must NOT be PATCH-renamed
68
+ downward to 1.1.0; SystemExit(2) with downgrade wording."""
69
+ higher = _v("1.2.0", "PREPARE_FOR_SUBMISSION", vid="ed-120")
70
+ stderr = _assert_decide_exits(
71
+ self, "1.1.0", [higher], ground_truth=None,
72
+ )
73
+ self.assertIn("1.2.0", stderr)
74
+ self.assertIn("ed-120", stderr)
75
+ self.assertIn("downgrade", stderr.lower())
76
+ self.assertIn("PREPARE_FOR_SUBMISSION", stderr)
77
+
78
+ def test_create_blocked_by_higher_editable_other_state(self):
79
+ """Issue 10 covers every editable state, not just PFS. A draft
80
+ in WAITING_FOR_REVIEW above target must also block CREATE."""
81
+ higher = _v("1.2.0", "WAITING_FOR_REVIEW", vid="ed-wfr-120")
82
+ stderr = _assert_decide_exits(
83
+ self, "1.1.0", [higher], ground_truth=None,
84
+ )
85
+ self.assertIn("1.2.0", stderr)
86
+ self.assertIn("ed-wfr-120", stderr)
87
+ self.assertIn("downgrade", stderr.lower())
88
+ self.assertIn("WAITING_FOR_REVIEW", stderr)
89
+
90
+ def test_create_blocked_by_equal_version_editable(self):
91
+ """When an editable already sits at exactly target, REUSE wins
92
+ via ``_find_match_at_target`` BEFORE decide_create is reached;
93
+ no CREATE-path downgrade-rename is attempted."""
94
+ match = _v("1.1.0", "PREPARE_FOR_SUBMISSION", vid="ed-110")
95
+ result, stderr, calls = _run_decide_for_version(
96
+ "1.1.0", versions=[match], ground_truth=None,
97
+ )
98
+ self.assertEqual(result["decision"], "REUSE")
99
+ self.assertEqual(result["appStoreVersionId"], "ed-110")
100
+ self.assertEqual(calls, [])
101
+ self.assertIn("decision=REUSE", stderr)
102
+
103
+ def test_lower_in_review_skipped_create_via_post(self):
104
+ """Issue 13: a WAITING_FOR_REVIEW row at version < target must
105
+ NOT be PATCH-renamed (Apple is reviewing it). CREATE falls
106
+ through to a plain POST with stale_editable_id=None."""
107
+ in_review = _v("1.0.5", "WAITING_FOR_REVIEW", vid="ed-wfr-105")
108
+ result, stderr, calls = _run_decide_for_version(
109
+ "1.1.0", versions=[in_review], ground_truth="1.0.5",
110
+ )
111
+ self.assertEqual(result["decision"], "CREATE")
112
+ self.assertEqual(result["versionString"], "1.1.0")
113
+ self.assertEqual(len(calls), 1)
114
+ self.assertIsNone(calls[0].kwargs.get("stale_editable_id"))
115
+ self.assertNotIn("PATCH-rename", stderr)
116
+
117
+
118
+ if __name__ == "__main__":
119
+ unittest.main()
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tests for mmv_floor_check.py.
4
+
5
+ Covers:
6
+ - ``get_combined_floor`` per-source breakdown across appStoreVersions,
7
+ preReleaseVersions, and builds->preReleaseVersion.
8
+
9
+ ``get_ground_truth_floor`` tests live in ``test_ground_truth_floor.py``
10
+ (round 11 split). The cross-check error-message tests live in
11
+ ``test_mmv_floor_crosscheck.py`` (round 8 split).
12
+ ``fetch_versions`` pagination / filter / limit / logging tests live in
13
+ ``test_fetch_versions.py``. The /builds and /preReleaseVersions
14
+ fetcher tests live in ``test_asc_build_history.py`` and
15
+ ``test_asc_builds_prerelease.py`` respectively.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import sys
21
+ import unittest
22
+ from pathlib import Path
23
+ from unittest import mock
24
+
25
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
26
+
27
+ import manage_marketing_version as mmv # noqa: E402
28
+ import asc_build_history # noqa: E402
29
+
30
+ from tests_common import ( # noqa: E402
31
+ _FakeResponse,
32
+ _mk_prerelease_entry,
33
+ _v,
34
+ )
35
+
36
+
37
+ def _builds_payload_with_prerelease(
38
+ build_id: str, build_version: str, pr_id: str, pr_version: str,
39
+ ) -> dict:
40
+ """Combined-floor fixture: one /builds record paired with a
41
+ preReleaseVersion include. Used to verify the combined-floor wiring
42
+ consults the relationship rather than build.attributes.version."""
43
+ return {
44
+ "data": [{
45
+ "id": build_id,
46
+ "type": "builds",
47
+ "attributes": {"version": build_version},
48
+ "relationships": {
49
+ "preReleaseVersion": {
50
+ "data": {"type": "preReleaseVersions", "id": pr_id},
51
+ },
52
+ },
53
+ }],
54
+ "included": [{
55
+ "id": pr_id,
56
+ "type": "preReleaseVersions",
57
+ "attributes": {"version": pr_version, "platform": "IOS"},
58
+ }],
59
+ "links": {},
60
+ }
61
+
62
+
63
+ class CombinedFloorTests(unittest.TestCase):
64
+ """Combined floor spans appStoreVersions + preReleaseVersions +
65
+ builds->preReleaseVersion. /v1/apps/{id}/builds.attributes.version
66
+ (integer build number) is NOT consulted as a marketing version."""
67
+
68
+ def test_combined_floor_excludes_builds_attributes_version(self):
69
+ seen_paths: list[str] = []
70
+ seen_params: list[dict | None] = []
71
+ builds_payload = _builds_payload_with_prerelease(
72
+ "b-42", "42", "pr-10", "1.0",
73
+ )
74
+
75
+ def fake_request(method, path, token, *, params=None, **_kw):
76
+ seen_paths.append(path)
77
+ seen_params.append(params)
78
+ if "/builds" in path:
79
+ return _FakeResponse(builds_payload)
80
+ return _FakeResponse({"data": [], "links": {}})
81
+
82
+ with mock.patch.object(
83
+ asc_build_history, "request", side_effect=fake_request):
84
+ floor, _ = mmv.get_combined_floor(
85
+ "111", "tok",
86
+ appstore_versions=[_v("1.0", "READY_FOR_SALE")],
87
+ )
88
+
89
+ self.assertTrue(seen_paths)
90
+ self.assertTrue(any("/builds" in p for p in seen_paths))
91
+ self.assertTrue(any("/preReleaseVersions" in p for p in seen_paths))
92
+ builds_params = [
93
+ p for p, path in zip(seen_params, seen_paths)
94
+ if "/builds" in path and p is not None
95
+ ]
96
+ self.assertTrue(builds_params)
97
+ self.assertEqual(builds_params[0].get("include"), "preReleaseVersion")
98
+ self.assertEqual(floor, "1.0")
99
+ self.assertFalse(
100
+ hasattr(asc_build_history, "fetch_build_versions"),
101
+ "asc_build_history.fetch_build_versions must stay removed",
102
+ )
103
+
104
+ def test_combined_floor_includes_prerelease(self):
105
+ with mock.patch.object(
106
+ asc_build_history, "fetch_prerelease_versions",
107
+ return_value=["1.0.4"]), \
108
+ mock.patch.object(
109
+ asc_build_history, "fetch_builds_prerelease_versions",
110
+ return_value=[]):
111
+ floor, _ = mmv.get_combined_floor(
112
+ "111", "tok",
113
+ appstore_versions=[_v("1.0", "READY_FOR_SALE")],
114
+ )
115
+ self.assertEqual(floor, "1.0.4")
116
+
117
+ def test_combined_floor_none_when_empty_everywhere(self):
118
+ with mock.patch.object(
119
+ asc_build_history, "fetch_prerelease_versions",
120
+ return_value=[]), \
121
+ mock.patch.object(
122
+ asc_build_history, "fetch_builds_prerelease_versions",
123
+ return_value=[]):
124
+ floor, _ = mmv.get_combined_floor(
125
+ "111", "tok", appstore_versions=[],
126
+ )
127
+ self.assertIsNone(floor)
128
+
129
+ def test_combined_floor_unknown_endpoint_shape_doesnt_crash(self):
130
+ def fake_request(method, path, token, *, params=None, **_kw):
131
+ return _FakeResponse({
132
+ "data": [
133
+ {"id": "null-version", "attributes": {}},
134
+ _mk_prerelease_entry("1.0.3", pid="good"),
135
+ ],
136
+ "links": {},
137
+ })
138
+
139
+ with mock.patch.object(
140
+ asc_build_history, "request", side_effect=fake_request):
141
+ prereleases = asc_build_history.fetch_prerelease_versions(
142
+ "111", "tok"
143
+ )
144
+ self.assertEqual(prereleases, ["1.0.3"])
145
+
146
+
147
+ # GroundTruthCrossCheckMessageTests +
148
+ # GroundTruthCrossCheckProductionPerSourceTests were extracted to
149
+ # test_mmv_floor_crosscheck.py (round 8).
150
+ # ComputeNextVersionStrictnessTests was extracted to
151
+ # test_compute_next_version.py (round 8).
152
+ # GroundTruthFloorTests was extracted to test_ground_truth_floor.py
153
+ # (round 11).
154
+ # All three splits keep this file under the 10-functions-per-file cap.
155
+
156
+
157
+ if __name__ == "__main__":
158
+ unittest.main()
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tests for the ground-truth cross-check error message produced by
4
+ ``assert_target_meets_floor``.
5
+
6
+ Covers the per-source breakdown surfaced in production (not
7
+ synthesized): when the binding floor comes from preReleaseVersions or
8
+ buildsViaPreRelease, the error message MUST attribute the floor to the
9
+ real source rather than emitting ``<none>``.
10
+
11
+ Extracted from ``test_mmv_floor_check.py`` in round 8 to keep each
12
+ test file under the 10-functions-per-file cap. The
13
+ ``GroundTruthFloorTests`` and ``CombinedFloorTests`` classes still
14
+ live in ``test_mmv_floor_check.py``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import sys
21
+ import unittest
22
+ from pathlib import Path
23
+ from unittest import mock
24
+
25
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
26
+
27
+ import manage_marketing_version as mmv # noqa: E402
28
+ import asc_build_history # noqa: E402
29
+
30
+ from tests_common import ( # noqa: E402
31
+ _assert_decide_exits,
32
+ _assert_mentions_build_setting_source,
33
+ _v,
34
+ )
35
+
36
+
37
+ def setUpModule() -> None:
38
+ """Pin auto-bump to 'none' for the whole module.
39
+
40
+ The cross-check tests assert the historical fail-on-floor error
41
+ message text. With auto-bump enabled (the default 'patch'), the
42
+ floor-violation path would auto-bump instead of failing, which
43
+ is a different code path covered elsewhere."""
44
+ global _ENV_PATCH
45
+ _ENV_PATCH = mock.patch.dict(
46
+ os.environ, {"MARKETING_VERSION_AUTO_BUMP": "none"}, clear=False,
47
+ )
48
+ _ENV_PATCH.start()
49
+
50
+
51
+ def tearDownModule() -> None:
52
+ _ENV_PATCH.stop()
53
+
54
+
55
+ class GroundTruthCrossCheckMessageTests(unittest.TestCase):
56
+ """The cross-check error message MUST name each source separately
57
+ AND reference at least one build-setting source name."""
58
+
59
+ def test_error_message_lists_each_source_max(self):
60
+ per_source = {
61
+ "appStoreVersions": "1.0",
62
+ "preReleaseVersions": "1.0.4",
63
+ "buildsViaPreRelease": None,
64
+ }
65
+ stderr = _assert_decide_exits(
66
+ self, "1.0.0", [_v("1.0", "READY_FOR_SALE")],
67
+ ground_truth="1.0.4", combined_floor="1.0.4",
68
+ per_source=per_source,
69
+ )
70
+ self.assertIn("appStoreVersions", stderr)
71
+ self.assertIn("preReleaseVersions", stderr)
72
+ self.assertIn("buildsViaPreRelease", stderr)
73
+ self.assertIn("1.0.4", stderr)
74
+ _assert_mentions_build_setting_source(self, stderr)
75
+
76
+
77
+ class GroundTruthCrossCheckProductionPerSourceTests(unittest.TestCase):
78
+ """Production seam: when the binding floor comes from preReleaseVersions
79
+ or buildsViaPreRelease, the error message MUST surface the real
80
+ per-source value (not synthesized `<none>`)."""
81
+
82
+ def test_error_attributes_floor_to_prerelease_when_prerelease_pinned(self):
83
+ per_source = {
84
+ "appStoreVersions": "1.0",
85
+ "preReleaseVersions": "1.1.1",
86
+ "buildsViaPreRelease": None,
87
+ }
88
+ stderr = _assert_decide_exits(
89
+ self, "1.0.0", [_v("1.0", "READY_FOR_SALE")],
90
+ ground_truth=None, combined_floor="1.1.1",
91
+ per_source=per_source,
92
+ )
93
+ self.assertIn("preReleaseVersions=1.1.1", stderr)
94
+ self.assertNotIn("preReleaseVersions=<none>", stderr)
95
+ self.assertIn("appStoreVersions=1.0", stderr)
96
+ self.assertIn("1.1.1", stderr)
97
+ _assert_mentions_build_setting_source(self, stderr)
98
+
99
+ def test_error_attributes_floor_to_buildsViaPreRelease_when_builds_pinned(self):
100
+ per_source = {
101
+ "appStoreVersions": "1.0",
102
+ "preReleaseVersions": None,
103
+ "buildsViaPreRelease": "1.2.0",
104
+ }
105
+ stderr = _assert_decide_exits(
106
+ self, "1.0.0", [_v("1.0", "READY_FOR_SALE")],
107
+ ground_truth=None, combined_floor="1.2.0",
108
+ per_source=per_source,
109
+ )
110
+ self.assertIn("buildsViaPreRelease=1.2.0", stderr)
111
+ self.assertNotIn("buildsViaPreRelease=<none>", stderr)
112
+
113
+ def test_get_combined_floor_returns_tuple_in_production(self):
114
+ """The mmv.get_combined_floor wrapper now forwards (floor, per_source)
115
+ from asc_version_fetch verbatim -- no synthesis."""
116
+ with mock.patch.object(
117
+ asc_build_history, "fetch_prerelease_versions",
118
+ return_value=["1.1.1"]), \
119
+ mock.patch.object(
120
+ asc_build_history, "fetch_builds_prerelease_versions",
121
+ return_value=[]):
122
+ floor, per_source = mmv.get_combined_floor(
123
+ "111", "tok",
124
+ appstore_versions=[_v("1.0", "READY_FOR_SALE")],
125
+ )
126
+ self.assertEqual(floor, "1.1.1")
127
+ self.assertEqual(per_source.get("preReleaseVersions"), "1.1.1")
128
+ self.assertEqual(per_source.get("appStoreVersions"), "1.0")
129
+
130
+
131
+ if __name__ == "__main__":
132
+ unittest.main()
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tests for pure-helper functions in the manage_marketing_version family.
4
+
5
+ Covers:
6
+ - ``semver_tuple`` parsing edge cases.
7
+ - ``bump_patch_for_message`` text used inside the build-setting error
8
+ message.
9
+
10
+ Split out of ``test_manage_marketing_version.py`` (Issue 15: file
11
+ function-count cap) so each file groups tests by behaviour boundary.
12
+ ``DecideForVersionTests`` remains in the original module since those
13
+ tests are the orchestrator's primary surface.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import sys
19
+ import unittest
20
+ from pathlib import Path
21
+
22
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
23
+
24
+ import manage_marketing_version as mmv # noqa: E402
25
+
26
+ from mmv_floor_check import bump_patch_for_message # noqa: E402
27
+
28
+
29
+ class SemverParsingTests(unittest.TestCase):
30
+ """Pure-function coverage: semver_tuple."""
31
+
32
+ def test_semver_tuple_parses_three_part(self):
33
+ self.assertEqual(mmv.semver_tuple("1.2.3"), (1, 2, 3))
34
+
35
+ def test_semver_tuple_parses_two_part_as_zero_patch(self):
36
+ self.assertEqual(mmv.semver_tuple("1.2"), (1, 2, 0))
37
+
38
+ def test_semver_tuple_nonmatch_returns_sentinel(self):
39
+ self.assertEqual(mmv.semver_tuple("beta"), (-1, -1, -1))
40
+ self.assertEqual(mmv.semver_tuple(""), (-1, -1, -1))
41
+
42
+
43
+ class BumpPatchForMessageTests(unittest.TestCase):
44
+ """Helper used in the build-setting error message text."""
45
+
46
+ def test_bumps_three_part_patch(self):
47
+ self.assertEqual(bump_patch_for_message("1.0.6"), "1.0.7")
48
+
49
+ def test_treats_missing_patch_as_zero(self):
50
+ self.assertEqual(bump_patch_for_message("1.2"), "1.2.1")
51
+
52
+ def test_non_semver_returns_passthrough_hint(self):
53
+ self.assertIn("foo", bump_patch_for_message("foo"))
54
+
55
+
56
+ if __name__ == "__main__":
57
+ unittest.main()