gowalk-cicd 1.0.109 → 1.0.111

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
@@ -122,6 +122,9 @@ node /path/to/gowalk-cicd/bin/cli.mjs
122
122
  and Play service account into an ephemeral runner checkout.
123
123
  Tracked `creds/` files remain a backward-compatible read fallback. Refreshed signing
124
124
  files stay ephemeral and are never staged; `persist-signing-cache` is ignored.
125
+ Signing preparation never issues a distribution certificate on a cache miss: the ephemeral
126
+ job cannot retain its new private key. Require a supplied usable identity, preserve failed
127
+ material for reconciliation and report `apple_signing_identity_required` without provider writes.
125
128
  Version source staged before signing is preserved separately with its original index bytes.
126
129
  - App Group identifiers come from each archive target's entitlements. `app_groups.py`
127
130
  checks exact values in cached and fresh profiles; a fresh mismatch fails before profile
@@ -366,6 +369,10 @@ node /path/to/gowalk-cicd/bin/cli.mjs
366
369
  refusal permits a review hold. Unknown/transport/post-commit uncertainty does not replay.
367
370
  The existing signed AAB, proxy, track/status/rollout and notes survive one bounded retry;
368
371
  only a successful hold emits `play_review_pending`. Keep raw child output out of receipts.
372
+ Each attempt also emits brace-free `play_upload_diagnostic` with fixed observed progress,
373
+ process outcome and error signals from bounded private stdout/stderr line buffers.
374
+ Preserve the v1 upload result and retry rules; diagnostics never authorize replay or prove
375
+ an operation completed. See README for the independent diagnostic schema and field vocabulary.
369
376
  - The JDK is chosen by `scripts/select_jdk.py`, called from the workflow before
370
377
  `setup-java`. The `JAVA_VERSION` repo variable always wins; otherwise Flutter
371
378
  gets 17 and native Gradle gets 21, except that a positively-detected Kotlin
package/README.md CHANGED
@@ -124,9 +124,16 @@ registry digests over those bytes—survive the secret round trip. The workflow
124
124
  materializes them with mode `0600` only in the ephemeral runner
125
125
  checkout. Refreshed signing files are never staged or committed by the
126
126
  composite action, including when a legacy workflow passes `persist-signing-cache: true`.
127
- That input is retained as an ignored compatibility input. The distribution identity
128
- files come from the signing registry provisioner; pass their paths to the secret tooling without printing
129
- their contents.
127
+ That input is retained as an ignored compatibility input.
128
+
129
+ The action reuses a supplied identity and never issues a new distribution certificate. A missing,
130
+ invalid or revoked legacy identity fails with `apple_signing_identity_required`; reconcile and
131
+ provision retained material through the encrypted inputs above before retrying. A temporary
132
+ job cannot be the sole owner of a new private key. Existing legacy cache files remain readable
133
+ and are preserved after refusal; registry identities retain their stricter ownership checks.
134
+
135
+ Distribution identity files come from the signing registry provisioner; pass their paths
136
+ to the secret tooling without printing their contents.
130
137
 
131
138
  Legacy private repositories can still provide the API key at:
132
139
 
@@ -353,6 +360,7 @@ the situation and whose **message** is one line of JSON carrying a versioned
353
360
  | --- | --- | --- |
354
361
  | `play_review_pending` | `gowalk-cicd/play-review-pending.v1` | `package`, `track`, `version_code` |
355
362
  | `play_upload_result` | `gowalk-cicd/play-upload.v1` | `ok`, `code`, `attempts`, `review_pending`, upstream identity |
363
+ | `play_upload_diagnostic` | `gowalk-cicd/play-upload-diagnostic.v1` | Fixed process evidence; see below |
356
364
  | `store_version_locked` | `gowalk-cicd/store-version-locked.v1` | `version`, `state`, `build_number` |
357
365
  | `firebase_symbols_pending` | `gowalk-cicd/firebase-symbols-pending.v1` | `platform`, `app_id`, `source_sha`, `artifact`, `file`, `sha256`, `status` |
358
366
  | `mobile_upload_reused` | `gowalk-cicd/mobile-upload-reused.v1` | `platform`, `fingerprint`, `run_id`, `run_attempt`, `source_sha`, `deployment_id`, `next_action` |
@@ -453,6 +461,20 @@ successful review-hold retry emits the existing `play_review_pending` receipt.
453
461
  Ordinary retry success never misreports an unsubmitted release. The wrapper does
454
462
  not depend on check-run annotations becoming visible while its job is running.
455
463
 
