gowalk-cicd 1.0.66 → 1.0.68
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 +13 -3
- package/README.md +16 -2
- package/action/.daemux-version +1 -1
- package/android-action/.daemux-version +1 -1
- package/android-action/action.yml +19 -8
- package/android-action/scripts/play_inventory.py +50 -0
- package/android-action/scripts/play_preflight.py +19 -7
- package/android-action/scripts/play_store_proxy.py +27 -6
- package/android-action/scripts/resolve_android.py +39 -92
- package/android-action/scripts/test_android_config.py +7 -20
- package/android-action/scripts/test_early_play_workflow.py +77 -0
- package/android-action/scripts/test_google_bootstrap_proxy.py +77 -0
- package/android-action/scripts/test_google_bootstrap_wiring.py +50 -0
- package/android-action/scripts/test_play_inventory.py +98 -0
- package/android-action/scripts/test_play_store_proxy.py +2 -1
- package/android-action/scripts/test_resolve_play_readiness.py +83 -0
- package/backend-action/.daemux-version +1 -1
- package/package.json +1 -1
- package/templates/deploy-web.yml +8 -1
- package/templates/deploy.yml +23 -3
package/CLAUDE.md
CHANGED
|
@@ -128,6 +128,13 @@ node /path/to/gowalk-cicd/bin/cli.mjs
|
|
|
128
128
|
Crashlytics Buildtools reads them first and rejects authenticated proxy URLs. HTTPS is tunneled
|
|
129
129
|
without TLS interception; no upstream failure may fall back to the destination.
|
|
130
130
|
The wrapper disables Gradle daemons and closes its relay when the command ends.
|
|
131
|
+
- Flutter bootstrap is part of that boundary: before the Flutter setup action,
|
|
132
|
+
`play_store_proxy.py --bootstrap-env` validates the Google account pin and writes
|
|
133
|
+
both proxy/bypass spellings to the private job environment without console output.
|
|
134
|
+
SDK manifest/archive curl requests use that pin. Subsequent pub, analysis, tests,
|
|
135
|
+
localization, global activation and builds run through the existing owned relay.
|
|
136
|
+
The helper's bootstrap and command modes require only Python's standard library;
|
|
137
|
+
importing the Google API client must not become an unprotected bootstrap prerequisite.
|
|
131
138
|
- Backend runtime credentials use the single encrypted `BACKEND_RUNTIME_ENV`
|
|
132
139
|
secret. The action writes it to host-side `.runtime.env` with mode 0600 and
|
|
133
140
|
supplies it to Compose after the generated `.env`; never print its content.
|
|
@@ -192,9 +199,12 @@ node /path/to/gowalk-cicd/bin/cli.mjs
|
|
|
192
199
|
delivery request. This requires no human handoff. A green CI run alone is not
|
|
193
200
|
evidence that the deferred symbols reached Firebase. Android retains its Dart
|
|
194
201
|
symbols and uploads them before the store bundle, using the configured proxy.
|
|
195
|
-
- Play readiness
|
|
196
|
-
|
|
197
|
-
|
|
202
|
+
- Play readiness and AAB/APK version inventory share one prebuild edit. Retry OAuth
|
|
203
|
+
transport, inventory reads and cleanup of that known edit; never replay an ambiguous
|
|
204
|
+
`edits.insert`. Only an explicit package-404 allows first-release defaults; failed
|
|
205
|
+
inventory is not an empty account. The action exposes `play-api-ready` so current
|
|
206
|
+
workflows skip the redundant postbuild check. Error annotations carry safe
|
|
207
|
+
phase/category evidence and any cleanup edit ID without raw provider exceptions.
|
|
198
208
|
- The iOS action reads every script and prompt from the snapshot it takes of
|
|
199
209
|
itself in its first step (`$SWIFT_APP_ACTION`, under `RUNNER_TEMP`), never
|
|
200
210
|
from `${{ github.action_path }}` after that step. The plugin self-update
|
package/README.md
CHANGED
|
@@ -298,13 +298,23 @@ 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
|
+
|
|
301
311
|
The API readiness preflight retries transient OAuth transport failures and cleanup of
|
|
302
312
|
its own known edit up to three times, with one- and two-second delays. It never
|
|
303
313
|
replays an edit creation whose outcome is unknown. Every request keeps the assigned
|
|
304
314
|
Google proxy; an absent edit after cleanup is already closed. Exhausted recovery
|
|
305
315
|
emits the `play_preflight_failed` error annotation with schema
|
|
306
|
-
`gowalk-cicd/play-preflight-failure.v1`, phase (`oauth_refresh`, `edit_create
|
|
307
|
-
`edit_cleanup`), classified code and attempt count. Provider bodies, credential
|
|
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
|
|
308
318
|
values and raw network errors are omitted. A cleanup failure also records the known
|
|
309
319
|
edit ID and `cleanup_required: true` so recovery targets that edit.
|
|
310
320
|
|
|
@@ -624,6 +634,10 @@ metadata and binary transfer. Provision `APPLE_STORE_PROXY_URL` and
|
|
|
624
634
|
`GOOGLE_STORE_PROXY_URL` as encrypted repository secrets before deployment. A missing
|
|
625
635
|
proxy refuses provider calls; there is no direct fallback. Refresh existing workflow
|
|
626
636
|
files with the released installer while preserving application-specific build inputs.
|
|
637
|
+
The Google pin also covers Flutter SDK manifest/archive downloads and early `pub`
|
|
638
|
+
operations in iOS, Android and web workflows. Bootstrap validates it before SDK setup;
|
|
639
|
+
subsequent Dart and JVM operations use the same owned relay as release builds. A fork
|
|
640
|
+
without the repository secret cannot start provider downloads through these workflows.
|
|
627
641
|
The Apple uploader retains `apple-upload-<run>-<attempt>` receipts with IPA SHA-256,
|
|
628
642
|
upload ID and processing readback, and resumes matching bytes after interruption.
|
|
629
643
|
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.68
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.68
|
|
@@ -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,14 @@ outputs:
|
|
|
76
79
|
runs:
|
|
77
80
|
using: composite
|
|
78
81
|
steps:
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
#
|
|
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
|
-
shell:
|
|
84
|
-
|
|
86
|
+
shell: >-
|
|
87
|
+
python3 ${{ github.action_path }}/scripts/play_store_proxy.py
|
|
88
|
+
-- bash --noprofile --norc -e -o pipefail {0}
|
|
89
|
+
run: python3 -m pip install --disable-pip-version-check -q 'google-auth>=2.40,<3' 'requests>=2.32,<3'
|
|
85
90
|
|
|
86
91
|
- name: Resolve Android app and credentials
|
|
87
92
|
id: config
|
|
@@ -95,19 +100,25 @@ runs:
|
|
|
95
100
|
# --- Flutter --------------------------------------------------------
|
|
96
101
|
- name: Get Flutter dependencies
|
|
97
102
|
if: ${{ steps.config.outputs.project_kind == 'flutter' }}
|
|
98
|
-
shell:
|
|
103
|
+
shell: >-
|
|
104
|
+
python3 ${{ github.action_path }}/scripts/play_store_proxy.py
|
|
105
|
+
-- bash --noprofile --norc -e -o pipefail {0}
|
|
99
106
|
run: flutter pub get
|
|
100
107
|
|
|
101
108
|
- name: Analyze Flutter app
|
|
102
109
|
if: ${{ steps.config.outputs.project_kind == 'flutter' && inputs.run-tests == 'true' }}
|
|
103
|
-
shell:
|
|
110
|
+
shell: >-
|
|
111
|
+
python3 ${{ github.action_path }}/scripts/play_store_proxy.py
|
|
112
|
+
-- bash --noprofile --norc -e -o pipefail {0}
|
|
104
113
|
run: flutter analyze
|
|
105
114
|
|
|
106
115
|
# `flutter test` hard-fails with "Test directory \"test\" not found" when a repo
|
|
107
116
|
# has no tests, which is not a real failure. Skip instead of blowing up the deploy.
|
|
108
117
|
- name: Test Flutter app
|
|
109
118
|
if: ${{ steps.config.outputs.project_kind == 'flutter' && inputs.run-tests == 'true' }}
|
|
110
|
-
shell:
|
|
119
|
+
shell: >-
|
|
120
|
+
python3 ${{ github.action_path }}/scripts/play_store_proxy.py
|
|
121
|
+
-- bash --noprofile --norc -e -o pipefail {0}
|
|
111
122
|
run: |
|
|
112
123
|
if [ -d test ]; then
|
|
113
124
|
flutter test
|
|
@@ -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,6 +4,7 @@
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
6
|
import argparse
|
|
7
|
+
from contextlib import contextmanager
|
|
7
8
|
from functools import partial
|
|
8
9
|
import json
|
|
9
10
|
import os
|
|
@@ -37,6 +38,16 @@ def main() -> None:
|
|
|
37
38
|
|
|
38
39
|
|
|
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."""
|
|
40
51
|
credentials = service_account.Credentials.from_service_account_file(
|
|
41
52
|
account, scopes=[SCOPE]
|
|
42
53
|
)
|
|
@@ -48,12 +59,12 @@ def check_ready(client, package: str, account: Path) -> None:
|
|
|
48
59
|
response = call("edit_create", lambda: client.post(url, headers=headers, json={}, timeout=30),
|
|
49
60
|
accepted=(200, 404))
|
|
50
61
|
if response.status_code == 404:
|
|
51
|
-
write_ready(False)
|
|
52
62
|
print(
|
|
53
63
|
"::warning::Google Play package is not API-ready. The app session must upload the "
|
|
54
|
-
"
|
|
55
|
-
"verify the resulting release before retrying API delivery."
|
|
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
69
|
try:
|
|
59
70
|
edit_id = json.loads(response.text)["id"]
|
|
@@ -62,10 +73,11 @@ def check_ready(client, package: str, account: Path) -> None:
|
|
|
62
73
|
except (ValueError, TypeError, KeyError):
|
|
63
74
|
fail("edit_create", "invalid_response", 1)
|
|
64
75
|
delete_url = f"{url}/{quote(edit_id, safe='')}"
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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))
|
|
69
81
|
|
|
70
82
|
|
|
71
83
|
if __name__ == "__main__":
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Google CI transport uses only the account-pinned proxy, including OAuth exchanges.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Four entry points, all from the one validated ``GOOGLE_STORE_PROXY_URL``:
|
|
4
4
|
|
|
5
5
|
* ``python3 play_store_proxy.py`` refuses a missing or malformed proxy.
|
|
6
6
|
* ``python3 play_store_proxy.py -- <command …>`` runs a build tool with the explicit
|
|
@@ -10,6 +10,8 @@ Three entry points, all from the one validated ``GOOGLE_STORE_PROXY_URL``:
|
|
|
10
10
|
* ``python3 play_store_proxy.py --github-env`` clears the lower-case ambient bypass
|
|
11
11
|
(``no_proxy``) for the rest of the job, so a JavaScript upload step that can only
|
|
12
12
|
carry upper-case ``env`` keys cannot be bypassed by a runner's ambient value.
|
|
13
|
+
* ``python3 play_store_proxy.py --bootstrap-env`` protects the curl-based Flutter
|
|
14
|
+
SDK setup through its private job environment, before Python dependencies exist.
|
|
13
15
|
"""
|
|
14
16
|
from __future__ import annotations
|
|
15
17
|
|
|
@@ -18,16 +20,16 @@ import subprocess
|
|
|
18
20
|
import sys
|
|
19
21
|
from urllib.parse import urlsplit
|
|
20
22
|
|
|
21
|
-
import requests
|
|
22
|
-
|
|
23
23
|
LOWERCASE_BYPASS = ("no_proxy",)
|
|
24
|
+
BOOTSTRAP_VARIABLES = ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "NO_PROXY", "no_proxy")
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
def proxy_url() -> str:
|
|
27
28
|
value = os.environ.get("GOOGLE_STORE_PROXY_URL", "").strip()
|
|
28
29
|
try:
|
|
29
30
|
parsed = urlsplit(value)
|
|
30
|
-
valid = parsed.scheme in {"http", "https"} and parsed.hostname and parsed.port
|
|
31
|
+
valid = (parsed.scheme in {"http", "https"} and parsed.hostname and parsed.port
|
|
32
|
+
and not any(char in value for char in "\r\n\0"))
|
|
31
33
|
except ValueError:
|
|
32
34
|
valid = False
|
|
33
35
|
if not valid:
|
|
@@ -41,8 +43,10 @@ def environment() -> dict[str, str]:
|
|
|
41
43
|
"http_proxy": value, "https_proxy": value, "NO_PROXY": "", "no_proxy": ""}
|
|
42
44
|
|
|
43
45
|
|
|
44
|
-
def session()
|
|
46
|
+
def session():
|
|
45
47
|
value = proxy_url()
|
|
48
|
+
import requests
|
|
49
|
+
|
|
46
50
|
client = requests.Session()
|
|
47
51
|
client.trust_env = False
|
|
48
52
|
client.proxies = {"http": value, "https": value}
|
|
@@ -72,14 +76,31 @@ def clear_github_env_bypass(path: str | None = None) -> list[str]:
|
|
|
72
76
|
return lines
|
|
73
77
|
|
|
74
78
|
|
|
79
|
+
def bootstrap_github_env() -> None:
|
|
80
|
+
"""Protect curl-based SDK setup before Python dependencies or a JVM exist.
|
|
81
|
+
|
|
82
|
+
GITHUB_ENV is a runner-owned environment file, never console output. Dart
|
|
83
|
+
and JVM commands still use ``--`` and its credential-free owned relay.
|
|
84
|
+
"""
|
|
85
|
+
env = environment()
|
|
86
|
+
target = os.environ.get("GITHUB_ENV", "")
|
|
87
|
+
if not target:
|
|
88
|
+
raise SystemExit("GITHUB_ENV is not set; run this inside a GitHub Actions step")
|
|
89
|
+
with open(target, "a", encoding="utf-8") as handle:
|
|
90
|
+
handle.writelines(f"{name}={env[name]}\n" for name in BOOTSTRAP_VARIABLES)
|
|
91
|
+
|
|
92
|
+
|
|
75
93
|
def main(args: list[str]) -> int:
|
|
94
|
+
if args == ["--bootstrap-env"]:
|
|
95
|
+
bootstrap_github_env()
|
|
96
|
+
return 0
|
|
76
97
|
if args[:1] == ["--github-env"]:
|
|
77
98
|
clear_github_env_bypass()
|
|
78
99
|
return 0
|
|
79
100
|
if args[:1] == ["--"]:
|
|
80
101
|
return run(args[1:])
|
|
81
102
|
if args:
|
|
82
|
-
raise SystemExit("usage: play_store_proxy.py [--github-env | -- <command …>]")
|
|
103
|
+
raise SystemExit("usage: play_store_proxy.py [--bootstrap-env | --github-env | -- <command …>]")
|
|
83
104
|
proxy_url()
|
|
84
105
|
return 0
|
|
85
106
|
|
|
@@ -9,7 +9,7 @@ import sys
|
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
|
|
11
11
|
import gradle_wrapper
|
|
12
|
-
from
|
|
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
|
|
40
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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,
|
|
207
|
-
("package_name",
|
|
208
|
-
("service_account",
|
|
209
|
-
("build_number",
|
|
210
|
-
("
|
|
211
|
-
("
|
|
212
|
-
("
|
|
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,
|
|
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.
|
|
269
|
-
|
|
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.
|
|
280
|
-
|
|
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
|
|
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.
|
|
290
|
-
|
|
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,77 @@
|
|
|
1
|
+
"""SDK bootstrap and real curl/Dart clients cannot silently use direct transport."""
|
|
2
|
+
import base64
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import unittest
|
|
9
|
+
from unittest import mock
|
|
10
|
+
|
|
11
|
+
import play_store_proxy
|
|
12
|
+
from test_jvm_proxy_relay import upstream
|
|
13
|
+
|
|
14
|
+
SCRIPT = Path(play_store_proxy.__file__).resolve()
|
|
15
|
+
DART = os.environ.get("DART_SDK_BIN", "")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BootstrapTests(unittest.TestCase):
|
|
19
|
+
def test_bootstrap_has_no_python_dependency_and_curl_uses_the_pinned_exit(self):
|
|
20
|
+
with tempfile.TemporaryDirectory() as directory, upstream() as (proxy, seen):
|
|
21
|
+
target = Path(directory) / "github.env"
|
|
22
|
+
env = {**os.environ, "GOOGLE_STORE_PROXY_URL": proxy, "GITHUB_ENV": str(target),
|
|
23
|
+
"https_proxy": "http://127.0.0.1:1", "no_proxy": "*", "NO_PROXY": "*"}
|
|
24
|
+
result = subprocess.run([sys.executable, "-S", str(SCRIPT), "--bootstrap-env"],
|
|
25
|
+
env=env, capture_output=True, text=True, timeout=10)
|
|
26
|
+
self.assertEqual((result.returncode, result.stdout, result.stderr), (0, "", ""))
|
|
27
|
+
env.update(line.split("=", 1) for line in target.read_text().splitlines())
|
|
28
|
+
result = subprocess.run(["curl", "--fail", "--silent", "--show-error", "--max-time", "5",
|
|
29
|
+
"http://unresolvable.invalid/flutter-sdk"],
|
|
30
|
+
env=env, capture_output=True, text=True, timeout=10)
|
|
31
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
32
|
+
self.assertEqual(result.stdout, "through pinned proxy")
|
|
33
|
+
auth = "Basic " + base64.b64encode(b"fixture-user:fixture-pass").decode()
|
|
34
|
+
self.assertEqual(seen, [("GET", "http://unresolvable.invalid/flutter-sdk", auth)])
|
|
35
|
+
self.assertNotIn("fixture-pass", result.stderr)
|
|
36
|
+
|
|
37
|
+
def test_missing_malformed_or_injected_proxy_refuses_before_creating_bootstrap_environment(self):
|
|
38
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
39
|
+
target = Path(directory) / "github.env"
|
|
40
|
+
for proxy in ("", "private-malformed-value", "http://assigned.test:12\nNO_PROXY=*"):
|
|
41
|
+
env = {**os.environ, "GOOGLE_STORE_PROXY_URL": proxy, "GITHUB_ENV": str(target)}
|
|
42
|
+
result = subprocess.run([sys.executable, "-S", str(SCRIPT), "--bootstrap-env"],
|
|
43
|
+
env=env, capture_output=True, text=True, timeout=10)
|
|
44
|
+
self.assertNotEqual(result.returncode, 0)
|
|
45
|
+
self.assertFalse(target.exists())
|
|
46
|
+
self.assertNotIn(proxy or "never-matches", result.stderr)
|
|
47
|
+
|
|
48
|
+
def test_command_wrapper_also_runs_before_requests_is_installed(self):
|
|
49
|
+
env = {**os.environ, "GOOGLE_STORE_PROXY_URL": "http://127.0.0.1:1"}
|
|
50
|
+
result = subprocess.run([sys.executable, "-S", str(SCRIPT), "--", sys.executable, "-c", "pass"],
|
|
51
|
+
env=env, capture_output=True, text=True, timeout=10)
|
|
52
|
+
self.assertEqual((result.returncode, result.stdout, result.stderr), (0, "", ""))
|
|
53
|
+
|
|
54
|
+
@unittest.skipUnless(DART and Path(DART).is_file(), "set DART_SDK_BIN to an installed native Dart executable")
|
|
55
|
+
def test_actual_dart_http_client_uses_the_relay_and_rejects_invalid_tls(self):
|
|
56
|
+
source = ("import 'dart:io'; import 'dart:convert'; "
|
|
57
|
+
"Future<void> main(List<String> args) async { var c=HttpClient(); "
|
|
58
|
+
"try { var r=await (await c.getUrl(Uri.parse(args[0]))).close(); "
|
|
59
|
+
"stdout.write(await r.transform(utf8.decoder).join()); } "
|
|
60
|
+
"catch (_) { exitCode=7; } finally { c.close(force:true); } }")
|
|
61
|
+
with tempfile.TemporaryDirectory() as directory, upstream() as (proxy, seen):
|
|
62
|
+
script = Path(directory) / "probe.dart"
|
|
63
|
+
script.write_text(source)
|
|
64
|
+
for scheme, expected in (("http", 0), ("https", 7)):
|
|
65
|
+
env = {"GOOGLE_STORE_PROXY_URL": proxy, "NO_PROXY": "*", "no_proxy": "*"}
|
|
66
|
+
with mock.patch.dict(os.environ, env):
|
|
67
|
+
result = subprocess.run([sys.executable, str(SCRIPT), "--", DART, "--disable-dart-dev",
|
|
68
|
+
str(script), f"{scheme}://unresolvable.invalid/pub"],
|
|
69
|
+
env=os.environ, capture_output=True, text=True, timeout=20)
|
|
70
|
+
self.assertEqual(result.returncode, expected, result.stderr)
|
|
71
|
+
self.assertNotIn("fixture-pass", result.stdout + result.stderr)
|
|
72
|
+
self.assertEqual([(method, url) for method, url, _auth in seen], [
|
|
73
|
+
("GET", "http://unresolvable.invalid/pub"), ("CONNECT", "unresolvable.invalid:443")])
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
unittest.main()
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Every distributed Flutter bootstrap enters the shared protected transport first."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import re
|
|
4
|
+
import unittest
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
ROOT = Path(__file__).resolve().parents[2]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BootstrapWiringTests(unittest.TestCase):
|
|
12
|
+
def test_each_flutter_sdk_setup_has_a_prior_mandatory_account_guard(self):
|
|
13
|
+
checked = 0
|
|
14
|
+
for name in ("deploy.yml", "deploy-web.yml"):
|
|
15
|
+
workflow = yaml.safe_load((ROOT / "templates" / name).read_text())
|
|
16
|
+
for job in workflow["jobs"].values():
|
|
17
|
+
steps = job.get("steps", [])
|
|
18
|
+
for index, step in enumerate(steps):
|
|
19
|
+
if not step.get("uses", "").startswith("subosito/flutter-action@"):
|
|
20
|
+
continue
|
|
21
|
+
checked += 1
|
|
22
|
+
guard = steps[index - 1]
|
|
23
|
+
self.assertEqual(guard.get("if"), step.get("if"))
|
|
24
|
+
self.assertEqual(job["env"]["GOOGLE_STORE_PROXY_URL"],
|
|
25
|
+
"${{ secrets.GOOGLE_STORE_PROXY_URL }}")
|
|
26
|
+
self.assertTrue(guard["run"].endswith("play_store_proxy.py --bootstrap-env"))
|
|
27
|
+
self.assertNotIn("continue-on-error", guard)
|
|
28
|
+
self.assertEqual(checked, 3)
|
|
29
|
+
|
|
30
|
+
def test_remaining_flutter_commands_run_inside_the_owned_relay(self):
|
|
31
|
+
groups = []
|
|
32
|
+
for name in ("deploy.yml", "deploy-web.yml"):
|
|
33
|
+
workflow = yaml.safe_load((ROOT / "templates" / name).read_text())
|
|
34
|
+
groups.extend(job.get("steps", []) for job in workflow["jobs"].values())
|
|
35
|
+
groups.append(yaml.safe_load((ROOT / "android-action/action.yml").read_text())["runs"]["steps"])
|
|
36
|
+
checked = 0
|
|
37
|
+
for steps in groups:
|
|
38
|
+
for step in steps:
|
|
39
|
+
run = step.get("run", "")
|
|
40
|
+
if not re.search(r"\b(?:flutter (?:pub|analyze|test|build|gen-l10n)|dart pub)\b", run):
|
|
41
|
+
continue
|
|
42
|
+
if 'play_store_proxy.py" --' in run:
|
|
43
|
+
continue
|
|
44
|
+
checked += 1
|
|
45
|
+
self.assertIn("play_store_proxy.py -- bash", step.get("shell", ""), step.get("name"))
|
|
46
|
+
self.assertEqual(checked, 6)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
if __name__ == "__main__":
|
|
50
|
+
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()
|
|
@@ -4,6 +4,7 @@ from pathlib import Path
|
|
|
4
4
|
import sys
|
|
5
5
|
import unittest
|
|
6
6
|
from unittest import mock
|
|
7
|
+
import requests
|
|
7
8
|
|
|
8
9
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
9
10
|
import play_store_proxy
|
|
@@ -12,7 +13,7 @@ import play_store_proxy
|
|
|
12
13
|
class PlayProxyTests(unittest.TestCase):
|
|
13
14
|
def test_missing_proxy_never_constructs_a_direct_session(self):
|
|
14
15
|
with mock.patch.dict(os.environ, {"GOOGLE_STORE_PROXY_URL": ""}), \
|
|
15
|
-
mock.patch.object(
|
|
16
|
+
mock.patch.object(requests, "Session") as session:
|
|
16
17
|
with self.assertRaises(SystemExit):
|
|
17
18
|
play_store_proxy.session()
|
|
18
19
|
session.assert_not_called()
|
|
@@ -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.
|
|
1
|
+
1.0.68
|
package/package.json
CHANGED
package/templates/deploy-web.yml
CHANGED
|
@@ -26,8 +26,13 @@ jobs:
|
|
|
26
26
|
name: Flutter Web Artifact
|
|
27
27
|
runs-on: ubuntu-24.04
|
|
28
28
|
timeout-minutes: 20
|
|
29
|
+
env:
|
|
30
|
+
GOOGLE_STORE_PROXY_URL: ${{ secrets.GOOGLE_STORE_PROXY_URL }}
|
|
29
31
|
steps:
|
|
30
32
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
|
33
|
+
- name: Require the Google proxy before Flutter SDK setup
|
|
34
|
+
shell: bash
|
|
35
|
+
run: python3 .github/actions/android-app/scripts/play_store_proxy.py --bootstrap-env
|
|
31
36
|
- uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
|
|
32
37
|
with:
|
|
33
38
|
channel: stable
|
|
@@ -45,7 +50,9 @@ jobs:
|
|
|
45
50
|
git config --global \
|
|
46
51
|
url."https://x-access-token:${GIT_PRIVATE_TOKEN}@github.com/".insteadOf "https://github.com/"
|
|
47
52
|
- name: Build Flutter web release
|
|
48
|
-
shell:
|
|
53
|
+
shell: >-
|
|
54
|
+
python3 .github/actions/android-app/scripts/play_store_proxy.py
|
|
55
|
+
-- bash --noprofile --norc -e -o pipefail {0}
|
|
49
56
|
run: |
|
|
50
57
|
flutter pub get
|
|
51
58
|
if [ -f l10n.yaml ]; then flutter gen-l10n; fi
|
package/templates/deploy.yml
CHANGED
|
@@ -77,6 +77,10 @@ jobs:
|
|
|
77
77
|
else
|
|
78
78
|
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
|
79
79
|
fi
|
|
80
|
+
- name: Require the Google proxy before Flutter SDK setup
|
|
81
|
+
if: ${{ steps.flutter.outputs.enabled == 'true' }}
|
|
82
|
+
shell: bash
|
|
83
|
+
run: python3 .github/actions/android-app/scripts/play_store_proxy.py --bootstrap-env
|
|
80
84
|
- name: Set up Flutter
|
|
81
85
|
if: ${{ steps.flutter.outputs.enabled == 'true' }}
|
|
82
86
|
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
|
|
@@ -113,7 +117,9 @@ jobs:
|
|
|
113
117
|
echo "Configured authenticated github.com remotes for pub."
|
|
114
118
|
- name: Prepare Flutter iOS project
|
|
115
119
|
if: ${{ steps.flutter.outputs.enabled == 'true' }}
|
|
116
|
-
shell:
|
|
120
|
+
shell: >-
|
|
121
|
+
python3 .github/actions/android-app/scripts/play_store_proxy.py
|
|
122
|
+
-- bash --noprofile --norc -e -o pipefail {0}
|
|
117
123
|
env:
|
|
118
124
|
ONBOARDING_PUBLIC_ORIGIN: ${{ vars.ONBOARDING_PUBLIC_ORIGIN }}
|
|
119
125
|
run: |
|
|
@@ -326,6 +332,10 @@ jobs:
|
|
|
326
332
|
distribution: temurin
|
|
327
333
|
java-version: ${{ steps.jdk.outputs.version }}
|
|
328
334
|
cache: gradle
|
|
335
|
+
- name: Require the Google proxy before Flutter SDK setup
|
|
336
|
+
if: ${{ steps.mode.outputs.mode == 'local' && steps.flutter.outputs.enabled == 'true' }}
|
|
337
|
+
shell: bash
|
|
338
|
+
run: python3 .github/actions/android-app/scripts/play_store_proxy.py --bootstrap-env
|
|
329
339
|
- name: Set up Flutter
|
|
330
340
|
if: ${{ steps.mode.outputs.mode == 'local' && steps.flutter.outputs.enabled == 'true' }}
|
|
331
341
|
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
|
|
@@ -393,7 +403,9 @@ jobs:
|
|
|
393
403
|
# before anything analyses or compiles them. No-op for apps without l10n.
|
|
394
404
|
- name: Generate localizations
|
|
395
405
|
if: ${{ steps.mode.outputs.mode == 'local' && steps.flutter.outputs.enabled == 'true' }}
|
|
396
|
-
shell:
|
|
406
|
+
shell: >-
|
|
407
|
+
python3 .github/actions/android-app/scripts/play_store_proxy.py
|
|
408
|
+
-- bash --noprofile --norc -e -o pipefail {0}
|
|
397
409
|
run: |
|
|
398
410
|
if [ -f l10n.yaml ]; then
|
|
399
411
|
flutter pub get
|
|
@@ -431,7 +443,7 @@ jobs:
|
|
|
431
443
|
# the action autoupdates on consumers while this file does not, and the
|
|
432
444
|
# flag and its safety net must never be split across that boundary.
|
|
433
445
|
- name: Install Google Play preflight dependencies
|
|
434
|
-
if: ${{ steps.mode.outputs.mode == 'local' }}
|
|
446
|
+
if: ${{ steps.mode.outputs.mode == 'local' && steps.android.outputs.play-api-ready == '' }}
|
|
435
447
|
shell: bash
|
|
436
448
|
run: python3 -m pip install --disable-pip-version-check 'google-auth>=2.40,<3' 'requests>=2.32,<3'
|
|
437
449
|
- name: Check Google Play API readiness
|
|
@@ -441,7 +453,15 @@ jobs:
|
|
|
441
453
|
env:
|
|
442
454
|
PACKAGE_NAME: ${{ steps.android.outputs.package-name }}
|
|
443
455
|
SERVICE_ACCOUNT: ${{ steps.android.outputs.play-service-account }}
|
|
456
|
+
EARLY_READINESS: ${{ steps.android.outputs.play-api-ready }}
|
|
444
457
|
run: |
|
|
458
|
+
# Current actions verify readiness before compiling. Older action copies
|
|
459
|
+
# have no output, so retain their standalone preflight compatibility.
|
|
460
|
+
case "$EARLY_READINESS" in
|
|
461
|
+
true|false) echo "ready=$EARLY_READINESS" >> "$GITHUB_OUTPUT"; exit 0 ;;
|
|
462
|
+
'') ;;
|
|
463
|
+
*) echo "::error::Invalid Android readiness output"; exit 1 ;;
|
|
464
|
+
esac
|
|
445
465
|
python3 .github/actions/android-app/scripts/play_preflight.py \
|
|
446
466
|
--package "$PACKAGE_NAME" \
|
|
447
467
|
--service-account "$SERVICE_ACCOUNT"
|