gowalk-cicd 1.0.32 → 1.0.34
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 +7 -0
- package/action/.daemux-version +1 -1
- package/action/action.yml +27 -3
- package/action/scripts/cert_factory.py +17 -1
- package/action/scripts/test_certificate_cap_policy.py +70 -0
- package/android-action/.daemux-version +1 -1
- package/backend-action/.daemux-version +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -572,6 +572,7 @@ common ones:
|
|
|
572
572
|
| `bundle-id` | Override the auto-detected bundle identifier |
|
|
573
573
|
| `team-id` | Override the auto-detected team ID |
|
|
574
574
|
| `app-store-apple-id` | Numeric ASC app ID (override auto-lookup) |
|
|
575
|
+
| `certificate-cap-policy` | Full-cap behavior: `fail` preserves identities; default `revoke-oldest` rotates. |
|
|
575
576
|
| `run-tests` | `false` to skip the simulator test stage |
|
|
576
577
|
| `uses-non-exempt-encryption` | Value for `ITSAppUsesNonExemptEncryption` |
|
|
577
578
|
| `archive` | `false` to build-only (PR runs without secrets) |
|
|
@@ -591,6 +592,12 @@ inside GitHub Actions.
|
|
|
591
592
|
Once the app exists, all subsequent builds and uploads are fully automated via
|
|
592
593
|
the ASC API key.
|
|
593
594
|
|
|
595
|
+
For accounts where automatic certificate revocation is not acceptable, pass
|
|
596
|
+
`certificate-cap-policy: 'fail'`. The action may add a certificate when Apple has a free
|
|
597
|
+
slot, but a full-cap response aborts without listing or revoking existing identities. If
|
|
598
|
+
signing preparation fails after creating a certificate, the default-branch workflow first
|
|
599
|
+
commits any completed cache files, then re-raises the failure so the private key is not lost.
|
|
600
|
+
|
|
594
601
|
## Troubleshooting
|
|
595
602
|
|
|
596
603
|
**"No app found for bundle ID"** — the app record doesn't exist yet. Run the
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.34
|
package/action/action.yml
CHANGED
|
@@ -53,6 +53,13 @@ inputs:
|
|
|
53
53
|
compatibility — passing a value emits a notice.
|
|
54
54
|
required: false
|
|
55
55
|
default: ""
|
|
56
|
+
certificate-cap-policy:
|
|
57
|
+
description: >
|
|
58
|
+
Behavior when Apple reports the two-certificate Distribution cap.
|
|
59
|
+
'revoke-oldest' preserves historical behavior; 'fail' aborts without
|
|
60
|
+
listing or revoking any existing certificate.
|
|
61
|
+
required: false
|
|
62
|
+
default: "revoke-oldest"
|
|
56
63
|
upload:
|
|
57
64
|
description: "Set to 'false' to stop after producing the IPA"
|
|
58
65
|
required: false
|
|
@@ -362,10 +369,19 @@ runs:
|
|
|
362
369
|
WORKSPACE: ${{ inputs.workspace || env.CFG_WORKSPACE }}
|
|
363
370
|
TEAM_ID: ${{ inputs.team-id || env.CFG_TEAM_ID }}
|
|
364
371
|
CREDS_DIR: ${{ github.workspace }}/creds
|
|
365
|
-
|
|
372
|
+
CERTIFICATE_CAP_POLICY: ${{ inputs.certificate-cap-policy }}
|
|
373
|
+
run: |
|
|
374
|
+
set +e
|
|
375
|
+
python3 "${{ github.action_path }}/scripts/prepare_signing.py"
|
|
376
|
+
signing_exit=$?
|
|
377
|
+
set -e
|
|
378
|
+
echo "SIGNING_PREP_EXIT=$signing_exit" >> "$GITHUB_ENV"
|
|
379
|
+
if [ "$signing_exit" -ne 0 ]; then
|
|
380
|
+
echo "::error::Signing preparation failed; preserving any generated cache before failing."
|
|
381
|
+
fi
|
|
366
382
|
|
|
367
383
|
# DESIGN DECISION (intentional, not a bug):
|
|
368
|
-
# The next
|
|
384
|
+
# The next five steps stage + commit-back the Apple Distribution
|
|
369
385
|
# code-signing identity (.p12 + provisioning profiles) AND any
|
|
370
386
|
# autoupdate of the vendored app-ci actions into the consumer
|
|
371
387
|
# repo's default branch. This is a deliberate trade-off chosen over
|
|
@@ -386,7 +402,8 @@ runs:
|
|
|
386
402
|
# alone without redundant retries: (1) copy bot scripts to RUNNER_TEMP
|
|
387
403
|
# so autoupdate's `npx` cannot mid-run swap script bytes under us,
|
|
388
404
|
# (2) stage cert files, (3) check for newer app-ci on npm and
|
|
389
|
-
# stage refreshed action files, (4) commit + push whatever is staged
|
|
405
|
+
# stage refreshed action files, (4) commit + push whatever is staged,
|
|
406
|
+
# (5) re-raise a deferred prepare failure after preserving its cache.
|
|
390
407
|
|
|
391
408
|
# Stage scripts to RUNNER_TEMP so the autoupdate step's `npx` (which
|
|
392
409
|
# overwrites .github/actions/swift-app/ — the same dir as ${action_path}
|
|
@@ -494,6 +511,13 @@ runs:
|
|
|
494
511
|
GITHUB_REF_NAME: ${{ github.ref_name }}
|
|
495
512
|
run: bash "$BOT_SCRIPTS_DIR/commit_bot_changes.sh"
|
|
496
513
|
|
|
514
|
+
- name: Re-raise signing preparation failure
|
|
515
|
+
if: ${{ inputs.archive == 'true' && env.SIGNING_PREP_EXIT != '0' }}
|
|
516
|
+
shell: bash
|
|
517
|
+
run: |
|
|
518
|
+
echo "::error::Signing preparation failed with exit $SIGNING_PREP_EXIT."
|
|
519
|
+
exit "$SIGNING_PREP_EXIT"
|
|
520
|
+
|
|
497
521
|
- name: Stamp Info.plist versions (CFBundleVersion + CFBundleShortVersionString)
|
|
498
522
|
if: ${{ inputs.archive == 'true' }}
|
|
499
523
|
shell: bash
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Cert-creation primitives for the iOS native TestFlight action.
|
|
4
4
|
|
|
5
5
|
Owns the cryptographic legwork (RSA key + CSR), Apple's per-team cert
|
|
6
|
-
cap
|
|
6
|
+
cap policy (fail closed or revoke OLDEST on 409), and PKCS12 serialisation. Split
|
|
7
7
|
from ``prepare_signing.py`` so the orchestrator there stays focused on
|
|
8
8
|
the load-or-regen control flow and the file count stays under the
|
|
9
9
|
project's per-file limits.
|
|
@@ -36,6 +36,16 @@ CERT_REVOKE_PROPAGATION_DELAY_SEC = float(
|
|
|
36
36
|
CERT_POST_RETRIES_AFTER_REVOKE = int(
|
|
37
37
|
os.getenv("CERT_POST_RETRIES_AFTER_REVOKE", "2")
|
|
38
38
|
)
|
|
39
|
+
CERTIFICATE_CAP_POLICIES = frozenset({"revoke-oldest", "fail"})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def certificate_cap_policy() -> str:
|
|
43
|
+
"""Return the configured full-cap behavior before any provider call."""
|
|
44
|
+
policy = os.getenv("CERTIFICATE_CAP_POLICY", "revoke-oldest").strip().lower()
|
|
45
|
+
if policy not in CERTIFICATE_CAP_POLICIES:
|
|
46
|
+
allowed = ", ".join(sorted(CERTIFICATE_CAP_POLICIES))
|
|
47
|
+
raise SystemExit(f"invalid CERTIFICATE_CAP_POLICY {policy!r}; use {allowed}")
|
|
48
|
+
return policy
|
|
39
49
|
|
|
40
50
|
|
|
41
51
|
def generate_key_and_csr() -> tuple[rsa.RSAPrivateKey, bytes]:
|
|
@@ -137,6 +147,7 @@ def create_distribution_cert(token: str, csr_b64: str) -> tuple[str, bytes]:
|
|
|
137
147
|
backoff to absorb Apple's revoke -> create propagation lag (see
|
|
138
148
|
:func:`_post_with_revoke_backoff`).
|
|
139
149
|
"""
|
|
150
|
+
cap_policy = certificate_cap_policy()
|
|
140
151
|
body = {
|
|
141
152
|
"data": {
|
|
142
153
|
"type": "certificates",
|
|
@@ -150,6 +161,11 @@ def create_distribution_cert(token: str, csr_b64: str) -> tuple[str, bytes]:
|
|
|
150
161
|
"POST", "/certificates", token, json_body=body, allow_status={409}
|
|
151
162
|
)
|
|
152
163
|
if resp.status_code == 409:
|
|
164
|
+
if cap_policy == "fail":
|
|
165
|
+
raise SystemExit(
|
|
166
|
+
"distribution certificate cap reached; policy=fail preserves all "
|
|
167
|
+
"existing certificates and refuses automatic revocation"
|
|
168
|
+
)
|
|
153
169
|
# Revoke the OLDEST cert, NOT the newest. The newest cert signed
|
|
154
170
|
# the previous build that may still be in ASC processing — revoking
|
|
155
171
|
# it during processing yields ITMS-90035 on the prior build.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Distribution-certificate cap policy and action wiring tests."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import unittest
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from unittest import mock
|
|
10
|
+
|
|
11
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
12
|
+
|
|
13
|
+
import cert_factory # noqa: E402
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
ACTION_YAML = Path(__file__).resolve().parents[1] / "action.yml"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CertificateCapPolicyTests(unittest.TestCase):
|
|
20
|
+
def test_invalid_policy_fails_before_provider_call(self):
|
|
21
|
+
with mock.patch.dict(os.environ, {"CERTIFICATE_CAP_POLICY": "rotate-all"}, clear=True):
|
|
22
|
+
with mock.patch.object(cert_factory, "request") as request:
|
|
23
|
+
with self.assertRaisesRegex(SystemExit, "invalid CERTIFICATE_CAP_POLICY"):
|
|
24
|
+
cert_factory.create_distribution_cert("token", "csr")
|
|
25
|
+
request.assert_not_called()
|
|
26
|
+
|
|
27
|
+
def test_fail_policy_preserves_every_certificate_on_cap(self):
|
|
28
|
+
response = mock.MagicMock(status_code=409)
|
|
29
|
+
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:
|
|
31
|
+
with mock.patch.object(cert_factory, "oldest_distribution_cert_id") as oldest:
|
|
32
|
+
with self.assertRaisesRegex(SystemExit, "refuses automatic revocation"):
|
|
33
|
+
cert_factory.create_distribution_cert("token", "csr")
|
|
34
|
+
request.assert_called_once()
|
|
35
|
+
oldest.assert_not_called()
|
|
36
|
+
|
|
37
|
+
def test_default_policy_keeps_backward_compatible_rotation(self):
|
|
38
|
+
capped = mock.MagicMock(status_code=409)
|
|
39
|
+
created = mock.MagicMock()
|
|
40
|
+
created.json.return_value = {
|
|
41
|
+
"data": {"id": "NEW", "attributes": {"certificateContent": "Y2VydA=="}}
|
|
42
|
+
}
|
|
43
|
+
with mock.patch.dict(os.environ, {}, clear=True):
|
|
44
|
+
with mock.patch.object(
|
|
45
|
+
cert_factory, "request", side_effect=[capped, mock.MagicMock()]
|
|
46
|
+
) as request:
|
|
47
|
+
with mock.patch.object(cert_factory, "oldest_distribution_cert_id", return_value="OLD"):
|
|
48
|
+
with mock.patch.object(cert_factory, "_post_with_revoke_backoff", return_value=created):
|
|
49
|
+
cert_id, cert_der = cert_factory.create_distribution_cert("token", "csr")
|
|
50
|
+
self.assertEqual((cert_id, cert_der), ("NEW", b"cert"))
|
|
51
|
+
request.assert_any_call("DELETE", "/certificates/OLD", "token")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class CertificateCapActionWiringTests(unittest.TestCase):
|
|
55
|
+
def test_action_exposes_policy_and_persists_partial_cache_before_failure(self):
|
|
56
|
+
source = ACTION_YAML.read_text(encoding="utf-8")
|
|
57
|
+
self.assertRegex(
|
|
58
|
+
source,
|
|
59
|
+
r"(?m)^ certificate-cap-policy:\n(?: .*\n)*? default: \"revoke-oldest\"$",
|
|
60
|
+
)
|
|
61
|
+
self.assertIn("CERTIFICATE_CAP_POLICY: ${{ inputs.certificate-cap-policy }}", source)
|
|
62
|
+
self.assertIn("SIGNING_PREP_EXIT=$signing_exit", source)
|
|
63
|
+
self.assertIn("env.SIGNING_PREP_EXIT != '0'", source)
|
|
64
|
+
commit_at = source.index("- name: Commit + push staged bot changes")
|
|
65
|
+
failure_at = source.index("- name: Re-raise signing preparation failure")
|
|
66
|
+
self.assertLess(commit_at, failure_at)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
if __name__ == "__main__":
|
|
70
|
+
unittest.main()
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.34
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.34
|