464
+ Every completed uploader attempt also emits `play_upload_diagnostic`, independently of the
465
+ unchanged v1 result. Its brace-free message starts with `gowalk-cicd/play-upload-diagnostic.v1`
466
+ and carries these space-separated fields in order: `attempt=<1|2>`, `phase=<phase>`,
467
+ `exit_code=<0-255|unknown>`, `signal=<signal>`, `error_count=<count|unknown>`,
468
+ `cancelled=<true|false>`, `commit_reported=<true|false>`, `signals=<signals>`.
469
+ The phase is the last observed official progress line: `startup`, `edit_create`, `tracks_read`,
470
+ `bundle_upload`, `track_update` or `edit_commit`. It does not prove that operation completed.
471
+ The signal is `none`, `SIGTERM`, `SIGINT`, `SIGKILL` or `other`; observed error signals are
472
+ comma-separated `connection_reset`, `connection_refused`, `connection_timeout`, `dns_failure`,
473
+ `tls_verification`, `output_truncated`, or `none`. Each private stdout/stderr line buffer is
474
+ bounded to 16 KiB. No provider message, path, URL, track or credential is republished.
475
+ These observations never authorize a retry. Missing commit output does not prove no mutation,
476
+ and a reported commit does not prove review submission. Unknown failures remain unclassified.
477
+
456
478
  An expired/deleted edit does not identify its cause. Another edit opened with the
457
479
  same API user/package can invalidate it; avoid competing read edits while uploading.
458
480
  An external actor is not proven by an expired-edit error alone. Ambiguous outcomes
@@ -1 +1 @@
1
- 1.0.109
1
+ 1.0.111
@@ -3,10 +3,9 @@
3
3
  Persistent cache for the Apple Distribution cert + per-bundle provisioning
4
4
  profiles, stored under the workspace's ``creds/`` directory.
5
5
 
6
- CI no longer regenerates a fresh cert and N profiles on every run. Apple
7
- caps each team at 2 distribution certs and 7-day-rolling profile churn
8
- triggers throttling, so re-using the same identity across runs is the
9
- only sane long-term strategy. We keep:
6
+ The caller supplies a retained identity through encrypted CI inputs or an existing
7
+ legacy cache. CI reuses it and may refresh per-app profiles, but never issues a
8
+ replacement distribution identity in its temporary checkout. The layout is:
10
9
 
11
10
  creds/
12
11
  cert.p12 PKCS12 with cert + private key
@@ -28,12 +27,8 @@ Reuse rules (encoded in :func:`load_cached_cert` and
28
27
  has matching cert_id AND expiration is more than 30 days away AND
29
28
  the .mobileprovision file is on disk.
30
29
 
31
- Anything outside reuse triggers regen + cache replacement (atomic).
32
-
33
- `p12_password` stays ``"ci"`` deliberately. Encryption-at-rest of the
34
- PKCS12 is decorative when the ``.p8`` ASC API key sits next to it in
35
- plaintext — anyone who can read one can read the other. The repo MUST
36
- stay private; the cert lives there for warm-cache reasons, not secrecy.
30
+ An unusable identity is preserved for reconciliation by its credential owner.
31
+ New signing files remain ephemeral and are never staged into source.
37
32
  """
38
33
 
39
34
  from __future__ import annotations
@@ -59,24 +54,14 @@ from profile_store import (
59
54
  )
60
55
 
61
56
 
62
- # Auto-renew when the cert (or any profile) is within this many days of
63
- # expiring. Apple distribution certs are valid for 1 year and profiles
64
- # for 1 year, so 30 days is plenty of headroom for CI to roll.
57
+ # Refuse identity reuse inside this window; the credential owner provisions its replacement.
65
58
  RENEW_THRESHOLD_DAYS = 30
66
59
 
67
60
  # Surface a non-fatal warning when the cert is within this many days of
68
- # expiry but not yet inside the auto-renew window. Gives operators
69
- # visibility before the renew actually fires.
61
+ # expiry but not yet inside the refusal window, so its owner can provision a replacement.
70
62
  WARN_THRESHOLD_DAYS = 60
71
63
 
72
- # DESIGN DECISION (intentional, not a bug):
73
- # P12 password is a hardcoded literal because encryption-at-rest is
74
- # decorative in this storage model: the .p12 lives next to the .p8 ASC
75
- # API key in the same private repo. Both are private keys; protecting one
76
- # with a sourced password while the other sits in plaintext alongside
77
- # adds bug surface (password drift between meta.json and actual encryption)
78
- # without raising the security floor. If this storage model changes (e.g.
79
- # .p8 moves to GH Secrets), revisit and source this from a secret too.
64
+ # Compatibility default for existing legacy metadata; supplied identities carry their own password.
80
65
  P12_PASSWORD = "ci"
81
66
 
82
67
  # File-name layout under creds/. Centralised so callers never compute
@@ -2,28 +2,14 @@
2
2
  """
3
3
  Prepare iOS code signing on a fresh CI runner (manual-signing variant).
4
4
 
5
- Caching: the Apple Distribution cert + per-bundle provisioning profiles
6
- live under the workspace's ``creds/`` directory and are reused across
7
- runs. See ``creds_store.py`` for the on-disk layout and reuse rules.
8
- This script orchestrates the load-or-regen flow:
9
-
10
- 1. Try to reuse the cached cert (decrypts + NotAfter > 30d +
11
- ``GET /certificates/{cert_id}`` = 200).
12
- 2. On a legacy cache miss, invalidate the cache and create a fresh cert via
13
- ``cert_factory``. A registry-managed identity instead fails closed: this
14
- action never creates, replaces, or revokes centrally owned certificates.
15
- 3. Hand the cert id + cache_hit flag to ``provision_all_bundles``,
16
- which decides per-bundle whether to reuse a cached profile or
17
- create a new one and updates the manifest.
18
- 4. Orphan profiles (manifest entries for bundles no longer in the
19
- target list) get GC'd from disk + manifest.
20
- 5. After successful prep the workspace ``creds/`` tree is dirty with
21
- any refreshed artifacts; ``action.yml`` commits the diff back when
22
- the run is on the default branch.
23
-
24
- Sibling modules: ``cert_factory`` (key+CSR+ASC POST), ``keychain``
25
- (throwaway runner keychain), ``profile_manager`` (per-bundle profile
26
- lifecycle), ``profile_io`` (CMS decode), ``creds_store`` (on-disk cache).
5
+ The action consumes a retained Apple Distribution identity from encrypted CI inputs
6
+ or an existing legacy cache. It never creates, replaces or revokes a certificate:
7
+ new private keys in this ephemeral checkout would be lost at job cleanup.
8
+
9
+ After confirming the supplied identity is usable and live on Apple, create/reuse
10
+ per-bundle provisioning profiles, patch the ephemeral Xcode project and import
11
+ that identity into the job keychain. Registry ownership and digest checks remain
12
+ mandatory when its marker is present. No signing files enter source maintenance.
27
13
 
28
14
  Env inputs: ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH (key trio), PROJECT
29
15
  (.xcodeproj), TEAM_ID (optional; derived from profile plist when empty),
@@ -41,7 +27,6 @@ from pathlib import Path
41
27
 
42
28
  import app_groups
43
29
  import capabilities
44
- import cert_factory
45
30
  import creds_store
46
31
  from asc_common import make_jwt
47
32
  from keychain import setup_keychain
@@ -67,15 +52,15 @@ def _warn_if_renewal_near(cached: creds_store.CachedCert) -> None:
67
52
  if renew_at < cached.not_after <= warn_at:
68
53
  print(
69
54
  f"::warning::Cert {cached.cert_id} expires in "
70
- f"{(cached.not_after - now).days}d; auto-renew fires at "
55
+ f"{(cached.not_after - now).days}d; provision its replacement before "
71
56
  f"{creds_store.RENEW_THRESHOLD_DAYS}d remaining."
72
57
  )
73
58
 
74
59
 
75
- def _load_or_create_cert(
60
+ def _load_signing_identity(
76
61
  token: str, creds_dir: Path
77
62
  ) -> tuple[str, bytes, str, bool]:
78
- """Load a usable identity or create one only for an unmanaged legacy repo."""
63
+ """Reuse retained signing material; CI cannot durably own newly issued keys."""
79
64
  managed = creds_store.registry_managed(creds_dir)
80
65
  cached = creds_store.load_cached_cert(creds_dir)
81
66
  expected_digest = cached.certificate_sha256 if cached and managed else None
@@ -97,25 +82,15 @@ def _load_or_create_cert(
97
82
  "Reconcile and replace the account identity in app-robot."
98
83
  )
99
84
 
100
- if cached:
101
- print(
102
- f"Cached cert {cached.cert_id} no longer alive on Apple; "
103
- "invalidating cache and regenerating"
104
- )
105
- creds_store.invalidate_cache(creds_dir)
106
-
107
- private_key, csr_pem = cert_factory.generate_key_and_csr()
108
- cert_id, cert_der = cert_factory.create_distribution_cert(
109
- token, csr_pem.decode("ascii")
110
- )
111
- password = creds_store.P12_PASSWORD
112
- p12_bytes = cert_factory.serialize_p12(private_key, cert_der, password)
113
- not_after = creds_store.cert_not_after_from_der(cert_der)
114
- creds_store.write_cert_bundle(
115
- creds_dir, cert_id, p12_bytes, password, not_after
85
+ reason = "unusable" if cached else "missing_or_invalid"
86
+ evidence = {"schema": "gowalk-cicd/apple-signing-identity-required.v1", "mode": "legacy",
87
+ "reason": reason, "certificate_creation_attempted": False}
88
+ print("::error title=apple_signing_identity_required::" + json.dumps(evidence, separators=(",", ":")))
89
+ raise SystemExit(
90
+ "A retained Apple Distribution signing identity is required; provision it through encrypted CI inputs. "
91
+ "Refusing certificate creation because this job cannot retain a new private key durably. "
92
+ "Existing signing files are preserved."
116
93
  )
117
- print(f"Persisted fresh cert bundle to {creds_dir}")
118
- return cert_id, p12_bytes, password, False
119
94
 
120
95
 
121
96
  def _resolve_creds_dir() -> Path:
@@ -232,7 +207,7 @@ def main() -> None:
232
207
  )
233
208
 
234
209
  groups_by_bundle = app_groups.declared_by_bundle(project, targets)
235
- cert_id, p12_bytes, p12_pass, cache_hit = _load_or_create_cert(
210
+ cert_id, p12_bytes, p12_pass, cache_hit = _load_signing_identity(
236
211
  token, creds_dir
237
212
  )
238
213
  mappings, effective_team = provision_all_bundles(
@@ -68,7 +68,7 @@ class GroupRequirementsTest(unittest.TestCase):
68
68
  with mock.patch.dict(prepare_signing.os.environ, env, clear=True), \
69
69
  mock.patch.object(prepare_signing, "make_jwt", return_value="test"), \
70
70
  mock.patch.object(prepare_signing, "discover_signable_targets", return_value=targets), \
71
- mock.patch.object(prepare_signing, "_load_or_create_cert", return_value=("C", b"p12", "", True)), \
71
+ mock.patch.object(prepare_signing, "_load_signing_identity", return_value=("C", b"p12", "", True)), \
72
72
  mock.patch.object(
73
73
  prepare_signing, "provision_all_bundles", return_value=([], "TEAM")) as provision, \
74
74
  mock.patch.object(prepare_signing, "patch_project_signing"), \
@@ -14,6 +14,7 @@ from unittest import mock
14
14
 
15
15
  sys.path.insert(0, str(Path(__file__).resolve().parent))
16
16
 
17
+ import cert_factory # noqa: E402
17
18
  import creds_store # noqa: E402
18
19
  import prepare_signing # noqa: E402
19
20
 
@@ -39,11 +40,11 @@ class RegistryManagedSigningTests(unittest.TestCase):
39
40
  with self._managed_dir() as raw:
40
41
  creds_dir = Path(raw)
41
42
  (creds_dir / "cert.p12").unlink()
42
- with mock.patch.object(prepare_signing.cert_factory, "generate_key_and_csr") as generate:
43
- with mock.patch.object(prepare_signing.cert_factory, "create_distribution_cert") as create:
43
+ with mock.patch.object(cert_factory, "generate_key_and_csr") as generate:
44
+ with mock.patch.object(cert_factory, "create_distribution_cert") as create:
44
45
  with mock.patch.object(prepare_signing.creds_store, "invalidate_cache") as invalidate:
45
46
  with self.assertRaisesRegex(SystemExit, "bundle is incomplete"):
46
- prepare_signing._load_or_create_cert("token", creds_dir)
47
+ prepare_signing._load_signing_identity("token", creds_dir)
47
48
  generate.assert_not_called()
48
49
  create.assert_not_called()
49
50
  invalidate.assert_not_called()
@@ -59,9 +60,9 @@ class RegistryManagedSigningTests(unittest.TestCase):
59
60
  with self._managed_dir() as raw:
60
61
  with mock.patch.object(prepare_signing.creds_store, "load_cached_cert", return_value=cached):
61
62
  with mock.patch.object(prepare_signing.creds_store, "verify_cert_alive", return_value=False):
62
- with mock.patch.object(prepare_signing.cert_factory, "create_distribution_cert") as create:
63
+ with mock.patch.object(cert_factory, "create_distribution_cert") as create:
63
64
  with self.assertRaisesRegex(SystemExit, "not active on Apple or does not match"):
64
- prepare_signing._load_or_create_cert("token", Path(raw))
65
+ prepare_signing._load_signing_identity("token", Path(raw))
65
66
  create.assert_not_called()
66
67
 
67
68
  def test_active_managed_identity_is_reused(self):
@@ -75,8 +76,8 @@ class RegistryManagedSigningTests(unittest.TestCase):
75
76
  with self._managed_dir() as raw:
76
77
  with mock.patch.object(prepare_signing.creds_store, "load_cached_cert", return_value=cached):
77
78
  with mock.patch.object(prepare_signing.creds_store, "verify_cert_alive", return_value=True):
78
- with mock.patch.object(prepare_signing.cert_factory, "create_distribution_cert") as create:
79
- result = prepare_signing._load_or_create_cert("token", Path(raw))
79
+ with mock.patch.object(cert_factory, "create_distribution_cert") as create:
80
+ result = prepare_signing._load_signing_identity("token", Path(raw))
80
81
  self.assertEqual(result, ("CERT1", b"p12", "secret", True))
81
82
  create.assert_not_called()
82
83
 
@@ -95,14 +96,14 @@ class RegistryManagedSigningTests(unittest.TestCase):
95
96
  with self._managed_dir(state="updating") as raw:
96
97
  with mock.patch.object(prepare_signing.creds_store, "load_cached_cert") as load:
97
98
  with self.assertRaisesRegex(SystemExit, "not committed and ready"):
98
- prepare_signing._load_or_create_cert("token", Path(raw))
99
+ prepare_signing._load_signing_identity("token", Path(raw))
99
100
  load.assert_not_called()
100
101
 
101
102
  def test_ready_marker_rejects_tampered_bundle(self):
102
103
  with self._managed_dir() as raw:
103
104
  (Path(raw) / "cert.p12").write_bytes(b"tampered")
104
105
  with self.assertRaisesRegex(SystemExit, "digest mismatch"):
105
- prepare_signing._load_or_create_cert("token", Path(raw))
106
+ prepare_signing._load_signing_identity("token", Path(raw))
106
107
 
107
108
  def test_metadata_without_ownership_marker_remains_legacy(self):
108
109
  with tempfile.TemporaryDirectory() as raw:
@@ -0,0 +1,110 @@
1
+ """Temporary release checkouts must never issue an identity whose private key they cannot retain."""
2
+ from contextlib import ExitStack, redirect_stdout
3
+ from datetime import datetime, timedelta, timezone
4
+ import io
5
+ import json
6
+ from pathlib import Path
7
+ import tempfile
8
+ import unittest
9
+ from unittest import mock
10
+
11
+ from cryptography import x509
12
+ from cryptography.hazmat.primitives import hashes, serialization
13
+ from cryptography.hazmat.primitives.asymmetric import rsa
14
+ from cryptography.hazmat.primitives.serialization import pkcs12
15
+ from cryptography.x509.oid import NameOID
16
+
17
+ import cert_factory
18
+ import creds_store
19
+ import prepare_signing
20
+
21
+
22
+ def retained_identity(root, *, expires=90):
23
+ key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
24
+ name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'Synthetic signing test')])
25
+ now = datetime.now(timezone.utc)
26
+ cert = (x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(key.public_key())
27
+ .serial_number(x509.random_serial_number()).not_valid_before(now - timedelta(days=1))
28
+ .not_valid_after(now + timedelta(days=expires)).sign(key, hashes.SHA256()))
29
+ p12 = pkcs12.serialize_key_and_certificates(b'test', key, cert, None,
30
+ serialization.BestAvailableEncryption(b'synthetic'))
31
+ (root / 'cert.p12').write_bytes(p12)
32
+ (root / 'cert.meta.json').write_text(json.dumps({
33
+ 'cert_id': 'SYNTHETIC', 'not_after': (now + timedelta(days=expires)).isoformat(),
34
+ 'p12_password': 'synthetic'}))
35
+ (root / 'profiles.manifest.json').write_text('{"profiles":[]}')
36
+ return p12
37
+
38
+
39
+ def files(root):
40
+ return {str(p.relative_to(root)): p.read_bytes() for p in root.rglob('*') if p.is_file()}
41
+
42
+
43
+ class RetainedSigningIdentityTests(unittest.TestCase):
44
+ def setUp(self):
45
+ self.stack = ExitStack()
46
+ self.addCleanup(self.stack.close)
47
+ self.root = Path(self.stack.enter_context(tempfile.TemporaryDirectory()))
48
+ self.generate = self.stack.enter_context(mock.patch.object(cert_factory, 'generate_key_and_csr'))
49
+ self.create = self.stack.enter_context(mock.patch.object(cert_factory, 'create_distribution_cert'))
50
+ self.provider = self.stack.enter_context(mock.patch.object(creds_store, 'request'))
51
+ self.output = self.stack.enter_context(redirect_stdout(io.StringIO()))
52
+
53
+ def assert_refused_and_preserved(self):
54
+ before = files(self.root)
55
+ with self.assertRaisesRegex(SystemExit, 'retained Apple Distribution signing identity'):
56
+ prepare_signing._load_signing_identity('synthetic-token', self.root)
57
+ self.assertTrue(before == files(self.root))
58
+ self.generate.assert_not_called()
59
+ self.create.assert_not_called()
60
+ annotation = next(line.split('::', 2)[2] for line in self.output.getvalue().splitlines()
61
+ if line.startswith('::error title=apple_signing_identity_required::'))
62
+ self.assertFalse(json.loads(annotation)['certificate_creation_attempted'])
63
+
64
+ def test_empty_checkout_refuses_without_provider_traffic(self):
65
+ self.assert_refused_and_preserved()
66
+ self.provider.assert_not_called()
67
+
68
+ def test_corrupt_retained_material_is_preserved_without_provider_traffic(self):
69
+ retained_identity(self.root)
70
+ (self.root / 'cert.p12').write_bytes(b'synthetic-corrupt-p12')
71
+ self.assert_refused_and_preserved()
72
+ self.provider.assert_not_called()
73
+
74
+ def test_near_expiry_material_is_preserved_for_reconciliation(self):
75
+ retained_identity(self.root, expires=2)
76
+ self.assert_refused_and_preserved()
77
+ self.provider.assert_not_called()
78
+
79
+ def test_revoked_retained_identity_only_reads_apple(self):
80
+ retained_identity(self.root)
81
+ self.provider.return_value = mock.Mock(status_code=404)
82
+ self.assert_refused_and_preserved()
83
+ self.provider.assert_called_once()
84
+ self.assertEqual(self.provider.call_args.args[:2], ('GET', '/certificates/SYNTHETIC'))
85
+
86
+ def test_indeterminate_apple_read_does_not_create_or_destroy_material(self):
87
+ retained_identity(self.root)
88
+ before = files(self.root)
89
+ self.provider.side_effect = SystemExit('synthetic transport unavailable')
90
+ with self.assertRaisesRegex(SystemExit, 'transport unavailable'):
91
+ prepare_signing._load_signing_identity('synthetic-token', self.root)
92
+ self.assertTrue(before == files(self.root))
93
+ self.generate.assert_not_called()
94
+ self.create.assert_not_called()
95
+
96
+ def test_active_legacy_identity_reuses_identical_material_across_runs(self):
97
+ p12 = retained_identity(self.root)
98
+ before = files(self.root)
99
+ self.provider.return_value = mock.Mock(status_code=200)
100
+ for _ in range(2):
101
+ identity = prepare_signing._load_signing_identity('synthetic-token', self.root)
102
+ self.assertTrue(identity == ('SYNTHETIC', p12, 'synthetic', True))
103
+ self.assertTrue(before == files(self.root))
104
+ self.assertEqual(self.provider.call_count, 2)
105
+ self.generate.assert_not_called()
106
+ self.create.assert_not_called()
107
+
108
+
109
+ if __name__ == '__main__':
110
+ unittest.main()
@@ -56,7 +56,7 @@ class PrepareSigningTeamIdOptionalTests(unittest.TestCase):
56
56
  ):
57
57
  with mock.patch.object(
58
58
  prepare_signing,
59
- "_load_or_create_cert",
59
+ "_load_signing_identity",
60
60
  return_value=("CERTID", b"FAKEP12", "password", False),
61
61
  ):
62
62
  with mock.patch.object(
@@ -95,7 +95,7 @@ class PrepareSigningTeamIdOptionalTests(unittest.TestCase):
95
95
  ):
96
96
  with mock.patch.object(
97
97
  prepare_signing,
98
- "_load_or_create_cert",
98
+ "_load_signing_identity",
99
99
  return_value=("CID", b"P12", "password", False),
100
100
  ):
101
101
  with mock.patch.object(
@@ -1 +1 @@
1
- 1.0.109
1
+ 1.0.111
@@ -0,0 +1,73 @@
1
+ // Observe only fixed progress/error patterns; never retain provider text in a receipt.
2
+ const phases = new Map([
3
+ ['Creating a new Edit for this release', 'edit_create'],
4
+ ['Committing the Edit', 'edit_commit'],
5
+ ]);
6
+ const prefixes = new Map([
7
+ ["Validating tracks: '", 'tracks_read'], ['Uploading ', 'bundle_upload'],
8
+ ['::debug::Creating release for:', 'track_update'],
9
+ ]);
10
+ const tlsCodes = ['CERT_HAS_EXPIRED', 'DEPTH_ZERO_SELF_SIGNED_CERT', 'SELF_SIGNED_CERT_IN_CHAIN',
11
+ 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', 'ERR_TLS_CERT_ALTNAME_INVALID'];
12
+ const patterns = new Map([
13
+ ['connection_reset', /\bECONNRESET\b|\bsocket hang up\b/i],
14
+ ['connection_refused', /\bECONNREFUSED\b/],
15
+ ['connection_timeout', /\b(?:ETIMEDOUT|ESOCKETTIMEDOUT|ECONNABORTED)\b/],
16
+ ['dns_failure', /\b(?:ENOTFOUND|EAI_AGAIN)\b/],
17
+ ['tls_verification', new RegExp(`\\b(?:${tlsCodes.join('|')})\\b`)],
18
+ ]);
19
+ const knownPhases = new Set(['startup', ...phases.values(), ...prefixes.values()]);
20
+ const knownSignals = new Set([...patterns.keys(), 'output_truncated']);
21
+ const limit = 16384;
22
+
23
+ function create() {
24
+ return {phase: 'startup', signals: new Set(), pending: {stdout: '', stderr: ''},
25
+ discard: {stdout: false, stderr: false}};
26
+ }
27
+
28
+ function line(state, stream, value) {
29
+ if (stream === 'stdout') {
30
+ if (phases.has(value)) state.phase = phases.get(value);
31
+ for (const [prefix, phase] of prefixes) if (value.startsWith(prefix)) state.phase = phase;
32
+ if (!value.startsWith('::error')) return;
33
+ value = value.replace(/%0D/g, '\r').replace(/%0A/g, '\n').replace(/%25/g, '%');
34
+ }
35
+ for (const [signal, pattern] of patterns) if (pattern.test(value)) state.signals.add(signal);
36
+ }
37
+
38
+ function consume(state, stream, chunk) {
39
+ state.pending[stream] += chunk;
40
+ let end;
41
+ while ((end = state.pending[stream].indexOf('\n')) !== -1) {
42
+ const value = state.pending[stream].slice(0, end).replace(/\r$/, '');
43
+ state.pending[stream] = state.pending[stream].slice(end + 1);
44
+ if (state.discard[stream]) state.discard[stream] = false;
45
+ else if (value.length <= limit) line(state, stream, value);
46
+ else state.signals.add('output_truncated');
47
+ }
48
+ if (state.pending[stream].length > limit) {
49
+ state.pending[stream] = ''; state.discard[stream] = true; state.signals.add('output_truncated');
50
+ }
51
+ }
52
+
53
+ function snapshot(state) {
54
+ return {phase: state.phase, signals: [...state.signals].sort()};
55
+ }
56
+
57
+ function annotation(attempt, result) {
58
+ const diagnostic = result.diagnostic || {};
59
+ const phase = knownPhases.has(diagnostic.phase) ? diagnostic.phase : 'startup';
60
+ const signals = Array.isArray(diagnostic.signals)
61
+ ? [...new Set(diagnostic.signals.filter(value => knownSignals.has(value)))].sort() : [];
62
+ const exit = Number.isInteger(result.status) && result.status >= 0 && result.status <= 255
63
+ ? result.status : 'unknown';
64
+ const signal = ['SIGTERM', 'SIGINT', 'SIGKILL'].includes(result.signal)
65
+ ? result.signal : result.signal ? 'other' : 'none';
66
+ const errors = Number.isSafeInteger(result.errors) && result.errors >= 0 ? result.errors : 'unknown';
67
+ return '::notice title=play_upload_diagnostic::gowalk-cicd/play-upload-diagnostic.v1'
68
+ + ` attempt=${attempt === 2 ? 2 : 1} phase=${phase} exit_code=${exit} signal=${signal}`
69
+ + ` error_count=${errors} cancelled=${result.cancelled === true} commit_reported=${result.committed === true}`
70
+ + ` signals=${signals.join(',') || 'none'}`;
71
+ }
72
+
73
+ module.exports = {create, consume, snapshot, annotation};
@@ -2,6 +2,7 @@
2
2
  const {spawn} = require('node:child_process');
3
3
  const {readFileSync, writeFileSync} = require('node:fs');
4
4
  const {join} = require('node:path');
5
+ const diagnostics = require('./diagnostics.cjs');
5
6
  const messages = new Map([
6
7
  ['This edit has expired, please create a new Edit.', 'edit_expired'],
7
8
  ['This Edit has been deleted.', 'edit_deleted'],
@@ -40,6 +41,7 @@ function execute(program, env, directory, timeout) {
40
41
  const output = join(directory, 'output'), exports = join(directory, 'environment');
41
42
  writeFileSync(output, '', {mode: 0o600}); writeFileSync(exports, '', {mode: 0o600});
42
43
  const state = {pending: '', errors: 0, code: 'unclassified_failure', ambiguous: false};
44
+ const diagnostic = diagnostics.create();
43
45
  const child = spawn(process.execPath, [program], {
44
46
  env: {...env, GITHUB_OUTPUT: output, GITHUB_ENV: exports}, stdio: ['ignore', 'pipe', 'pipe'],
45
47
  });
@@ -50,15 +52,19 @@ function execute(program, env, directory, timeout) {
50
52
  };
51
53
  const timer = setTimeout(stop, timeout);
52
54
  process.on('SIGTERM', stop); process.on('SIGINT', stop);
53
- child.stdout.setEncoding('utf8'); child.stdout.on('data', chunk => consume(state, chunk));
54
- child.stderr.resume();
55
+ child.stdout.setEncoding('utf8'); child.stdout.on('data', chunk => {
56
+ consume(state, chunk); diagnostics.consume(diagnostic, 'stdout', chunk);
57
+ });
58
+ child.stderr.setEncoding('utf8');
59
+ child.stderr.on('data', chunk => diagnostics.consume(diagnostic, 'stderr', chunk));
55
60
  child.on('error', () => { state.ambiguous = true; });
56
61
  child.on('close', (status, signal) => {
57
62
  clearTimeout(timer); clearTimeout(force); process.off('SIGTERM', stop); process.off('SIGINT', stop);
58
63
  consume(state, '\n');
64
+ diagnostics.consume(diagnostic, 'stdout', '\n'); diagnostics.consume(diagnostic, 'stderr', '\n');
59
65
  const committed = /(?:^|\n)committedEditId(?:=|<<)/.test(readFileSync(output, 'utf8'));
60
66
  resolve({status, signal, cancelled, committed, errors: state.errors,
61
- code: state.ambiguous ? 'unclassified_failure' : state.code});
67
+ code: state.ambiguous ? 'unclassified_failure' : state.code, diagnostic: diagnostics.snapshot(diagnostic)});
62
68
  });
63
69
  });
64
70
  }
@@ -4,9 +4,10 @@ const {join} = require('node:path');
4
4
  const source = require('./source.cjs');
5
5
  const processRunner = require('./process.cjs');
6
6
  const owner = require('./owner.cjs');
7
+ const diagnostics = require('./diagnostics.cjs');
7
8
 
8
9
  async function upload(program, env, directory, execute = processRunner.execute, timeout = 3600000,
9
- cancelled = () => false) {
10
+ cancelled = () => false, report = () => {}) {
10
11
  const deadline = Date.now() + timeout;
11
12
  let mode = 'false';
12
13
  for (let attempt = 1; attempt <= 2; attempt++) {
@@ -14,6 +15,7 @@ async function upload(program, env, directory, execute = processRunner.execute,
14
15
  const target = join(directory, String(attempt)); mkdirSync(target, {mode: 0o700});
15
16
  const result = await execute(program, {...env, INPUT_CHANGESNOTSENTFORREVIEW: mode}, target,
16
17
  Math.max(1, deadline - Date.now()));
18
+ report(attempt, result);
17
19
  const reviewPending = mode === 'true';
18
20
  if (result.status === 0 && !result.signal && !result.cancelled && result.errors === 0) {
19
21
  return {ok: true, code: reviewPending ? 'review_pending' : 'uploaded', attempts: attempt,
@@ -39,7 +41,8 @@ async function main() {
39
41
  directory = mkdtempSync(join(env.RUNNER_TEMP, 'gowalk-play-upload-'));
40
42
  const program = await source.prepare(directory);
41
43
  receipt = await owner.withOwner(env, env.INPUT_PACKAGENAME, cancelled =>
42
- upload(program, env, directory, processRunner.execute, 3600000, cancelled));
44
+ upload(program, env, directory, processRunner.execute, 3600000, cancelled,
45
+ (attempt, result) => console.log(diagnostics.annotation(attempt, result))));
43
46
  } catch (error) {
44
47
  const allowed = ['assigned_proxy_required', 'runner_context_required', 'uploader_integrity_refused',
45
48
  'uploader_source_unavailable', ...owner.codes];
@@ -1 +1 @@
1
- 1.0.109
1
+ 1.0.111
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.109",
3
+ "version": "1.0.111",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {