gowalk-cicd 1.0.13 → 1.0.15

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/README.md CHANGED
@@ -66,6 +66,36 @@ workflow reads these files from the checkout, so they must be available to
66
66
  GitHub Actions (this package's zero-config convention is to track them only in
67
67
  a private repository).
68
68
 
69
+ ### Choosing which stores a repo ships to
70
+
71
+ `deploy.yml` runs iOS and Android in parallel. Set the repository variable
72
+ `DEPLOY_PLATFORMS` when one of them is intentionally out of scope, so an
73
+ unrelated failure on the platform you do not care about cannot block the
74
+ release you do:
75
+
76
+ | `DEPLOY_PLATFORMS` | Result |
77
+ |---|---|
78
+ | unset / `both` | iOS + Android (default) |
79
+ | `android` | Android only — the iOS job is skipped |
80
+ | `ios` | iOS only — the Android job is skipped |
81
+
82
+ ```bash
83
+ gh variable set DEPLOY_PLATFORMS --body android
84
+ ```
85
+
86
+ ### Track, status and staged rollout
87
+
88
+ | Variable | Default | Purpose |
89
+ |---|---|---|
90
+ | `GOOGLE_PLAY_TRACK` | `internal` | `internal`, `alpha`, `beta`, `production`, or a custom track name |
91
+ | `GOOGLE_PLAY_STATUS` | `completed` | `completed` (full rollout) or `inProgress` (staged) |
92
+ | `GOOGLE_PLAY_USER_FRACTION` | `0.2` | Audience share, **only** read when the status is `inProgress` |
93
+
94
+ Play rejects an `inProgress` release that does not declare its audience share,
95
+ and rejects a `completed` one that does. The workflow sends `userFraction` only
96
+ in the mode that accepts it, so setting the fraction while leaving the status at
97
+ `completed` is harmless rather than a failed upload.
98
+
69
99
  Google Play requires the first AAB to be uploaded through Play Console. The
70
100
  first CI run still succeeds and retains the signed AAB as an
71
101
  `android-<package>-<versionCode>` workflow artifact. Upload that artifact once
@@ -175,6 +205,29 @@ committed back. If the module computes its `versionCode` instead of declaring a
175
205
  literal, the action emits a `::warning::` and leaves the project's own value
176
206
  alone.
177
207
 
208
+ ### Gradle wrapper floor (Flutter apps)
209
+
210
+ The Flutter Gradle plugin refuses to apply to a project whose wrapper is older
211
+ than the SDK's floor:
212
+
213
+ ```
214
+ Your project's Gradle version (8.11.1) is lower than Flutter's minimum
215
+ supported version of 8.14.0.
216
+ ```
217
+
218
+ CI installs the current `stable` Flutter, so that floor rises on Flutter's
219
+ release cadence rather than the app's — every app in a fleet breaks on the same
220
+ day, long after the last commit that could have anticipated it. Before building,
221
+ the action reads the floor out of the runner's Flutter SDK and, when the
222
+ project's `gradle-wrapper.properties` is below it, rewrites `distributionUrl`
223
+ **in the checkout** with a `::warning::`. Same policy as the versionCode
224
+ rewrite: the edit is never committed back, so the project keeps whatever
225
+ version its authors chose. Commit the bump yourself to silence the warning.
226
+
227
+ (The rewritten name is verified against `services.gradle.org` because Gradle's
228
+ own naming is inconsistent across majors — `gradle-8.14-all.zip` but
229
+ `gradle-9.0.0-all.zip`.)
230
+
178
231
  ### iOS delivery
179
232
 
180
233
  The iOS composite action runs on `macos-15` and:
@@ -1 +1 @@
1
- 1.0.13
1
+ 1.0.15
@@ -72,20 +72,49 @@ def _bundle_id_from_configs(objects: dict, config_ids: list[str]) -> str:
72
72
  return ""
73
73
 
74
74
 
75
- def _entitlements_from_configs(objects: dict, config_ids: list[str]) -> str:
75
+ def _expand_entitlements_refs(value: str, target_name: str) -> str:
76
+ """Expand the build-setting references we can resolve without xcodebuild.
77
+
78
+ ``$(TARGET_NAME)`` is the idiom every xcodegen target template uses for a
79
+ per-target entitlements path, and ``$(SRCROOT)``/``$(PROJECT_DIR)`` both
80
+ name the directory the path is already relative to. Those three are
81
+ unambiguous from the pbxproj alone; anything else is left in place so the
82
+ caller can reject the value.
83
+ """
84
+ for reference in (f"$(TARGET_NAME)", "${TARGET_NAME}"):
85
+ value = value.replace(reference, target_name)
86
+ for name in ("SRCROOT", "PROJECT_DIR"):
87
+ value = value.replace(f"$({name})/", "").replace(f"${{{name}}}/", "")
88
+ return value
89
+
90
+
91
+ def _entitlements_from_configs(
92
+ objects: dict, config_ids: list[str], target_name: str = "", base: Path | None = None
93
+ ) -> str:
76
94
  """First CODE_SIGN_ENTITLEMENTS path across the given configs.
77
95
 
78
96
  Needed to reconcile the App ID's capabilities with what the app declares —
79
- a profile only carries capabilities enabled on its App ID. Build-setting
80
- references are skipped rather than half-expanded; a missed entitlements
81
- file costs a clearer error, a wrong one costs a wrong App ID edit.
97
+ a profile only carries capabilities enabled on its App ID.
98
+
99
+ A literal path is returned as-is (the caller warns when it does not exist).
100
+ A path built from build settings is expanded only for the references we can
101
+ resolve exactly, and then only accepted when the resulting file is really
102
+ there — a missed entitlements file costs a clearer error, a wrong one costs
103
+ a wrong App ID edit.
82
104
  """
83
105
  for cid in config_ids:
84
106
  cfg = objects.get(cid) or {}
85
107
  settings = cfg.get("buildSettings") or {}
86
108
  value = (settings.get("CODE_SIGN_ENTITLEMENTS") or "").strip().strip('"')
87
- if value and "$(" not in value and "${" not in value:
109
+ if not value:
110
+ continue
111
+ if "$(" not in value and "${" not in value:
88
112
  return value
113
+ expanded = _expand_entitlements_refs(value, target_name)
114
+ if "$(" in expanded or "${" in expanded:
115
+ continue
116
+ if base is None or (base / expanded).is_file():
117
+ return expanded
89
118
  return ""
90
119
 
91
120
 
