gowalk-cicd 1.0.56 → 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 CHANGED
@@ -98,10 +98,16 @@ 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
- (`IOS_DISTRIBUTION_P12_BASE64`, `IOS_DISTRIBUTION_CERT_META_JSON`,
104
- `IOS_DISTRIBUTION_CERT_REGISTRY_JSON`), and the Android keystore, properties
109
+ (`IOS_DISTRIBUTION_P12_BASE64`, `IOS_DISTRIBUTION_CERT_META_BASE64`,
110
+ `IOS_DISTRIBUTION_CERT_REGISTRY_BASE64`), and the Android keystore, properties
105
111
  and Play service account into an ephemeral runner checkout.
106
112
  Tracked `creds/` files remain a backward-compatible fallback.
107
113
  - Backend runtime credentials use the single encrypted `BACKEND_RUNTIME_ENV`
package/README.md CHANGED
@@ -45,10 +45,12 @@ the reusable Apple Distribution identity:
45
45
 
46
46
  - `ASC_KEY_P8`, `ASC_KEY_ID`, `ASC_ISSUER_ID`
47
47
  - `IOS_DISTRIBUTION_P12_BASE64`
48
- - `IOS_DISTRIBUTION_CERT_META_JSON`
49
- - `IOS_DISTRIBUTION_CERT_REGISTRY_JSON`
48
+ - `IOS_DISTRIBUTION_CERT_META_BASE64`
49
+ - `IOS_DISTRIBUTION_CERT_REGISTRY_BASE64`
50
50
 
51
- The workflow materializes them with mode `0600` only in the ephemeral runner
51
+ All three distribution-identity values are base64 so their exact bytes—and the
52
+ registry digests over those bytes—survive the secret round trip. The workflow
53
+ materializes them with mode `0600` only in the ephemeral runner
52
54
  checkout. Secret-backed signing files are never staged or committed by the
53
55
  composite action. The distribution identity files come from the signing
54
56
  registry provisioner; pass their paths to the secret tooling without printing
@@ -641,6 +643,14 @@ retains its own symbols precisely so that this half-updated state is safe.
641
643
 
642
644
  The iOS composite action runs on `macos-15` and:
643
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
+
644
654
  1. **Auto-detects** your `.xcodeproj` / `.xcworkspace`, scheme, bundle ID,
645
655
  and `team_id` (from the ASC API key). No `ci.config.yaml` required —
646
656
  override via action inputs only if auto-detection fails.
@@ -657,7 +667,7 @@ The iOS composite action runs on `macos-15` and:
657
667
  `CI-<bundle_id>`. Patches the `.pbxproj` to use Manual signing against those
658
668
  profiles.
659
669
  6. **Archives** with `xcodebuild archive`, exports the IPA, and uploads via
660
- `xcrun altool`.
670
+ Apple's build-upload REST API through `APPLE_STORE_PROXY_URL`.
661
671
  7. **Sets "What's New"** on every declared localization (reads
662
672
  `fastlane/metadata/ios/<locale>/release_notes.txt` if present, or from the
663
673
  `app-store-whats-new` input).
@@ -1 +1 @@
1
- 1.0.56
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 xcrun / altool
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 where xcrun/altool expect it (~/.appstoreconnect/private_keys).
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
- echo "Uploading $IPA"
814
- LOG="$RUNNER_TEMP/altool.log"
815
- set +e
816
- xcrun altool --upload-app \
817
- --type ios \
818
- --file "$IPA" \
819
- --apiKey "$ASC_KEY_ID" \
820
- --apiIssuer "$ASC_ISSUER_ID" \
821
- --output-format normal 2>&1 | tee "$LOG"
822
- ALTOOL_RC=${PIPESTATUS[0]}
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
- f"attempts: {exc!r}"
167
+ "attempts through the configured proxy"
166
168
  )
167
169
  delay = backoffs[attempt]
