gowalk-cicd 1.0.85 → 1.0.87

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
@@ -123,6 +123,9 @@ node /path/to/gowalk-cicd/bin/cli.mjs
123
123
  Version source staged before signing is preserved separately with its original index bytes.
124
124
  - Never revoke a distribution certificate automatically, including legacy caches.
125
125
  `certificate-cap-policy` accepts only `fail`; a full cap requires registry reconciliation.
126
+ A certificate POST's HTTP 409 alone does not prove a full cap. `certificate_failure.py`
127
+ emits fixed reason/code evidence in `apple_certificate_rejected`; raw provider details stay private.
128
+ Certificate creation sends the complete PEM CSR in `csrContent`, including its framing and newlines.
126
129
  - Keep preinstalled Android SDK/NDKs. Every Gradle test/build and SDK download runs
127
130
  under `play_store_proxy.py --`, whose owned loopback relay gives Java an explicit
128
131
  authenticated account exit without credentials in JVM options. The child HTTP_PROXY
package/README.md CHANGED
@@ -852,6 +852,13 @@ common ones:
852
852
  | `ai-metadata` | `false` to disable AI auto-fill of empty ASC metadata |
853
853
  | `ai-metadata-model` | GitHub Models model id (default `openai/gpt-4o`) |
854
854
 
855
+ Certificate creation sends the complete PEM CSR in Apple's `csrContent` field, including its framing and newlines.
856
+ Certificate creation refusals emit `apple_certificate_rejected` with schema
857
+ `gowalk-cicd/apple-certificate-rejected.v1`, `operation`, `http_status`, `reason` and `error_codes`.
858
+ HTTP 409 is classified as `certificate_limit` only when Apple's returned detail identifies the limit;
859
+ otherwise it remains `unclassified_conflict`. Codes come from a fixed allowlist. Raw provider text and
860
+ CSR data are excluded. Neither outcome permits automatic certificate deletion or another creation attempt.
861
+
855
862
  ## First-time app setup
856
863
 
857
864
  The app record must exist in App Store Connect before the first CI upload.
@@ -1 +1 @@
1
- 1.0.85
1
+ 1.0.87
@@ -18,6 +18,7 @@ import base64
18
18
  import os
19
19
 
20
20
  from asc_common import request
21
+ import certificate_failure
21
22
  from cryptography import x509
22
23
  from cryptography.hazmat.primitives import hashes, serialization
23
24
  from cryptography.hazmat.primitives.asymmetric import rsa
@@ -34,11 +35,11 @@ def certificate_cap_policy() -> str:
34
35
 
35
36
 
36
37
  def generate_key_and_csr() -> tuple[rsa.RSAPrivateKey, bytes]:
37
- """Generate a fresh 2048-bit RSA key + PEM CSR (base64 body only).
38
+ """Generate a fresh 2048-bit RSA key and complete PEM CSR.
38
39
 