@@ -102,6 +131,7 @@ def discover_signable_targets(project_path: str) -> list[dict]:
102
131
  """
103
132
  pbx = _load_pbxproj(project_path)
104
133
  objects = pbx["objects"]
134
+ base = Path(project_path).parent
105
135
  targets: list[dict] = []
106
136
  for obj in objects.values():
107
137
  if obj.get("isa") != "PBXNativeTarget":
@@ -120,7 +150,9 @@ def discover_signable_targets(project_path: str) -> list[dict]:
120
150
  "name": name,
121
151
  "bundle_id": bundle_id,
122
152
  "config_ids": config_ids,
123
- "entitlements": _entitlements_from_configs(objects, config_ids),
153
+ "entitlements": _entitlements_from_configs(
154
+ objects, config_ids, target_name=name, base=base
155
+ ),
124
156
  }
125
157
  )
126
158
  if not targets:
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env python3
2
+ """Entitlements-path resolution in pbxproj_editor."""
3
+
4
+ import unittest
5
+ from pathlib import Path
6
+ from tempfile import TemporaryDirectory
7
+
8
+ from pbxproj_editor import _entitlements_from_configs, _expand_entitlements_refs
9
+
10
+
11
+ def _objects(value):
12
+ return {"cfg": {"buildSettings": {"CODE_SIGN_ENTITLEMENTS": value}}}
13
+
14
+
15
+ class ExpandEntitlementsRefsTest(unittest.TestCase):
16
+ def test_expands_target_name_both_spellings(self):
17
+ self.assertEqual(
18
+ _expand_entitlements_refs("$(TARGET_NAME)/$(TARGET_NAME).entitlements", "Blocker"),
19
+ "Blocker/Blocker.entitlements",
20
+ )
21
+ self.assertEqual(
22
+ _expand_entitlements_refs("${TARGET_NAME}/App.entitlements", "Blocker"),
23
+ "Blocker/App.entitlements",
24
+ )
25
+
26
+ def test_srcroot_prefix_drops_out(self):
27
+ self.assertEqual(
28
+ _expand_entitlements_refs("$(SRCROOT)/App/App.entitlements", "App"),
29
+ "App/App.entitlements",
30
+ )
31
+
32
+ def test_unknown_reference_is_left_alone(self):
33
+ self.assertIn("$(CONFIGURATION)", _expand_entitlements_refs(
34
+ "$(CONFIGURATION)/App.entitlements", "App"))
35
+
36
+
37
+ class EntitlementsFromConfigsTest(unittest.TestCase):
38
+ def test_literal_path_is_returned_without_touching_disk(self):
39
+ self.assertEqual(
40
+ _entitlements_from_configs(_objects("App/App.entitlements"), ["cfg"],
41
+ target_name="App", base=Path("/nonexistent")),
42
+ "App/App.entitlements",
43
+ )
44
+
45
+ def test_target_name_reference_resolves_when_the_file_exists(self):
46
+ with TemporaryDirectory() as tmp:
47
+ root = Path(tmp)
48
+ (root / "Blocker").mkdir()
49
+ (root / "Blocker" / "Blocker.entitlements").write_text("<plist/>")
50
+ self.assertEqual(
51
+ _entitlements_from_configs(
52
+ _objects("$(TARGET_NAME)/$(TARGET_NAME).entitlements"), ["cfg"],
53
+ target_name="Blocker", base=root),
54
+ "Blocker/Blocker.entitlements",
55
+ )
56
+
57
+ def test_expanded_path_that_does_not_exist_is_rejected(self):
58
+ with TemporaryDirectory() as tmp:
59
+ self.assertEqual(
60
+ _entitlements_from_configs(
61
+ _objects("$(TARGET_NAME)/$(TARGET_NAME).entitlements"), ["cfg"],
62
+ target_name="Ghost", base=Path(tmp)),
63
+ "",
64
+ )
65
+
66
+ def test_unresolvable_reference_is_skipped(self):
67
+ self.assertEqual(
68
+ _entitlements_from_configs(
69
+ _objects("$(CONFIGURATION)/App.entitlements"), ["cfg"],
70
+ target_name="App", base=Path("/tmp")),
71
+ "",
72
+ )
73
+
74
+ def test_missing_setting_yields_empty(self):
75
+ self.assertEqual(
76
+ _entitlements_from_configs({"cfg": {"buildSettings": {}}}, ["cfg"]), "")
77
+
78
+
79
+ if __name__ == "__main__":
80
+ unittest.main()
@@ -1 +1 @@
1
- 1.0.13
1
+ 1.0.15
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env python3
2
+ """Raise a Flutter app's Gradle wrapper to the version its Flutter SDK requires.
3
+
4
+ The Flutter Gradle plugin refuses to apply when the project's wrapper is older
5
+ than the SDK's floor:
6
+
7
+ Your project's Gradle version (8.11.1) is lower than Flutter's minimum
8
+ supported version of 8.14.0.
9
+
10
+ CI installs the current `stable` Flutter, so that floor rises on Flutter's
11
+ release cadence, not the app's. Every app in a fleet then breaks on the same
12
+ day — long after the last commit that could have anticipated it.
13
+
14
+ This raises the wrapper inside the CI checkout, the same way the action already
15
+ rewrites versionCode there. The edit is never committed back; the project keeps
16
+ whatever version its authors chose, and the build stops depending on the
17
+ project having been touched since the last Flutter release.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ from pathlib import Path
24
+
25
+ # `stable` moves the floor over time, so read it from the SDK rather than
26
+ # hardcoding a number this file would have to chase.
27
+ _CHECKER = Path("packages/flutter_tools/gradle/src/main/kotlin/DependencyVersionChecker.kt")
28
+ _ERROR_VERSION = re.compile(r"errorGradleVersion\s*:\s*Version\s*=\s*Version\((\d+),\s*(\d+),\s*(\d+)\)")
29
+ _DISTRIBUTION = re.compile(r"^(distributionUrl=.*gradle-)([0-9]+(?:\.[0-9]+)*)(-(?:all|bin)\.zip)$", re.M)
30
+
31
+ Version = tuple[int, ...]
32
+
33
+
34
+ def parse_version(text: str) -> Version | None:
35
+ if not re.fullmatch(r"[0-9]+(\.[0-9]+)*", text or ""):
36
+ return None
37
+ return tuple(int(part) for part in text.split("."))
38
+
39
+
40
+ def _render(version: Version) -> str:
41
+ return ".".join(str(part) for part in version)
42
+
43
+
44
+ def _stripped(version: Version) -> str:
45
+ parts = list(version)
46
+ while len(parts) > 2 and parts[-1] == 0:
47
+ parts.pop()
48
+ return _render(tuple(parts))
49
+
50
+
51
+ def candidates(version: Version) -> list[str]:
52
+ """Distribution names to try, best guess first.
53
+
54
+ Gradle's naming is not consistent across majors: 8.x drops a trailing zero
55
+ patch (`gradle-8.14-all.zip`; `gradle-8.14.0-all.zip` is a 404) while 9.x
56
+ keeps it (`gradle-9.0.0-all.zip`; `gradle-9.0-all.zip` is a 404). Getting
57
+ this wrong swaps the error being fixed for a download failure that does not
58
+ look like our doing, so order by the observed rule and keep the other form
59
+ as a fallback.
60
+ """
61
+ stripped, full = _stripped(version), _render(version)
62
+ order = [full, stripped] if version and version[0] >= 9 else [stripped, full]
63
+ return list(dict.fromkeys(order))
64
+
65
+
66
+ def _exists(name: str) -> bool | None:
67
+ """True/False if the distribution could be checked, None if we could not."""
68
+ import urllib.error
69
+ import urllib.request
70
+
71
+ url = f"https://services.gradle.org/distributions/gradle-{name}-all.zip"
72
+ request = urllib.request.Request(url, method="HEAD")
73
+ try:
74
+ with urllib.request.urlopen(request, timeout=15) as response:
75
+ return response.status == 200
76
+ except urllib.error.HTTPError as exc:
77
+ return exc.code == 200
78
+ except OSError:
79
+ return None
80
+
81
+
82
+ def normalize(version: Version, probe=_exists) -> str:
83
+ """The distribution name to write into distributionUrl.
84
+
85
+ Probes services.gradle.org so a future naming change corrects itself; falls
86
+ back to the ordering in `candidates` when the network is unavailable, which
87
+ is no worse than not probing at all.
88
+ """
89
+ options = candidates(version)
90
+ for name in options:
91
+ if probe(name) is True:
92
+ return name
93
+ return options[0]
94
+
95
+
96
+ def flutter_minimum_gradle(flutter_root: Path) -> Version | None:
97
+ """The oldest Gradle this Flutter SDK will apply its plugin against."""
98
+ checker = flutter_root / _CHECKER
99
+ try:
100
+ match = _ERROR_VERSION.search(checker.read_text(encoding="utf-8"))
101
+ except OSError:
102
+ return None
103
+ if not match:
104
+ return None
105
+ return tuple(int(group) for group in match.groups())
106
+
107
+
108
+ def wrapper_properties(workspace: Path, gradle_root: Path | None = None) -> Path:
109
+ root = gradle_root or (workspace / "android")
110
+ return root / "gradle" / "wrapper" / "gradle-wrapper.properties"
111
+
112
+
113
+ def read_wrapper_version(properties: Path) -> Version | None:
114
+ try:
115
+ match = _DISTRIBUTION.search(properties.read_text(encoding="utf-8"))
116
+ except OSError:
117
+ return None
118
+ return parse_version(match.group(2)) if match else None
119
+
120
+
121
+ def set_wrapper_version(properties: Path, version: Version) -> bool:
122
+ try:
123
+ text = properties.read_text(encoding="utf-8")
124
+ except OSError:
125
+ return False
126
+ replaced, count = _DISTRIBUTION.subn(
127
+ lambda m: f"{m.group(1)}{normalize(version)}{m.group(3)}", text
128
+ )
129
+ if not count:
130
+ return False
131
+ properties.write_text(replaced, encoding="utf-8")
132
+ return True
133
+
134
+
135
+ def ensure_minimum(workspace: Path, flutter_root: Path, gradle_root: Path | None = None) -> str:
136
+ """Raise the wrapper to Flutter's floor. Returns a line for the build log."""
137
+ properties = wrapper_properties(workspace, gradle_root)
138
+ if not properties.is_file():
139
+ return ""
140
+ minimum = flutter_minimum_gradle(flutter_root)
141
+ if minimum is None:
142
+ return (
143
+ "::warning::Could not read Flutter's minimum Gradle version from "
144
+ f"{flutter_root}; leaving the wrapper alone"
145
+ )
146
+ current = read_wrapper_version(properties)
147
+ if current is None:
148
+ return f"::warning::No Gradle distributionUrl found in {properties}; leaving it alone"
149
+ if current >= minimum:
150
+ return f"Gradle wrapper {normalize(current)} meets Flutter's minimum {normalize(minimum)}"
151
+ if not set_wrapper_version(properties, minimum):
152
+ return f"::warning::Failed to rewrite the Gradle distributionUrl in {properties}"
153
+ return (
154
+ f"::warning::Gradle wrapper {normalize(current)} is below Flutter's minimum "
155
+ f"{normalize(minimum)}; raised it to {normalize(minimum)} for this build only. "
156
+ f"Commit the bump in {properties.name} to make it permanent."
157
+ )
@@ -4,9 +4,11 @@
4
4
  from __future__ import annotations
5
5
 
6
6
  import os
7
+ import shutil
7
8
  import sys
8
9
  from pathlib import Path
9
10
 
11
+ import gradle_wrapper
10
12
  from android_config import (
11
13
  ConfigError,
12
14
  detect_package_name,
@@ -126,6 +128,22 @@ def apply_gradle_version(project, build_number: int, build_name: str) -> None:
126
128
  )
127
129
 
128
130
 
131
+ def flutter_root() -> Path | None:
132
+ """Where the runner's Flutter SDK lives.
133
+
134
+ flutter-action exports FLUTTER_ROOT; a self-hosted runner with Flutter
135
+ merely on PATH does not, so fall back to walking up from the binary
136
+ (<root>/bin/flutter).
137
+ """
138
+ declared = os.environ.get("FLUTTER_ROOT")
139
+ if declared:
140
+ return Path(declared)
141
+ binary = shutil.which("flutter")
142
+ if not binary:
143
+ return None
144
+ return Path(binary).resolve().parent.parent
145
+
146
+
129
147
  def main() -> None:
130
148
  workspace = Path(os.environ.get("GITHUB_WORKSPACE", ".")).resolve()
131
149
  env_path = Path(os.environ["GITHUB_ENV"])
@@ -151,6 +169,18 @@ def main() -> None:
151
169
  apply_gradle_version(
152
170
  project, build_number, os.environ.get("INPUT_BUILD_NAME") or ""
153
171
  )
172
+ else:
173
+ # Flutter refuses to apply its Gradle plugin to a wrapper older than
174
+ # the SDK's floor, and CI tracks `stable`, so that floor rises
175
+ # without the app changing. Raise it in the checkout rather than
176
+ # letting every app in the fleet break on Flutter's release day.
177
+ root = flutter_root()
178
+ if root is None:
179
+ print("::warning::Flutter SDK not found; skipping the Gradle wrapper check")
180
+ else:
181
+ message = gradle_wrapper.ensure_minimum(workspace, root)
182
+ if message:
183
+ print(message)
154
184
  except (ConfigError, KeyError, OSError) as exc:
155
185
  print(f"::error::{exc}")
156
186
  raise SystemExit(1) from exc
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env python3
2
+ """Tests for raising a Flutter app's Gradle wrapper to the SDK's floor."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import tempfile
7
+ import unittest
8
+ from pathlib import Path
9
+
10
+ import gradle_wrapper as gw
11
+
12
+ CHECKER_SOURCE = """
13
+ package com.flutter.gradle
14
+ object DependencyVersionChecker {
15
+ internal val warnGradleVersion: Version = Version(8, 7, 0)
16
+ internal val errorGradleVersion: Version = Version(8, 14, 0)
17
+ }
18
+ """
19
+
20
+ WRAPPER = (
21
+ "distributionBase=GRADLE_USER_HOME\n"
22
+ "distributionPath=wrapper/dists\n"
23
+ "distributionUrl=https\\://services.gradle.org/distributions/gradle-8.11.1-all.zip\n"
24
+ )
25
+
26
+
27
+ def never(_name: str) -> bool:
28
+ """Probe stub for tests: the network is never consulted."""
29
+ return False
30
+
31
+
32
+ class RenderTest(unittest.TestCase):
33
+ def test_eight_x_drops_a_trailing_zero_patch(self):
34
+ self.assertEqual(gw.candidates((8, 14, 0))[0], "8.14")
35
+
36
+ def test_nine_x_keeps_a_trailing_zero_patch(self):
37
+ # gradle-9.0-all.zip is a 404; gradle-9.0.0-all.zip is the real name.
38
+ self.assertEqual(gw.candidates((9, 0, 0))[0], "9.0.0")
39
+
40
+ def test_a_real_patch_is_never_stripped(self):
41
+ self.assertEqual(gw.candidates((8, 14, 3)), ["8.14.3"])
42
+
43
+ def test_the_other_spelling_stays_available_as_a_fallback(self):
44
+ self.assertIn("8.14.0", gw.candidates((8, 14, 0)))
45
+
46
+ def test_falls_back_to_the_first_candidate_without_network(self):
47
+ self.assertEqual(gw.normalize((8, 14, 0), probe=never), "8.14")
48
+
49
+ def test_a_successful_probe_wins_over_the_ordering(self):
50
+ self.assertEqual(
51
+ gw.normalize((8, 14, 0), probe=lambda name: name == "8.14.0"), "8.14.0"
52
+ )
53
+
54
+
55
+ class WrapperFileTest(unittest.TestCase):
56
+ def setUp(self):
57
+ self.tmp = tempfile.TemporaryDirectory()
58
+ self.workspace = Path(self.tmp.name)
59
+ self.properties = gw.wrapper_properties(self.workspace)
60
+ self.properties.parent.mkdir(parents=True)
61
+ self.properties.write_text(WRAPPER, encoding="utf-8")
62
+ self.flutter = self.workspace / "flutter"
63
+ checker = self.flutter / gw._CHECKER
64
+ checker.parent.mkdir(parents=True)
65
+ checker.write_text(CHECKER_SOURCE, encoding="utf-8")
66
+ self.addCleanup(self.tmp.cleanup)
67
+
68
+ def test_reads_the_declared_version(self):
69
+ self.assertEqual(gw.read_wrapper_version(self.properties), (8, 11, 1))
70
+
71
+ def test_reads_the_sdk_floor(self):
72
+ self.assertEqual(gw.flutter_minimum_gradle(self.flutter), (8, 14, 0))
73
+
74
+ def test_raises_a_wrapper_below_the_floor(self):
75
+ gw.set_wrapper_version(self.properties, (8, 14, 0))
76
+ self.assertEqual(gw.read_wrapper_version(self.properties), (8, 14,))
77
+ self.assertIn("gradle-8.14-all.zip", self.properties.read_text())
78
+
79
+ def test_preserves_a_bin_distribution(self):
80
+ self.properties.write_text(
81
+ WRAPPER.replace("-all.zip", "-bin.zip"), encoding="utf-8"
82
+ )
83
+ gw.set_wrapper_version(self.properties, (8, 14, 0))
84
+ self.assertIn("gradle-8.14-bin.zip", self.properties.read_text())
85
+
86
+ def test_leaves_a_wrapper_already_at_the_floor_alone(self):
87
+ self.properties.write_text(
88
+ WRAPPER.replace("8.11.1", "8.14.3"), encoding="utf-8"
89
+ )
90
+ message = gw.ensure_minimum(self.workspace, self.flutter)
91
+ self.assertIn("meets Flutter's minimum", message)
92
+ self.assertIn("gradle-8.14.3-all.zip", self.properties.read_text())
93
+
94
+ def test_warns_and_raises_when_below_the_floor(self):
95
+ message = gw.ensure_minimum(self.workspace, self.flutter)
96
+ self.assertTrue(message.startswith("::warning::"))
97
+ self.assertIn("8.11.1", message)
98
+ self.assertIn("gradle-8.14", self.properties.read_text())
99
+
100
+ def test_no_wrapper_is_not_an_error(self):
101
+ self.properties.unlink()
102
+ self.assertEqual(gw.ensure_minimum(self.workspace, self.flutter), "")
103
+
104
+ def test_unreadable_sdk_leaves_the_wrapper_untouched(self):
105
+ message = gw.ensure_minimum(self.workspace, self.workspace / "nope")
106
+ self.assertTrue(message.startswith("::warning::"))
107
+ self.assertIn("gradle-8.11.1-all.zip", self.properties.read_text())
108
+
109
+ def test_a_wrapper_without_a_distribution_url_is_left_alone(self):
110
+ self.properties.write_text("distributionBase=GRADLE_USER_HOME\n", encoding="utf-8")
111
+ message = gw.ensure_minimum(self.workspace, self.flutter)
112
+ self.assertIn("No Gradle distributionUrl", message)
113
+
114
+ def test_honours_an_explicit_gradle_root(self):
115
+ root = self.workspace / "nested"
116
+ properties = gw.wrapper_properties(self.workspace, root)
117
+ properties.parent.mkdir(parents=True)
118
+ properties.write_text(WRAPPER, encoding="utf-8")
119
+ gw.ensure_minimum(self.workspace, self.flutter, gradle_root=root)
120
+ self.assertIn("gradle-8.14", properties.read_text())
121
+
122
+
123
+ if __name__ == "__main__":
124
+ unittest.main()
package/bin/cli.mjs CHANGED
@@ -3,13 +3,22 @@
3
3
  import { readFileSync } from 'node:fs';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { dirname, join } from 'node:path';
6
- import updateNotifier from 'update-notifier';
7
-
8
6
  const __filename = fileURLToPath(import.meta.url);
9
7
  const __dirname = dirname(__filename);
10
8
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
11
9
 
12
- const notifier = updateNotifier({ pkg });
10
+ // The update nag is a nicety, not a dependency of installing. Running the CLI
11
+ // straight out of a source checkout (`node ../gowalk-cicd/bin/cli.mjs`, the
12
+ // documented local-development path) has no node_modules, and a hard import
13
+ // would take the whole installer down with it.
14
+ async function loadNotifier() {
15
+ try {
16
+ const { default: updateNotifier } = await import('update-notifier');
17
+ return updateNotifier({ pkg });
18
+ } catch {
19
+ return { notify() {} };
20
+ }
21
+ }
13
22
 
14
23
  const args = process.argv.slice(2);
15
24
  let dryRun = false;
@@ -60,4 +69,5 @@ After install:
60
69
  const { runInstall } = await import('../src/install.mjs');
61
70
  await runInstall({ dryRun });
62
71
 
72
+ const notifier = await loadNotifier();
63
73
  notifier.notify();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.13",
3
+ "version": "1.0.15",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,8 +19,15 @@ concurrency:
19
19
  cancel-in-progress: true
20
20
 
21
21
  jobs:
22
+ # Repository variable DEPLOY_PLATFORMS selects which stores this repo ships to:
23
+ # unset / 'both' — iOS and Android (the default)
24
+ # 'android' — Android only; skip the iOS job entirely
25
+ # 'ios' — iOS only; skip the Android job entirely
26
+ # Use it when one platform is intentionally out of scope for a repo, so an
27
+ # unrelated failure on the other platform cannot block the release you want.
22
28
  ios:
23
29
  name: iOS TestFlight
30
+ if: ${{ vars.DEPLOY_PLATFORMS != 'android' }}
24
31
  runs-on: macos-15
25
32
  timeout-minutes: 60
26
33
  steps:
@@ -78,6 +85,7 @@ jobs:
78
85
 
79
86
  android:
80
87
  name: Android Google Play
88
+ if: ${{ vars.DEPLOY_PLATFORMS != 'ios' }}
81
89
  runs-on: ubuntu-24.04
82
90
  timeout-minutes: 45
83
91
  steps:
@@ -187,6 +195,10 @@ jobs:
187
195
  releaseFiles: ${{ steps.android.outputs.bundle-path }}
188
196
  tracks: ${{ vars.GOOGLE_PLAY_TRACK || 'internal' }}
189
197
  status: ${{ vars.GOOGLE_PLAY_STATUS || 'completed' }}
198
+ # Play rejects an 'inProgress' release that does not declare how much of
199
+ # the audience it reaches. Only send the fraction in that mode: passing it
200
+ # alongside status 'completed' is an error rather than a no-op.
201
+ userFraction: ${{ vars.GOOGLE_PLAY_STATUS == 'inProgress' && (vars.GOOGLE_PLAY_USER_FRACTION || '0.2') || '' }}
190
202
  # Localized release notes: commit distribution/whatsnew/whatsnew-<bcp47>
191
203
  # files (e.g. whatsnew-en-US, whatsnew-de-DE) and they ship with every
192
204
  # release. Empty when the directory is absent — the action skips it.