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 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 retains the local SHA-256, version and provider upload/file IDs. It commits
969
- completed part transfers with `uploaded: true`, following Apple's
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. A failed upload emits the typed
975
- `apple_build_upload_failed` error annotation with schema
976
- `gowalk-cicd/apple-build-upload-failed.v1`; retain the exact IPA/receipt and read back
977
- the provider state before retrying. All requests and parts still use the Apple proxy.
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.
@@ -1 +1 @@
1
- 1.0.63
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(ValueError):
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(ValueError):
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
- with self.assertRaises(ValueError):
93
- upload_build.deliver(Path("unused"), Path("unused-report"), 1)
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
- return asc_common.request(method, path, token, json_body=body, params=params,
27
- max_attempts=3 if method == "GET" else 1).json()
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
- with zipfile.ZipFile(ipa) as archive:
32
- names = [name for name in archive.namelist()
33
- if name.startswith("Payload/") and name.endswith(".app/Info.plist") and name.count("/") == 2]
34
- if len(names) != 1:
35
- raise ValueError("IPA must contain one top-level app Info.plist")
36
- info = plistlib.loads(archive.read(names[0]))
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 ValueError("multiple matching Apple build uploads; resolve their provider state before retrying")
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 ValueError("existing Apple build upload belongs to different IPA bytes; do not overwrite it")
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 ValueError("Apple returned an unsupported upload destination")
102
+ raise UploadError("upload_destination_invalid", "Apple returned an unsupported upload destination")
82
103
  if operation.get("method") != "PUT":
83
- raise ValueError("Apple returned an unsupported upload method")
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 ValueError("IPA changed during upload")
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 RuntimeError(f"Apple upload part refused ({response.status_code}); read upload state before retrying")
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 ValueError("Apple upload operations do not cover the IPA exactly")
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 ValueError("Apple upload operations do not cover the IPA exactly")
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
- store_proxy.proxies() # Refuse before any identity or provider request.
124
- info = identity(ipa)
125
- app_id = os.environ["APP_STORE_APPLE_ID"]
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") != info["bundle_id"]:
128
- raise ValueError("IPA bundle identity does not match the selected App Store app")
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 = {**info, "upload_id": record["id"], "file_id": file["id"], "proxy_required": True}
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
- receipt["state"] = state
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 RuntimeError("Apple build upload failed processing; inspect the upload ID's provider errors")
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 RuntimeError("Apple build processing is pending; reuse this upload ID on continuation")
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
- print('::error title=apple_build_upload_failed::' + json.dumps({
160
- "schema": "gowalk-cicd/apple-build-upload-failed.v1",
161
- "receipt_available": args.report.is_file(),
162
- "action": "retain the IPA and receipt; read provider upload state before retrying"}))
163
- message = "Apple REST upload did not complete; retain its receipt and resolve the provider state"
164
- raise SystemExit(message) from None
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.63
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=120):
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
- writer.write(b"HTTP/1.1 502 Proxy unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
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.63
1
+ 1.0.65
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.63",
3
+ "version": "1.0.65",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {