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 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
@@ -1 +1 @@
1
- 1.0.32
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' && inputs.manage-app-store-version == '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
- run: python3 "${{ github.action_path }}/scripts/prepare_signing.py"
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 four steps stage + commit-back the Apple Distribution
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 rotation (revoke OLDEST on 409), and PKCS12 serialisation. Split
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.32
1
+ 1.0.33
@@ -1 +1 @@
1
- 1.0.32
1
+ 1.0.33
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.32",
3
+ "version": "1.0.33",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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()