gowalk-cicd 1.0.63 → 1.0.65
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/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/jvm_proxy_relay.py +13 -2
- package/android-action/scripts/test_jvm_proxy_relay.py +28 -1
- package/backend-action/.daemux-version +1 -1
- package/package.json +1 -1
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.65
|
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.65
|
|
@@ -13,6 +13,11 @@ import threading
|
|
|
13
13
|
from urllib.parse import unquote, urlsplit
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
# A tunnel carries whole SDK/NDK/CMake downloads and Gradle's keep-alive pools; an
|
|
17
|
+
# idle read is not a failure, so pumps wait long and the tunnel closes cleanly.
|
|
18
|
+
IDLE_TIMEOUT = 900.0
|
|
19
|
+
|
|
20
|
+
|
|
16
21
|
class Relay:
|
|
17
22
|
def __init__(self, proxy: str):
|
|
18
23
|
self.proxy = urlsplit(proxy)
|
|
@@ -56,13 +61,14 @@ class Relay:
|
|
|
56
61
|
return reader, writer
|
|
57
62
|
|
|
58
63
|
async def _pump(self, reader, writer):
|
|
59
|
-
while data := await asyncio.wait_for(reader.read(65536), timeout=
|
|
64
|
+
while data := await asyncio.wait_for(reader.read(65536), timeout=IDLE_TIMEOUT):
|
|
60
65
|
writer.write(data)
|
|
61
66
|
await writer.drain()
|
|
62
67
|
|
|
63
68
|
async def _handle(self, reader, writer):
|
|
64
69
|
upstream = None
|
|
65
70
|
pumps = []
|
|
71
|
+
established = False
|
|
66
72
|
try:
|
|
67
73
|
request = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), timeout=15)
|
|
68
74
|
upstream_reader, upstream = await self._upstream(request)
|
|
@@ -72,12 +78,17 @@ class Relay:
|
|
|
72
78
|
raise ConnectionError("upstream tunnel refused")
|
|
73
79
|
writer.write(b"HTTP/1.1 200 Connection established\r\n\r\n")
|
|
74
80
|
await writer.drain()
|
|
81
|
+
established = True
|
|
75
82
|
pumps = [asyncio.create_task(self._pump(reader, upstream)),
|
|
76
83
|
asyncio.create_task(self._pump(upstream_reader, writer))]
|
|
77
84
|
await asyncio.wait(pumps, return_when=asyncio.FIRST_COMPLETED)
|
|
78
85
|
except (OSError, ValueError, IndexError, asyncio.TimeoutError,
|
|
79
86
|
asyncio.IncompleteReadError, asyncio.LimitOverrunError):
|
|
80
|
-
|
|
87
|
+
# Once bytes flow, the stream is the client's protocol (usually TLS): a
|
|
88
|
+
# 502 written into it corrupts the transfer instead of failing it, so an
|
|
89
|
+
# established connection only closes.
|
|
90
|
+
if not established:
|
|
91
|
+
writer.write(b"HTTP/1.1 502 Proxy unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
|
|
81
92
|
finally:
|
|
82
93
|
for task in pumps:
|
|
83
94
|
task.cancel()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Real socket and Java proofs: authenticated upstream only, no target fallback."""
|
|
2
2
|
import base64
|
|
3
|
-
from contextlib import closing, contextmanager
|
|
3
|
+
from contextlib import closing, contextmanager, suppress
|
|
4
4
|
import http.client
|
|
5
5
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
6
6
|
import os
|
|
@@ -10,7 +10,9 @@ import socket
|
|
|
10
10
|
import subprocess
|
|
11
11
|
import tempfile
|
|
12
12
|
import threading
|
|
13
|
+
import time
|
|
13
14
|
import unittest
|
|
15
|
+
from unittest import mock
|
|
14
16
|
|
|
15
17
|
import jvm_proxy_relay as relay
|
|
16
18
|
|
|
@@ -117,5 +119,30 @@ class JvmProxyTests(unittest.TestCase):
|
|
|
117
119
|
self.assertIn("-Dorg.gradle.daemon=false", env["GRADLE_OPTS"])
|
|
118
120
|
|
|
119
121
|
|
|
122
|
+
class TunnelLifetimeTests(unittest.TestCase):
|
|
123
|
+
def test_idle_tunnel_outlives_a_short_pause_and_never_receives_a_502(self):
|
|
124
|
+
# A CMake/NDK download reads a remote zip through one keep-alive tunnel with
|
|
125
|
+
# pauses between range requests; the relay must neither time the pause out
|
|
126
|
+
# nor inject an HTTP status into the established byte stream.
|
|
127
|
+
with upstream() as (proxy, _seen), mock.patch.object(relay, "IDLE_TIMEOUT", 0.3):
|
|
128
|
+
with relay.running(proxy) as port:
|
|
129
|
+
with socket.create_connection(("127.0.0.1", port), timeout=5) as client:
|
|
130
|
+
client.sendall(b"CONNECT unresolvable.invalid:443 HTTP/1.1\r\nHost: ignored\r\n\r\n")
|
|
131
|
+
self.assertIn(b"200", client.recv(4096))
|
|
132
|
+
client.sendall(b"abcd")
|
|
133
|
+
self.assertEqual(client.recv(4), b"abcd")
|
|
134
|
+
time.sleep(0.6) # longer than the (patched) idle timeout: the pumps have given up
|
|
135
|
+
client.settimeout(2)
|
|
136
|
+
received = b""
|
|
137
|
+
with suppress(OSError):
|
|
138
|
+
while chunk := client.recv(4096):
|
|
139
|
+
received += chunk
|
|
140
|
+
self.assertNotIn(b"502", received)
|
|
141
|
+
self.assertNotIn(b"HTTP/1.1", received)
|
|
142
|
+
|
|
143
|
+
def test_idle_timeout_is_long_enough_for_sdk_downloads(self):
|
|
144
|
+
self.assertGreaterEqual(relay.IDLE_TIMEOUT, 600)
|
|
145
|
+
|
|
146
|
+
|
|
120
147
|
if __name__ == "__main__":
|
|
121
148
|
unittest.main()
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.65
|