168
170
  print(
169
- f"ASC {method} {path} network error ({exc!r}); "
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 xcrun / altool",
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()
@@ -38,6 +38,7 @@ class _OkResponse:
38
38
  return {"data": []}
39
39
 
40
40
 
41
+ @mock.patch.dict(os.environ, {"APPLE_STORE_PROXY_URL": "http://proxy.test:1234"})
41
42
  class RequestTimeoutTests(unittest.TestCase):
42
43
  """``request()`` must pass an explicit ``(connect, read)`` timeout."""
43
44
 
@@ -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.56
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 = requests.post(url, headers=headers, json={}, timeout=30)
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 = requests.delete(delete_url, headers=headers, timeout=30)
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
- main()
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 = requests.post(
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 = requests.get(
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
- requests.delete(
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(f"::warning::Could not read existing Play versionCodes ({exc}); "
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.56
1
+ 1.0.58
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.56",
3
+ "version": "1.0.58",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/install.mjs CHANGED
@@ -213,8 +213,8 @@ function printSummary() {
213
213
  console.log('Next steps:');
214
214
  console.log(' Recommended: configure encrypted Actions secrets:');
215
215
  console.log(' ASC_KEY_P8, ASC_KEY_ID, ASC_ISSUER_ID');
216
- console.log(' IOS_DISTRIBUTION_P12_BASE64, IOS_DISTRIBUTION_CERT_META_JSON');
217
- console.log(' IOS_DISTRIBUTION_CERT_REGISTRY_JSON');
216
+ console.log(' IOS_DISTRIBUTION_P12_BASE64, IOS_DISTRIBUTION_CERT_META_BASE64');
217
+ console.log(' IOS_DISTRIBUTION_CERT_REGISTRY_BASE64');
218
218
  console.log(' ANDROID_UPLOAD_KEY_BASE64, ANDROID_SIGNING_PROPERTIES');
219
219
  console.log(' GOOGLE_PLAY_SERVICE_ACCOUNT_JSON');
220
220
  console.log(' The workflow materializes them with mode 0600 only on its ephemeral runner.');
@@ -10,6 +10,7 @@ on:
10
10
  - 'pubspec.lock'
11
11
  - 'l10n.yaml'
12
12
  - 'analysis_options.yaml'
13
+ - '.github/actions/*/.daemux-version'
13
14
  - '.github/workflows/deploy-web.yml'
14
15
  workflow_dispatch:
15
16
 
@@ -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
@@ -43,8 +46,8 @@ jobs:
43
46
  CI_ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
44
47
  CI_ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
45
48
  CI_DISTRIBUTION_P12: ${{ secrets.IOS_DISTRIBUTION_P12_BASE64 }}
46
- CI_DISTRIBUTION_META: ${{ secrets.IOS_DISTRIBUTION_CERT_META_JSON }}
47
- CI_DISTRIBUTION_REGISTRY: ${{ secrets.IOS_DISTRIBUTION_CERT_REGISTRY_JSON }}
49
+ CI_DISTRIBUTION_META: ${{ secrets.IOS_DISTRIBUTION_CERT_META_BASE64 }}
50
+ CI_DISTRIBUTION_REGISTRY: ${{ secrets.IOS_DISTRIBUTION_CERT_REGISTRY_BASE64 }}
48
51
  run: |
49
52
  umask 077
50
53
  mkdir -p creds
@@ -59,11 +62,11 @@ jobs:
59
62
  exit 0
60
63
  fi
61
64
  : "${CI_DISTRIBUTION_P12:?IOS_DISTRIBUTION_P12_BASE64 secret is required}"
62
- : "${CI_DISTRIBUTION_META:?IOS_DISTRIBUTION_CERT_META_JSON secret is required}"
63
- : "${CI_DISTRIBUTION_REGISTRY:?IOS_DISTRIBUTION_CERT_REGISTRY_JSON secret is required}"
65
+ : "${CI_DISTRIBUTION_META:?IOS_DISTRIBUTION_CERT_META_BASE64 secret is required}"
66
+ : "${CI_DISTRIBUTION_REGISTRY:?IOS_DISTRIBUTION_CERT_REGISTRY_BASE64 secret is required}"
64
67
  printf '%s' "$CI_DISTRIBUTION_P12" | base64 -D > creds/cert.p12
65
- printf '%s' "$CI_DISTRIBUTION_META" > creds/cert.meta.json
66
- printf '%s' "$CI_DISTRIBUTION_REGISTRY" > creds/cert.registry.json
68
+ printf '%s' "$CI_DISTRIBUTION_META" | base64 -D > creds/cert.meta.json
69
+ printf '%s' "$CI_DISTRIBUTION_REGISTRY" | base64 -D > creds/cert.registry.json
67
70
  echo "source=encrypted" >> "$GITHUB_OUTPUT"
68
71
  - name: Detect Flutter
69
72
  id: flutter
@@ -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 }}