gowalk-cicd 1.0.62 → 1.0.64
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 +3 -1
- package/README.md +22 -6
- package/action/.daemux-version +1 -1
- package/action/action.yml +13 -0
- package/action/scripts/next_build_number.py +35 -0
- package/action/scripts/test_next_build_number.py +33 -0
- package/action/scripts/test_upload_build.py +163 -5
- package/action/scripts/upload_build.py +67 -36
- package/action/scripts/upload_receipt.py +70 -0
- package/android-action/.daemux-version +1 -1
- package/android-action/scripts/crashlytics_symbols.py +3 -5
- package/android-action/scripts/jvm_proxy_relay.py +7 -0
- package/android-action/scripts/play_store_proxy.py +3 -3
- package/android-action/scripts/test_crashlytics_symbols.py +1 -1
- package/android-action/scripts/test_crashlytics_symbols_proxy.py +52 -0
- package/android-action/scripts/test_jvm_proxy_relay.py +22 -0
- package/android-action/scripts/test_play_store_proxy.py +3 -2
- package/backend-action/.daemux-version +1 -1
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -123,7 +123,9 @@ node /path/to/gowalk-cicd/bin/cli.mjs
|
|
|
123
123
|
`certificate-cap-policy` accepts only `fail`; a full cap requires registry reconciliation.
|
|
124
124
|
- Keep preinstalled Android SDK/NDKs. Every Gradle test/build and SDK download runs
|
|
125
125
|
under `play_store_proxy.py --`, whose owned loopback relay gives Java an explicit
|
|
126
|
-
authenticated account exit without credentials in JVM options.
|
|
126
|
+
authenticated account exit without credentials in JVM options. The child HTTP_PROXY
|
|
127
|
+
and HTTPS_PROXY variables also name the credential-free loopback relay because
|
|
128
|
+
Crashlytics Buildtools reads them first and rejects authenticated proxy URLs. HTTPS is tunneled
|
|
127
129
|
without TLS interception; no upstream failure may fall back to the destination.
|
|
128
130
|
The wrapper disables Gradle daemons and closes its relay when the command ends.
|
|
129
131
|
- Backend runtime credentials use the single encrypted `BACKEND_RUNTIME_ENV`
|
package/README.md
CHANGED
|
@@ -280,6 +280,7 @@ the situation and whose **message** is one line of JSON carrying a versioned
|
|
|
280
280
|
| `play_review_pending` | `gowalk-cicd/play-review-pending.v1` | `package`, `track`, `version_code` |
|
|
281
281
|
| `store_version_locked` | `gowalk-cicd/store-version-locked.v1` | `version`, `state`, `build_number` |
|
|
282
282
|
| `firebase_symbols_pending` | `gowalk-cicd/firebase-symbols-pending.v1` | `platform`, `app_id`, `source_sha`, `artifact`, `file`, `sha256`, `status` |
|
|
283
|
+
| `apple_build_upload_failed` (error) | `gowalk-cicd/apple-build-upload-failed.v2` | `category`, `stage`, `receipt_available`, receipt identifiers (`cfBundleVersion`, `upload_id`, `file_id`, `state`, `provider_status`, `provider_code`, `provider_pointer`), `action` |
|
|
283
284
|
|
|
284
285
|
Add a field by bumping the schema version; never change the meaning of an
|
|
285
286
|
existing one.
|
|
@@ -965,13 +966,28 @@ MIT
|
|
|
965
966
|
|
|
966
967
|
### Recovering an Apple REST upload failure
|
|
967
968
|
|
|
968
|
-
The uploader
|
|
969
|
-
|
|
969
|
+
The uploader writes its receipt (`apple-upload-<run>-<attempt>`, schema
|
|
970
|
+
`gowalk-cicd/apple-build-upload-receipt.v1`) after every stage — `identity` (local
|
|
971
|
+
SHA-256, file name/size, bundle and versions), `app_verified`, `upload_reserved`
|
|
972
|
+
(`upload_id`), `file_reserved` (`file_id`), `transferred`, `processing` and `complete`
|
|
973
|
+
(`build_id`) — so a job that fails at any point still names what the provider holds. It
|
|
974
|
+
commits completed part transfers with `uploaded: true`, following Apple's
|
|
970
975
|
[BuildUpload walkthrough](https://developer.apple.com/videos/play/wwdc2025/324/).
|
|
971
976
|
The IPA endpoint rejected an optional `sourceFileChecksums` SHA_256 object even though
|
|
972
977
|
the generic schema lists that algorithm; the uploader no longer sends that attribute.
|
|
973
978
|
A completed file is reused only when its content-derived filename and size match, and
|
|
974
|
-
any returned file checksum is verified.
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
979
|
+
any returned file checksum is verified.
|
|
980
|
+
|
|
981
|
+
A failed upload emits the typed `apple_build_upload_failed` error annotation with schema
|
|
982
|
+
`gowalk-cicd/apple-build-upload-failed.v2`: `category` (`proxy_missing`, `ipa_invalid`,
|
|
983
|
+
`configuration_missing`, `app_mismatch`, `provider_request_failed`, `upload_identity_conflict`,
|
|
984
|
+
`upload_destination_invalid`, `ipa_changed`, `part_refused`, `processing_failed`,
|
|
985
|
+
`processing_pending`, or a fallback such as `local_io`), `stage`, `receipt_available` and
|
|
986
|
+
the receipt's own identifiers (`cfBundleVersion`, `upload_id`, `file_id`, `state`,
|
|
987
|
+
`provider_status`, `provider_code`, `provider_pointer`). Exception text is never
|
|
988
|
+
published. The job also retains the exact exported IPA as `apple-ipa-<run>-<attempt>`
|
|
989
|
+
(14 days) so a continuation can complete that same upload identity instead of rebuilding
|
|
990
|
+
different bytes under the same number. Build numbers come from `/builds`,
|
|
991
|
+
`/preReleaseVersions` **and** `/apps/{id}/buildUploads`, so an unfinished or refused
|
|
992
|
+
reservation is never reused; delete such a reservation only after reading it back.
|
|
993
|
+
All requests and parts still use the Apple proxy.
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.64
|
package/action/action.yml
CHANGED
|
@@ -821,6 +821,7 @@ runs:
|
|
|
821
821
|
if-no-files-found: ignore
|
|
822
822
|
|
|
823
823
|
- name: Upload to TestFlight
|
|
824
|
+
id: upload
|
|
824
825
|
if: ${{ inputs.archive == 'true' && inputs.upload == 'true' }}
|
|
825
826
|
shell: bash
|
|
826
827
|
env:
|
|
@@ -838,6 +839,18 @@ runs:
|
|
|
838
839
|
path: ${{ runner.temp }}/apple-build-upload.json
|
|
839
840
|
if-no-files-found: ignore
|
|
840
841
|
|
|
842
|
+
# A failed REST upload keeps the exact exported IPA next to its receipt, so a
|
|
843
|
+
# continuation can resume or complete that same upload identity instead of
|
|
844
|
+
# rebuilding different bytes under the number the receipt names.
|
|
845
|
+
- name: Retain the exported IPA for upload recovery
|
|
846
|
+
if: ${{ always() && inputs.archive == 'true' && inputs.upload == 'true' && steps.upload.outcome != 'success' }}
|
|
847
|
+
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
|
|
848
|
+
with:
|
|
849
|
+
name: apple-ipa-${{ github.run_id }}-${{ github.run_attempt }}
|
|
850
|
+
path: ${{ runner.temp }}/export/*.ipa
|
|
851
|
+
if-no-files-found: ignore
|
|
852
|
+
retention-days: 14
|
|
853
|
+
|
|
841
854
|
# Both App Store metadata writers are skipped while an App Store version
|
|
842
855
|
# is locked (STORE_VERSION_LOCKED, the TESTFLIGHT_ONLY decision): there is
|
|
843
856
|
# no editable version id to write to, and each script requires one.
|
|
@@ -20,6 +20,12 @@ to fail with::
|
|
|
20
20
|
So we union build versions from BOTH ``/builds`` and
|
|
21
21
|
``/preReleaseVersions?include=builds`` before taking the max.
|
|
22
22
|
|
|
23
|
+
A REST build upload reserves its number the moment ``/buildUploads`` is
|
|
24
|
+
created. A reservation whose transfer or commit never finished (state
|
|
25
|
+
AWAITING_UPLOAD or PROCESSING, or one Apple refused) appears in neither
|
|
26
|
+
list above, yet the uploader must never reuse that number for different
|
|
27
|
+
IPA bytes, so ``/apps/{id}/buildUploads`` is the third source.
|
|
28
|
+
|
|
23
29
|
Environment
|
|
24
30
|
-----------
|
|
25
31
|
ASC_KEY_ID API key ID
|
|
@@ -121,6 +127,34 @@ def fetch_prerelease_versions(app_id: str, token: str) -> set[int]:
|
|
|
121
127
|
return {n for n in map(_build_version_int, builds) if n is not None}
|
|
122
128
|
|
|
123
129
|
|
|
130
|
+
def _upload_version_int(upload: dict) -> int | None:
|
|
131
|
+
try:
|
|
132
|
+
return int((upload.get("attributes") or {}).get("cfBundleVersion"))
|
|
133
|
+
except (TypeError, ValueError):
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def fetch_build_upload_versions(app_id: str, token: str) -> set[int]:
|
|
138
|
+
"""Versions reserved by REST build uploads (``/apps/{id}/buildUploads``), any state.
|
|
139
|
+
|
|
140
|
+
Every reservation counts, finished or not: a fresh number is always safe,
|
|
141
|
+
while reusing one that an unfinished or refused upload still holds makes
|
|
142
|
+
the next run collide with that upload identity.
|
|
143
|
+
"""
|
|
144
|
+
data = get_json(
|
|
145
|
+
f"/apps/{app_id}/buildUploads",
|
|
146
|
+
token,
|
|
147
|
+
params={"filter[platform]": "IOS", "limit": "200"},
|
|
148
|
+
)
|
|
149
|
+
uploads = data.get("data", []) or []
|
|
150
|
+
print(f"ASC /buildUploads: scanning {len(uploads)} upload reservations", file=sys.stderr)
|
|
151
|
+
for upload in uploads[:3]:
|
|
152
|
+
attrs = upload.get("attributes") or {}
|
|
153
|
+
state = (attrs.get("state") or {}).get("state") if isinstance(attrs.get("state"), dict) else attrs.get("state")
|
|
154
|
+
print(f"ASC upload reservation: v={attrs.get('cfBundleVersion')!r} state={state!r}", file=sys.stderr)
|
|
155
|
+
return {n for n in map(_upload_version_int, uploads) if n is not None}
|
|
156
|
+
|
|
157
|
+
|
|
124
158
|
def _read_pbxproj_build_number() -> int | None:
|
|
125
159
|
"""Best-effort local fallback: read CURRENT_PROJECT_VERSION from pbxproj.
|
|
126
160
|
|
|
@@ -162,6 +196,7 @@ def resolve_next_build_number(app_id: str, token: str) -> int:
|
|
|
162
196
|
"""Aggregate every known source of uploaded build versions."""
|
|
163
197
|
versions = fetch_build_versions(app_id, token)
|
|
164
198
|
versions |= fetch_prerelease_versions(app_id, token)
|
|
199
|
+
versions |= fetch_build_upload_versions(app_id, token)
|
|
165
200
|
highest = max(versions, default=0)
|
|
166
201
|
print(f"ASC highest build version seen: {highest}", file=sys.stderr)
|
|
167
202
|
return highest + 1
|
|
@@ -83,12 +83,45 @@ class FetchPreReleaseVersionsTests(unittest.TestCase):
|
|
|
83
83
|
self.assertEqual(got, set())
|
|
84
84
|
|
|
85
85
|
|
|
86
|
+
class FetchBuildUploadVersionsTests(unittest.TestCase):
|
|
87
|
+
def _upload(self, version: str, state: str) -> dict:
|
|
88
|
+
return {"type": "buildUploads", "attributes": {"cfBundleVersion": version, "platform": "IOS",
|
|
89
|
+
"state": {"state": state}}}
|
|
90
|
+
|
|
91
|
+
def test_every_reservation_counts_whatever_its_state(self):
|
|
92
|
+
payload = {"data": [self._upload("2", "AWAITING_UPLOAD"), self._upload("3", "FAILED"),
|
|
93
|
+
self._upload("4", "COMPLETE"), self._upload("x", "PROCESSING")]}
|
|
94
|
+
with mock.patch.object(nb, "get_json", return_value=payload) as mget:
|
|
95
|
+
got = nb.fetch_build_upload_versions("111", "tok")
|
|
96
|
+
self.assertEqual(got, {2, 3, 4})
|
|
97
|
+
args, kwargs = mget.call_args
|
|
98
|
+
self.assertEqual(args[0], "/apps/111/buildUploads")
|
|
99
|
+
self.assertEqual(kwargs["params"]["filter[platform]"], "IOS")
|
|
100
|
+
|
|
101
|
+
def test_no_reservations_is_empty(self):
|
|
102
|
+
with mock.patch.object(nb, "get_json", return_value={"data": []}):
|
|
103
|
+
self.assertEqual(nb.fetch_build_upload_versions("111", "tok"), set())
|
|
104
|
+
|
|
105
|
+
|
|
86
106
|
class ResolveNextBuildNumberTests(unittest.TestCase):
|
|
107
|
+
def setUp(self):
|
|
108
|
+
patcher = mock.patch.object(nb, "fetch_build_upload_versions", return_value=set())
|
|
109
|
+
self.uploads = patcher.start()
|
|
110
|
+
self.addCleanup(patcher.stop)
|
|
111
|
+
|
|
87
112
|
def test_max_across_both_sources_plus_one(self):
|
|
88
113
|
with mock.patch.object(nb, "fetch_build_versions", return_value={1, 2}), \
|
|
89
114
|
mock.patch.object(nb, "fetch_prerelease_versions", return_value={5}):
|
|
90
115
|
self.assertEqual(nb.resolve_next_build_number("111", "tok"), 6)
|
|
91
116
|
|
|
117
|
+
def test_unfinished_upload_reservation_is_never_reused(self):
|
|
118
|
+
# The exact production collision: build 1 processed, build 2 reserved by a REST
|
|
119
|
+
# upload whose commit was refused, so /builds and /preReleaseVersions know only 1.
|
|
120
|
+
self.uploads.return_value = {2}
|
|
121
|
+
with mock.patch.object(nb, "fetch_build_versions", return_value={1}), \
|
|
122
|
+
mock.patch.object(nb, "fetch_prerelease_versions", return_value={1}):
|
|
123
|
+
self.assertEqual(nb.resolve_next_build_number("111", "tok"), 3)
|
|
124
|
+
|
|
92
125
|
def test_prerelease_fills_gap_when_builds_empty(self):
|
|
93
126
|
# Simulates the exact bug: /builds misses the fresh upload but
|
|
94
127
|
# /preReleaseVersions still sees it.
|
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
"""REST IPA upload identity, resume, complete byte coverage and proxy-only transfer."""
|
|
2
2
|
import hashlib
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
3
5
|
import os
|
|
4
6
|
from pathlib import Path
|
|
5
7
|
import plistlib
|
|
6
8
|
import sys
|
|
7
9
|
import tempfile
|
|
8
10
|
import unittest
|
|
11
|
+
from contextlib import redirect_stderr, redirect_stdout
|
|
9
12
|
from unittest import mock
|
|
10
13
|
import zipfile
|
|
11
14
|
|
|
12
15
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
13
16
|
import upload_build
|
|
17
|
+
import upload_receipt
|
|
18
|
+
from upload_receipt import UploadError
|
|
14
19
|
|
|
15
20
|
|
|
16
21
|
class BuildUploadTests(unittest.TestCase):
|
|
@@ -37,9 +42,11 @@ class BuildUploadTests(unittest.TestCase):
|
|
|
37
42
|
existing = {"data": [{"id": "file", "attributes": {
|
|
38
43
|
"assetType": "ASSET", "fileName": "other.ipa", "fileSize": 10}}]}
|
|
39
44
|
with mock.patch.object(upload_build, "api", return_value=existing) as api:
|
|
40
|
-
with self.assertRaises(
|
|
45
|
+
with self.assertRaises(UploadError) as caught:
|
|
41
46
|
upload_build.upload_file({"id": "upload"}, {"fileName": "wanted.ipa", "fileSize": 10})
|
|
42
47
|
self.assertEqual(api.call_count, 1)
|
|
48
|
+
self.assertEqual(caught.exception.category, "upload_identity_conflict")
|
|
49
|
+
self.assertEqual(caught.exception.details, {"upload_id": "upload", "file_id": "file"})
|
|
43
50
|
|
|
44
51
|
def test_part_transfer_uses_proxy_without_asc_bearer(self):
|
|
45
52
|
with tempfile.TemporaryDirectory() as temporary:
|
|
@@ -57,7 +64,7 @@ class BuildUploadTests(unittest.TestCase):
|
|
|
57
64
|
def test_missing_parts_never_mark_a_file_uploaded(self):
|
|
58
65
|
file = {"id": "file", "attributes": {"uploadOperations": [{"offset": 0, "length": 4}]}}
|
|
59
66
|
with mock.patch.object(upload_build, "api") as api, mock.patch.object(upload_build, "transfer") as transfer:
|
|
60
|
-
with self.assertRaises(
|
|
67
|
+
with self.assertRaises(UploadError):
|
|
61
68
|
upload_build.transfer_all(Path("unused"), file, {"fileSize": 5, "sha256": "hash"})
|
|
62
69
|
api.assert_not_called()
|
|
63
70
|
transfer.assert_not_called()
|
|
@@ -88,11 +95,15 @@ class BuildDeliveryTests(unittest.TestCase):
|
|
|
88
95
|
with mock.patch.object(upload_build, "identity", return_value={"bundle_id": "com.expected.app"}), \
|
|
89
96
|
mock.patch.object(upload_build, "api", return_value={"data": {"attributes": {
|
|
90
97
|
"bundleId": "com.other.app"}}}) as api, \
|
|
91
|
-
mock.patch.object(upload_build, "upload_record") as create
|
|
92
|
-
|
|
93
|
-
|
|
98
|
+
mock.patch.object(upload_build, "upload_record") as create, \
|
|
99
|
+
tempfile.TemporaryDirectory() as temporary:
|
|
100
|
+
report = Path(temporary) / "receipt.json"
|
|
101
|
+
with self.assertRaises(UploadError) as caught:
|
|
102
|
+
upload_build.deliver(Path("unused"), report, 1)
|
|
94
103
|
self.assertEqual(api.call_count, 1)
|
|
95
104
|
create.assert_not_called()
|
|
105
|
+
self.assertEqual(caught.exception.category, "app_mismatch")
|
|
106
|
+
self.assertEqual(json.loads(report.read_text())["stage"], "identity")
|
|
96
107
|
|
|
97
108
|
@mock.patch.dict(os.environ, {"APP_STORE_APPLE_ID": "app", "APPLE_STORE_PROXY_URL": "http://proxy.test:1234"})
|
|
98
109
|
def test_completed_readback_retains_provider_build_identity(self):
|
|
@@ -113,5 +124,152 @@ class BuildDeliveryTests(unittest.TestCase):
|
|
|
113
124
|
self.assertEqual(api.call_args.kwargs["params"], {"include": "build"})
|
|
114
125
|
|
|
115
126
|
|
|
127
|
+
IDENTITY = {"bundle_id": "com.expected.app", "cfBundleVersion": "2", "cfBundleShortVersionString": "1.0.0",
|
|
128
|
+
"sha256": "ab" * 32, "fileName": "ab" * 32 + ".ipa", "fileSize": 10}
|
|
129
|
+
ENV = {"APP_STORE_APPLE_ID": "app", "APPLE_STORE_PROXY_URL": "http://proxy.test:1234"}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class ReceiptStageTests(unittest.TestCase):
|
|
133
|
+
"""The receipt exists from the first local identity on and names every provider id it learns."""
|
|
134
|
+
|
|
135
|
+
@mock.patch.dict(os.environ, ENV)
|
|
136
|
+
def test_identity_is_written_before_the_first_provider_request(self):
|
|
137
|
+
with tempfile.TemporaryDirectory() as temporary, \
|
|
138
|
+
mock.patch.object(upload_build, "identity", return_value=dict(IDENTITY)), \
|
|
139
|
+
mock.patch.object(upload_build, "api", side_effect=SystemExit("ASC GET failed: 503 body")):
|
|
140
|
+
report = Path(temporary) / "receipt.json"
|
|
141
|
+
with self.assertRaises(SystemExit):
|
|
142
|
+
upload_build.deliver(Path("unused"), report, 1)
|
|
143
|
+
receipt = json.loads(report.read_text())
|
|
144
|
+
self.assertEqual(receipt["stage"], "identity")
|
|
145
|
+
self.assertEqual(receipt["sha256"], IDENTITY["sha256"])
|
|
146
|
+
self.assertEqual(receipt["cfBundleVersion"], "2")
|
|
147
|
+
self.assertTrue(receipt["proxy_required"])
|
|
148
|
+
|
|
149
|
+
@mock.patch.dict(os.environ, ENV)
|
|
150
|
+
def test_upload_id_is_written_before_the_file_is_reserved(self):
|
|
151
|
+
with tempfile.TemporaryDirectory() as temporary, \
|
|
152
|
+
mock.patch.object(upload_build, "identity", return_value=dict(IDENTITY)), \
|
|
153
|
+
mock.patch.object(upload_build, "api", return_value={"data": {"attributes": {
|
|
154
|
+
"bundleId": "com.expected.app"}}}), \
|
|
155
|
+
mock.patch.object(upload_build, "upload_record", return_value={"id": "upload-1"}), \
|
|
156
|
+
mock.patch.object(upload_build, "upload_file", side_effect=UploadError(
|
|
157
|
+
"upload_identity_conflict", "different bytes", upload_id="upload-1", file_id="file-old")):
|
|
158
|
+
report = Path(temporary) / "receipt.json"
|
|
159
|
+
with self.assertRaises(UploadError):
|
|
160
|
+
upload_build.deliver(Path("unused"), report, 1)
|
|
161
|
+
receipt = json.loads(report.read_text())
|
|
162
|
+
self.assertEqual(receipt["stage"], "upload_reserved")
|
|
163
|
+
self.assertEqual(receipt["upload_id"], "upload-1")
|
|
164
|
+
self.assertEqual(receipt["app_id"], "app")
|
|
165
|
+
|
|
166
|
+
@mock.patch.dict(os.environ, {"APP_STORE_APPLE_ID": "app"}, clear=False)
|
|
167
|
+
def test_missing_proxy_is_classified_without_any_request(self):
|
|
168
|
+
with tempfile.TemporaryDirectory() as temporary, \
|
|
169
|
+
mock.patch.dict(os.environ, {"APPLE_STORE_PROXY_URL": ""}), \
|
|
170
|
+
mock.patch.object(upload_build, "api") as api:
|
|
171
|
+
with self.assertRaises(UploadError) as caught:
|
|
172
|
+
upload_build.deliver(Path("unused"), Path(temporary) / "receipt.json", 1)
|
|
173
|
+
api.assert_not_called()
|
|
174
|
+
self.assertEqual(caught.exception.category, "proxy_missing")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class FailureAnnotationTests(unittest.TestCase):
|
|
178
|
+
"""The typed annotation carries the category and identifiers, never exception text."""
|
|
179
|
+
|
|
180
|
+
def run_main(self, exc: BaseException, report: Path) -> tuple[dict, str, str]:
|
|
181
|
+
out, err = io.StringIO(), io.StringIO()
|
|
182
|
+
argv = ["upload_build.py", "app.ipa", "--report", str(report)]
|
|
183
|
+
with mock.patch.object(upload_build, "deliver", side_effect=exc), \
|
|
184
|
+
mock.patch.object(sys, "argv", argv), redirect_stdout(out), redirect_stderr(err):
|
|
185
|
+
with self.assertRaises(SystemExit) as caught:
|
|
186
|
+
upload_build.main()
|
|
187
|
+
self.assertIn("retain its receipt and IPA", str(caught.exception.code))
|
|
188
|
+
prefix = "::error title=apple_build_upload_failed::"
|
|
189
|
+
line = next(row for row in out.getvalue().splitlines() if row.startswith(prefix))
|
|
190
|
+
return json.loads(line.split("::", 2)[2]), out.getvalue(), str(caught.exception.code)
|
|
191
|
+
|
|
192
|
+
def test_provider_exit_text_stays_out_of_the_annotation_and_receipt(self):
|
|
193
|
+
with tempfile.TemporaryDirectory() as temporary:
|
|
194
|
+
report = Path(temporary) / "receipt.json"
|
|
195
|
+
report.write_text(json.dumps({"stage": "file_reserved", "upload_id": "upload-1", "file_id": "file-1",
|
|
196
|
+
"cfBundleVersion": "2"}))
|
|
197
|
+
secret = "Bearer eyJ-secret-token-value"
|
|
198
|
+
failure, out, err = self.run_main(SystemExit(f"ASC PATCH /x failed: 409 {secret}"), report)
|
|
199
|
+
receipt = json.loads(report.read_text())
|
|
200
|
+
self.assertEqual(failure["schema"], "gowalk-cicd/apple-build-upload-failed.v2")
|
|
201
|
+
self.assertEqual((failure["category"], failure["stage"]), ("provider_request_failed", "file_reserved"))
|
|
202
|
+
self.assertEqual((failure["upload_id"], failure["file_id"], failure["cfBundleVersion"]),
|
|
203
|
+
("upload-1", "file-1", "2"))
|
|
204
|
+
self.assertTrue(failure["receipt_available"])
|
|
205
|
+
self.assertEqual(receipt["failure"], {"category": "provider_request_failed", "exception": "SystemExit"})
|
|
206
|
+
for text in (out, err, json.dumps(receipt)):
|
|
207
|
+
self.assertNotIn("secret-token-value", text)
|
|
208
|
+
self.assertNotIn("ASC PATCH", text)
|
|
209
|
+
|
|
210
|
+
def test_classified_details_are_published(self):
|
|
211
|
+
with tempfile.TemporaryDirectory() as temporary:
|
|
212
|
+
report = Path(temporary) / "receipt.json"
|
|
213
|
+
exc = UploadError("provider_request_failed", "refused", provider_status=409,
|
|
214
|
+
provider_code="ENTITY_ERROR.ATTRIBUTE.INVALID",
|
|
215
|
+
provider_pointer="/data/attributes/sourceFileChecksums")
|
|
216
|
+
failure, _, err = self.run_main(exc, report)
|
|
217
|
+
self.assertEqual(failure["provider_status"], 409)
|
|
218
|
+
self.assertEqual(failure["provider_code"], "ENTITY_ERROR.ATTRIBUTE.INVALID")
|
|
219
|
+
self.assertEqual(failure["stage"], "start")
|
|
220
|
+
self.assertTrue(failure["receipt_available"])
|
|
221
|
+
self.assertIn("provider_request_failed at stage start", err)
|
|
222
|
+
self.assertNotIn("refused", err)
|
|
223
|
+
|
|
224
|
+
def test_unknown_failures_fall_back_to_a_safe_category(self):
|
|
225
|
+
self.assertEqual(upload_receipt.classify(KeyError("data")), "provider_response_shape")
|
|
226
|
+
self.assertEqual(upload_receipt.classify(OSError("disk")), "local_io")
|
|
227
|
+
self.assertEqual(upload_receipt.classify(ValueError("x")), "invalid_state")
|
|
228
|
+
self.assertEqual(upload_receipt.classify(RuntimeError("x")), "upload_failed")
|
|
229
|
+
self.assertEqual(upload_receipt.classify(ZeroDivisionError()), "unexpected")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class ProviderRefusalTests(unittest.TestCase):
|
|
233
|
+
@mock.patch.dict(os.environ, {"ASC_KEY_ID": "k", "ASC_ISSUER_ID": "i", "ASC_KEY_PATH": "p"})
|
|
234
|
+
def test_provider_refusal_becomes_a_classified_error_with_the_apple_code(self):
|
|
235
|
+
response = mock.Mock(status_code=409)
|
|
236
|
+
response.json.return_value = {"errors": [{"code": "ENTITY_ERROR.ATTRIBUTE.INVALID",
|
|
237
|
+
"detail": "body text that must not leak",
|
|
238
|
+
"source": {"pointer": "/data/attributes/sourceFileChecksums"}}]}
|
|
239
|
+
with mock.patch.object(upload_build.asc_common, "make_jwt", return_value="jwt"), \
|
|
240
|
+
mock.patch.object(upload_build.asc_common, "request", return_value=response) as request:
|
|
241
|
+
with self.assertRaises(UploadError) as caught:
|
|
242
|
+
upload_build.api("PATCH", "/buildUploadFiles/f", {"data": {}})
|
|
243
|
+
self.assertEqual(caught.exception.category, "provider_request_failed")
|
|
244
|
+
self.assertEqual(caught.exception.details, {
|
|
245
|
+
"provider_status": 409, "provider_code": "ENTITY_ERROR.ATTRIBUTE.INVALID",
|
|
246
|
+
"provider_pointer": "/data/attributes/sourceFileChecksums"})
|
|
247
|
+
self.assertNotIn("body text", json.dumps(caught.exception.details))
|
|
248
|
+
self.assertEqual(request.call_args.kwargs["allow_status"], upload_build.CLASSIFIED_STATUSES)
|
|
249
|
+
self.assertEqual(request.call_args.kwargs["max_attempts"], 1)
|
|
250
|
+
|
|
251
|
+
def test_unreadable_ipa_is_classified(self):
|
|
252
|
+
with tempfile.TemporaryDirectory() as temporary:
|
|
253
|
+
ipa = Path(temporary) / "app.ipa"
|
|
254
|
+
ipa.write_bytes(b"not a zip")
|
|
255
|
+
with self.assertRaises(UploadError) as caught:
|
|
256
|
+
upload_build.identity(ipa)
|
|
257
|
+
self.assertEqual(caught.exception.category, "ipa_invalid")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class ActionWiringTests(unittest.TestCase):
|
|
261
|
+
def test_failed_upload_retains_the_exported_ipa_next_to_its_receipt(self):
|
|
262
|
+
text = (Path(__file__).resolve().parents[1] / "action.yml").read_text()
|
|
263
|
+
upload = text.index("- name: Upload to TestFlight")
|
|
264
|
+
self.assertIn("id: upload", text[upload:upload + 200])
|
|
265
|
+
retain = text.index("- name: Retain the exported IPA for upload recovery")
|
|
266
|
+
self.assertLess(text.index("- name: Retain Apple upload receipt"), retain)
|
|
267
|
+
block = text[retain:retain + 700]
|
|
268
|
+
self.assertIn("steps.upload.outcome != 'success'", block)
|
|
269
|
+
self.assertIn("name: apple-ipa-${{ github.run_id }}-${{ github.run_attempt }}", block)
|
|
270
|
+
self.assertIn("path: ${{ runner.temp }}/export/*.ipa", block)
|
|
271
|
+
self.assertIn("if-no-files-found: ignore", block)
|
|
272
|
+
|
|
273
|
+
|
|
116
274
|
if __name__ == "__main__":
|
|
117
275
|
unittest.main()
|
|
@@ -16,6 +16,12 @@ import zipfile
|
|
|
16
16
|
import asc_common
|
|
17
17
|
import apple_store_proxy as store_proxy
|
|
18
18
|
import upload_checksums
|
|
19
|
+
import upload_receipt
|
|
20
|
+
from upload_receipt import Receipt, UploadError
|
|
21
|
+
|
|
22
|
+
# Provider refusals the uploader classifies itself instead of letting the shared
|
|
23
|
+
# client exit with the response body in its message.
|
|
24
|
+
CLASSIFIED_STATUSES = {400, 401, 403, 404, 409, 412, 422}
|
|
19
25
|
|
|
20
26
|
|
|
21
27
|
def api(method: str, path: str, body=None, params=None) -> dict:
|
|
@@ -23,17 +29,29 @@ def api(method: str, path: str, body=None, params=None) -> dict:
|
|
|
23
29
|
os.environ["ASC_KEY_PATH"])
|
|
24
30
|
# An uncertain create is discovered from the provider on the next invocation,
|
|
25
31
|
# never repeated automatically under a second upload identity.
|
|
26
|
-
|
|
27
|
-
|
|
32
|
+
response = asc_common.request(method, path, token, json_body=body, params=params,
|
|
33
|
+
allow_status=CLASSIFIED_STATUSES, max_attempts=3 if method == "GET" else 1)
|
|
34
|
+
if response.status_code >= 400:
|
|
35
|
+
try:
|
|
36
|
+
error = (response.json().get("errors") or [{}])[0]
|
|
37
|
+
except ValueError:
|
|
38
|
+
error = {}
|
|
39
|
+
raise UploadError("provider_request_failed", f"Apple refused {method} {path}",
|
|
40
|
+
provider_status=response.status_code, provider_code=str(error.get("code") or ""),
|
|
41
|
+
provider_pointer=str((error.get("source") or {}).get("pointer") or ""))
|
|
42
|
+
return response.json()
|
|
28
43
|
|
|
29
44
|
|
|
30
45
|
def identity(ipa: Path) -> dict:
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
46
|
+
try:
|
|
47
|
+
with zipfile.ZipFile(ipa) as archive:
|
|
48
|
+
names = [name for name in archive.namelist()
|
|
49
|
+
if name.startswith("Payload/") and name.endswith(".app/Info.plist") and name.count("/") == 2]
|
|
50
|
+
if len(names) != 1:
|
|
51
|
+
raise UploadError("ipa_invalid", "IPA must contain one top-level app Info.plist")
|
|
52
|
+
info = plistlib.loads(archive.read(names[0]))
|
|
53
|
+
except (zipfile.BadZipFile, plistlib.InvalidFileException, KeyError) as exc:
|
|
54
|
+
raise UploadError("ipa_invalid", "IPA is not a readable app archive") from exc
|
|
37
55
|
with ipa.open("rb") as stream:
|
|
38
56
|
digest = hashlib.file_digest(stream, "sha256").hexdigest()
|
|
39
57
|
return {"bundle_id": str(info["CFBundleIdentifier"]),
|
|
@@ -49,7 +67,8 @@ def upload_record(app_id: str, info: dict) -> dict:
|
|
|
49
67
|
rows = api("GET", f"/apps/{app_id}/buildUploads", params=params).get("data") or []
|
|
50
68
|
rows = [row for row in rows if ((row.get("attributes") or {}).get("state") or {}).get("state") != "FAILED"]
|
|
51
69
|
if len(rows) > 1:
|
|
52
|
-
raise
|
|
70
|
+
raise UploadError("upload_identity_conflict",
|
|
71
|
+
"multiple matching Apple build uploads; resolve their provider state before retrying")
|
|
53
72
|
if rows:
|
|
54
73
|
return rows[0]
|
|
55
74
|
return api("POST", "/buildUploads", {"data": {
|
|
@@ -65,7 +84,9 @@ def upload_file(record: dict, info: dict) -> dict:
|
|
|
65
84
|
if assets:
|
|
66
85
|
if len(assets) != 1 or any(assets[0]["attributes"].get(key) != info[key]
|
|
67
86
|
for key in ("fileName", "fileSize")):
|
|
68
|
-
raise
|
|
87
|
+
raise UploadError("upload_identity_conflict",
|
|
88
|
+
"existing Apple build upload belongs to different IPA bytes; do not overwrite it",
|
|
89
|
+
upload_id=record["id"], file_id=str(assets[0].get("id") or ""))
|
|
69
90
|
return assets[0]
|
|
70
91
|
return api("POST", "/buildUploadFiles", {"data": {
|
|
71
92
|
"type": "buildUploadFiles", "attributes": {
|
|
@@ -78,19 +99,20 @@ def transfer(ipa: Path, operation: dict) -> None:
|
|
|
78
99
|
parsed = urlsplit(operation["url"])
|
|
79
100
|
hosts = (".apple.com", ".icloud.com", ".amazonaws.com")
|
|
80
101
|
if parsed.scheme != "https" or not any((parsed.hostname or "").endswith(host) for host in hosts):
|
|
81
|
-
raise
|
|
102
|
+
raise UploadError("upload_destination_invalid", "Apple returned an unsupported upload destination")
|
|
82
103
|
if operation.get("method") != "PUT":
|
|
83
|
-
raise
|
|
104
|
+
raise UploadError("upload_destination_invalid", "Apple returned an unsupported upload method")
|
|
84
105
|
with ipa.open("rb") as stream:
|
|
85
106
|
stream.seek(operation["offset"])
|
|
86
107
|
data = stream.read(operation["length"])
|
|
87
108
|
if len(data) != operation["length"]:
|
|
88
|
-
raise
|
|
109
|
+
raise UploadError("ipa_changed", "IPA changed during upload")
|
|
89
110
|
headers = {row["name"]: row["value"] for row in operation.get("requestHeaders") or []}
|
|
90
111
|
response = store_proxy.request("PUT", operation["url"], headers=headers, data=data,
|
|
91
112
|
timeout=(20, 300), allow_redirects=False)
|
|
92
113
|
if not 200 <= response.status_code < 300:
|
|
93
|
-
raise
|
|
114
|
+
raise UploadError("part_refused", "Apple upload part refused; read upload state before retrying",
|
|
115
|
+
provider_status=response.status_code)
|
|
94
116
|
|
|
95
117
|
|
|
96
118
|
def transfer_all(ipa: Path, file: dict, info: dict) -> None:
|
|
@@ -103,10 +125,10 @@ def transfer_all(ipa: Path, file: dict, info: dict) -> None:
|
|
|
103
125
|
offset = 0
|
|
104
126
|
for operation in operations:
|
|
105
127
|
if operation.get("offset") != offset or operation.get("length", 0) <= 0:
|
|
106
|
-
raise
|
|
128
|
+
raise UploadError("upload_destination_invalid", "Apple upload operations do not cover the IPA exactly")
|
|
107
129
|
offset += operation["length"]
|
|
108
130
|
if offset != info["fileSize"]:
|
|
109
|
-
raise
|
|
131
|
+
raise UploadError("upload_destination_invalid", "Apple upload operations do not cover the IPA exactly")
|
|
110
132
|
with ThreadPoolExecutor(max_workers=3) as executor:
|
|
111
133
|
results = [executor.submit(transfer, ipa, operation) for operation in operations]
|
|
112
134
|
for result in results:
|
|
@@ -120,30 +142,39 @@ def transfer_all(ipa: Path, file: dict, info: dict) -> None:
|
|
|
120
142
|
|
|
121
143
|
|
|
122
144
|
def deliver(ipa: Path, report: Path, timeout: int) -> dict:
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
145
|
+
receipt = Receipt(report)
|
|
146
|
+
try:
|
|
147
|
+
store_proxy.proxies() # Refuse before any identity or provider request.
|
|
148
|
+
except SystemExit as exc:
|
|
149
|
+
raise UploadError("proxy_missing", "the account-pinned Apple proxy is not configured") from exc
|
|
150
|
+
receipt.update("identity", **identity(ipa))
|
|
151
|
+
app_id = os.environ.get("APP_STORE_APPLE_ID") or ""
|
|
152
|
+
if not app_id:
|
|
153
|
+
raise UploadError("configuration_missing", "APP_STORE_APPLE_ID is empty")
|
|
126
154
|
app = api("GET", f"/apps/{app_id}")["data"]
|
|
127
|
-
if (app.get("attributes") or {}).get("bundleId") !=
|
|
128
|
-
raise
|
|
155
|
+
if (app.get("attributes") or {}).get("bundleId") != receipt.data["bundle_id"]:
|
|
156
|
+
raise UploadError("app_mismatch", "IPA bundle identity does not match the selected App Store app")
|
|
157
|
+
info = receipt.update("app_verified", app_id=app_id)
|
|
129
158
|
record = upload_record(app_id, info)
|
|
159
|
+
receipt.update("upload_reserved", upload_id=record["id"])
|
|
130
160
|
file = upload_file(record, info)
|
|
131
|
-
receipt
|
|
132
|
-
report.write_text(json.dumps(receipt, indent=2))
|
|
161
|
+
receipt.update("file_reserved", file_id=file["id"])
|
|
133
162
|
transfer_all(ipa, file, info)
|
|
163
|
+
receipt.update("transferred")
|
|
134
164
|
deadline = time.monotonic() + timeout
|
|
135
165
|
while True:
|
|
136
166
|
current = api("GET", f"/buildUploads/{record['id']}", params={"include": "build"})["data"]
|
|
137
167
|
state = ((current.get("attributes") or {}).get("state") or {}).get("state")
|
|
138
|
-
|
|
139
|
-
receipt["build_id"] = (((current.get("relationships") or {}).get("build") or {}).get("data") or {}).get("id")
|
|
140
|
-
report.write_text(json.dumps(receipt, indent=2))
|
|
168
|
+
build_id = (((current.get("relationships") or {}).get("build") or {}).get("data") or {}).get("id")
|
|
141
169
|
if state == "COMPLETE":
|
|
142
|
-
return receipt
|
|
170
|
+
return receipt.update("complete", state=state, build_id=build_id)
|
|
171
|
+
receipt.update("processing", state=state, build_id=build_id)
|
|
143
172
|
if state == "FAILED":
|
|
144
|
-
raise
|
|
173
|
+
raise UploadError("processing_failed",
|
|
174
|
+
"Apple build upload failed processing; inspect the upload ID's provider errors")
|
|
145
175
|
if time.monotonic() >= deadline:
|
|
146
|
-
raise
|
|
176
|
+
raise UploadError("processing_pending",
|
|
177
|
+
"Apple build processing is pending; reuse this upload ID on continuation")
|
|
147
178
|
time.sleep(10)
|
|
148
179
|
|
|
149
180
|
|
|
@@ -155,13 +186,13 @@ def main() -> None:
|
|
|
155
186
|
args = parser.parse_args()
|
|
156
187
|
try:
|
|
157
188
|
receipt = deliver(args.ipa, args.report, args.timeout)
|
|
158
|
-
except (OSError, ValueError, KeyError, RuntimeError, SystemExit):
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
189
|
+
except (OSError, ValueError, KeyError, RuntimeError, SystemExit) as exc:
|
|
190
|
+
# The receipt and annotation carry the classified category and identifiers only;
|
|
191
|
+
# exception text can quote provider bodies or transport details and stays out.
|
|
192
|
+
failure = upload_receipt.record_failure(args.report, exc)
|
|
193
|
+
print("::error title=apple_build_upload_failed::" + json.dumps(failure))
|
|
194
|
+
raise SystemExit(f"Apple REST upload did not complete ({failure['category']} at stage "
|
|
195
|
+
f"{failure['stage']}); retain its receipt and IPA and resolve the provider state") from None
|
|
165
196
|
print(json.dumps(receipt))
|
|
166
197
|
|
|
167
198
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Incremental Apple upload receipt and the typed, credential-free failure annotation."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
RECEIPT_SCHEMA = "gowalk-cicd/apple-build-upload-receipt.v1"
|
|
8
|
+
FAILURE_SCHEMA = "gowalk-cicd/apple-build-upload-failed.v2"
|
|
9
|
+
ACTION = "retain the IPA and receipt; read provider upload state before retrying"
|
|
10
|
+
# Safe receipt fields the annotation republishes so recovery needs no log parsing.
|
|
11
|
+
PUBLISHED = ("cfBundleVersion", "cfBundleShortVersionString", "upload_id", "file_id", "state",
|
|
12
|
+
"provider_status", "provider_code", "provider_pointer")
|
|
13
|
+
FALLBACK_CATEGORIES = ((SystemExit, "provider_request_failed"), (KeyError, "provider_response_shape"),
|
|
14
|
+
(OSError, "local_io"), (ValueError, "invalid_state"), (RuntimeError, "upload_failed"))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class UploadError(RuntimeError):
|
|
18
|
+
"""A classified failure: `category` and `details` are safe to publish; the message never is."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, category: str, message: str, **details):
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.category = category
|
|
23
|
+
self.details = details
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Receipt:
|
|
27
|
+
"""The upload receipt written after every stage so a failed job still explains itself."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, path: Path):
|
|
30
|
+
self.path = path
|
|
31
|
+
self.data = {"schema": RECEIPT_SCHEMA, "proxy_required": True, "stage": "start"}
|
|
32
|
+
if path.is_file():
|
|
33
|
+
try:
|
|
34
|
+
previous = json.loads(path.read_text())
|
|
35
|
+
except ValueError:
|
|
36
|
+
previous = None
|
|
37
|
+
if isinstance(previous, dict):
|
|
38
|
+
self.data.update(previous)
|
|
39
|
+
|
|
40
|
+
def update(self, stage: str, **fields) -> dict:
|
|
41
|
+
self.data.update(fields)
|
|
42
|
+
self.data["stage"] = stage
|
|
43
|
+
self.path.write_text(json.dumps(self.data, indent=2))
|
|
44
|
+
return dict(self.data)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def classify(exc: BaseException) -> str:
|
|
48
|
+
if isinstance(exc, UploadError):
|
|
49
|
+
return exc.category
|
|
50
|
+
for kind, category in FALLBACK_CATEGORIES:
|
|
51
|
+
if isinstance(exc, kind):
|
|
52
|
+
return category
|
|
53
|
+
return "unexpected"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def record_failure(report: Path, exc: BaseException) -> dict:
|
|
57
|
+
"""Write the classified failure into the receipt and return the annotation payload.
|
|
58
|
+
|
|
59
|
+
Neither carries exception text: provider bodies and transport errors may quote
|
|
60
|
+
request details, so only the category, stage, exception class and the receipt's
|
|
61
|
+
own identifiers are published.
|
|
62
|
+
"""
|
|
63
|
+
receipt = Receipt(report)
|
|
64
|
+
category = classify(exc)
|
|
65
|
+
details = dict(getattr(exc, "details", {}) or {})
|
|
66
|
+
data = receipt.update(receipt.data["stage"], **details,
|
|
67
|
+
failure={"category": category, "exception": type(exc).__name__})
|
|
68
|
+
published = {key: data[key] for key in PUBLISHED if data.get(key) not in (None, "")}
|
|
69
|
+
return {"schema": FAILURE_SCHEMA, "category": category, "stage": data["stage"],
|
|
70
|
+
"receipt_available": report.is_file(), **published, "action": ACTION}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.64
|
|
@@ -27,12 +27,11 @@ from __future__ import annotations
|
|
|
27
27
|
import argparse
|
|
28
28
|
import os
|
|
29
29
|
import re
|
|
30
|
-
import subprocess
|
|
31
30
|
import sys
|
|
32
31
|
import tempfile
|
|
33
32
|
from pathlib import Path
|
|
34
33
|
|
|
35
|
-
from play_store_proxy import
|
|
34
|
+
from play_store_proxy import run as run_proxied
|
|
36
35
|
|
|
37
36
|
# Exact pin, not a range: this job holds the upload keystore and the Play
|
|
38
37
|
# service account, so no floating third-party code runs in it. Bump on purpose.
|
|
@@ -99,10 +98,9 @@ def _default_run(cmd: list[str]) -> int:
|
|
|
99
98
|
# A scratch cwd: the Crashlytics buildtools drop a .crashlytics/ directory
|
|
100
99
|
# (dump_syms.bin, ~4 MB) into the working directory, which must not be the
|
|
101
100
|
# consumer's checkout.
|
|
102
|
-
env = environment()
|
|
103
101
|
try:
|
|
104
102
|
with tempfile.TemporaryDirectory(prefix="crashlytics-upload-") as workdir:
|
|
105
|
-
return
|
|
103
|
+
return run_proxied(cmd, cwd=workdir)
|
|
106
104
|
except FileNotFoundError:
|
|
107
105
|
print(
|
|
108
106
|
"::error::npx is not on PATH, so the Firebase CLI cannot run. "
|
|
@@ -161,7 +159,7 @@ def main(argv: list[str] | None = None, environ: dict[str, str] | None = None, r
|
|
|
161
159
|
print(f"::error::no Dart symbol files in {args.symbols_dir}; nothing to upload to Crashlytics")
|
|
162
160
|
return 1
|
|
163
161
|
|
|
164
|
-
cmd = upload_command(app_id, args.symbols_dir, args.firebase_tools)
|
|
162
|
+
cmd = upload_command(app_id, args.symbols_dir.resolve(), args.firebase_tools)
|
|
165
163
|
print(f"Uploading {len(symbols)} Dart symbol file(s) to Crashlytics app {app_id}:")
|
|
166
164
|
print(" " + " ".join(cmd))
|
|
167
165
|
rc = run(cmd)
|
|
@@ -109,6 +109,13 @@ def running(proxy: str):
|
|
|
109
109
|
|
|
110
110
|
|
|
111
111
|
def environment(env: dict, port: int) -> dict:
|
|
112
|
+
# Crashlytics Buildtools prefers HTTP_PROXY to JVM options and rejects URLs
|
|
113
|
+
# with userinfo. All child clients use the same owned relay; authentication
|
|
114
|
+
# is added only on its connection to the configured account exit.
|
|
115
|
+
local = f"http://127.0.0.1:{port}"
|
|
116
|
+
for name in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"):
|
|
117
|
+
env[name] = local
|
|
118
|
+
env.update(NO_PROXY="", no_proxy="")
|
|
112
119
|
options = (f"-Dhttp.proxyHost=127.0.0.1 -Dhttp.proxyPort={port} "
|
|
113
120
|
f"-Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort={port} "
|
|
114
121
|
"-Dhttp.nonProxyHosts= -Dhttps.nonProxyHosts= -Djava.net.useSystemProxies=false")
|
|
@@ -4,7 +4,7 @@ Three 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
|
|
7
|
-
|
|
7
|
+
owned relay in both proxy spellings and both ``NO_PROXY`` spellings cleared. Setting the pairs
|
|
8
8
|
as YAML ``env`` keys is not an option: GitHub compares env keys case-insensitively
|
|
9
9
|
and rejects the workflow or action file as invalid.
|
|
10
10
|
* ``python3 play_store_proxy.py --github-env`` clears the lower-case ambient bypass
|
|
@@ -49,7 +49,7 @@ def session() -> requests.Session:
|
|
|
49
49
|
return client
|
|
50
50
|
|
|
51
51
|
|
|
52
|
-
def run(argv: list[str]) -> int:
|
|
52
|
+
def run(argv: list[str], *, cwd: str | None = None) -> int:
|
|
53
53
|
"""Run ``argv`` with the explicit proxy environment; the parent process is untouched."""
|
|
54
54
|
if not argv:
|
|
55
55
|
raise SystemExit("usage: play_store_proxy.py -- <command …>")
|
|
@@ -57,7 +57,7 @@ def run(argv: list[str]) -> int:
|
|
|
57
57
|
|
|
58
58
|
env = environment()
|
|
59
59
|
with running(proxy_url()) as port:
|
|
60
|
-
return subprocess.run(argv, env=jvm_environment(env, port), check=False).returncode
|
|
60
|
+
return subprocess.run(argv, env=jvm_environment(env, port), cwd=cwd, check=False).returncode
|
|
61
61
|
|
|
62
62
|
|
|
63
63
|
def clear_github_env_bypass(path: str | None = None) -> list[str]:
|
|
@@ -130,7 +130,7 @@ class MainTest(unittest.TestCase):
|
|
|
130
130
|
self.assertEqual(
|
|
131
131
|
self.calls,
|
|
132
132
|
[["npx", "--yes", cs.FIREBASE_TOOLS, "crashlytics:symbols:upload",
|
|
133
|
-
f"--app={ANDROID}", str(self.symbols)]],
|
|
133
|
+
f"--app={ANDROID}", str(self.symbols.resolve())]],
|
|
134
134
|
)
|
|
135
135
|
self.assertIn("Uploaded", out)
|
|
136
136
|
self.assertNotIn("::error::", out)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Exercise the actual symbol-uploader child process and its owned proxy lifetime."""
|
|
2
|
+
import base64
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import socket
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import unittest
|
|
10
|
+
from unittest import mock
|
|
11
|
+
|
|
12
|
+
import crashlytics_symbols as symbols
|
|
13
|
+
from test_jvm_proxy_relay import upstream
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SymbolsProxyTests(unittest.TestCase):
|
|
17
|
+
def test_real_upload_child_uses_relay_and_removes_scratch_after_failure(self):
|
|
18
|
+
probe = """
|
|
19
|
+
import json, os, sys, urllib.request
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from urllib.parse import urlsplit
|
|
22
|
+
proxy = urlsplit(os.environ['HTTPS_PROXY'])
|
|
23
|
+
assert proxy.hostname == '127.0.0.1' and proxy.username is None
|
|
24
|
+
assert os.environ['NO_PROXY'] == os.environ['no_proxy'] == ''
|
|
25
|
+
reply = urllib.request.urlopen('http://unresolvable.invalid/symbols', timeout=5).read()
|
|
26
|
+
assert reply == b'through pinned proxy'
|
|
27
|
+
Path(sys.argv[1]).write_text(json.dumps({'cwd': os.getcwd(), 'port': proxy.port}))
|
|
28
|
+
sys.exit(7)
|
|
29
|
+
"""
|
|
30
|
+
with tempfile.TemporaryDirectory() as directory, upstream() as (proxy, seen):
|
|
31
|
+
result_path = Path(directory) / "result.json"
|
|
32
|
+
with mock.patch.dict(os.environ, {"GOOGLE_STORE_PROXY_URL": proxy, "NO_PROXY": "*"}):
|
|
33
|
+
rc = symbols._default_run([sys.executable, "-c", probe, str(result_path)])
|
|
34
|
+
result = json.loads(result_path.read_text())
|
|
35
|
+
self.assertEqual(rc, 7)
|
|
36
|
+
self.assertFalse(Path(result["cwd"]).exists())
|
|
37
|
+
self.assertNotEqual(result["cwd"], os.getcwd())
|
|
38
|
+
with self.assertRaises(OSError):
|
|
39
|
+
socket.create_connection(("127.0.0.1", result["port"]), timeout=0.2)
|
|
40
|
+
auth = "Basic " + base64.b64encode(b"fixture-user:fixture-pass").decode()
|
|
41
|
+
self.assertEqual(seen, [("GET", "http://unresolvable.invalid/symbols", auth)])
|
|
42
|
+
|
|
43
|
+
def test_missing_proxy_refuses_before_spawning_the_uploader(self):
|
|
44
|
+
with mock.patch.dict(os.environ, {"GOOGLE_STORE_PROXY_URL": ""}), \
|
|
45
|
+
mock.patch("play_store_proxy.subprocess.run") as run:
|
|
46
|
+
with self.assertRaises(SystemExit):
|
|
47
|
+
symbols._default_run(["npx", "--yes", symbols.FIREBASE_TOOLS])
|
|
48
|
+
run.assert_not_called()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
if __name__ == "__main__":
|
|
52
|
+
unittest.main()
|
|
@@ -49,6 +49,28 @@ def upstream():
|
|
|
49
49
|
|
|
50
50
|
|
|
51
51
|
class JvmProxyTests(unittest.TestCase):
|
|
52
|
+
@unittest.skipUnless(shutil.which("java"), "Java runtime unavailable")
|
|
53
|
+
def test_environment_first_java_client_receives_a_credential_free_relay(self):
|
|
54
|
+
source = ('import java.net.*; public class Probe { public static void main(String[] args) '
|
|
55
|
+
'throws Exception { var p = new URI(System.getenv("HTTPS_PROXY")); '
|
|
56
|
+
'if(p.getUserInfo()!=null) throw new IllegalArgumentException("proxy userinfo unsupported"); '
|
|
57
|
+
'var proxy = new Proxy(Proxy.Type.HTTP,new InetSocketAddress(p.getHost(),p.getPort())); '
|
|
58
|
+
'var c = new URL("http://unresolvable.invalid/symbols").openConnection(proxy); '
|
|
59
|
+
'c.setConnectTimeout(3000); c.setReadTimeout(3000); '
|
|
60
|
+
'System.out.print(new String(c.getInputStream().readAllBytes())); }}')
|
|
61
|
+
with tempfile.TemporaryDirectory() as directory, upstream() as (proxy, seen):
|
|
62
|
+
target = Path(directory) / "Probe.java"
|
|
63
|
+
target.write_text(source)
|
|
64
|
+
with relay.running(proxy) as port:
|
|
65
|
+
env = relay.environment({**os.environ, "HTTPS_PROXY": proxy, "NO_PROXY": "*"}, port)
|
|
66
|
+
result = subprocess.run(["java", str(target)], env=env, capture_output=True, text=True, timeout=20)
|
|
67
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
68
|
+
self.assertEqual(result.stdout, "through pinned proxy")
|
|
69
|
+
auth = "Basic " + base64.b64encode(b"fixture-user:fixture-pass").decode()
|
|
70
|
+
self.assertEqual(seen, [("GET", "http://unresolvable.invalid/symbols", auth)])
|
|
71
|
+
self.assertNotIn("fixture-pass", result.stderr)
|
|
72
|
+
self.assertEqual(env["NO_PROXY"], "")
|
|
73
|
+
|
|
52
74
|
def test_http_and_connect_only_reach_the_authenticated_upstream_and_cleanup(self):
|
|
53
75
|
with upstream() as (proxy, seen):
|
|
54
76
|
with relay.running(proxy) as port:
|
|
@@ -44,8 +44,9 @@ class PlayProxyTests(unittest.TestCase):
|
|
|
44
44
|
run.return_value.returncode = 0
|
|
45
45
|
self.assertEqual(play_store_proxy.run([sys.executable, "-c", probe]), 0)
|
|
46
46
|
child = run.call_args.kwargs["env"]
|
|
47
|
-
self.
|
|
48
|
-
self.assertEqual(child["HTTP_PROXY"],
|
|
47
|
+
self.assertTrue(child["https_proxy"].startswith("http://127.0.0.1:"))
|
|
48
|
+
self.assertEqual(child["HTTP_PROXY"], child["https_proxy"])
|
|
49
|
+
self.assertEqual(child["GOOGLE_STORE_PROXY_URL"], value)
|
|
49
50
|
self.assertEqual(child["no_proxy"], "")
|
|
50
51
|
self.assertEqual(child["NO_PROXY"], "")
|
|
51
52
|
self.assertEqual(os.environ["no_proxy"], "*")
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.64
|