gowalk-cicd 1.0.57 → 1.0.58
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 +6 -0
- package/README.md +9 -1
- package/action/.daemux-version +1 -1
- package/action/action.yml +14 -26
- package/action/scripts/apple_store_proxy.py +28 -0
- package/action/scripts/asc_common.py +5 -3
- package/action/scripts/test_action_step_order.py +1 -1
- package/action/scripts/test_apple_store_proxy.py +44 -0
- package/action/scripts/test_asc_common_timeouts.py +1 -0
- package/action/scripts/test_upload_build.py +118 -0
- package/action/scripts/upload_build.py +164 -0
- package/android-action/.daemux-version +1 -1
- package/android-action/scripts/play_preflight.py +10 -4
- package/android-action/scripts/play_store_proxy.py +22 -0
- package/android-action/scripts/resolve_android.py +7 -5
- package/android-action/scripts/test_play_store_proxy.py +30 -0
- package/backend-action/.daemux-version +1 -1
- package/package.json +1 -1
- package/templates/deploy.yml +15 -0
package/CLAUDE.md
CHANGED
|
@@ -98,6 +98,12 @@ node /path/to/gowalk-cicd/bin/cli.mjs
|
|
|
98
98
|
Detect and warn, tell the user to remove lines manually.
|
|
99
99
|
- Store API calls belong in GitHub Actions. Local tests may build/sign but must
|
|
100
100
|
never connect to App Store Connect or Google Play.
|
|
101
|
+
- Every Apple/Google store API call, OAuth exchange and binary upload requires its
|
|
102
|
+
assigned `APPLE_STORE_PROXY_URL` / `GOOGLE_STORE_PROXY_URL` repository secret.
|
|
103
|
+
Python clients enforce explicit proxies; Play upload steps set the proxy for the
|
|
104
|
+
pinned Google client. Missing configuration must stop before a provider call.
|
|
105
|
+
Apple IPA uploads use `action/scripts/upload_build.py`, not native upload tools.
|
|
106
|
+
It retains provider IDs/checksums and verifies matching IPA identity before resume.
|
|
101
107
|
- Signing material should use encrypted Actions secrets. `deploy.yml`
|
|
102
108
|
materializes `ASC_KEY_P8` + its ID/issuer, the Apple distribution identity
|
|
103
109
|
(`IOS_DISTRIBUTION_P12_BASE64`, `IOS_DISTRIBUTION_CERT_META_BASE64`,
|
package/README.md
CHANGED
|
@@ -643,6 +643,14 @@ retains its own symbols precisely so that this half-updated state is safe.
|
|
|
643
643
|
|
|
644
644
|
The iOS composite action runs on `macos-15` and:
|
|
645
645
|
|
|
646
|
+
All Apple and Google store requests require an account-pinned proxy, including OAuth,
|
|
647
|
+
metadata and binary transfer. Provision `APPLE_STORE_PROXY_URL` and
|
|
648
|
+
`GOOGLE_STORE_PROXY_URL` as encrypted repository secrets before deployment. A missing
|
|
649
|
+
proxy refuses provider calls; there is no direct fallback. Refresh existing workflow
|
|
650
|
+
files with the released installer while preserving application-specific build inputs.
|
|
651
|
+
The Apple uploader retains `apple-upload-<run>-<attempt>` receipts with IPA SHA-256,
|
|
652
|
+
upload ID and processing readback, and resumes matching bytes after interruption.
|
|
653
|
+
|
|
646
654
|
1. **Auto-detects** your `.xcodeproj` / `.xcworkspace`, scheme, bundle ID,
|
|
647
655
|
and `team_id` (from the ASC API key). No `ci.config.yaml` required —
|
|
648
656
|
override via action inputs only if auto-detection fails.
|
|
@@ -659,7 +667,7 @@ The iOS composite action runs on `macos-15` and:
|
|
|
659
667
|
`CI-<bundle_id>`. Patches the `.pbxproj` to use Manual signing against those
|
|
660
668
|
profiles.
|
|
661
669
|
6. **Archives** with `xcodebuild archive`, exports the IPA, and uploads via
|
|
662
|
-
`
|
|
670
|
+
Apple's build-upload REST API through `APPLE_STORE_PROXY_URL`.
|
|
663
671
|
7. **Sets "What's New"** on every declared localization (reads
|
|
664
672
|
`fastlane/metadata/ios/<locale>/release_notes.txt` if present, or from the
|
|
665
673
|
`app-store-whats-new` input).
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.58
|
package/action/action.yml
CHANGED
|
@@ -345,7 +345,7 @@ runs:
|
|
|
345
345
|
test
|
|
346
346
|
fi
|
|
347
347
|
|
|
348
|
-
- name: Stage ASC API key for
|
|
348
|
+
- name: Stage ASC API key for authenticated REST uploads
|
|
349
349
|
if: ${{ inputs.archive == 'true' }}
|
|
350
350
|
shell: bash
|
|
351
351
|
run: |
|
|
@@ -353,7 +353,7 @@ runs:
|
|
|
353
353
|
# prints env: blocks on step-group expansion, which leaks secrets.
|
|
354
354
|
# read_config.py already validated creds/AuthKey_<KEY_ID>_Issuer_<UUID>.p8
|
|
355
355
|
# exists and exported ASC_KEY_P8_PATH; we copy the file on disk and
|
|
356
|
-
# stage it
|
|
356
|
+
# stage it for the authenticated REST clients only.
|
|
357
357
|
: "${ASC_KEY_ID:?ASC_KEY_ID env var is required}"
|
|
358
358
|
: "${ASC_ISSUER_ID:?ASC_ISSUER_ID env var is required}"
|
|
359
359
|
: "${ASC_KEY_P8_PATH:?ASC_KEY_P8_PATH env var is required (set by read_config.py)}"
|
|
@@ -364,9 +364,6 @@ runs:
|
|
|
364
364
|
mkdir -p "$RUNNER_TEMP/asc"
|
|
365
365
|
cp "$ASC_KEY_P8_PATH" "$RUNNER_TEMP/asc/AuthKey.p8"
|
|
366
366
|
chmod 600 "$RUNNER_TEMP/asc/AuthKey.p8"
|
|
367
|
-
mkdir -p ~/.appstoreconnect/private_keys
|
|
368
|
-
cp "$RUNNER_TEMP/asc/AuthKey.p8" \
|
|
369
|
-
~/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8
|
|
370
367
|
echo "ASC_KEY_PATH=$RUNNER_TEMP/asc/AuthKey.p8" >> "$GITHUB_ENV"
|
|
371
368
|
|
|
372
369
|
- name: Resolve marketing version (project source of truth)
|
|
@@ -808,29 +805,20 @@ runs:
|
|
|
808
805
|
- name: Upload to TestFlight
|
|
809
806
|
if: ${{ inputs.archive == 'true' && inputs.upload == 'true' }}
|
|
810
807
|
shell: bash
|
|
808
|
+
env:
|
|
809
|
+
APP_STORE_APPLE_ID: ${{ inputs.app-store-apple-id || env.CFG_APP_STORE_APPLE_ID }}
|
|
811
810
|
run: |
|
|
812
811
|
IPA=$(ls "$RUNNER_TEMP"/export/*.ipa | head -1)
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
set -e
|
|
824
|
-
# altool occasionally exits 0 even on failure; verify the final outcome marker.
|
|
825
|
-
# Apple's altool prints transient "ERROR:" lines for retried network blips
|
|
826
|
-
# before emitting "UPLOAD SUCCEEDED" — don't false-positive on those.
|
|
827
|
-
if [ "$ALTOOL_RC" -ne 0 ] \
|
|
828
|
-
|| grep -qE '^Failed to upload|ERROR ITMS-' "$LOG" \
|
|
829
|
-
|| ! grep -qE 'UPLOAD SUCCEEDED|No errors uploading archive' "$LOG"; then
|
|
830
|
-
echo "altool reported an upload failure (rc=$ALTOOL_RC)"
|
|
831
|
-
exit 1
|
|
832
|
-
fi
|
|
833
|
-
echo "Upload succeeded"
|
|
812
|
+
python3 "$SWIFT_APP_ACTION/scripts/upload_build.py" "$IPA" \
|
|
813
|
+
--report "$RUNNER_TEMP/apple-build-upload.json"
|
|
814
|
+
|
|
815
|
+
- name: Retain Apple upload receipt
|
|
816
|
+
if: ${{ always() && inputs.archive == 'true' && inputs.upload == 'true' }}
|
|
817
|
+
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
|
|
818
|
+
with:
|
|
819
|
+
name: apple-upload-${{ github.run_id }}-${{ github.run_attempt }}
|
|
820
|
+
path: ${{ runner.temp }}/apple-build-upload.json
|
|
821
|
+
if-no-files-found: ignore
|
|
834
822
|
|
|
835
823
|
# Both App Store metadata writers are skipped while an App Store version
|
|
836
824
|
# is locked (STORE_VERSION_LOCKED, the TESTFLIGHT_ONLY decision): there is
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Explicit account-pinned transport for CI; missing proxy never means direct egress."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from urllib.parse import urlsplit
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def proxies() -> dict[str, str]:
|
|
11
|
+
value = os.environ.get("APPLE_STORE_PROXY_URL", "").strip()
|
|
12
|
+
try:
|
|
13
|
+
parsed = urlsplit(value)
|
|
14
|
+
valid = parsed.scheme in {"http", "https"} and parsed.hostname and parsed.port
|
|
15
|
+
except ValueError:
|
|
16
|
+
valid = False
|
|
17
|
+
if not valid:
|
|
18
|
+
raise SystemExit("APPLE_STORE_PROXY_URL must contain the account-pinned proxy; direct requests are refused")
|
|
19
|
+
return {"http": value, "https": value}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def request(method: str, url: str, **kwargs) -> requests.Response:
|
|
23
|
+
# An explicit map also wins over NO_PROXY and any unrelated runner proxy.
|
|
24
|
+
try:
|
|
25
|
+
return requests.request(method, url, proxies=proxies(), **kwargs)
|
|
26
|
+
except requests.RequestException:
|
|
27
|
+
# Proxy errors can embed credentials and signed upload URLs.
|
|
28
|
+
raise RuntimeError("Apple request failed through the configured proxy") from None
|
|
@@ -16,6 +16,8 @@ from typing import Any
|
|
|
16
16
|
import jwt
|
|
17
17
|
import requests
|
|
18
18
|
|
|
19
|
+
from apple_store_proxy import proxies
|
|
20
|
+
|
|
19
21
|
|
|
20
22
|
ASC_BASE = "https://api.appstoreconnect.apple.com/v1"
|
|
21
23
|
|
|
@@ -162,11 +164,11 @@ def _retry_network_error(
|
|
|
162
164
|
if attempt >= total - 1:
|
|
163
165
|
raise SystemExit(
|
|
164
166
|
f"ASC {method} {path} network error after {total} "
|
|
165
|
-
|
|
167
|
+
"attempts through the configured proxy"
|
|
166
168
|
)
|
|
167
169
|
delay = backoffs[attempt]
|
|
168
170
|
print(
|
|
169
|
-
f"ASC {method} {path} network error
|
|
171
|
+
f"ASC {method} {path} network error through the configured proxy; "
|
|
170
172
|
f"retrying in {delay}s ({attempt + 1}/{total - 1})",
|
|
171
173
|
file=sys.stderr,
|
|
172
174
|
)
|
|
@@ -222,7 +224,7 @@ def request(
|
|
|
222
224
|
try:
|
|
223
225
|
resp = requests.request(
|
|
224
226
|
method, url, headers=headers, params=params,
|
|
225
|
-
json=json_body, timeout=timeout,
|
|
227
|
+
json=json_body, timeout=timeout, proxies=proxies(),
|
|
226
228
|
)
|
|
227
229
|
except requests.RequestException as exc:
|
|
228
230
|
_retry_network_error(method, path, attempt, total, backoffs, exc)
|
|
@@ -47,7 +47,7 @@ class TestActionStepOrder(unittest.TestCase):
|
|
|
47
47
|
order["Commit + push refreshed app-ci"])
|
|
48
48
|
# ... and before every other step that talks to a store or signs.
|
|
49
49
|
for later in ("Resolve credentials + auto-detect Xcode project",
|
|
50
|
-
"Stage ASC API key for
|
|
50
|
+
"Stage ASC API key for authenticated REST uploads",
|
|
51
51
|
"Prepare signing (cert + keychain + per-target profiles + pbxproj)",
|
|
52
52
|
"Archive"):
|
|
53
53
|
self.assertLess(order["Commit + push refreshed app-ci"], order[later], later)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Apple API and upload bytes cannot bypass the configured proxy."""
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import sys
|
|
5
|
+
import unittest
|
|
6
|
+
from unittest import mock
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
9
|
+
import apple_store_proxy
|
|
10
|
+
import asc_common
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AppleProxyTests(unittest.TestCase):
|
|
14
|
+
def test_missing_proxy_stops_before_any_request(self):
|
|
15
|
+
with mock.patch.dict(os.environ, {"APPLE_STORE_PROXY_URL": ""}), \
|
|
16
|
+
mock.patch.object(apple_store_proxy.requests, "request") as request:
|
|
17
|
+
with self.assertRaises(SystemExit):
|
|
18
|
+
apple_store_proxy.request("PUT", "https://upload.apple.com/part", data=b"ipa")
|
|
19
|
+
request.assert_not_called()
|
|
20
|
+
|
|
21
|
+
def test_explicit_proxy_is_used_despite_no_proxy_and_other_environment(self):
|
|
22
|
+
configured = "http://assigned.proxy.test:1234"
|
|
23
|
+
response = mock.Mock(status_code=200)
|
|
24
|
+
with mock.patch.dict(os.environ, {"APPLE_STORE_PROXY_URL": configured,
|
|
25
|
+
"HTTPS_PROXY": "http://wrong.proxy:9876", "NO_PROXY": "*"}), \
|
|
26
|
+
mock.patch.object(apple_store_proxy.requests, "request", return_value=response) as request:
|
|
27
|
+
asc_common.request("GET", "/apps/123", "test-token")
|
|
28
|
+
self.assertEqual(request.call_args.kwargs["proxies"], {"http": configured, "https": configured})
|
|
29
|
+
apple_store_proxy.request("PUT", "https://upload.apple.com/part", data=b"ipa")
|
|
30
|
+
self.assertEqual(request.call_args.kwargs["proxies"], {"http": configured, "https": configured})
|
|
31
|
+
self.assertNotIn("Authorization", request.call_args.kwargs.get("headers", {}))
|
|
32
|
+
|
|
33
|
+
def test_upload_transport_failure_does_not_print_proxy_or_signed_url(self):
|
|
34
|
+
with mock.patch.dict(os.environ, {"APPLE_STORE_PROXY_URL": "http://proxy.test:1234"}), \
|
|
35
|
+
mock.patch.object(apple_store_proxy.requests, "request", side_effect=
|
|
36
|
+
apple_store_proxy.requests.RequestException("private-password signed-url")):
|
|
37
|
+
with self.assertRaises(RuntimeError) as caught:
|
|
38
|
+
apple_store_proxy.request("PUT", "https://upload.apple.com/part")
|
|
39
|
+
self.assertNotIn("private-password", str(caught.exception))
|
|
40
|
+
self.assertNotIn("signed-url", str(caught.exception))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if __name__ == "__main__":
|
|
44
|
+
unittest.main()
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""REST IPA upload identity, resume, complete byte coverage and proxy-only transfer."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import plistlib
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import unittest
|
|
9
|
+
from unittest import mock
|
|
10
|
+
import zipfile
|
|
11
|
+
|
|
12
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
13
|
+
import upload_build
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BuildUploadTests(unittest.TestCase):
|
|
17
|
+
def test_ipa_identity_comes_from_the_archived_app(self):
|
|
18
|
+
with tempfile.TemporaryDirectory() as temporary:
|
|
19
|
+
ipa = Path(temporary) / "app.ipa"
|
|
20
|
+
with zipfile.ZipFile(ipa, "w") as archive:
|
|
21
|
+
archive.writestr("Payload/App.app/Info.plist", plistlib.dumps({
|
|
22
|
+
"CFBundleIdentifier": "com.example.app", "CFBundleVersion": "8",
|
|
23
|
+
"CFBundleShortVersionString": "1.2.0"}))
|
|
24
|
+
info = upload_build.identity(ipa)
|
|
25
|
+
self.assertEqual(info["cfBundleVersion"], "8")
|
|
26
|
+
self.assertEqual(info["cfBundleShortVersionString"], "1.2.0")
|
|
27
|
+
self.assertEqual(info["sha256"], hashlib.sha256(ipa.read_bytes()).hexdigest())
|
|
28
|
+
|
|
29
|
+
def test_existing_upload_is_adopted_without_another_create(self):
|
|
30
|
+
info = {"cfBundleVersion": "8", "cfBundleShortVersionString": "1.2.0"}
|
|
31
|
+
with mock.patch.object(upload_build, "api", return_value={"data": [{"id": "existing"}]}) as api:
|
|
32
|
+
self.assertEqual(upload_build.upload_record("app", info)["id"], "existing")
|
|
33
|
+
self.assertEqual(api.call_count, 1)
|
|
34
|
+
self.assertEqual(api.call_args.args[0], "GET")
|
|
35
|
+
|
|
36
|
+
def test_existing_different_bytes_are_not_replaced(self):
|
|
37
|
+
existing = {"data": [{"id": "file", "attributes": {
|
|
38
|
+
"assetType": "ASSET", "fileName": "other.ipa", "fileSize": 10}}]}
|
|
39
|
+
with mock.patch.object(upload_build, "api", return_value=existing) as api:
|
|
40
|
+
with self.assertRaises(ValueError):
|
|
41
|
+
upload_build.upload_file({"id": "upload"}, {"fileName": "wanted.ipa", "fileSize": 10})
|
|
42
|
+
self.assertEqual(api.call_count, 1)
|
|
43
|
+
|
|
44
|
+
def test_part_transfer_uses_proxy_without_asc_bearer(self):
|
|
45
|
+
with tempfile.TemporaryDirectory() as temporary:
|
|
46
|
+
ipa = Path(temporary) / "app.ipa"
|
|
47
|
+
ipa.write_bytes(b"abcde")
|
|
48
|
+
operation = {"url": "https://upload.apple.com/part", "method": "PUT", "offset": 1,
|
|
49
|
+
"length": 3, "requestHeaders": [{"name": "Content-Type", "value": "application/octet-stream"}]}
|
|
50
|
+
response = mock.Mock(status_code=200)
|
|
51
|
+
with mock.patch.object(upload_build.store_proxy, "request", return_value=response) as request:
|
|
52
|
+
upload_build.transfer(ipa, operation)
|
|
53
|
+
self.assertEqual(request.call_args.kwargs["data"], b"bcd")
|
|
54
|
+
self.assertNotIn("Authorization", request.call_args.kwargs["headers"])
|
|
55
|
+
self.assertFalse(request.call_args.kwargs["allow_redirects"])
|
|
56
|
+
|
|
57
|
+
def test_missing_parts_never_mark_a_file_uploaded(self):
|
|
58
|
+
file = {"id": "file", "attributes": {"uploadOperations": [{"offset": 0, "length": 4}]}}
|
|
59
|
+
with mock.patch.object(upload_build, "api") as api, mock.patch.object(upload_build, "transfer") as transfer:
|
|
60
|
+
with self.assertRaises(ValueError):
|
|
61
|
+
upload_build.transfer_all(Path("unused"), file, {"fileSize": 5, "sha256": "hash"})
|
|
62
|
+
api.assert_not_called()
|
|
63
|
+
transfer.assert_not_called()
|
|
64
|
+
|
|
65
|
+
def test_parts_are_joined_before_committing_the_checksum(self):
|
|
66
|
+
file = {"id": "file", "attributes": {"uploadOperations": [{"offset": 0, "length": 2},
|
|
67
|
+
{"offset": 2, "length": 3}]}}
|
|
68
|
+
done = []
|
|
69
|
+
with mock.patch.object(upload_build, "transfer", side_effect=lambda path, op: done.append(op["offset"])), \
|
|
70
|
+
mock.patch.object(upload_build, "api") as api:
|
|
71
|
+
upload_build.transfer_all(Path("unused"), file, {"fileSize": 5, "sha256": "digest"})
|
|
72
|
+
self.assertEqual(sorted(done), [0, 2])
|
|
73
|
+
body = api.call_args.args[2]["data"]["attributes"]
|
|
74
|
+
self.assertEqual(body, {"uploaded": True, "sourceFileChecksums": {
|
|
75
|
+
"file": {"hash": "digest", "algorithm": "SHA_256"}}})
|
|
76
|
+
|
|
77
|
+
def test_failed_part_never_commits_the_file(self):
|
|
78
|
+
file = {"id": "file", "attributes": {"uploadOperations": [{"offset": 0, "length": 5}]}}
|
|
79
|
+
with mock.patch.object(upload_build, "transfer", side_effect=RuntimeError("transport")), \
|
|
80
|
+
mock.patch.object(upload_build, "api") as api:
|
|
81
|
+
with self.assertRaises(RuntimeError):
|
|
82
|
+
upload_build.transfer_all(Path("unused"), file, {"fileSize": 5, "sha256": "digest"})
|
|
83
|
+
api.assert_not_called()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class BuildDeliveryTests(unittest.TestCase):
|
|
87
|
+
@mock.patch.dict(os.environ, {"APP_STORE_APPLE_ID": "app", "APPLE_STORE_PROXY_URL": "http://proxy.test:1234"})
|
|
88
|
+
def test_other_app_is_refused_before_creating_uploads(self):
|
|
89
|
+
with mock.patch.object(upload_build, "identity", return_value={"bundle_id": "com.expected.app"}), \
|
|
90
|
+
mock.patch.object(upload_build, "api", return_value={"data": {"attributes": {
|
|
91
|
+
"bundleId": "com.other.app"}}}) as api, \
|
|
92
|
+
mock.patch.object(upload_build, "upload_record") as create:
|
|
93
|
+
with self.assertRaises(ValueError):
|
|
94
|
+
upload_build.deliver(Path("unused"), Path("unused-report"), 1)
|
|
95
|
+
self.assertEqual(api.call_count, 1)
|
|
96
|
+
create.assert_not_called()
|
|
97
|
+
|
|
98
|
+
@mock.patch.dict(os.environ, {"APP_STORE_APPLE_ID": "app", "APPLE_STORE_PROXY_URL": "http://proxy.test:1234"})
|
|
99
|
+
def test_completed_readback_retains_provider_build_identity(self):
|
|
100
|
+
reads = [{"data": {"attributes": {"bundleId": "com.expected.app"}}}, {"data": {
|
|
101
|
+
"attributes": {"state": {"state": "COMPLETE"}},
|
|
102
|
+
"relationships": {"build": {"data": {"id": "build-8"}}}}}]
|
|
103
|
+
with tempfile.TemporaryDirectory() as temporary, \
|
|
104
|
+
mock.patch.object(upload_build, "identity", return_value={"bundle_id": "com.expected.app"}), \
|
|
105
|
+
mock.patch.object(upload_build, "api", side_effect=reads) as api, \
|
|
106
|
+
mock.patch.object(upload_build, "upload_record", return_value={"id": "upload"}), \
|
|
107
|
+
mock.patch.object(upload_build, "upload_file", return_value={"id": "file"}), \
|
|
108
|
+
mock.patch.object(upload_build, "transfer_all"):
|
|
109
|
+
report = Path(temporary) / "receipt.json"
|
|
110
|
+
result = upload_build.deliver(Path("unused"), report, 1)
|
|
111
|
+
self.assertEqual(result["build_id"], "build-8")
|
|
112
|
+
self.assertEqual(result["state"], "COMPLETE")
|
|
113
|
+
self.assertIn('"build-8"', report.read_text())
|
|
114
|
+
self.assertEqual(api.call_args.kwargs["params"], {"include": "build"})
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
if __name__ == "__main__":
|
|
118
|
+
unittest.main()
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Upload an IPA through Apple's build-upload REST API and the account-pinned proxy."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import plistlib
|
|
12
|
+
import time
|
|
13
|
+
from urllib.parse import urlsplit
|
|
14
|
+
import zipfile
|
|
15
|
+
|
|
16
|
+
import asc_common
|
|
17
|
+
import apple_store_proxy as store_proxy
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def api(method: str, path: str, body=None, params=None) -> dict:
|
|
21
|
+
token = asc_common.make_jwt(os.environ["ASC_KEY_ID"], os.environ["ASC_ISSUER_ID"],
|
|
22
|
+
os.environ["ASC_KEY_PATH"])
|
|
23
|
+
# An uncertain create is discovered from the provider on the next invocation,
|
|
24
|
+
# never repeated automatically under a second upload identity.
|
|
25
|
+
return asc_common.request(method, path, token, json_body=body, params=params,
|
|
26
|
+
max_attempts=3 if method == "GET" else 1).json()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def identity(ipa: Path) -> dict:
|
|
30
|
+
with zipfile.ZipFile(ipa) as archive:
|
|
31
|
+
names = [name for name in archive.namelist()
|
|
32
|
+
if name.startswith("Payload/") and name.endswith(".app/Info.plist") and name.count("/") == 2]
|
|
33
|
+
if len(names) != 1:
|
|
34
|
+
raise ValueError("IPA must contain one top-level app Info.plist")
|
|
35
|
+
info = plistlib.loads(archive.read(names[0]))
|
|
36
|
+
with ipa.open("rb") as stream:
|
|
37
|
+
digest = hashlib.file_digest(stream, "sha256").hexdigest()
|
|
38
|
+
return {"bundle_id": str(info["CFBundleIdentifier"]),
|
|
39
|
+
"cfBundleVersion": str(info["CFBundleVersion"]),
|
|
40
|
+
"cfBundleShortVersionString": str(info["CFBundleShortVersionString"]),
|
|
41
|
+
"sha256": digest, "fileName": digest + ".ipa", "fileSize": ipa.stat().st_size}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def upload_record(app_id: str, info: dict) -> dict:
|
|
45
|
+
params = {"filter[cfBundleVersion]": info["cfBundleVersion"],
|
|
46
|
+
"filter[cfBundleShortVersionString]": info["cfBundleShortVersionString"],
|
|
47
|
+
"filter[platform]": "IOS", "limit": 200}
|
|
48
|
+
rows = api("GET", f"/apps/{app_id}/buildUploads", params=params).get("data") or []
|
|
49
|
+
rows = [row for row in rows if ((row.get("attributes") or {}).get("state") or {}).get("state") != "FAILED"]
|
|
50
|
+
if len(rows) > 1:
|
|
51
|
+
raise ValueError("multiple matching Apple build uploads; resolve their provider state before retrying")
|
|
52
|
+
if rows:
|
|
53
|
+
return rows[0]
|
|
54
|
+
return api("POST", "/buildUploads", {"data": {
|
|
55
|
+
"type": "buildUploads", "attributes": {"platform": "IOS", **{
|
|
56
|
+
key: info[key] for key in ("cfBundleVersion", "cfBundleShortVersionString")}},
|
|
57
|
+
"relationships": {"app": {"data": {"type": "apps", "id": app_id}}},
|
|
58
|
+
}})["data"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def upload_file(record: dict, info: dict) -> dict:
|
|
62
|
+
rows = api("GET", f"/buildUploads/{record['id']}/buildUploadFiles").get("data") or []
|
|
63
|
+
assets = [row for row in rows if (row.get("attributes") or {}).get("assetType") == "ASSET"]
|
|
64
|
+
if assets:
|
|
65
|
+
if len(assets) != 1 or any(assets[0]["attributes"].get(key) != info[key]
|
|
66
|
+
for key in ("fileName", "fileSize")):
|
|
67
|
+
raise ValueError("existing Apple build upload belongs to different IPA bytes; do not overwrite it")
|
|
68
|
+
return assets[0]
|
|
69
|
+
return api("POST", "/buildUploadFiles", {"data": {
|
|
70
|
+
"type": "buildUploadFiles", "attributes": {
|
|
71
|
+
"assetType": "ASSET", "uti": "com.apple.ipa", "fileName": info["fileName"], "fileSize": info["fileSize"]},
|
|
72
|
+
"relationships": {"buildUpload": {"data": {"type": "buildUploads", "id": record["id"]}}},
|
|
73
|
+
}})["data"]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def transfer(ipa: Path, operation: dict) -> None:
|
|
77
|
+
parsed = urlsplit(operation["url"])
|
|
78
|
+
hosts = (".apple.com", ".icloud.com", ".amazonaws.com")
|
|
79
|
+
if parsed.scheme != "https" or not any((parsed.hostname or "").endswith(host) for host in hosts):
|
|
80
|
+
raise ValueError("Apple returned an unsupported upload destination")
|
|
81
|
+
if operation.get("method") != "PUT":
|
|
82
|
+
raise ValueError("Apple returned an unsupported upload method")
|
|
83
|
+
with ipa.open("rb") as stream:
|
|
84
|
+
stream.seek(operation["offset"])
|
|
85
|
+
data = stream.read(operation["length"])
|
|
86
|
+
if len(data) != operation["length"]:
|
|
87
|
+
raise ValueError("IPA changed during upload")
|
|
88
|
+
headers = {row["name"]: row["value"] for row in operation.get("requestHeaders") or []}
|
|
89
|
+
response = store_proxy.request("PUT", operation["url"], headers=headers, data=data,
|
|
90
|
+
timeout=(20, 300), allow_redirects=False)
|
|
91
|
+
if not 200 <= response.status_code < 300:
|
|
92
|
+
raise RuntimeError(f"Apple upload part refused ({response.status_code}); read upload state before retrying")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def transfer_all(ipa: Path, file: dict, info: dict) -> None:
|
|
96
|
+
attributes = file.get("attributes") or {}
|
|
97
|
+
state = (attributes.get("assetDeliveryState") or {}).get("state")
|
|
98
|
+
if state == "COMPLETE":
|
|
99
|
+
expected = {"hash": info["sha256"], "algorithm": "SHA_256"}
|
|
100
|
+
if (attributes.get("sourceFileChecksums") or {}).get("file") != expected:
|
|
101
|
+
raise ValueError("Apple's completed file checksum does not match this IPA")
|
|
102
|
+
return
|
|
103
|
+
operations = sorted(attributes.get("uploadOperations") or [], key=lambda row: row["offset"])
|
|
104
|
+
offset = 0
|
|
105
|
+
for operation in operations:
|
|
106
|
+
if operation.get("offset") != offset or operation.get("length", 0) <= 0:
|
|
107
|
+
raise ValueError("Apple upload operations do not cover the IPA exactly")
|
|
108
|
+
offset += operation["length"]
|
|
109
|
+
if offset != info["fileSize"]:
|
|
110
|
+
raise ValueError("Apple upload operations do not cover the IPA exactly")
|
|
111
|
+
with ThreadPoolExecutor(max_workers=3) as executor:
|
|
112
|
+
results = [executor.submit(transfer, ipa, operation) for operation in operations]
|
|
113
|
+
for result in results:
|
|
114
|
+
result.result()
|
|
115
|
+
api("PATCH", f"/buildUploadFiles/{file['id']}", {"data": {
|
|
116
|
+
"type": "buildUploadFiles", "id": file["id"], "attributes": {"uploaded": True,
|
|
117
|
+
"sourceFileChecksums": {"file": {"hash": info["sha256"], "algorithm": "SHA_256"}}},
|
|
118
|
+
}})
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def deliver(ipa: Path, report: Path, timeout: int) -> dict:
|
|
122
|
+
store_proxy.proxies() # Refuse before any identity or provider request.
|
|
123
|
+
info = identity(ipa)
|
|
124
|
+
app_id = os.environ["APP_STORE_APPLE_ID"]
|
|
125
|
+
app = api("GET", f"/apps/{app_id}")["data"]
|
|
126
|
+
if (app.get("attributes") or {}).get("bundleId") != info["bundle_id"]:
|
|
127
|
+
raise ValueError("IPA bundle identity does not match the selected App Store app")
|
|
128
|
+
record = upload_record(app_id, info)
|
|
129
|
+
file = upload_file(record, info)
|
|
130
|
+
receipt = {**info, "upload_id": record["id"], "file_id": file["id"], "proxy_required": True}
|
|
131
|
+
report.write_text(json.dumps(receipt, indent=2))
|
|
132
|
+
transfer_all(ipa, file, info)
|
|
133
|
+
deadline = time.monotonic() + timeout
|
|
134
|
+
while True:
|
|
135
|
+
current = api("GET", f"/buildUploads/{record['id']}", params={"include": "build"})["data"]
|
|
136
|
+
state = ((current.get("attributes") or {}).get("state") or {}).get("state")
|
|
137
|
+
receipt["state"] = state
|
|
138
|
+
receipt["build_id"] = (((current.get("relationships") or {}).get("build") or {}).get("data") or {}).get("id")
|
|
139
|
+
report.write_text(json.dumps(receipt, indent=2))
|
|
140
|
+
if state == "COMPLETE":
|
|
141
|
+
return receipt
|
|
142
|
+
if state == "FAILED":
|
|
143
|
+
raise RuntimeError("Apple build upload failed processing; inspect the upload ID's provider errors")
|
|
144
|
+
if time.monotonic() >= deadline:
|
|
145
|
+
raise RuntimeError("Apple build processing is pending; reuse this upload ID on continuation")
|
|
146
|
+
time.sleep(10)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def main() -> None:
|
|
150
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
151
|
+
parser.add_argument("ipa", type=Path)
|
|
152
|
+
parser.add_argument("--report", required=True, type=Path)
|
|
153
|
+
parser.add_argument("--timeout", type=int, default=900)
|
|
154
|
+
args = parser.parse_args()
|
|
155
|
+
try:
|
|
156
|
+
receipt = deliver(args.ipa, args.report, args.timeout)
|
|
157
|
+
except (OSError, ValueError, KeyError, RuntimeError):
|
|
158
|
+
message = "Apple REST upload did not complete; retain its receipt and resolve the provider state"
|
|
159
|
+
raise SystemExit(message) from None
|
|
160
|
+
print(json.dumps(receipt))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
if __name__ == "__main__":
|
|
164
|
+
main()
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.58
|
|
@@ -12,6 +12,8 @@ import google.auth.transport.requests
|
|
|
12
12
|
import requests
|
|
13
13
|
from google.oauth2 import service_account
|
|
14
14
|
|
|
15
|
+
from play_store_proxy import session
|
|
16
|
+
|
|
15
17
|
|
|
16
18
|
SCOPE = "https://www.googleapis.com/auth/androidpublisher"
|
|
17
19
|
BASE_URL = "https://androidpublisher.googleapis.com/androidpublisher/v3"
|
|
@@ -36,13 +38,14 @@ def main() -> None:
|
|
|
36
38
|
parser.add_argument("--service-account", required=True, type=Path)
|
|
37
39
|
args = parser.parse_args()
|
|
38
40
|
|
|
41
|
+
client = session()
|
|
39
42
|
credentials = service_account.Credentials.from_service_account_file(
|
|
40
43
|
args.service_account, scopes=[SCOPE]
|
|
41
44
|
)
|
|
42
|
-
credentials.refresh(google.auth.transport.requests.Request())
|
|
45
|
+
credentials.refresh(google.auth.transport.requests.Request(session=client))
|
|
43
46
|
headers = {"Authorization": f"Bearer {credentials.token}"}
|
|
44
47
|
url = f"{BASE_URL}/applications/{args.package}/edits"
|
|
45
|
-
response =
|
|
48
|
+
response = client.post(url, headers=headers, json={}, timeout=30)
|
|
46
49
|
if response.status_code == 404:
|
|
47
50
|
write_ready(False)
|
|
48
51
|
print(
|
|
@@ -57,7 +60,7 @@ def main() -> None:
|
|
|
57
60
|
|
|
58
61
|
edit_id = json.loads(response.text)["id"]
|
|
59
62
|
delete_url = f"{url}/{edit_id}"
|
|
60
|
-
deleted =
|
|
63
|
+
deleted = client.delete(delete_url, headers=headers, timeout=30)
|
|
61
64
|
if deleted.status_code not in (200, 204):
|
|
62
65
|
message = response_message(deleted)
|
|
63
66
|
raise SystemExit(f"cannot close Google Play preflight edit: {message}")
|
|
@@ -66,4 +69,7 @@ def main() -> None:
|
|
|
66
69
|
|
|
67
70
|
|
|
68
71
|
if __name__ == "__main__":
|
|
69
|
-
|
|
72
|
+
try:
|
|
73
|
+
main()
|
|
74
|
+
except requests.RequestException:
|
|
75
|
+
raise SystemExit("Google Play preflight failed through the assigned proxy") from None
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Google CI transport uses only the account-pinned proxy, including OAuth exchanges."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from urllib.parse import urlsplit
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def session() -> requests.Session:
|
|
11
|
+
value = os.environ.get("GOOGLE_STORE_PROXY_URL", "").strip()
|
|
12
|
+
try:
|
|
13
|
+
parsed = urlsplit(value)
|
|
14
|
+
valid = parsed.scheme in {"http", "https"} and parsed.hostname and parsed.port
|
|
15
|
+
except ValueError:
|
|
16
|
+
valid = False
|
|
17
|
+
if not valid:
|
|
18
|
+
raise SystemExit("GOOGLE_STORE_PROXY_URL must contain the account-pinned proxy; direct requests are refused")
|
|
19
|
+
client = requests.Session()
|
|
20
|
+
client.trust_env = False
|
|
21
|
+
client.proxies = {"http": value, "https": value}
|
|
22
|
+
return client
|
|
@@ -9,6 +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
13
|
from android_config import (
|
|
13
14
|
ConfigError,
|
|
14
15
|
detect_package_name,
|
|
@@ -41,6 +42,7 @@ def highest_play_version_code(package_name: str, service_account: Path) -> int |
|
|
|
41
42
|
Best-effort: any failure (offline, no permission, app not yet created) returns
|
|
42
43
|
None so the caller falls back to GITHUB_RUN_NUMBER.
|
|
43
44
|
"""
|
|
45
|
+
client = session()
|
|
44
46
|
try:
|
|
45
47
|
import google.auth.transport.requests # noqa: PLC0415
|
|
46
48
|
import requests # noqa: PLC0415
|
|
@@ -50,17 +52,17 @@ def highest_play_version_code(package_name: str, service_account: Path) -> int |
|
|
|
50
52
|
str(service_account),
|
|
51
53
|
scopes=["https://www.googleapis.com/auth/androidpublisher"],
|
|
52
54
|
)
|
|
53
|
-
creds.refresh(google.auth.transport.requests.Request())
|
|
55
|
+
creds.refresh(google.auth.transport.requests.Request(session=client))
|
|
54
56
|
base = "https://androidpublisher.googleapis.com/androidpublisher/v3"
|
|
55
57
|
headers = {"Authorization": f"Bearer {creds.token}"}
|
|
56
58
|
|
|
57
|
-
edit =
|
|
59
|
+
edit = client.post(
|
|
58
60
|
f"{base}/applications/{package_name}/edits", headers=headers, timeout=60
|
|
59
61
|
)
|
|
60
62
|
edit.raise_for_status()
|
|
61
63
|
edit_id = edit.json()["id"]
|
|
62
64
|
try:
|
|
63
|
-
listed =
|
|
65
|
+
listed = client.get(
|
|
64
66
|
f"{base}/applications/{package_name}/edits/{edit_id}/bundles",
|
|
65
67
|
headers=headers,
|
|
66
68
|
timeout=60,
|
|
@@ -72,14 +74,14 @@ def highest_play_version_code(package_name: str, service_account: Path) -> int |
|
|
|
72
74
|
if b.get("versionCode") is not None
|
|
73
75
|
]
|
|
74
76
|
finally:
|
|
75
|
-
|
|
77
|
+
client.delete(
|
|
76
78
|
f"{base}/applications/{package_name}/edits/{edit_id}",
|
|
77
79
|
headers=headers,
|
|
78
80
|
timeout=60,
|
|
79
81
|
)
|
|
80
82
|
return max(codes) if codes else None
|
|
81
83
|
except Exception as exc: # noqa: BLE001 - never fail the build over this
|
|
82
|
-
print(
|
|
84
|
+
print("::warning::Could not read existing Play versionCodes through the configured proxy; "
|
|
83
85
|
f"falling back to GITHUB_RUN_NUMBER")
|
|
84
86
|
return None
|
|
85
87
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Google OAuth and API requests use an explicit, non-fallback session."""
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import sys
|
|
5
|
+
import unittest
|
|
6
|
+
from unittest import mock
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
9
|
+
import play_store_proxy
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PlayProxyTests(unittest.TestCase):
|
|
13
|
+
def test_missing_proxy_never_constructs_a_direct_session(self):
|
|
14
|
+
with mock.patch.dict(os.environ, {"GOOGLE_STORE_PROXY_URL": ""}), \
|
|
15
|
+
mock.patch.object(play_store_proxy.requests, "Session") as session:
|
|
16
|
+
with self.assertRaises(SystemExit):
|
|
17
|
+
play_store_proxy.session()
|
|
18
|
+
session.assert_not_called()
|
|
19
|
+
|
|
20
|
+
def test_ambient_proxy_and_no_proxy_cannot_override_account_exit(self):
|
|
21
|
+
value = "http://assigned.proxy.test:1234"
|
|
22
|
+
with mock.patch.dict(os.environ, {"GOOGLE_STORE_PROXY_URL": value,
|
|
23
|
+
"HTTPS_PROXY": "http://other.proxy:9876", "NO_PROXY": "*"}):
|
|
24
|
+
with play_store_proxy.session() as client:
|
|
25
|
+
self.assertFalse(client.trust_env)
|
|
26
|
+
self.assertEqual(client.proxies, {"http": value, "https": value})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
if __name__ == "__main__":
|
|
30
|
+
unittest.main()
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.58
|
package/package.json
CHANGED
package/templates/deploy.yml
CHANGED
|
@@ -27,6 +27,9 @@ jobs:
|
|
|
27
27
|
# unrelated failure on the other platform cannot block the release you want.
|
|
28
28
|
ios:
|
|
29
29
|
name: iOS TestFlight
|
|
30
|
+
env:
|
|
31
|
+
APPLE_STORE_PROXY_URL: ${{ secrets.APPLE_STORE_PROXY_URL }}
|
|
32
|
+
GOOGLE_STORE_PROXY_URL: ${{ secrets.GOOGLE_STORE_PROXY_URL }}
|
|
30
33
|
if: ${{ vars.DEPLOY_PLATFORMS != 'android' }}
|
|
31
34
|
runs-on: macos-15
|
|
32
35
|
timeout-minutes: 60
|
|
@@ -207,6 +210,8 @@ jobs:
|
|
|
207
210
|
|
|
208
211
|
android:
|
|
209
212
|
name: Android Google Play
|
|
213
|
+
env:
|
|
214
|
+
GOOGLE_STORE_PROXY_URL: ${{ secrets.GOOGLE_STORE_PROXY_URL }}
|
|
210
215
|
if: ${{ vars.DEPLOY_PLATFORMS != 'ios' }}
|
|
211
216
|
runs-on: ubuntu-24.04
|
|
212
217
|
timeout-minutes: 45
|
|
@@ -453,6 +458,11 @@ jobs:
|
|
|
453
458
|
continue-on-error: true
|
|
454
459
|
if: ${{ steps.mode.outputs.mode == 'local' && steps.play.outputs.ready == 'true' }}
|
|
455
460
|
uses: r0adkll/upload-google-play@e738b9dd8f2476ea806d921b64aacd24f34515a5 # v1.1.5
|
|
461
|
+
env:
|
|
462
|
+
HTTPS_PROXY: ${{ secrets.GOOGLE_STORE_PROXY_URL }}
|
|
463
|
+
HTTP_PROXY: ${{ secrets.GOOGLE_STORE_PROXY_URL }}
|
|
464
|
+
NO_PROXY: ''
|
|
465
|
+
no_proxy: ''
|
|
456
466
|
with:
|
|
457
467
|
serviceAccountJson: ${{ steps.android.outputs.play-service-account }}
|
|
458
468
|
packageName: ${{ steps.android.outputs.package-name }}
|
|
@@ -477,6 +487,11 @@ jobs:
|
|
|
477
487
|
&& steps.play.outputs.ready == 'true'
|
|
478
488
|
&& steps.play-upload.outcome == 'failure' }}
|
|
479
489
|
uses: r0adkll/upload-google-play@e738b9dd8f2476ea806d921b64aacd24f34515a5 # v1.1.5
|
|
490
|
+
env:
|
|
491
|
+
HTTPS_PROXY: ${{ secrets.GOOGLE_STORE_PROXY_URL }}
|
|
492
|
+
HTTP_PROXY: ${{ secrets.GOOGLE_STORE_PROXY_URL }}
|
|
493
|
+
NO_PROXY: ''
|
|
494
|
+
no_proxy: ''
|
|
480
495
|
with:
|
|
481
496
|
serviceAccountJson: ${{ steps.android.outputs.play-service-account }}
|
|
482
497
|
packageName: ${{ steps.android.outputs.package-name }}
|