39
40
  Returns the private key (kept in memory; the caller serialises it
40
- into the PKCS12) and the CSR with PEM headers stripped, ready for
41
- Apple's ``csrContent`` field.
41
+ into the PKCS12) and the PEM CSR, including its framing and newlines,
42
+ for Apple's ``csrContent`` field.
42
43
  """
43
44
  private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
44
45
  csr = (
@@ -54,20 +55,17 @@ def generate_key_and_csr() -> tuple[rsa.RSAPrivateKey, bytes]:
54
55
  .sign(private_key, hashes.SHA256())
55
56
  )
56
57
  csr_pem = csr.public_bytes(serialization.Encoding.PEM)
57
- csr_payload = b"".join(
58
- line for line in csr_pem.splitlines() if not line.startswith(b"-----")
59
- )
60
- return private_key, csr_payload
58
+ return private_key, csr_pem
61
59
 
62
60
 
63
- def create_distribution_cert(token: str, csr_b64: str) -> tuple[str, bytes]:
61
+ def create_distribution_cert(token: str, csr_pem: str) -> tuple[str, bytes]:
64
62
  """POST one CSR; a full team cap preserves every existing certificate."""
65
63
  certificate_cap_policy()
66
64
  body = {
67
65
  "data": {
68
66
  "type": "certificates",
69
67
  "attributes": {
70
- "csrContent": csr_b64,
68
+ "csrContent": csr_pem,
71
69
  "certificateType": "DISTRIBUTION",
72
70
  },
73
71
  }
@@ -76,10 +74,7 @@ def create_distribution_cert(token: str, csr_b64: str) -> tuple[str, bytes]:
76
74
  "POST", "/certificates", token, json_body=body, allow_status={409}
77
75
  )
78
76
  if resp.status_code == 409:
79
- raise SystemExit(
80
- "distribution certificate cap reached; policy=fail preserves all "
81
- "existing certificates and refuses automatic revocation; reconcile the account registry"
82
- )
77
+ certificate_failure.refuse(resp)
83
78
  data = resp.json()["data"]
84
79
  cert_id = data["id"]
85
80
  cert_der = base64.b64decode(data["attributes"]["certificateContent"])
@@ -0,0 +1,47 @@
1
+ """A certificate POST conflict is not proof of quota exhaustion."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+
6
+ _CODES = frozenset({"ENTITY_ERROR", "ENTITY_ERROR.ATTRIBUTE.INVALID", "ENTITY_ERROR.ATTRIBUTE.REQUIRED",
7
+ "ENTITY_ERROR.RELATIONSHIP.INVALID", "ENTITY_ERROR.RELATIONSHIP.REQUIRED",
8
+ "STATE_ERROR", "FORBIDDEN_ERROR", "UNEXPECTED_ERROR"})
9
+ _CAP_DETAILS = (
10
+ "maximum number of certificates",
11
+ "maximum allowed number of certificates",
12
+ "you already have a current distribution certificate or a pending certificate request",
13
+ "certificate limit has been reached",
14
+ "certificate limit reached",
15
+ )
16
+
17
+
18
+ def conflict(response) -> dict:
19
+ """Use fixed labels and code allowlists; never return provider text, URLs or CSR data."""
20
+ try:
21
+ payload = response.json()
22
+ except (ValueError, TypeError):
23
+ payload = {}
24
+ errors = payload.get("errors", []) if isinstance(payload, dict) else []
25
+ errors = errors[:32] if isinstance(errors, list) else []
26
+ codes, capped = set(), False
27
+ for error in errors:
28
+ if not isinstance(error, dict):
29
+ continue
30
+ code = error.get("code")
31
+ if isinstance(code, str) and code in _CODES:
32
+ codes.add(code)
33
+ detail = error.get("detail")
34
+ if isinstance(detail, str):
35
+ capped |= any(marker in detail[:8192].lower() for marker in _CAP_DETAILS)
36
+ return {"schema": "gowalk-cicd/apple-certificate-rejected.v1", "operation": "certificates_createInstance",
37
+ "http_status": 409, "reason": "certificate_limit" if capped else "unclassified_conflict",
38
+ "error_codes": sorted(codes)}
39
+
40
+
41
+ def refuse(response) -> None:
42
+ result = conflict(response)
43
+ print("::error title=apple_certificate_rejected::" + json.dumps(result, separators=(",", ":")))
44
+ raise SystemExit(
45
+ f"Apple certificate request rejected (HTTP 409; reason={result['reason']}); "
46
+ "policy=fail preserves all existing certificates and refuses automatic revocation"
47
+ )
@@ -103,9 +103,9 @@ def _load_or_create_cert(
103
103
  )
104
104
  creds_store.invalidate_cache(creds_dir)
105
105
 
106
- private_key, csr_b64 = cert_factory.generate_key_and_csr()
106
+ private_key, csr_pem = cert_factory.generate_key_and_csr()
107
107
  cert_id, cert_der = cert_factory.create_distribution_cert(
108
- token, csr_b64.decode()
108
+ token, csr_pem.decode("ascii")
109
109
  )
110
110
  password = creds_store.P12_PASSWORD
111
111
  p12_bytes = cert_factory.serialize_p12(private_key, cert_der, password)
@@ -5,6 +5,8 @@ from __future__ import annotations
5
5
  import os
6
6
  import sys
7
7
  import unittest
8
+ from contextlib import redirect_stdout
9
+ from io import StringIO
8
10
  from pathlib import Path
9
11
  from unittest import mock
10
12
 
