gowalk-cicd 1.0.61 → 1.0.62

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -108,7 +108,11 @@ node /path/to/gowalk-cicd/bin/cli.mjs
108
108
  Python clients enforce explicit proxies; Play upload steps set the proxy for the
109
109
  pinned Google client. Missing configuration must stop before a provider call.
110
110
  Apple IPA uploads use `action/scripts/upload_build.py`, not native upload tools.
111
- It retains provider IDs/checksums and verifies matching IPA identity before resume.
111
+ It retains provider IDs/local SHA-256 and verifies matching IPA identity before resume.
112
+ Finalization sends `uploaded:true` as Apple's documented BuildUpload flow does; the
113
+ IPA endpoint rejected the optional SHA_256 checksum attribute despite the generic
114
+ schema advertising it. Completed-file reuse requires the content-derived filename
115
+ and size; any checksum returned by Apple is verified against the local file.
112
116
  - Signing material should use encrypted Actions secrets. `deploy.yml`
113
117
  materializes `ASC_KEY_P8` + its ID/issuer, the Apple distribution identity
114
118
  (`IOS_DISTRIBUTION_P12_BASE64`, `IOS_DISTRIBUTION_CERT_META_BASE64`,
package/README.md CHANGED
@@ -962,3 +962,16 @@ even that first auto-bootstrap.
962
962
  ## License
963
963
 
964
964
  MIT
965
+
966
+ ### Recovering an Apple REST upload failure
967
+
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
970
+ [BuildUpload walkthrough](https://developer.apple.com/videos/play/wwdc2025/324/).
971
+ The IPA endpoint rejected an optional `sourceFileChecksums` SHA_256 object even though
972
+ the generic schema lists that algorithm; the uploader no longer sends that attribute.
973
+ 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.
@@ -1 +1 @@
1
- 1.0.61
1
+ 1.0.62
@@ -62,7 +62,7 @@ class BuildUploadTests(unittest.TestCase):
62
62
  api.assert_not_called()
63
63
  transfer.assert_not_called()
64
64
 
65
- def test_parts_are_joined_before_committing_the_checksum(self):
65
+ def test_parts_are_joined_before_committing_the_uploaded_file(self):
66
66
  file = {"id": "file", "attributes": {"uploadOperations": [{"offset": 0, "length": 2},
67
67
  {"offset": 2, "length": 3}]}}
68
68
  done = []
@@ -71,8 +71,7 @@ class BuildUploadTests(unittest.TestCase):
71
71
  upload_build.transfer_all(Path("unused"), file, {"fileSize": 5, "sha256": "digest"})
72
72
  self.assertEqual(sorted(done), [0, 2])
73
73
  body = api.call_args.args[2]["data"]["attributes"]
74
- self.assertEqual(body, {"uploaded": True, "sourceFileChecksums": {
75
- "file": {"hash": "digest", "algorithm": "SHA_256"}}})
74
+ self.assertEqual(body, {"uploaded": True})
76
75
 
77
76
  def test_failed_part_never_commits_the_file(self):
78
77
  file = {"id": "file", "attributes": {"uploadOperations": [{"offset": 0, "length": 5}]}}
@@ -0,0 +1,74 @@
1
+ """Completed-file recovery keeps byte identity without inventing optional provider attributes."""
2
+ import base64
3
+ from contextlib import redirect_stdout
4
+ import hashlib
5
+ import io
6
+ from pathlib import Path
7
+ import sys
8
+ import tempfile
9
+ import unittest
10
+ from unittest import mock
11
+
12
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
13
+ import upload_build
14
+
15
+
16
+ class CompletedUploadTests(unittest.TestCase):
17
+ def test_provider_exit_emits_typed_failure_without_exception_content(self):
18
+ output = io.StringIO()
19
+ with tempfile.TemporaryDirectory() as temporary, redirect_stdout(output), \
20
+ mock.patch.object(sys, "argv", ["upload_build.py", "app.ipa", "--report", temporary + "/receipt.json"]), \
21
+ mock.patch.object(upload_build, "deliver", side_effect=SystemExit("private-provider-detail")):
22
+ with self.assertRaisesRegex(SystemExit, "retain its receipt"):
23
+ upload_build.main()
24
+ self.assertIn("::error title=apple_build_upload_failed::", output.getvalue())
25
+ self.assertNotIn("private-provider-detail", output.getvalue())
26
+
27
+ def test_provider_completion_without_optional_checksum_reuses_exact_file(self):
28
+ info = {"fileName": "source-sha256.ipa", "fileSize": 5}
29
+ file = {"attributes": {**info, "assetDeliveryState": {"state": "COMPLETE"}}}
30
+ with mock.patch.object(upload_build, "api") as api, mock.patch.object(upload_build, "transfer") as transfer:
31
+ upload_build.transfer_all(Path("not-read"), file, info)
32
+ api.assert_not_called()
33
+ transfer.assert_not_called()
34
+ file["attributes"]["fileName"] = "another-source.ipa"
35
+ with self.assertRaisesRegex(ValueError, "identity"):
36
+ upload_build.transfer_all(Path("not-read"), file, info)
37
+
38
+ def test_present_provider_checksums_are_verified_against_actual_bytes(self):
39
+ with tempfile.TemporaryDirectory() as temporary:
40
+ ipa = Path(temporary) / "app.ipa"
41
+ ipa.write_bytes(b"the uploaded file")
42
+ info = {"fileName": "source-sha256.ipa", "fileSize": ipa.stat().st_size}
43
+ for algorithm, digest in [("SHA_256", hashlib.sha256(ipa.read_bytes())),
44
+ ("MD5", hashlib.md5(ipa.read_bytes()))]:
45
+ for value in [digest.hexdigest(), base64.b64encode(digest.digest()).decode()]:
46
+ with self.subTest(algorithm=algorithm, encoding=value):
47
+ attributes = {**info, "assetDeliveryState": {"state": "COMPLETE"},
48
+ "sourceFileChecksums": {"file": {"hash": value, "algorithm": algorithm}}}
49
+ upload_build.transfer_all(ipa, {"attributes": attributes}, info)
50
+ attributes["sourceFileChecksums"]["file"]["hash"] = "different-bytes"
51
+ with self.assertRaisesRegex(ValueError, "checksum"):
52
+ upload_build.transfer_all(ipa, {"attributes": attributes}, info)
53
+
54
+ def test_commit_uses_documented_uploaded_attribute_after_all_bytes_finish(self):
55
+ # Apple's WWDC25 BuildUpload sample sends uploaded=true. The observed IPA
56
+ # endpoint rejected the optional SHA_256 sourceFileChecksums object with409.
57
+ parts = [{"offset": 0, "length": 2}, {"offset": 2, "length": 3}]
58
+ done = []
59
+ def provider(method, path, body):
60
+ self.assertEqual(sorted(done), [0, 2])
61
+ attributes = body["data"]["attributes"]
62
+ if "sourceFileChecksums" in attributes:
63
+ raise SystemExit("409 ENTITY_ERROR.ATTRIBUTE.INVALID")
64
+ self.assertTrue(attributes["uploaded"])
65
+ return {"data": {"id": "existing-file"}}
66
+ with mock.patch.object(upload_build, "api", side_effect=provider) as api, \
67
+ mock.patch.object(upload_build, "transfer", side_effect=lambda _path, op: done.append(op["offset"])):
68
+ upload_build.transfer_all(Path("not-read"), {"id": "existing-file", "attributes": {
69
+ "uploadOperations": parts}}, {"fileSize": 5, "sha256": "local-receipt-digest"})
70
+ self.assertEqual(api.call_count, 1)
71
+
72
+
73
+ if __name__ == "__main__":
74
+ unittest.main()
@@ -15,6 +15,7 @@ import zipfile
15
15
 
16
16
  import asc_common
17
17
  import apple_store_proxy as store_proxy
18
+ import upload_checksums
18
19
 
19
20
 
20
21
  def api(method: str, path: str, body=None, params=None) -> dict:
@@ -96,9 +97,7 @@ def transfer_all(ipa: Path, file: dict, info: dict) -> None:
96
97
  attributes = file.get("attributes") or {}
97
98
  state = (attributes.get("assetDeliveryState") or {}).get("state")
98
99
  if state == "COMPLETE":
99
- expected = {"hash": info["sha256"], "algorithm": "SHA_256"}
100
- if (attributes.get("sourceFileChecksums") or {}).get("file") != expected:
101
- raise ValueError("Apple's completed file checksum does not match this IPA")
100
+ upload_checksums.completed(ipa, attributes, info)
102
101
  return
103
102
  operations = sorted(attributes.get("uploadOperations") or [], key=lambda row: row["offset"])
104
103
  offset = 0
@@ -113,8 +112,10 @@ def transfer_all(ipa: Path, file: dict, info: dict) -> None:
113
112
  for result in results:
114
113
  result.result()
115
114
  api("PATCH", f"/buildUploadFiles/{file['id']}", {"data": {
116
- "type": "buildUploadFiles", "id": file["id"], "attributes": {"uploaded": True,
117
- "sourceFileChecksums": {"file": {"hash": info["sha256"], "algorithm": "SHA_256"}}},
115
+ # Apple's build-upload contract commits with uploaded=true. Its generic
116
+ # checksum enum includes SHA_256, but IPA finalization rejected that
117
+ # optional attribute with ENTITY_ERROR.ATTRIBUTE.INVALID in production.
118
+ "type": "buildUploadFiles", "id": file["id"], "attributes": {"uploaded": True},
118
119
  }})
119
120
 
120
121
 
@@ -154,7 +155,11 @@ def main() -> None:
154
155
  args = parser.parse_args()
155
156
  try:
156
157
  receipt = deliver(args.ipa, args.report, args.timeout)
157
- except (OSError, ValueError, KeyError, RuntimeError):
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"}))
158
163
  message = "Apple REST upload did not complete; retain its receipt and resolve the provider state"
159
164
  raise SystemExit(message) from None
160
165
  print(json.dumps(receipt))
@@ -0,0 +1,24 @@
1
+ """Check completed upload identity without requiring Apple's optional checksum attribute."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ import hashlib
6
+ from pathlib import Path
7
+
8
+
9
+ def completed(ipa: Path, attributes: dict, info: dict) -> None:
10
+ if any(attributes.get(key) != info[key] for key in ("fileName", "fileSize")):
11
+ raise ValueError("Apple's completed file identity does not match this IPA")
12
+ checksum = (attributes.get("sourceFileChecksums") or {}).get("file")
13
+ if checksum is None:
14
+ # The name is the local SHA-256 plus .ipa; upload_file verified this same
15
+ # provider-owned file identity before any transfer or completed-file reuse.
16
+ return
17
+ algorithm = {"SHA_256": "sha256", "MD5": "md5"}.get(checksum.get("algorithm"))
18
+ if algorithm is None:
19
+ raise ValueError("Apple returned an unsupported completed-file checksum algorithm")
20
+ with ipa.open("rb") as stream:
21
+ digest = hashlib.file_digest(stream, algorithm)
22
+ expected = {digest.hexdigest(), digest.hexdigest().upper(), base64.b64encode(digest.digest()).decode()}
23
+ if checksum.get("hash") not in expected:
24
+ raise ValueError("Apple's completed file checksum does not match this IPA")
@@ -1 +1 @@
1
- 1.0.61
1
+ 1.0.62
@@ -1 +1 @@
1
- 1.0.61
1
+ 1.0.62
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.61",
3
+ "version": "1.0.62",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {