gowalk-cicd 1.0.65 → 1.0.67

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 CHANGED
@@ -192,6 +192,12 @@ node /path/to/gowalk-cicd/bin/cli.mjs
192
192
  delivery request. This requires no human handoff. A green CI run alone is not
193
193
  evidence that the deferred symbols reached Firebase. Android retains its Dart
194
194
  symbols and uploads them before the store bundle, using the configured proxy.
195
+ - Play readiness and AAB/APK version inventory share one prebuild edit. Retry OAuth
196
+ transport, inventory reads and cleanup of that known edit; never replay an ambiguous
197
+ `edits.insert`. Only an explicit package-404 allows first-release defaults; failed
198
+ inventory is not an empty account. The action exposes `play-api-ready` so current
199
+ workflows skip the redundant postbuild check. Error annotations carry safe
200
+ phase/category evidence and any cleanup edit ID without raw provider exceptions.
195
201
  - The iOS action reads every script and prompt from the snapshot it takes of
196
202
  itself in its first step (`$SWIFT_APP_ACTION`, under `RUNNER_TEMP`), never
197
203
  from `${{ github.action_path }}` after that step. The plugin self-update
package/README.md CHANGED
@@ -298,6 +298,26 @@ and prompt from that copy.
298
298
 
299
299
  ### Google Play upload retry
300
300
 
301
+ Android resolves Play readiness and existing AAB/APK version codes together, in one
302
+ owned edit before compilation or Crashlytics work. Auth, permission, transport and
303
+ invalid inventory failures stop there; they never select a fallback version as though
304
+ the package were new. A positively absent package (edit creation returns 404) keeps
305
+ the first-release path: build and retain the signed AAB for the app session's console
306
+ upload. Explicit version pins are preserved and still require the readiness check.
307
+ The action exports `play-api-ready`; current workflows consume that result without
308
+ another store call. A newer workflow with an older action copy retains the original
309
+ standalone preflight. Refreshing the workflow alongside actions removes that late call.
310
+
311
+ The API readiness preflight retries transient OAuth transport failures and cleanup of
312
+ its own known edit up to three times, with one- and two-second delays. It never
313
+ replays an edit creation whose outcome is unknown. Every request keeps the assigned
314
+ Google proxy; an absent edit after cleanup is already closed. Exhausted recovery
315
+ emits the `play_preflight_failed` error annotation with schema
316
+ `gowalk-cicd/play-preflight-failure.v1`, phase (`oauth_refresh`, `edit_create`,
317
+ `version_inventory` or `edit_cleanup`), classified code and attempt count. Provider bodies, credential
318
+ values and raw network errors are omitted. A cleanup failure also records the known
319
+ edit ID and `cleanup_required: true` so recovery targets that edit.
320
+
301
321
  A Play edit is a short-lived server-side object, and an AAB upload that runs
302
322
  long enough outlives one:
303
323
 
@@ -1 +1 @@
1
- 1.0.65
1
+ 1.0.67
@@ -1 +1 @@
1
- 1.0.65
1
+ 1.0.67
@@ -63,6 +63,9 @@ outputs:
63
63
  play-service-account:
64
64
  description: Path to the discovered Google Play service-account JSON
65
65
  value: ${{ steps.config.outputs.service_account }}
66
+ play-api-ready:
67
+ description: Prebuild readiness from the same owned edit used to read existing version codes
68
+ value: ${{ steps.config.outputs.play_api_ready }}
66
69
  project-kind:
67
70
  description: Detected build system — `flutter` or `gradle`
68
71
  value: ${{ steps.config.outputs.project_kind }}
@@ -76,12 +79,12 @@ outputs:
76
79
  runs:
77
80
  using: composite
78
81
  steps:
79
- # resolve_android.py asks Google Play for the highest versionCode already
80
- # uploaded, so it needs these. Installing them here (rather than relying on the
81
- # caller) keeps the action self-contained.
82
+ # Resolve readiness and highest versionCode together before any compilation.
83
+ # Missing dependencies or an indeterminate store read must fail here; they are
84
+ # not evidence of a new package with no existing version codes.
82
85
  - name: Install Play API dependencies
83
86
  shell: bash
84
- run: python3 -m pip install --disable-pip-version-check -q 'google-auth>=2.40,<3' 'requests>=2.32,<3' || true
87
+ run: python3 -m pip install --disable-pip-version-check -q 'google-auth>=2.40,<3' 'requests>=2.32,<3'
85
88
 
86
89
  - name: Resolve Android app and credentials
87
90
  id: config
@@ -0,0 +1,50 @@
1
+ """Resolve readiness and version inventory in one owned, proxy-bound Play edit."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from play_preflight import opened_edit
8
+ from play_preflight_transport import call, fail
9
+ from play_store_proxy import session
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class PlayInventory:
14
+ ready: bool
15
+ highest_version: int | None
16
+
17
+
18
+ def version_codes(response, kind: str) -> list[int]:
19
+ try:
20
+ payload = response.json()
21
+ if not isinstance(payload, dict) or "error" in payload:
22
+ raise ValueError("invalid inventory")
23
+ rows = payload.get(kind, [])
24
+ if not isinstance(rows, list):
25
+ raise ValueError("invalid inventory")
26
+ values = []
27
+ for row in rows:
28
+ raw = row["versionCode"]
29
+ if isinstance(raw, bool) or not isinstance(raw, (int, str)):
30
+ raise ValueError("invalid version")
31
+ value = int(raw)
32
+ if not 1 <= value <= 2_100_000_000:
33
+ raise ValueError("invalid version")
34
+ values.append(value)
35
+ return values
36
+ except (ValueError, TypeError, KeyError):
37
+ fail("version_inventory", "invalid_response", 1)
38
+
39
+
40
+ def read_inventory(package: str, account: Path) -> PlayInventory:
41
+ with session() as client, opened_edit(client, package, account) as edit:
42
+ if edit is None:
43
+ return PlayInventory(ready=False, highest_version=None)
44
+ url, headers = edit
45
+ codes = []
46
+ for kind in ("bundles", "apks"):
47
+ response = call("version_inventory", lambda: client.get(f"{url}/{kind}", headers=headers, timeout=30),
48
+ retry=True, accepted=(200,))
49
+ codes.extend(version_codes(response, kind))
50
+ return PlayInventory(ready=True, highest_version=max(codes) if codes else None)
@@ -4,15 +4,17 @@
4
4
  from __future__ import annotations
5
5
 
6
6
  import argparse
7
+ from contextlib import contextmanager
8
+ from functools import partial
7
9
  import json
8
10
  import os
9
11
  from pathlib import Path
12
+ from urllib.parse import quote
10
13
 
11
14
  import google.auth.transport.requests
12
- from google.auth.exceptions import TransportError
13
- import requests
14
15
  from google.oauth2 import service_account
15
16
 
17
+ from play_preflight_transport import call, fail
16
18
  from play_store_proxy import session
17
19
 
18
20
 
@@ -25,52 +27,58 @@ def write_ready(value: bool) -> None:
25
27
  stream.write(f"ready={'true' if value else 'false'}\n")
26
28
 
27
29
 
28
- def response_message(response: requests.Response) -> str:
29
- try:
30
- payload = response.json()
31
- return str(payload.get("error", {}).get("message", "unknown API error"))[:500]
32
- except (ValueError, AttributeError):
33
- return "unknown API error"
34
-
35
-
36
30
  def main() -> None:
37
31
  parser = argparse.ArgumentParser()
38
32
  parser.add_argument("--package", required=True)
39
33
  parser.add_argument("--service-account", required=True, type=Path)
40
34
  args = parser.parse_args()
41
35
 
42
- client = session()
36
+ with session() as client:
37
+ check_ready(client, args.package, args.service_account)
38
+
39
+
40
+ def check_ready(client, package: str, account: Path) -> None:
41
+ with opened_edit(client, package, account) as edit:
42
+ ready = edit is not None
43
+ write_ready(ready)
44
+ if ready:
45
+ print("Google Play package is ready for automated uploads")
46
+
47
+
48
+ @contextmanager
49
+ def opened_edit(client, package: str, account: Path):
50
+ """Own one preflight edit; callers may perform reads before its verified cleanup."""
43
51
  credentials = service_account.Credentials.from_service_account_file(
44
- args.service_account, scopes=[SCOPE]
52
+ account, scopes=[SCOPE]
45
53
  )
46
- credentials.refresh(google.auth.transport.requests.Request(session=client))
54
+ request = partial(google.auth.transport.requests.Request(session=client), timeout=30)
55
+ call("oauth_refresh", lambda: credentials.refresh(request), retry=True)
47
56
  headers = {"Authorization": f"Bearer {credentials.token}"}
48
- url = f"{BASE_URL}/applications/{args.package}/edits"
49
- response = client.post(url, headers=headers, json={}, timeout=30)
57
+ url = f"{BASE_URL}/applications/{package}/edits"
58
+ # A timed-out insert may already have created an edit. Never blindly insert again.
59
+ response = call("edit_create", lambda: client.post(url, headers=headers, json={}, timeout=30),
60
+ accepted=(200, 404))
50
61
  if response.status_code == 404:
51
- write_ready(False)
52
62
  print(
53
- "::warning::Google Play package is not API-ready. Download the "
54
- "android-first-release artifact and upload it once in Play Console; "
55
- "later runs will deploy automatically."
63
+ "::warning::Google Play package is not API-ready. The app session must upload the "
64
+ "signed Android artifact retained by this build through its assigned Play Console "
65
+ "and verify the resulting release before retrying API delivery."
56
66
  )
67
+ yield None
57
68
  return
58
- if response.status_code != 200:
59
- message = response_message(response)
60
- raise SystemExit(f"Google Play preflight failed ({response.status_code}): {message}")
61
-
62
- edit_id = json.loads(response.text)["id"]
63
- delete_url = f"{url}/{edit_id}"
64
- deleted = client.delete(delete_url, headers=headers, timeout=30)
65
- if deleted.status_code not in (200, 204):
66
- message = response_message(deleted)
67
- raise SystemExit(f"cannot close Google Play preflight edit: {message}")
68
- write_ready(True)
69
- print("Google Play package is ready for automated uploads")
69
+ try:
70
+ edit_id = json.loads(response.text)["id"]
71
+ if not isinstance(edit_id, str) or not edit_id or len(edit_id) > 512:
72
+ raise ValueError("invalid edit identifier")
73
+ except (ValueError, TypeError, KeyError):
74
+ fail("edit_create", "invalid_response", 1)
75
+ delete_url = f"{url}/{quote(edit_id, safe='')}"
76
+ try:
77
+ yield (delete_url, headers)
78
+ finally:
79
+ call("edit_cleanup", lambda: client.delete(delete_url, headers=headers, timeout=30),
80
+ retry=True, edit_id=edit_id, accepted=(200, 204, 404))
70
81
 
71
82
 
72
83
  if __name__ == "__main__":
73
- try:
74
- main()
75
- except (requests.RequestException, TransportError):
76
- raise SystemExit("Google Play preflight failed through the assigned proxy") from None
84
+ main()
@@ -0,0 +1,66 @@
1
+ """Bound safe preflight retries without replaying an ambiguous edit creation."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import time
6
+
7
+ from google.auth.exceptions import RefreshError, TransportError
8
+ import requests
9
+
10
+ RETRY_STATUSES = {429, 500, 502, 503, 504}
11
+ TRANSPORT_ERRORS = (requests.RequestException, TransportError)
12
+
13
+
14
+ def transport_code(error: Exception) -> str:
15
+ pending, seen = [error], set()
16
+ while pending and len(seen) < 12:
17
+ item = pending.pop(0)
18
+ if id(item) in seen:
19
+ continue
20
+ seen.add(id(item))
21
+ for kind, code in ((requests.exceptions.ProxyError, "proxy_connection_failed"),
22
+ (requests.exceptions.SSLError, "tls_failed"),
23
+ (requests.exceptions.Timeout, "timed_out"),
24
+ (requests.exceptions.ConnectionError, "connection_failed")):
25
+ if isinstance(item, kind):
26
+ return code
27
+ pending.extend(value for value in (item.__cause__, item.__context__, *item.args)
28
+ if isinstance(value, Exception))
29
+ return "transport_failed"
30
+
31
+
32
+ def fail(phase: str, code: str, attempt: int, *, status: int | None = None,
33
+ edit_id: str | None = None) -> None:
34
+ evidence = {"schema": "gowalk-cicd/play-preflight-failure.v1", "phase": phase,
35
+ "code": code, "attempts": attempt, "route": "assigned_proxy"}
36
+ if status is not None:
37
+ evidence["status"] = status
38
+ if edit_id is not None:
39
+ evidence["cleanup_required"] = True
40
+ evidence["edit_id"] = edit_id
41
+ encoded = json.dumps(evidence, sort_keys=True)
42
+ annotation = encoded.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
43
+ print("::error title=play_preflight_failed::" + annotation)
44
+ raise SystemExit("Google Play preflight failed: " + encoded) from None
45
+
46
+
47
+ def call(phase: str, operation, *, retry: bool = False, edit_id: str | None = None,
48
+ accepted: tuple[int, ...] | None = None):
49
+ limit = 3 if retry else 1
50
+ for attempt in range(1, limit + 1):
51
+ try:
52
+ result = operation()
53
+ except TRANSPORT_ERRORS as error:
54
+ if attempt == limit:
55
+ fail(phase, transport_code(error), attempt, edit_id=edit_id)
56
+ except RefreshError:
57
+ fail(phase, "oauth_refresh_failed", attempt)
58
+ else:
59
+ if not retry or getattr(result, "status_code", None) not in RETRY_STATUSES:
60
+ if accepted is not None and result.status_code not in accepted:
61
+ fail(phase, "provider_refused", attempt, status=result.status_code, edit_id=edit_id)
62
+ return result
63
+ if attempt == limit:
64
+ fail(phase, "provider_unavailable", attempt, status=result.status_code, edit_id=edit_id)
65
+ print(f"Google Play preflight retry: phase={phase} attempt={attempt + 1}/{limit}")
66
+ time.sleep(attempt)
@@ -9,7 +9,7 @@ import sys
9
9
  from pathlib import Path
10
10
 
11
11
  import gradle_wrapper
12
- from play_store_proxy import session
12
+ from play_inventory import read_inventory
13
13
  from android_config import (
14
14
  ConfigError,
15
15
  detect_package_name,
@@ -36,74 +36,13 @@ def resolve_build_number(raw: str) -> int:
36
36
  return number
37
37
 
38
38
 
39
- def highest_play_version_code(package_name: str, service_account: Path) -> int | None:
40
- """Highest versionCode Google Play already holds for this package, or None.
41
-
42
- Best-effort: any failure (offline, no permission, app not yet created) returns
43
- None so the caller falls back to GITHUB_RUN_NUMBER.
44
- """
45
- client = session()
46
- try:
47
- import google.auth.transport.requests # noqa: PLC0415
48
- import requests # noqa: PLC0415
49
- from google.oauth2 import service_account as gsa # noqa: PLC0415
50
-
51
- creds = gsa.Credentials.from_service_account_file(
52
- str(service_account),
53
- scopes=["https://www.googleapis.com/auth/androidpublisher"],
54
- )
55
- creds.refresh(google.auth.transport.requests.Request(session=client))
56
- base = "https://androidpublisher.googleapis.com/androidpublisher/v3"
57
- headers = {"Authorization": f"Bearer {creds.token}"}
58
-
59
- edit = client.post(
60
- f"{base}/applications/{package_name}/edits", headers=headers, timeout=60
61
- )
62
- edit.raise_for_status()
63
- edit_id = edit.json()["id"]
64
- try:
65
- listed = client.get(
66
- f"{base}/applications/{package_name}/edits/{edit_id}/bundles",
67
- headers=headers,
68
- timeout=60,
69
- )
70
- listed.raise_for_status()
71
- codes = [
72
- int(b["versionCode"])
73
- for b in listed.json().get("bundles", [])
74
- if b.get("versionCode") is not None
75
- ]
76
- finally:
77
- client.delete(
78
- f"{base}/applications/{package_name}/edits/{edit_id}",
79
- headers=headers,
80
- timeout=60,
81
- )
82
- return max(codes) if codes else None
83
- except Exception as exc: # noqa: BLE001 - never fail the build over this
84
- print("::warning::Could not read existing Play versionCodes through the configured proxy; "
85
- f"falling back to GITHUB_RUN_NUMBER")
86
- return None
87
-
88
-
89
- def auto_build_number(package_name: str, service_account: Path) -> int:
90
- """versionCode to use when the caller did not pin one.
91
-
92
- GITHUB_RUN_NUMBER alone is wrong: it restarts at 1 on a freshly-onboarded repo,
93
- so it collides with versionCodes Play already holds and every upload is rejected
94
- ("Version code N has already been used"). Take whichever is higher: the run
95
- number, or one past the highest code Play knows about.
96
- """
39
+ def auto_build_number(highest: int | None) -> int:
40
+ """Choose the next version only from a successfully read Play inventory."""
97
41
  run_number = resolve_build_number(os.environ.get("GITHUB_RUN_NUMBER", "1"))
98
- highest = highest_play_version_code(package_name, service_account)
99
- if highest is None:
100
- return run_number
101
- candidate = max(run_number, highest + 1)
42
+ candidate = resolve_build_number(str(max(run_number, (highest or 0) + 1)))
102
43
  if candidate != run_number:
103
- print(
104
- f"Play already holds versionCode {highest}; using {candidate} "
105
- f"instead of GITHUB_RUN_NUMBER {run_number}"
106
- )
44
+ print(f"Play already holds versionCode {highest}; using {candidate} "
45
+ f"instead of GITHUB_RUN_NUMBER {run_number}")
107
46
  return candidate
108
47
 
109
48
 
@@ -148,10 +87,19 @@ def flutter_root() -> Path | None:
148
87
  return Path(binary).resolve().parent.parent
149
88
 
150
89
 
90
+ def prepare_flutter_toolchain(workspace: Path) -> None:
91
+ # Flutter stable can raise its Gradle/AGP/Kotlin floor. Repair the ephemeral
92
+ # checkout only after the store inventory has been read successfully.
93
+ root = flutter_root()
94
+ if root is None:
95
+ print("::warning::Flutter SDK not found; skipping the build toolchain check")
96
+ else:
97
+ for message in gradle_wrapper.ensure_toolchain(workspace, root):
98
+ print(message)
99
+
100
+
151
101
  def main() -> None:
152
102
  workspace = Path(os.environ.get("GITHUB_WORKSPACE", ".")).resolve()
153
- env_path = Path(os.environ["GITHUB_ENV"])
154
- output_path = Path(os.environ["GITHUB_OUTPUT"])
155
103
  gradle_root = ""
156
104
  gradle_module = ""
157
105
  try:
@@ -162,11 +110,12 @@ def main() -> None:
162
110
  package_name = os.environ.get("INPUT_PACKAGE_NAME") or detect_package_name(
163
111
  workspace, project.build_file if project else None
164
112
  )
113
+ inventory = read_inventory(package_name, service_account)
165
114
  pinned = os.environ.get("INPUT_BUILD_NUMBER") or ""
166
115
  if pinned:
167
116
  build_number = resolve_build_number(pinned)
168
117
  else:
169
- build_number = auto_build_number(package_name, service_account)
118
+ build_number = auto_build_number(inventory.highest_version)
170
119
  if project is not None:
171
120
  gradle_root = str(project.root)
172
121
  gradle_module = project.module
@@ -174,45 +123,43 @@ def main() -> None:
174
123
  project, build_number, os.environ.get("INPUT_BUILD_NAME") or ""
175
124
  )
176
125
  else:
177
- # Flutter refuses to apply its Gradle plugin when the project's
178
- # Gradle, AGP or Kotlin plugin is older than the SDK's floor, and CI
179
- # tracks `stable`, so those floors rise without the app changing.
180
- # Raise them in the checkout rather than letting every app in the
181
- # fleet break on Flutter's release day.
182
- root = flutter_root()
183
- if root is None:
184
- print("::warning::Flutter SDK not found; skipping the build toolchain check")
185
- else:
186
- for message in gradle_wrapper.ensure_toolchain(workspace, root):
187
- print(message)
126
+ prepare_flutter_toolchain(workspace)
188
127
  except (ConfigError, KeyError, OSError) as exc:
189
128
  print(f"::error::{exc}")
190
129
  raise SystemExit(1) from exc
191
130
 
192
- for secret in (signing.store_password, signing.key_password):
193
- print(f"::add-mask::{secret}")
194
131
  values = {
195
132
  "ANDROID_PACKAGE_NAME": package_name,
196
133
  "ANDROID_BUILD_NUMBER": str(build_number),
197
134
  "ANDROID_SIGNING_PROPERTIES": str(signing.properties_path),
198
135
  "ANDROID_KEYSTORE_PATH": str(signing.keystore_path),
199
136
  "ANDROID_PLAY_SERVICE_ACCOUNT": str(service_account),
137
+ "ANDROID_PLAY_API_READY": "true" if inventory.ready else "false",
200
138
  "ANDROID_PROJECT_KIND": kind,
201
139
  "ANDROID_GRADLE_ROOT": gradle_root,
202
140
  "ANDROID_GRADLE_MODULE": gradle_module,
203
141
  }
142
+ write_config(values, signing)
143
+ print(f"Resolved {kind} Android package {package_name}, build {build_number}")
144
+
145
+
146
+ def write_config(values: dict[str, str], signing) -> None:
147
+ env_path = Path(os.environ["GITHUB_ENV"])
148
+ output_path = Path(os.environ["GITHUB_OUTPUT"])
149
+ for secret in (signing.store_password, signing.key_password):
150
+ print(f"::add-mask::{secret}")
204
151
  for name, value in values.items():
205
152
  append_value(env_path, name, value)
206
- for name, value in (
207
- ("package_name", package_name),
208
- ("service_account", str(service_account)),
209
- ("build_number", str(build_number)),
210
- ("project_kind", kind),
211
- ("gradle_root", gradle_root),
212
- ("gradle_module", gradle_module),
153
+ for name, source in (
154
+ ("package_name", "ANDROID_PACKAGE_NAME"),
155
+ ("service_account", "ANDROID_PLAY_SERVICE_ACCOUNT"),
156
+ ("build_number", "ANDROID_BUILD_NUMBER"),
157
+ ("play_api_ready", "ANDROID_PLAY_API_READY"),
158
+ ("project_kind", "ANDROID_PROJECT_KIND"),
159
+ ("gradle_root", "ANDROID_GRADLE_ROOT"),
160
+ ("gradle_module", "ANDROID_GRADLE_MODULE"),
213
161
  ):
214
- append_value(output_path, name, value)
215
- print(f"Resolved {kind} Android package {package_name}, build {build_number}")
162
+ append_value(output_path, name, values[source])
216
163
 
217
164
 
218
165
  if __name__ == "__main__":
@@ -265,30 +265,17 @@ class AutoBuildNumberTest(unittest.TestCase):
265
265
  def test_uses_play_high_water_mark_when_run_number_is_lower(self) -> None:
266
266
  import resolve_android
267
267
 
268
- with mock.patch.object(resolve_android, "highest_play_version_code", return_value=173):
269
- with mock.patch.dict(os.environ, {"GITHUB_RUN_NUMBER": "1"}):
270
- # A freshly-onboarded repo starts at run 1; Play already has 173.
271
- self.assertEqual(
272
- resolve_android.auto_build_number("com.example.app", Path("sa.json")),
273
- 174,
274
- )
268
+ with mock.patch.dict(os.environ, {"GITHUB_RUN_NUMBER": "1"}):
269
+ self.assertEqual(resolve_android.auto_build_number(173), 174)
275
270
 
276
271
  def test_uses_run_number_when_it_is_already_higher(self) -> None:
277
272
  import resolve_android
278
273
 
279
- with mock.patch.object(resolve_android, "highest_play_version_code", return_value=5):
280
- with mock.patch.dict(os.environ, {"GITHUB_RUN_NUMBER": "42"}):
281
- self.assertEqual(
282
- resolve_android.auto_build_number("com.example.app", Path("sa.json")),
283
- 42,
284
- )
274
+ with mock.patch.dict(os.environ, {"GITHUB_RUN_NUMBER": "42"}):
275
+ self.assertEqual(resolve_android.auto_build_number(5), 42)
285
276
 
286
- def test_falls_back_to_run_number_when_play_is_unreachable(self) -> None:
277
+ def test_uses_run_number_for_a_successfully_read_empty_inventory(self) -> None:
287
278
  import resolve_android
288
279
 
289
- with mock.patch.object(resolve_android, "highest_play_version_code", return_value=None):
290
- with mock.patch.dict(os.environ, {"GITHUB_RUN_NUMBER": "7"}):
291
- self.assertEqual(
292
- resolve_android.auto_build_number("com.example.app", Path("sa.json")),
293
- 7,
294
- )
280
+ with mock.patch.dict(os.environ, {"GITHUB_RUN_NUMBER": "7"}):
281
+ self.assertEqual(resolve_android.auto_build_number(None), 7)
@@ -0,0 +1,77 @@
1
+ """Current actions avoid late store calls while older action copies remain compatible."""
2
+ import os
3
+ from pathlib import Path
4
+ import subprocess
5
+ import tempfile
6
+ import unittest
7
+
8
+ ROOT = Path(__file__).resolve().parents[2]
9
+ ACTION = (ROOT / "android-action/action.yml").read_text()
10
+ WORKFLOW = (ROOT / "templates/deploy.yml").read_text()
11
+
12
+
13
+ def readiness_step() -> str:
14
+ start = WORKFLOW.index(" - name: Check Google Play API readiness")
15
+ finish = WORKFLOW.index(" # Play edits", start)
16
+ return WORKFLOW[start:finish]
17
+
18
+
19
+ class EarlyPlayWorkflowTests(unittest.TestCase):
20
+ def run_step(self, readiness: str):
21
+ step = readiness_step().split(" run: |\n", 1)[1]
22
+ script = "\n".join(line[10:] for line in step.splitlines())
23
+ with tempfile.TemporaryDirectory() as tmp:
24
+ root = Path(tmp)
25
+ fake = root / "python3"
26
+ fake.write_text('#!/bin/sh\necho called >> "$TRACE"\necho ready=true >> "$GITHUB_OUTPUT"\n')
27
+ fake.chmod(0o700)
28
+ env = {**os.environ, "PATH": f"{root}:{os.environ['PATH']}", "EARLY_READINESS": readiness,
29
+ "TRACE": str(root / "trace"), "GITHUB_OUTPUT": str(root / "output"),
30
+ "PACKAGE_NAME": "com.example.app", "SERVICE_ACCOUNT": "fixture.json"}
31
+ result = subprocess.run(["bash", "-e", "-o", "pipefail", "-c", script], env=env,
32
+ capture_output=True, text=True, check=False)
33
+ output = (root / "output").read_text() if (root / "output").exists() else ""
34
+ return result, output, (root / "trace").exists()
35
+
36
+ def test_resolver_precedes_both_build_systems_and_dependency_failures_stop_it(self):
37
+ resolver = ACTION.index("- name: Resolve Android app and credentials")
38
+ for step in ("Get Flutter dependencies", "Analyze Flutter app", "Test Android app",
39
+ "Build release Android App Bundle", "Upload Dart symbols to Crashlytics"):
40
+ self.assertLess(resolver, ACTION.index(f"- name: {step}"))
41
+ dependencies = ACTION[ACTION.index("- name: Install Play API dependencies"):resolver]
42
+ self.assertNotIn("|| true", dependencies)
43
+ self.assertIn("value: ${{ steps.config.outputs.play_api_ready }}", ACTION)
44
+
45
+ def test_verified_readiness_does_not_make_another_store_request(self):
46
+ result, output, called = self.run_step("true")
47
+ self.assertEqual(result.returncode, 0, result.stderr)
48
+ self.assertEqual(output, "ready=true\n")
49
+ self.assertFalse(called)
50
+
51
+ def test_first_release_is_retained_and_false_readiness_only_skips_upload(self):
52
+ result, output, called = self.run_step("false")
53
+ self.assertEqual(result.returncode, 0, result.stderr)
54
+ self.assertEqual(output, "ready=false\n")
55
+ self.assertFalse(called)
56
+ start = WORKFLOW.index("- name: Build and sign Android release")
57
+ end = WORKFLOW.index("- name: Install Google Play preflight dependencies", start)
58
+ self.assertNotIn("ready ==", WORKFLOW[start:end])
59
+ self.assertIn("- name: Retain signed Android App Bundle", WORKFLOW[start:end])
60
+ self.assertIn("steps.play.outputs.ready == 'true'", WORKFLOW)
61
+
62
+ def test_older_action_without_new_output_uses_original_cli_contract(self):
63
+ result, output, called = self.run_step("")
64
+ self.assertEqual(result.returncode, 0, result.stderr)
65
+ self.assertEqual(output, "ready=true\n")
66
+ self.assertTrue(called)
67
+ self.assertRegex(readiness_step(), r'--package "\$PACKAGE_NAME"\s*\\\s*--service-account "\$SERVICE_ACCOUNT"')
68
+
69
+ def test_invalid_output_cannot_silently_skip_preflight(self):
70
+ result, output, called = self.run_step("unknown")
71
+ self.assertEqual(result.returncode, 1)
72
+ self.assertEqual(output, "")
73
+ self.assertFalse(called)
74
+
75
+
76
+ if __name__ == "__main__":
77
+ unittest.main()
@@ -0,0 +1,98 @@
1
+ """A single early edit verifies readiness and inventories AAB/APK version codes."""
2
+ import io
3
+ from pathlib import Path
4
+ import sys
5
+ import unittest
6
+ from unittest import mock
7
+
8
+ import requests
9
+
10
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
11
+ import play_inventory as inventory
12
+ import play_preflight as preflight
13
+ import play_preflight_transport as transport
14
+
15
+
16
+ class PlayInventoryTests(unittest.TestCase):
17
+ def setUp(self):
18
+ self.client = mock.MagicMock()
19
+ self.client.__enter__.return_value = self.client
20
+ self.client.post.return_value = mock.Mock(status_code=200, text='{"id":"edit-123"}')
21
+ self.client.delete.return_value = mock.Mock(status_code=204)
22
+ self.client.get.side_effect = [mock.Mock(status_code=200, json=lambda: {"bundles": [{"versionCode": 41}]}),
23
+ mock.Mock(status_code=200, json=lambda: {"apks": [{"versionCode": 55}]})]
24
+ self.credentials = mock.Mock(token="private-token")
25
+ self.enterContext(mock.patch.object(preflight.service_account.Credentials,
26
+ "from_service_account_file", return_value=self.credentials))
27
+ self.enterContext(mock.patch.object(inventory, "session", return_value=self.client))
28
+ self.sleep = self.enterContext(mock.patch.object(transport.time, "sleep"))
29
+ self.output = self.enterContext(mock.patch("sys.stdout", new_callable=io.StringIO))
30
+
31
+ def read(self):
32
+ return inventory.read_inventory("com.example.app", Path("account.json"))
33
+
34
+ def test_bundles_and_apks_share_one_edit_and_highest_code(self):
35
+ result = self.read()
36
+ self.assertTrue(result.ready)
37
+ self.assertEqual(result.highest_version, 55)
38
+ self.credentials.refresh.assert_called_once()
39
+ self.client.post.assert_called_once()
40
+ self.client.delete.assert_called_once()
41
+ self.assertEqual([c.args[0].rsplit("/", 1)[-1] for c in self.client.get.call_args_list], ["bundles", "apks"])
42
+
43
+ def test_positive_package_absence_skips_inventory_and_allows_first_artifact(self):
44
+ self.client.post.return_value.status_code = 404
45
+ self.assertEqual(self.read(), inventory.PlayInventory(False, None))
46
+ self.assertIn("assigned Play Console", self.output.getvalue())
47
+ self.client.get.assert_not_called()
48
+ self.client.delete.assert_not_called()
49
+
50
+ def test_successful_empty_inventory_is_distinct_from_package_absence(self):
51
+ self.client.get.side_effect = [mock.Mock(status_code=200, json=lambda: {}),
52
+ mock.Mock(status_code=200, json=lambda: {})]
53
+ self.assertEqual(self.read(), inventory.PlayInventory(True, None))
54
+ self.client.delete.assert_called_once()
55
+
56
+ def test_inventory_get_retry_keeps_the_same_edit(self):
57
+ good = mock.Mock(status_code=200, json=lambda: {})
58
+ self.client.get.side_effect = [requests.exceptions.ReadTimeout(), good, good]
59
+ self.assertTrue(self.read().ready)
60
+ self.assertEqual(self.client.get.call_count, 3)
61
+ self.client.post.assert_called_once()
62
+ self.client.delete.assert_called_once()
63
+
64
+ def test_permission_denial_is_fatal_and_closes_the_owned_edit(self):
65
+ self.client.get.side_effect = [mock.Mock(status_code=403, text="private-provider-body")]
66
+ with self.assertRaises(SystemExit) as caught:
67
+ self.read()
68
+ self.assertIn('"phase": "version_inventory"', str(caught.exception))
69
+ self.assertIn('"status": 403', str(caught.exception))
70
+ self.assertNotIn("private", str(caught.exception) + self.output.getvalue())
71
+ self.client.delete.assert_called_once()
72
+
73
+ def test_lost_edit_is_not_misclassified_as_absent_package(self):
74
+ self.client.get.side_effect = [mock.Mock(status_code=404)]
75
+ with self.assertRaises(SystemExit):
76
+ self.read()
77
+ self.client.post.assert_called_once()
78
+ self.client.delete.assert_called_once()
79
+
80
+ def test_invalid_inventory_is_fatal_and_cleanup_still_runs(self):
81
+ self.client.get.side_effect = [mock.Mock(status_code=200, json=lambda: {"bundles": [{"versionCode": "bad"}]})]
82
+ with self.assertRaises(SystemExit) as caught:
83
+ self.read()
84
+ self.assertIn('"code": "invalid_response"', str(caught.exception))
85
+ self.client.delete.assert_called_once()
86
+
87
+ def test_cleanup_failure_prevents_success_and_retains_its_edit_identifier(self):
88
+ self.client.delete.side_effect = requests.exceptions.ConnectionError("private-proxy")
89
+ with self.assertRaises(SystemExit) as caught:
90
+ self.read()
91
+ self.assertIn('"cleanup_required": true', str(caught.exception))
92
+ self.assertIn('"edit_id": "edit-123"', str(caught.exception))
93
+ self.client.post.assert_called_once()
94
+ self.assertEqual(self.client.delete.call_count, 3)
95
+
96
+
97
+ if __name__ == "__main__":
98
+ unittest.main()
@@ -0,0 +1,108 @@
1
+ """Preflight retries safe operations and never duplicates an ambiguous edit insert."""
2
+ import io
3
+ import json
4
+ from pathlib import Path
5
+ import sys
6
+ import unittest
7
+ from unittest import mock
8
+
9
+ import requests
10
+
11
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
12
+ import play_preflight as preflight
13
+ import play_preflight_transport as transport
14
+
15
+
16
+ class PlayPreflightTests(unittest.TestCase):
17
+ def setUp(self):
18
+ self.client = mock.Mock()
19
+ self.client.post.return_value = mock.Mock(status_code=200, text='{"id":"edit-123"}')
20
+ self.client.delete.return_value = mock.Mock(status_code=204)
21
+ self.credentials = mock.Mock(token="private-token")
22
+ self.factory = self.enterContext(mock.patch.object(preflight.service_account.Credentials,
23
+ "from_service_account_file"))
24
+ self.factory.return_value = self.credentials
25
+ self.ready = self.enterContext(mock.patch.object(preflight, "write_ready"))
26
+ self.sleep = self.enterContext(mock.patch.object(transport.time, "sleep"))
27
+ self.output = self.enterContext(mock.patch("sys.stdout", new_callable=io.StringIO))
28
+
29
+ def check(self):
30
+ return preflight.check_ready(self.client, "com.example.app", Path("account.json"))
31
+
32
+ def test_oauth_transport_recovers_before_one_edit_is_created(self):
33
+ self.credentials.refresh.side_effect = [requests.exceptions.ProxyError("private-proxy"), None]
34
+ self.check()
35
+ self.assertEqual(self.credentials.refresh.call_count, 2)
36
+ self.client.post.assert_called_once()
37
+ self.client.delete.assert_called_once()
38
+ self.ready.assert_called_once_with(True)
39
+ request = self.credentials.refresh.call_args.args[0]
40
+ self.assertIs(request.func.session, self.client)
41
+ self.assertEqual(request.keywords, {"timeout": 30})
42
+ self.assertNotIn("private", self.output.getvalue())
43
+
44
+ def test_insert_timeout_is_not_replayed_or_reported_ready(self):
45
+ self.client.post.side_effect = requests.exceptions.ReadTimeout("private-token")
46
+ with self.assertRaises(SystemExit) as caught:
47
+ self.check()
48
+ self.assertIn('"phase": "edit_create"', str(caught.exception))
49
+ self.assertIn('"code": "timed_out"', str(caught.exception))
50
+ self.assertNotIn("private", str(caught.exception))
51
+ self.client.post.assert_called_once()
52
+ self.client.delete.assert_not_called()
53
+ self.ready.assert_not_called()
54
+ self.sleep.assert_not_called()
55
+
56
+ def test_delete_timeout_then_absence_closes_only_the_known_edit(self):
57
+ self.client.delete.side_effect = [requests.exceptions.ReadTimeout(), mock.Mock(status_code=404)]
58
+ self.check()
59
+ self.client.post.assert_called_once()
60
+ self.assertEqual(self.client.delete.call_count, 2)
61
+ self.assertEqual(self.client.delete.call_args_list[0], self.client.delete.call_args_list[1])
62
+ self.assertTrue(self.client.delete.call_args.args[0].endswith("/edit-123"))
63
+ self.ready.assert_called_once_with(True)
64
+
65
+ def test_cleanup_exhaustion_identifies_edit_without_ready_or_duplicate_insert(self):
66
+ self.client.delete.side_effect = requests.exceptions.ConnectionError("private-proxy")
67
+ with self.assertRaises(SystemExit) as caught:
68
+ self.check()
69
+ evidence = json.loads(str(caught.exception).split(": ", 1)[1])
70
+ self.assertEqual(evidence["edit_id"], "edit-123")
71
+ self.assertTrue(evidence["cleanup_required"])
72
+ self.assertEqual(evidence["attempts"], 3)
73
+ self.assertNotIn("private", str(caught.exception))
74
+ self.client.post.assert_called_once()
75
+ self.assertEqual(self.client.delete.call_count, 3)
76
+ self.ready.assert_not_called()
77
+
78
+ def test_provider_refusal_hides_untrusted_body_and_is_not_retried(self):
79
+ self.client.post.return_value = mock.Mock(status_code=403, text="private-access-token")
80
+ with self.assertRaises(SystemExit) as caught:
81
+ self.check()
82
+ self.assertIn('"status": 403', str(caught.exception))
83
+ self.assertNotIn("private", str(caught.exception))
84
+ self.client.post.assert_called_once()
85
+ self.ready.assert_not_called()
86
+
87
+ def test_unregistered_package_keeps_first_release_path(self):
88
+ self.client.post.return_value = mock.Mock(status_code=404)
89
+ self.check()
90
+ self.ready.assert_called_once_with(False)
91
+ self.client.delete.assert_not_called()
92
+
93
+ def test_opaque_edit_identifier_is_encoded_in_cleanup_path(self):
94
+ self.client.post.return_value.text = '{"id":"opaque/id?x=y"}'
95
+ self.check()
96
+ self.assertTrue(self.client.delete.call_args.args[0].endswith("/opaque%2Fid%3Fx%3Dy"))
97
+
98
+ def test_missing_proxy_stops_before_reading_credentials(self):
99
+ with mock.patch.dict("os.environ", {"GOOGLE_STORE_PROXY_URL": ""}), \
100
+ mock.patch.object(sys, "argv", ["preflight", "--package", "com.example.app",
101
+ "--service-account", "account.json"]):
102
+ with self.assertRaises(SystemExit):
103
+ preflight.main()
104
+ self.factory.assert_not_called()
105
+
106
+
107
+ if __name__ == "__main__":
108
+ unittest.main()
@@ -0,0 +1,58 @@
1
+ """Run OAuth's real requests transport against an owned rejecting loopback proxy."""
2
+ from contextlib import redirect_stdout
3
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
4
+ import io
5
+ import os
6
+ from pathlib import Path
7
+ import sys
8
+ import threading
9
+ from types import SimpleNamespace
10
+ import unittest
11
+ from unittest import mock
12
+
13
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
14
+ import play_preflight as preflight
15
+ import play_preflight_transport as transport
16
+
17
+
18
+ class RejectProxy(BaseHTTPRequestHandler):
19
+ def do_CONNECT(self):
20
+ self.server.targets.append(self.path)
21
+ self.send_error(502, "private-proxy-error")
22
+
23
+ def log_message(self, *args):
24
+ pass
25
+
26
+
27
+ class PreflightProxyRuntimeTests(unittest.TestCase):
28
+ def test_oauth_retries_only_through_assigned_proxy_without_secret_output(self):
29
+ server = ThreadingHTTPServer(("127.0.0.1", 0), RejectProxy)
30
+ server.targets = []
31
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
32
+ thread.start()
33
+ env = {"GOOGLE_STORE_PROXY_URL": f"http://user:private-password@127.0.0.1:{server.server_port}",
34
+ "HTTP_PROXY": "http://127.0.0.1:1", "HTTPS_PROXY": "http://127.0.0.1:1", "NO_PROXY": "*"}
35
+ credentials = SimpleNamespace(refresh=lambda request: request("https://oauth2.googleapis.com/token",
36
+ method="POST", body="private-body"))
37
+ output = io.StringIO()
38
+ try:
39
+ with mock.patch.dict(os.environ, env), redirect_stdout(output), \
40
+ mock.patch.object(preflight.service_account.Credentials, "from_service_account_file",
41
+ return_value=credentials), \
42
+ mock.patch.object(transport.time, "sleep"), \
43
+ mock.patch.object(sys, "argv", ["preflight", "--package", "com.example.app",
44
+ "--service-account", "account.json"]):
45
+ with self.assertRaises(SystemExit) as caught:
46
+ preflight.main()
47
+ self.assertEqual(server.targets, ["oauth2.googleapis.com:443"] * 3)
48
+ self.assertIn('"phase": "oauth_refresh"', str(caught.exception))
49
+ self.assertIn('"code": "proxy_connection_failed"', str(caught.exception))
50
+ self.assertNotIn("private", str(caught.exception) + output.getvalue())
51
+ finally:
52
+ server.shutdown()
53
+ server.server_close()
54
+ thread.join(timeout=2)
55
+
56
+
57
+ if __name__ == "__main__":
58
+ unittest.main()
@@ -0,0 +1,76 @@
1
+ """Classified preflight diagnostics never include raw provider/credential exceptions."""
2
+ import io
3
+ from pathlib import Path
4
+ import sys
5
+ import unittest
6
+ from unittest import mock
7
+
8
+ from google.auth.exceptions import RefreshError, TransportError
9
+ import requests
10
+
11
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
12
+ import play_preflight_transport as transport
13
+
14
+
15
+ class PreflightTransportTests(unittest.TestCase):
16
+ def setUp(self):
17
+ self.sleep = self.enterContext(mock.patch.object(transport.time, "sleep"))
18
+ self.output = self.enterContext(mock.patch("sys.stdout", new_callable=io.StringIO))
19
+
20
+ def test_wrapped_oauth_transport_error_classification_does_not_read_raw_text(self):
21
+ for error, expected in ((requests.exceptions.ProxyError("private"), "proxy_connection_failed"),
22
+ (requests.exceptions.SSLError("private"), "tls_failed"),
23
+ (requests.exceptions.ReadTimeout("private"), "timed_out")):
24
+ wrapped = TransportError(error)
25
+ self.assertEqual(transport.transport_code(wrapped), expected)
26
+
27
+ def test_safe_operation_has_bounded_retry_and_sanitized_terminal_error(self):
28
+ operation = mock.Mock(side_effect=TransportError(requests.exceptions.ProxyError("private")))
29
+ with self.assertRaises(SystemExit) as caught:
30
+ transport.call("oauth_refresh", operation, retry=True)
31
+ self.assertEqual(operation.call_count, 3)
32
+ self.assertEqual(self.sleep.call_args_list, [mock.call(1), mock.call(2)])
33
+ self.assertIn('"code": "proxy_connection_failed"', str(caught.exception))
34
+ self.assertNotIn("private", str(caught.exception) + self.output.getvalue())
35
+
36
+ def test_oauth_provider_error_is_sanitized_without_an_extra_retry(self):
37
+ operation = mock.Mock(side_effect=RefreshError("private-provider-body"))
38
+ with self.assertRaises(SystemExit) as caught:
39
+ transport.call("oauth_refresh", operation, retry=True)
40
+ operation.assert_called_once()
41
+ self.assertIn("oauth_refresh_failed", str(caught.exception))
42
+ self.assertNotIn("private", str(caught.exception))
43
+
44
+ def test_cleanup_retries_server_failure_then_returns_the_success(self):
45
+ good = mock.Mock(status_code=204)
46
+ operation = mock.Mock(side_effect=[mock.Mock(status_code=503), good])
47
+ self.assertIs(transport.call("edit_cleanup", operation, retry=True, edit_id="123"), good)
48
+ self.assertEqual(operation.call_count, 2)
49
+
50
+ def test_ambiguous_server_error_on_insert_is_never_retried(self):
51
+ response = mock.Mock(status_code=503)
52
+ operation = mock.Mock(return_value=response)
53
+ self.assertIs(transport.call("edit_create", operation), response)
54
+ operation.assert_called_once()
55
+ self.sleep.assert_not_called()
56
+
57
+ def test_cleanup_server_failure_exhaustion_preserves_classified_evidence(self):
58
+ operation = mock.Mock(return_value=mock.Mock(status_code=503))
59
+ with self.assertRaises(SystemExit) as caught:
60
+ transport.call("edit_cleanup", operation, retry=True, edit_id="123")
61
+ self.assertIn('"code": "provider_unavailable"', str(caught.exception))
62
+ self.assertIn('"status": 503', str(caught.exception))
63
+ self.assertIn('"edit_id": "123"', str(caught.exception))
64
+ self.assertEqual(operation.call_count, 3)
65
+
66
+ def test_refusal_after_retry_records_the_actual_attempt_in_a_safe_annotation(self):
67
+ operation = mock.Mock(side_effect=[mock.Mock(status_code=503), mock.Mock(status_code=403)])
68
+ with self.assertRaises(SystemExit) as caught:
69
+ transport.call("edit_cleanup", operation, retry=True, edit_id="123", accepted=(200, 204, 404))
70
+ self.assertIn('"attempts": 2', str(caught.exception))
71
+ self.assertIn("::error title=play_preflight_failed::", self.output.getvalue())
72
+ self.assertIn('"cleanup_required": true', self.output.getvalue())
73
+
74
+
75
+ if __name__ == "__main__":
76
+ unittest.main()
@@ -0,0 +1,83 @@
1
+ """Store evidence must exist before configuration enables compilation or version writes."""
2
+ import io
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import sys
7
+ import tempfile
8
+ import unittest
9
+ from unittest import mock
10
+
11
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
12
+ import resolve_android as resolve
13
+ from play_inventory import PlayInventory
14
+
15
+
16
+ class ResolvePlayReadinessTests(unittest.TestCase):
17
+ def setUp(self):
18
+ self.root = Path(self.enterContext(tempfile.TemporaryDirectory())).resolve()
19
+ (self.root / "creds").mkdir()
20
+ (self.root / "pubspec.yaml").write_text("name: fixture\n")
21
+ app = self.root / "android/app"
22
+ app.mkdir(parents=True)
23
+ (app / "build.gradle").write_text('android { defaultConfig { applicationId "com.example.app" } }')
24
+ (self.root / "creds/key.jks").write_bytes(b"fixture")
25
+ (self.root / "creds/signing.properties").write_text(
26
+ "storeFile=key.jks\nstorePassword=fixture\nkeyPassword=fixture\nkeyAlias=fixture\n")
27
+ (self.root / "creds/account.json").write_text(json.dumps(
28
+ {"type": "service_account", "client_email": "fixture@example.invalid", "private_key": "fixture"}))
29
+ self.env_file, self.outputs = self.root / "env", self.root / "output"
30
+ self.enterContext(mock.patch.dict(os.environ, {"GITHUB_WORKSPACE": str(self.root),
31
+ "GITHUB_OUTPUT": str(self.outputs), "GITHUB_ENV": str(self.env_file), "GITHUB_RUN_NUMBER": "1",
32
+ "INPUT_BUILD_NUMBER": "", "INPUT_PACKAGE_NAME": "", "INPUT_BUILD_NAME": ""}))
33
+ self.read = self.enterContext(mock.patch.object(
34
+ resolve, "read_inventory", return_value=PlayInventory(True, 173)))
35
+ self.prepare = self.enterContext(mock.patch.object(resolve, "prepare_flutter_toolchain"))
36
+ self.enterContext(mock.patch("sys.stdout", new_callable=io.StringIO))
37
+
38
+ def test_known_inventory_is_read_once_and_prevents_a_version_collision(self):
39
+ resolve.main()
40
+ self.read.assert_called_once_with("com.example.app", self.root / "creds/account.json")
41
+ self.assertIn("build_number=174\n", self.outputs.read_text())
42
+ self.assertIn("play_api_ready=true\n", self.outputs.read_text())
43
+ self.assertIn("ANDROID_PLAY_API_READY=true\n", self.env_file.read_text())
44
+ self.prepare.assert_called_once_with(self.root)
45
+
46
+ def test_absent_package_keeps_configuration_for_a_first_release_artifact(self):
47
+ self.read.return_value = PlayInventory(False, None)
48
+ resolve.main()
49
+ self.assertIn("build_number=1\n", self.outputs.read_text())
50
+ self.assertIn("play_api_ready=false\n", self.outputs.read_text())
51
+ self.assertIn("ANDROID_SIGNING_PROPERTIES=", self.env_file.read_text())
52
+ self.prepare.assert_called_once()
53
+
54
+ def test_indeterminate_inventory_never_enables_a_build_or_mutates_toolchain(self):
55
+ self.read.side_effect = SystemExit("classified transport failure")
56
+ with self.assertRaises(SystemExit):
57
+ resolve.main()
58
+ self.prepare.assert_not_called()
59
+ self.assertFalse(self.env_file.exists())
60
+ self.assertFalse(self.outputs.exists())
61
+
62
+ def test_explicit_version_is_preserved_after_the_same_readiness_check(self):
63
+ with mock.patch.dict(os.environ, {"INPUT_BUILD_NUMBER": "200"}):
64
+ resolve.main()
65
+ self.read.assert_called_once()
66
+ self.assertIn("build_number=200\n", self.outputs.read_text())
67
+
68
+ def test_explicit_version_does_not_bypass_a_failed_store_read(self):
69
+ self.read.side_effect = SystemExit("classified auth failure")
70
+ with mock.patch.dict(os.environ, {"INPUT_BUILD_NUMBER": "200"}), self.assertRaises(SystemExit):
71
+ resolve.main()
72
+ self.prepare.assert_not_called()
73
+ self.assertFalse(self.outputs.exists())
74
+
75
+ def test_exhausted_version_space_does_not_overflow_or_fall_back(self):
76
+ self.read.return_value = PlayInventory(True, 2_100_000_000)
77
+ with self.assertRaises(SystemExit):
78
+ resolve.main()
79
+ self.assertFalse(self.outputs.exists())
80
+
81
+
82
+ if __name__ == "__main__":
83
+ unittest.main()
@@ -1 +1 @@
1
- 1.0.65
1
+ 1.0.67
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.65",
3
+ "version": "1.0.67",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -431,7 +431,7 @@ jobs:
431
431
  # the action autoupdates on consumers while this file does not, and the
432
432
  # flag and its safety net must never be split across that boundary.
433
433
  - name: Install Google Play preflight dependencies
434
- if: ${{ steps.mode.outputs.mode == 'local' }}
434
+ if: ${{ steps.mode.outputs.mode == 'local' && steps.android.outputs.play-api-ready == '' }}
435
435
  shell: bash
436
436
  run: python3 -m pip install --disable-pip-version-check 'google-auth>=2.40,<3' 'requests>=2.32,<3'
437
437
  - name: Check Google Play API readiness
@@ -441,7 +441,15 @@ jobs:
441
441
  env:
442
442
  PACKAGE_NAME: ${{ steps.android.outputs.package-name }}
443
443
  SERVICE_ACCOUNT: ${{ steps.android.outputs.play-service-account }}
444
+ EARLY_READINESS: ${{ steps.android.outputs.play-api-ready }}
444
445
  run: |
446
+ # Current actions verify readiness before compiling. Older action copies
447
+ # have no output, so retain their standalone preflight compatibility.
448
+ case "$EARLY_READINESS" in
449
+ true|false) echo "ready=$EARLY_READINESS" >> "$GITHUB_OUTPUT"; exit 0 ;;
450
+ '') ;;
451
+ *) echo "::error::Invalid Android readiness output"; exit 1 ;;
452
+ esac
445
453
  python3 .github/actions/android-app/scripts/play_preflight.py \
446
454
  --package "$PACKAGE_NAME" \
447
455
  --service-account "$SERVICE_ACCOUNT"