@@ -27,7 +29,8 @@ class CertificateCapPolicyTests(unittest.TestCase):
27
29
  def test_fail_policy_preserves_every_certificate_on_cap(self):
28
30
  response = mock.MagicMock(status_code=409)
29
31
  with mock.patch.dict(os.environ, {"CERTIFICATE_CAP_POLICY": "fail"}, clear=True):
30
- with mock.patch.object(cert_factory, "request", return_value=response) as request:
32
+ with (mock.patch.object(cert_factory, "request", return_value=response) as request,
33
+ redirect_stdout(StringIO())):
31
34
  with self.assertRaisesRegex(SystemExit, "refuses automatic revocation"):
32
35
  cert_factory.create_distribution_cert("token", "csr")
33
36
  request.assert_called_once()
@@ -36,7 +39,8 @@ class CertificateCapPolicyTests(unittest.TestCase):
36
39
  def test_default_policy_never_revokes_or_retries_a_full_cap(self):
37
40
  capped = mock.MagicMock(status_code=409)
38
41
  with mock.patch.dict(os.environ, {}, clear=True):
39
- with mock.patch.object(cert_factory, "request", return_value=capped) as request:
42
+ with (mock.patch.object(cert_factory, "request", return_value=capped) as request,
43
+ redirect_stdout(StringIO())):
40
44
  with self.assertRaisesRegex(SystemExit, "refuses automatic revocation"):
41
45
  cert_factory.create_distribution_cert("token", "csr")
42
46
  request.assert_called_once()
@@ -0,0 +1,64 @@
1
+ """Retain useful Apple refusal evidence without inventing a cap or exposing response data."""
2
+ import contextlib
3
+ import io
4
+ import json
5
+ import os
6
+ import sys
7
+ import unittest
8
+ from pathlib import Path
9
+ from unittest import mock
10
+
11
+ import requests
12
+
13
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
14
+ import cert_factory # noqa: E402
15
+
16
+
17
+ class CertificateFailureTests(unittest.TestCase):
18
+ def request(self, body):
19
+ response = requests.Response()
20
+ response.status_code = 409
21
+ response._content = body.encode()
22
+ output = io.StringIO()
23
+ with mock.patch.dict(os.environ, {"CERTIFICATE_CAP_POLICY": "fail"}, clear=True):
24
+ with mock.patch.object(cert_factory, "request", return_value=response) as request:
25
+ with contextlib.redirect_stdout(output), self.assertRaises(SystemExit) as failed:
26
+ cert_factory.create_distribution_cert("private-token", "private-csr")
27
+ request.assert_called_once()
28
+ self.assertEqual(request.call_args.args[:2], ("POST", "/certificates"))
29
+ text = output.getvalue() + str(failed.exception)
30
+ self.assertNotIn("private-", text)
31
+ return json.loads(output.getvalue().split("::", 2)[2]), str(failed.exception)
32
+
33
+ def test_limit_requires_the_provider_limit_detail(self):
34
+ for detail in ["Maximum number of certificates generated.",
35
+ "You already have a current Distribution certificate or a pending certificate request."]:
36
+ with self.subTest(detail=detail):
37
+ result, message = self.request(json.dumps({"errors": [{
38
+ "code": "ENTITY_ERROR.ATTRIBUTE.INVALID", "detail": detail,
39
+ "source": {"pointer": "private-source"}, "links": {"about": "private-url"}}]}))
40
+ self.assertEqual(result["reason"], "certificate_limit")
41
+ self.assertEqual(result["error_codes"], ["ENTITY_ERROR.ATTRIBUTE.INVALID"])
42
+ self.assertIn("refuses automatic revocation", message)
43
+
44
+ def test_other_conflicts_are_not_reported_as_quota(self):
45
+ cases = [{"errors": [{"code": "ENTITY_ERROR.ATTRIBUTE.INVALID", "detail": "Invalid private-csr"}]},
46
+ {"errors": [{"code": "private-secret", "detail": "private-response"}]},
47
+ {"errors": [{"code": ["private-value"]}]}, {"errors": "private-response"},
48
+ {"errors": ["private-response"]}, [], None]
49
+ for case in cases:
50
+ with self.subTest(case=case):
51
+ result, message = self.request(json.dumps(case))
52
+ self.assertEqual(result["reason"], "unclassified_conflict")
53
+ self.assertNotIn("cap reached", message)
54
+ self.assertEqual(result["http_status"], 409)
55
+ self.assertEqual(result["operation"], "certificates_createInstance")
56
+
57
+ def test_non_json_response_is_an_unclassified_conflict(self):
58
+ result, _ = self.request("<html>private-response</html>")
59
+ self.assertEqual(result["reason"], "unclassified_conflict")
60
+ self.assertEqual(result["error_codes"], [])
61
+
62
+
63
+ if __name__ == "__main__":
64
+ unittest.main()
@@ -0,0 +1,62 @@
1
+ """Verify Apple's PEM request boundary with real CSR, certificate and PKCS12 bytes."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ import datetime as dt
6
+ import json
7
+ import unittest
8
+ from contextlib import redirect_stdout
9
+ from io import StringIO
10
+ from unittest import mock
11
+
12
+ import requests
13
+ from cryptography import x509
14
+ from cryptography.hazmat.primitives import hashes, serialization
15
+ from cryptography.hazmat.primitives.serialization import pkcs12
16
+
17
+ import cert_factory
18
+
19
+
20
+ class CertificateRequestTests(unittest.TestCase):
21
+ def test_generated_pem_has_a_valid_signature_and_matching_public_key(self):
22
+ key, content = cert_factory.generate_key_and_csr()
23
+ csr = x509.load_pem_x509_csr(content)
24
+ self.assertTrue(csr.is_signature_valid)
25
+ self.assertEqual(csr.public_key().public_numbers(), key.public_key().public_numbers())
26
+
27
+ def test_posted_pem_and_returned_certificate_preserve_the_signing_identity(self):
28
+ key, content = cert_factory.generate_key_and_csr()
29
+ received = []
30
+
31
+ def apple_post(method, path, token, *, json_body, allow_status):
32
+ self.assertEqual((method, path), ("POST", "/certificates"))
33
+ attributes = json_body["data"]["attributes"]
34
+ self.assertEqual(attributes["certificateType"], "DISTRIBUTION")
35
+ csr = x509.load_pem_x509_csr(attributes["csrContent"].encode("ascii"))
36
+ self.assertTrue(csr.is_signature_valid)
37
+ now = dt.datetime.now(dt.timezone.utc)
38
+ cert = (x509.CertificateBuilder().subject_name(csr.subject).issuer_name(csr.subject)
39
+ .public_key(csr.public_key()).serial_number(x509.random_serial_number())
40
+ .not_valid_before(now - dt.timedelta(minutes=1))
41
+ .not_valid_after(now + dt.timedelta(days=1)).sign(key, hashes.SHA256()))
42
+ received.append(cert)
43
+ response = requests.Response()
44
+ response.status_code = 201
45
+ response._content = json.dumps({"data": {"id": "TESTCERT", "attributes": {
46
+ "certificateContent": base64.b64encode(cert.public_bytes(serialization.Encoding.DER)).decode()
47
+ }}}).encode()
48
+ return response
49
+
50
+ with mock.patch.object(cert_factory, "request", side_effect=apple_post) as request:
51
+ with redirect_stdout(StringIO()):
52
+ cert_id, der = cert_factory.create_distribution_cert("test-token", content.decode("ascii"))
53
+ request.assert_called_once()
54
+ self.assertEqual(cert_id, "TESTCERT")
55
+ p12 = cert_factory.serialize_p12(key, der, "test-only-password")
56
+ restored_key, restored_cert, _ = pkcs12.load_key_and_certificates(p12, b"test-only-password")
57
+ self.assertEqual(restored_key.public_key().public_numbers(), key.public_key().public_numbers())
58
+ self.assertEqual(restored_cert.fingerprint(hashes.SHA256()), received[0].fingerprint(hashes.SHA256()))
59
+
60
+
61
+ if __name__ == "__main__":
62
+ unittest.main()
@@ -1 +1 @@
1
- 1.0.85
1
+ 1.0.87
@@ -1 +1 @@
1
- 1.0.85
1
+ 1.0.87
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.85",
3
+ "version": "1.0.87",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {