gowalk-cicd 1.0.32 → 1.0.33
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 -8
- package/action/.daemux-version +1 -1
- package/action/action.yml +29 -27
- 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/templates/deploy.yml +0 -5
- package/action/scripts/test_testflight_version_slot_wiring.py +0 -50
package/README.md
CHANGED
|
@@ -437,13 +437,6 @@ The iOS composite action runs on `macos-15` and:
|
|
|
437
437
|
|
|
438
438
|
The iOS action requires only the p8. Everything else is derived.
|
|
439
439
|
|
|
440
|
-
Feature-branch runs installed from the standard workflow still archive and
|
|
441
|
-
upload to TestFlight, but pass `manage-app-store-version: 'false'`. This keeps
|
|
442
|
-
branch validation independent of the editable App Store release slot, so a
|
|
443
|
-
build can upload while the current release is locked in review. Default-branch
|
|
444
|
-
runs retain version creation and metadata automation. Direct action callers
|
|
445
|
-
keep the historical behavior because the new input defaults to `true`.
|
|
446
|
-
|
|
447
440
|
### App ID capabilities
|
|
448
441
|
|
|
449
442
|
A provisioning profile carries only the capabilities enabled on its App ID.
|
|
@@ -572,11 +565,11 @@ common ones:
|
|
|
572
565
|
| `bundle-id` | Override the auto-detected bundle identifier |
|
|
573
566
|
| `team-id` | Override the auto-detected team ID |
|
|
574
567
|
| `app-store-apple-id` | Numeric ASC app ID (override auto-lookup) |
|
|
568
|
+
| `certificate-cap-policy` | Full-cap behavior: `fail` preserves identities; default `revoke-oldest` rotates. |
|
|
575
569
|
| `run-tests` | `false` to skip the simulator test stage |
|
|
576
570
|
| `uses-non-exempt-encryption` | Value for `ITSAppUsesNonExemptEncryption` |
|
|
577
571
|
| `archive` | `false` to build-only (PR runs without secrets) |
|
|
578
572
|
| `upload` | `false` to archive but not upload to TestFlight |
|
|
579
|
-
| `manage-app-store-version` | `false` to upload without creating/editing the App Store release slot |
|
|
580
573
|
| `app-store-whats-new` | Inline "What's New" text (overrides files) |
|
|
581
574
|
| `ai-metadata` | `false` to disable AI auto-fill of empty ASC metadata |
|
|
582
575
|
| `ai-metadata-model` | GitHub Models model id (default `openai/gpt-4o`) |
|
|
@@ -591,6 +584,12 @@ inside GitHub Actions.
|
|
|
591
584
|
Once the app exists, all subsequent builds and uploads are fully automated via
|
|
592
585
|
the ASC API key.
|
|
593
586
|
|
|
587
|
+
For accounts where automatic certificate revocation is not acceptable, pass
|
|
588
|
+
`certificate-cap-policy: 'fail'`. The action may add a certificate when Apple has a free
|
|
589
|
+
slot, but a full-cap response aborts without listing or revoking existing identities. If
|
|
590
|
+
signing preparation fails after creating a certificate, the default-branch workflow first
|
|
591
|
+
commits any completed cache files, then re-raises the failure so the private key is not lost.
|
|
592
|
+
|
|
594
593
|
## Troubleshooting
|
|
595
594
|
|
|
596
595
|
**"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.33
|
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
|
|
@@ -64,13 +71,6 @@ inputs:
|
|
|
64
71
|
secrets. When 'false', `upload` is ignored.
|
|
65
72
|
required: false
|
|
66
73
|
default: "true"
|
|
67
|
-
manage-app-store-version:
|
|
68
|
-
description: >
|
|
69
|
-
Set to 'false' to archive and upload a TestFlight build without creating
|
|
70
|
-
or editing an App Store version. App Store metadata automation is skipped.
|
|
71
|
-
This is useful for feature-branch validation while a release is in review.
|
|
72
|
-
required: false
|
|
73
|
-
default: "true"
|
|
74
74
|
run-tests:
|
|
75
75
|
description: "Set to 'false' to skip the simulator test stage"
|
|
76
76
|
required: false
|
|
@@ -289,7 +289,7 @@ runs:
|
|
|
289
289
|
| tee -a "$GITHUB_ENV"
|
|
290
290
|
|
|
291
291
|
- name: Resolve App Store version slot (REUSE or CREATE)
|
|
292
|
-
if: ${{ inputs.archive == 'true'
|
|
292
|
+
if: ${{ inputs.archive == 'true' }}
|
|
293
293
|
shell: bash
|
|
294
294
|
env:
|
|
295
295
|
APP_STORE_APPLE_ID: ${{ inputs.app-store-apple-id || env.CFG_APP_STORE_APPLE_ID }}
|
|
@@ -335,18 +335,6 @@ runs:
|
|
|
335
335
|
echo "APP_STORE_VERSION_ID=$APP_STORE_VERSION_ID" >> "$GITHUB_ENV"
|
|
336
336
|
echo "Using marketing version $MARKETING_VERSION build $BUILD_NUMBER (decision=$DECISION_JSON)"
|
|
337
337
|
|
|
338
|
-
- name: Resolve TestFlight build number
|
|
339
|
-
if: ${{ inputs.archive == 'true' && inputs.manage-app-store-version == 'false' }}
|
|
340
|
-
shell: bash
|
|
341
|
-
env:
|
|
342
|
-
APP_STORE_APPLE_ID: ${{ inputs.app-store-apple-id || env.CFG_APP_STORE_APPLE_ID }}
|
|
343
|
-
run: |
|
|
344
|
-
set -o pipefail
|
|
345
|
-
BUILD_NUMBER=$(python3 "${{ github.action_path }}/scripts/next_build_number.py")
|
|
346
|
-
echo "BUILD_NUMBER=$BUILD_NUMBER" >> "$GITHUB_ENV"
|
|
347
|
-
echo "APP_STORE_VERSION_ID=" >> "$GITHUB_ENV"
|
|
348
|
-
echo "Using marketing version $MARKETING_VERSION build $BUILD_NUMBER without an App Store version slot"
|
|
349
|
-
|
|
350
338
|
- name: Notice on deprecated profile-name input
|
|
351
339
|
if: ${{ inputs.archive == 'true' && inputs.profile-name != '' }}
|
|
352
340
|
shell: bash
|
|
@@ -362,10 +350,19 @@ runs:
|
|
|
362
350
|
WORKSPACE: ${{ inputs.workspace || env.CFG_WORKSPACE }}
|
|
363
351
|
TEAM_ID: ${{ inputs.team-id || env.CFG_TEAM_ID }}
|
|
364
352
|
CREDS_DIR: ${{ github.workspace }}/creds
|
|
365
|
-
|
|
353
|
+
CERTIFICATE_CAP_POLICY: ${{ inputs.certificate-cap-policy }}
|
|
354
|
+
run: |
|
|
355
|
+
set +e
|
|
356
|
+
python3 "${{ github.action_path }}/scripts/prepare_signing.py"
|
|
357
|
+
signing_exit=$?
|
|
358
|
+
set -e
|
|
359
|
+
echo "SIGNING_PREP_EXIT=$signing_exit" >> "$GITHUB_ENV"
|
|
360
|
+
if [ "$signing_exit" -ne 0 ]; then
|
|
361
|
+
echo "::error::Signing preparation failed; preserving any generated cache before failing."
|
|
362
|
+
fi
|
|
366
363
|
|
|
367
364
|
# DESIGN DECISION (intentional, not a bug):
|
|
368
|
-
# The next
|
|
365
|
+
# The next five steps stage + commit-back the Apple Distribution
|
|
369
366
|
# code-signing identity (.p12 + provisioning profiles) AND any
|
|
370
367
|
# autoupdate of the vendored app-ci actions into the consumer
|
|
371
368
|
# repo's default branch. This is a deliberate trade-off chosen over
|
|
@@ -386,7 +383,8 @@ runs:
|
|
|
386
383
|
# alone without redundant retries: (1) copy bot scripts to RUNNER_TEMP
|
|
387
384
|
# so autoupdate's `npx` cannot mid-run swap script bytes under us,
|
|
388
385
|
# (2) stage cert files, (3) check for newer app-ci on npm and
|
|
389
|
-
# stage refreshed action files, (4) commit + push whatever is staged
|
|
386
|
+
# stage refreshed action files, (4) commit + push whatever is staged,
|
|
387
|
+
# (5) re-raise a deferred prepare failure after preserving its cache.
|
|
390
388
|
|
|
391
389
|
# Stage scripts to RUNNER_TEMP so the autoupdate step's `npx` (which
|
|
392
390
|
# overwrites .github/actions/swift-app/ — the same dir as ${action_path}
|
|
@@ -494,6 +492,13 @@ runs:
|
|
|
494
492
|
GITHUB_REF_NAME: ${{ github.ref_name }}
|
|
495
493
|
run: bash "$BOT_SCRIPTS_DIR/commit_bot_changes.sh"
|
|
496
494
|
|
|
495
|
+
- name: Re-raise signing preparation failure
|
|
496
|
+
if: ${{ inputs.archive == 'true' && env.SIGNING_PREP_EXIT != '0' }}
|
|
497
|
+
shell: bash
|
|
498
|
+
run: |
|
|
499
|
+
echo "::error::Signing preparation failed with exit $SIGNING_PREP_EXIT."
|
|
500
|
+
exit "$SIGNING_PREP_EXIT"
|
|
501
|
+
|
|
497
502
|
- name: Stamp Info.plist versions (CFBundleVersion + CFBundleShortVersionString)
|
|
498
503
|
if: ${{ inputs.archive == 'true' }}
|
|
499
504
|
shell: bash
|
|
@@ -709,9 +714,7 @@ runs:
|
|
|
709
714
|
|
|
710
715
|
- name: Detect empty ASC metadata fields
|
|
711
716
|
id: detect_metadata
|
|
712
|
-
if:
|
|
713
|
-
${{ inputs.archive == 'true' && inputs.upload == 'true'
|
|
714
|
-
&& inputs.manage-app-store-version == 'true' && inputs.ai-metadata != 'false' }}
|
|
717
|
+
if: ${{ inputs.archive == 'true' && inputs.upload == 'true' && inputs.ai-metadata != 'false' }}
|
|
715
718
|
shell: bash
|
|
716
719
|
env:
|
|
717
720
|
APP_STORE_APPLE_ID: ${{ inputs.app-store-apple-id || env.CFG_APP_STORE_APPLE_ID }}
|
|
@@ -966,7 +969,6 @@ runs:
|
|
|
966
969
|
# with the default (CFG_WHATS_NEW), preserving any AI-generated notes.
|
|
967
970
|
- name: Set App Store "What's New"
|
|
968
971
|
if: ${{ inputs.archive == 'true' && inputs.upload == 'true'
|
|
969
|
-
&& inputs.manage-app-store-version == 'true'
|
|
970
972
|
&& (inputs.app-store-whats-new != '' || env.CFG_WHATS_NEW != '') }}
|
|
971
973
|
shell: bash
|
|
972
974
|
env:
|
|
@@ -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.33
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.33
|
package/package.json
CHANGED
package/templates/deploy.yml
CHANGED
|
@@ -113,11 +113,6 @@ jobs:
|
|
|
113
113
|
workspace: ${{ steps.flutter.outputs.enabled == 'true' && 'ios/Runner.xcworkspace' || '' }}
|
|
114
114
|
scheme: ${{ steps.flutter.outputs.enabled == 'true' && 'Runner' || '' }}
|
|
115
115
|
run-tests: 'false' # tests belong to the CI gate, not the deploy path
|
|
116
|
-
# A feature-branch TestFlight build must not create or mutate a live
|
|
117
|
-
# App Store version. That also lets it upload while the current version
|
|
118
|
-
# is locked in review. Default-branch deploys retain metadata automation.
|
|
119
|
-
manage-app-store-version: >-
|
|
120
|
-
${{ github.ref_name == github.event.repository.default_branch && 'true' || 'false' }}
|
|
121
116
|
|
|
122
117
|
android:
|
|
123
118
|
name: Android Google Play
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import re
|
|
2
|
-
import unittest
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
ACTION_YAML = Path(__file__).resolve().parents[1] / "action.yml"
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def step_block(source: str, name: str) -> str:
|
|
10
|
-
pattern = rf" - name: {re.escape(name)}\n(?P<body>.*?)(?=\n - name:|\Z)"
|
|
11
|
-
match = re.search(pattern, source, re.DOTALL)
|
|
12
|
-
if match is None:
|
|
13
|
-
raise AssertionError(f"missing action step: {name}")
|
|
14
|
-
return match.group("body")
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
class TestTestFlightVersionSlotWiring(unittest.TestCase):
|
|
18
|
-
@classmethod
|
|
19
|
-
def setUpClass(cls) -> None:
|
|
20
|
-
cls.source = ACTION_YAML.read_text(encoding="utf-8")
|
|
21
|
-
|
|
22
|
-
def test_opt_out_is_backward_compatible(self) -> None:
|
|
23
|
-
self.assertRegex(
|
|
24
|
-
self.source,
|
|
25
|
-
r"(?m)^ manage-app-store-version:\n(?: .*\n)*? default: \"true\"$",
|
|
26
|
-
)
|
|
27
|
-
|
|
28
|
-
def test_opt_out_skips_store_version_but_keeps_build_number(self) -> None:
|
|
29
|
-
store_slot = step_block(self.source, "Resolve App Store version slot (REUSE or CREATE)")
|
|
30
|
-
testflight = step_block(self.source, "Resolve TestFlight build number")
|
|
31
|
-
|
|
32
|
-
self.assertIn("inputs.manage-app-store-version == 'true'", store_slot)
|
|
33
|
-
self.assertIn("inputs.manage-app-store-version == 'false'", testflight)
|
|
34
|
-
self.assertIn(
|
|
35
|
-
"APP_STORE_APPLE_ID: ${{ inputs.app-store-apple-id || env.CFG_APP_STORE_APPLE_ID }}",
|
|
36
|
-
testflight,
|
|
37
|
-
)
|
|
38
|
-
self.assertIn("next_build_number.py", testflight)
|
|
39
|
-
self.assertNotIn("manage_marketing_version.py", testflight)
|
|
40
|
-
|
|
41
|
-
def test_metadata_requires_a_managed_store_version(self) -> None:
|
|
42
|
-
detector = step_block(self.source, "Detect empty ASC metadata fields")
|
|
43
|
-
whats_new = step_block(self.source, 'Set App Store "What\'s New"')
|
|
44
|
-
|
|
45
|
-
self.assertIn("inputs.manage-app-store-version == 'true'", detector)
|
|
46
|
-
self.assertIn("inputs.manage-app-store-version == 'true'", whats_new)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if __name__ == "__main__":
|
|
50
|
-
unittest.main()
|