gowalk-cicd 1.0.39 → 1.0.40

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
@@ -330,14 +330,21 @@ its cached cert already revoked by a sibling, mints a new one, and revokes
330
330
  another. The symptom is a "Your Certificate Has Been Revoked" email on nearly
331
331
  every deploy.
332
332
 
333
- Fix: apps in the same Apple team should **share one cert**. Commit the *same*
334
- `creds/cert.p12` + `creds/cert.meta.json` into every repo of that team (an Apple
335
- Distribution cert is team-scoped, not app-scoped, so one cert signs them all).
336
- Each app then gets a cache hit and no new cert is ever created. Provisioning
337
- profiles stay per-app (named `CI-<bundle_id>`) and regenerate against the shared
338
- cert on the next run. When onboarding a new app into an existing team, copy a
339
- sibling's `cert.p12` + `cert.meta.json` in rather than letting the first run
340
- create a fresh cert.
333
+ Fix: apps in the same Apple team should **share one cert**. A fleet registry can
334
+ provision the same `creds/cert.p12` + `creds/cert.meta.json` into every repo of
335
+ that team (an Apple Distribution cert is team-scoped, not app-scoped, so one
336
+ cert signs them all). Registry provisioning must also write
337
+ `creds/cert.registry.json` with `managed_by: app-robot`, `state: ready`, and
338
+ SHA-256 commit digests for both `cert.p12` and `cert.meta.json`. It must include
339
+ the same `managed_by` field plus the DER certificate digest in
340
+ `cert.meta.json`.
341
+
342
+ The marker is a fail-closed ownership boundary: the action may reuse the
343
+ identity and create per-app provisioning profiles, but it never creates,
344
+ replaces, or revokes a registry-managed distribution certificate. Missing,
345
+ partially written, corrupt, expired, Apple-revoked, or resource-ID/P12-mismatched
346
+ managed material aborts with a reconciliation error. Legacy repos without the
347
+ marker keep the historical cache lifecycle.
341
348
 
342
349
  ## How it works
343
350
 
@@ -1 +1 @@
1
- 1.0.39
1
+ 1.0.40
package/action/action.yml CHANGED
@@ -460,6 +460,7 @@ runs:
460
460
  # the source of truth for "is there anything to commit".
461
461
  git add -f -- creds/cert.p12 2>/dev/null || true
462
462
  git add -f -- creds/cert.meta.json 2>/dev/null || true
463
+ git add -f -- creds/cert.registry.json 2>/dev/null || true
463
464
  git add -f -- creds/profiles.manifest.json 2>/dev/null || true
464
465
  # The whole profiles/ directory is action-managed (one
465
466
  # .mobileprovision per signable bundle, GC'd when bundles are
@@ -12,6 +12,7 @@ only sane long-term strategy. We keep:
12
12
  cert.p12 PKCS12 with cert + private key
13
13
  cert.meta.json {cert_id, not_after, p12_password,
14
14
  created_at}
15
+ cert.registry.json optional registry ownership marker
15
16
  profiles.manifest.json {cert_id, profiles:[
16
17
  {bundle_id, name, uuid, filename,
17
18
  expiration}]}
@@ -37,16 +38,25 @@ stay private; the cert lives there for warm-cache reasons, not secrecy.
37
38
 
38
39
  from __future__ import annotations
39
40
 
41
+ import base64
42
+ import hashlib
40
43
  import json
41
44
  import os
42
45
  from dataclasses import dataclass
43
46
  from datetime import datetime, timedelta, timezone
44
47
  from pathlib import Path
45
- from typing import Any
46
48
 
47
49
  from asc_common import request
48
50
  from cryptography import x509
49
- from cryptography.hazmat.primitives.serialization import pkcs12
51
+ from cryptography.hazmat.primitives.serialization import Encoding, pkcs12
52
+ from profile_store import (
53
+ ProfileEntry,
54
+ find_reusable_profile,
55
+ load_profile_manifest,
56
+ profile_path,
57
+ write_cached_profile,
58
+ write_profile_manifest,
59
+ )
50
60
 
51
61
 
52
62
  # Auto-renew when the cert (or any profile) is within this many days of
@@ -73,8 +83,10 @@ P12_PASSWORD = "ci"
73
83
  # their own paths and the layout stays a single fact in one place.
74
84
  _CERT_P12 = "cert.p12"
75
85
  _CERT_META = "cert.meta.json"
86
+ _CERT_REGISTRY = "cert.registry.json"
76
87
  _MANIFEST = "profiles.manifest.json"
77
88
  _PROFILES_SUBDIR = "profiles"
89
+ REGISTRY_MANAGER = "app-robot"
78
90
 
79
91
 
80
92
  @dataclass
@@ -83,15 +95,7 @@ class CachedCert:
83
95
  not_after: datetime
84
96
  p12_bytes: bytes
85
97
  password: str
86
-
87
-
88
- @dataclass
89
- class ProfileEntry:
90
- bundle_id: str
91
- name: str
92
- uuid: str
93
- filename: str
94
- expiration: datetime
98
+ certificate_sha256: str
95
99
 
96
100
 
97
101
  # --------------------------------------------------------------------------- #
@@ -119,13 +123,43 @@ def _now_utc() -> datetime:
119
123
  return datetime.now(timezone.utc)
120
124
 
121
125
 
122
- # --------------------------------------------------------------------------- #
123
- # Path computations (pure) #
124
- # --------------------------------------------------------------------------- #
126
+ def registry_managed(creds_dir: Path) -> bool:
127
+ """Return whether the identity is owned by the app-robot registry.
125
128
 
126
- def profile_path(creds_dir: Path, uuid: str) -> Path:
127
- """Return the on-disk path for a cached profile by UUID (pure)."""
128
- return creds_dir / _PROFILES_SUBDIR / f"{uuid}.mobileprovision"
129
+ The dedicated marker makes ownership detectable even when the P12 or
130
+ metadata is missing/corrupt. Ownership is intentionally marker-only: a
131
+ rollback removes this panel-owned file while keeping the working P12/meta
132
+ cache intact. Unknown managers fail closed instead of falling into legacy
133
+ rotation.
134
+ """
135
+ marker = creds_dir / _CERT_REGISTRY
136
+ return _registry_payload_managed(marker) if marker.exists() else False
137
+
138
+
139
+ def _registry_payload_managed(path: Path) -> bool:
140
+ try:
141
+ payload = json.loads(path.read_text())
142
+ except (OSError, ValueError) as exc:
143
+ raise SystemExit(f"registry marker is unreadable: {exc}") from exc
144
+ manager = payload.get("managed_by")
145
+ if not manager:
146
+ raise SystemExit("registry marker is missing managed_by")
147
+ if manager != REGISTRY_MANAGER:
148
+ raise SystemExit(f"unsupported signing registry manager: {manager}")
149
+ if payload.get("state") != "ready":
150
+ raise SystemExit("registry-managed signing bundle is not committed and ready")
151
+ files = (("p12_sha256", _CERT_P12), ("metadata_sha256", _CERT_META))
152
+ for field, filename in files:
153
+ expected = str(payload.get(field) or "").lower()
154
+ if len(expected) != 64 or any(char not in "0123456789abcdef" for char in expected):
155
+ raise SystemExit(f"registry marker has invalid {field}")
156
+ try:
157
+ actual = hashlib.sha256((path.parent / filename).read_bytes()).hexdigest()
158
+ except OSError as exc:
159
+ raise SystemExit(f"registry signing bundle is incomplete: {filename}") from exc
160
+ if actual != expected:
161
+ raise SystemExit(f"registry signing bundle digest mismatch: {filename}")
162
+ return True
129
163
 
130
164
 
131
165
  # --------------------------------------------------------------------------- #
@@ -133,18 +167,7 @@ def profile_path(creds_dir: Path, uuid: str) -> Path:
133
167
  # --------------------------------------------------------------------------- #
134
168
 
135
169
  def load_cached_cert(creds_dir: Path) -> CachedCert | None:
136
- """Return the cached cert iff p12 + meta exist and decrypt cleanly.
137
-
138
- Returns ``None`` (and logs a ``::warning::``) on any of:
139
- * missing p12 or meta file
140
- * meta JSON corrupt / missing required keys
141
- * p12 fails to decrypt with the stored password
142
- * cert NotAfter is within RENEW_THRESHOLD_DAYS
143
-
144
- A returned :class:`CachedCert` is NOT yet validated against Apple —
145
- the caller must still confirm the cert id is alive via
146
- :func:`verify_cert_alive`.
147
- """
170
+ """Load a locally valid cache; the caller still verifies it with Apple."""
148
171
  p12 = creds_dir / _CERT_P12
149
172
  meta = creds_dir / _CERT_META
150
173
  if not p12.exists() or not meta.exists():
@@ -156,21 +179,30 @@ def load_cached_cert(creds_dir: Path) -> CachedCert | None:
156
179
  not_after = _parse_iso(meta_raw["not_after"])
157
180
  password = meta_raw.get("p12_password") or P12_PASSWORD
158
181
  except (OSError, ValueError, KeyError) as exc:
159
- print(f"::warning::cert.meta.json unreadable ({exc}); regenerating cert")
182
+ print(f"::warning::cert.meta.json unreadable ({exc}); identity cannot be reused")
160
183
  return None
161
184
 
162
185
  p12_bytes = p12.read_bytes()
163
186
  try:
164
- pkcs12.load_key_and_certificates(p12_bytes, password.encode())
187
+ _, certificate, _ = pkcs12.load_key_and_certificates(p12_bytes, password.encode())
165
188
  except (ValueError, TypeError) as exc:
166
- print(f"::warning::cert.p12 decrypt failed ({exc}); regenerating cert")
189
+ print(f"::warning::cert.p12 decrypt failed ({exc}); identity cannot be reused")
190
+ return None
191
+ if certificate is None:
192
+ print("::warning::cert.p12 has no certificate; identity cannot be reused")
193
+ return None
194
+ certificate_sha256 = hashlib.sha256(
195
+ certificate.public_bytes(Encoding.DER)).hexdigest()
196
+ declared_digest = str(meta_raw.get("certificate_sha256") or "").lower()
197
+ if declared_digest and declared_digest != certificate_sha256:
198
+ print("::warning::cert.meta.json certificate digest does not match cert.p12")
167
199
  return None
168
200
 
169
201
  deadline = _now_utc() + timedelta(days=RENEW_THRESHOLD_DAYS)
170
202
  if not_after <= deadline:
171
203
  print(
172
204
  f"::warning::Cached cert {cert_id} expires {not_after.isoformat()} "
173
- f"(within {RENEW_THRESHOLD_DAYS}d); regenerating"
205
+ f"(within {RENEW_THRESHOLD_DAYS}d); identity requires replacement"
174
206
  )
175
207
  return None
176
208
 
@@ -179,10 +211,12 @@ def load_cached_cert(creds_dir: Path) -> CachedCert | None:
179
211
  not_after=not_after,
180
212
  p12_bytes=p12_bytes,
181
213
  password=password,
214
+ certificate_sha256=certificate_sha256,
182
215
  )
183
216
 
184
217
 
185
- def verify_cert_alive(token: str, cert_id: str) -> bool:
218
+ def verify_cert_alive(token: str, cert_id: str,
219
+ expected_certificate_sha256: str | None = None) -> bool:
186
220
  """Return True iff GET /certificates/{cert_id} returns 200.
187
221
 
188
222
  A 404 means Apple revoked it (manually or via the cap-rotation done
@@ -195,7 +229,16 @@ def verify_cert_alive(token: str, cert_id: str) -> bool:
195
229
  token,
196
230
  allow_status={404},
197
231
  )
198
- return resp.status_code == 200
232
+ if resp.status_code != 200:
233
+ return False
234
+ if expected_certificate_sha256 is None:
235
+ return True
236
+ try:
237
+ encoded = resp.json()["data"]["attributes"]["certificateContent"]
238
+ certificate_der = base64.b64decode(encoded, validate=True)
239
+ except (KeyError, TypeError, ValueError):
240
+ return False
241
+ return hashlib.sha256(certificate_der).hexdigest() == expected_certificate_sha256
199
242
 
200
243
 
201
244
  def write_cert_bundle(
@@ -221,77 +264,6 @@ def write_cert_bundle(
221
264
  _atomic_write(creds_dir / _CERT_META, json.dumps(meta, indent=2).encode())
222
265
 
223
266
 
224
- # --------------------------------------------------------------------------- #
225
- # Profile manifest #
226
- # --------------------------------------------------------------------------- #
227
-
228
- def load_profile_manifest(creds_dir: Path) -> dict:
229
- """Return the parsed manifest, or an empty skeleton if missing/corrupt."""
230
- path = creds_dir / _MANIFEST
231
- if not path.exists():
232
- return {"cert_id": None, "profiles": []}
233
- try:
234
- data = json.loads(path.read_text())
235
- except (OSError, ValueError) as exc:
236
- print(f"::warning::profiles.manifest.json unreadable ({exc}); resetting")
237
- return {"cert_id": None, "profiles": []}
238
- data.setdefault("cert_id", None)
239
- data.setdefault("profiles", [])
240
- return data
241
-
242
-
243
- def write_profile_manifest(creds_dir: Path, manifest: dict) -> None:
244
- """Persist the manifest atomically with profiles sorted by bundle_id."""
245
- sorted_profiles = sorted(
246
- manifest.get("profiles", []),
247
- key=lambda entry: entry.get("bundle_id", ""),
248
- )
249
- payload = {
250
- "cert_id": manifest.get("cert_id"),
251
- "profiles": sorted_profiles,
252
- }
253
- _atomic_write(
254
- creds_dir / _MANIFEST, json.dumps(payload, indent=2).encode()
255
- )
256
-
257
-
258
- def find_reusable_profile(
259
- manifest: dict,
260
- bundle_id: str,
261
- cert_id: str,
262
- creds_dir: Path,
263
- ) -> ProfileEntry | None:
264
- """Return a reusable :class:`ProfileEntry` for ``bundle_id`` or None.
265
-
266
- Reusable requires: manifest cert_id matches current cert_id, an
267
- entry exists for the bundle, expiration is past the renew
268
- threshold, and the .mobileprovision file is on disk.
269
- """
270
- if manifest.get("cert_id") != cert_id:
271
- return None
272
- deadline = _now_utc() + timedelta(days=RENEW_THRESHOLD_DAYS)
273
- for raw in manifest.get("profiles", []):
274
- if raw.get("bundle_id") != bundle_id:
275
- continue
276
- try:
277
- expiration = _parse_iso(raw["expiration"])
278
- uuid = raw["uuid"]
279
- except (KeyError, ValueError):
280
- return None
281
- if expiration <= deadline:
282
- return None
283
- if not profile_path(creds_dir, uuid).exists():
284
- return None
285
- return ProfileEntry(
286
- bundle_id=bundle_id,
287
- name=raw.get("name", ""),
288
- uuid=uuid,
289
- filename=raw.get("filename", f"{uuid}.mobileprovision"),
290
- expiration=expiration,
291
- )
292
- return None
293
-
294
-
295
267
  # --------------------------------------------------------------------------- #
296
268
  # Cache invalidation + cross-module utilities #
297
269
  # --------------------------------------------------------------------------- #
@@ -308,11 +280,6 @@ def invalidate_cache(creds_dir: Path) -> None:
308
280
  print(f"Invalidated signing cache under {creds_dir}")
309
281
 
310
282
 
311
- def write_cached_profile(creds_dir: Path, uuid: str, profile_der: bytes) -> None:
312
- """Persist a fresh .mobileprovision atomically under ``profiles/``."""
313
- _atomic_write(profile_path(creds_dir, uuid), profile_der)
314
-
315
-
316
283
  def cert_not_after_from_der(cert_der: bytes) -> datetime:
317
284
  """Extract NotAfter from a DER-encoded certificate, normalised to UTC.
318
285
 
@@ -9,9 +9,9 @@ This script orchestrates the load-or-regen flow:
9
9
 
10
10
  1. Try to reuse the cached cert (decrypts + NotAfter > 30d +
11
11
  ``GET /certificates/{cert_id}`` = 200).
12
- 2. On miss, invalidate the cache and create a fresh cert via
13
- ``cert_factory`` (handling Apple's per-team cap of 2 by revoking
14
- the OLDEST existing one).
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
15
  3. Hand the cert id + cache_hit flag to ``provision_all_bundles``,
16
16
  which decides per-bundle whether to reuse a cached profile or
17
17
  create a new one and updates the manifest.
@@ -59,35 +59,43 @@ def optional_env(name: str) -> str:
59
59
  return os.environ.get(name, "")
60
60
 
61
61
 
62
+ def _warn_if_renewal_near(cached: creds_store.CachedCert) -> None:
63
+ now = datetime.now(timezone.utc)
64
+ renew_at = now + timedelta(days=creds_store.RENEW_THRESHOLD_DAYS)
65
+ warn_at = now + timedelta(days=creds_store.WARN_THRESHOLD_DAYS)
66
+ if renew_at < cached.not_after <= warn_at:
67
+ print(
68
+ f"::warning::Cert {cached.cert_id} expires in "
69
+ f"{(cached.not_after - now).days}d; auto-renew fires at "
70
+ f"{creds_store.RENEW_THRESHOLD_DAYS}d remaining."
71
+ )
72
+
73
+
62
74
  def _load_or_create_cert(
63
75
  token: str, creds_dir: Path
64
76
  ) -> tuple[str, bytes, str, bool]:
65
- """Return ``(cert_id, p12_bytes, password, cache_hit)``.
66
-
67
- Cache hit when the on-disk cert decrypts, NotAfter > 30d away, and
68
- Apple confirms the cert id is still alive. On miss the cache is
69
- invalidated, a new cert is created (handling Apple's per-team cap),
70
- and the cache is repopulated atomically.
71
- """
77
+ """Load a usable identity or create one only for an unmanaged legacy repo."""
78
+ managed = creds_store.registry_managed(creds_dir)
72
79
  cached = creds_store.load_cached_cert(creds_dir)
73
- if cached and creds_store.verify_cert_alive(token, cached.cert_id):
80
+ expected_digest = cached.certificate_sha256 if cached and managed else None
81
+ if cached and creds_store.verify_cert_alive(token, cached.cert_id, expected_digest):
74
82
  print(
75
83
  f"Reusing cached distribution cert {cached.cert_id} "
76
84
  f"(NotAfter {cached.not_after.isoformat()})"
77
85
  )
78
- # Warn when inside T-60d..T-30d so operators see renewal is pending
79
- # but auto-renew has not yet fired.
80
- now = datetime.now(timezone.utc)
81
- renew_at = now + timedelta(days=creds_store.RENEW_THRESHOLD_DAYS)
82
- warn_at = now + timedelta(days=creds_store.WARN_THRESHOLD_DAYS)
83
- if renew_at < cached.not_after <= warn_at:
84
- print(
85
- f"::warning::Cert {cached.cert_id} expires in "
86
- f"{(cached.not_after - now).days}d; auto-renew fires at "
87
- f"{creds_store.RENEW_THRESHOLD_DAYS}d remaining."
88
- )
86
+ _warn_if_renewal_near(cached)
89
87
  return cached.cert_id, cached.p12_bytes, cached.password, True
90
88
 
89
+ if managed:
90
+ detail = "missing, corrupt, or within the renewal window"
91
+ if cached:
92
+ detail = f"certificate {cached.cert_id} is not active on Apple or does not match cert.p12"
93
+ raise SystemExit(
94
+ "registry-managed distribution identity is unusable "
95
+ f"({detail}); refusing certificate creation or revocation. "
96
+ "Reconcile and replace the account identity in app-robot."
97
+ )
98
+
91
99
  if cached:
92
100
  print(
93
101
  f"Cached cert {cached.cert_id} no longer alive on Apple; "
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env python3
2
+ """Persistent provisioning-profile cache under an app's creds directory."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from dataclasses import dataclass
8
+ from datetime import datetime, timedelta, timezone
9
+ from pathlib import Path
10
+
11
+ RENEW_THRESHOLD_DAYS = 30
12
+ _MANIFEST = "profiles.manifest.json"
13
+ _PROFILES_SUBDIR = "profiles"
14
+
15
+
16
+ @dataclass
17
+ class ProfileEntry:
18
+ bundle_id: str
19
+ name: str
20
+ uuid: str
21
+ filename: str
22
+ expiration: datetime
23
+
24
+
25
+ def _atomic_write(path: Path, data: bytes) -> None:
26
+ path.parent.mkdir(parents=True, exist_ok=True)
27
+ temp = path.with_suffix(path.suffix + ".tmp")
28
+ temp.write_bytes(data)
29
+ os.replace(temp, path)
30
+
31
+
32
+ def _parse_iso(value: str) -> datetime:
33
+ if value.endswith("Z"):
34
+ value = value[:-1] + "+00:00"
35
+ parsed = datetime.fromisoformat(value)
36
+ return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
37
+
38
+
39
+ def profile_path(creds_dir: Path, uuid: str) -> Path:
40
+ return creds_dir / _PROFILES_SUBDIR / f"{uuid}.mobileprovision"
41
+
42
+
43
+ def load_profile_manifest(creds_dir: Path) -> dict:
44
+ path = creds_dir / _MANIFEST
45
+ if not path.exists():
46
+ return {"cert_id": None, "profiles": []}
47
+ try:
48
+ data = json.loads(path.read_text())
49
+ except (OSError, ValueError) as exc:
50
+ print(f"::warning::profiles.manifest.json unreadable ({exc}); resetting")
51
+ return {"cert_id": None, "profiles": []}
52
+ data.setdefault("cert_id", None)
53
+ data.setdefault("profiles", [])
54
+ return data
55
+
56
+
57
+ def write_profile_manifest(creds_dir: Path, manifest: dict) -> None:
58
+ profiles = sorted(manifest.get("profiles", []),
59
+ key=lambda entry: entry.get("bundle_id", ""))
60
+ payload = {"cert_id": manifest.get("cert_id"), "profiles": profiles}
61
+ _atomic_write(creds_dir / _MANIFEST, json.dumps(payload, indent=2).encode())
62
+
63
+
64
+ def find_reusable_profile(manifest: dict, bundle_id: str, cert_id: str,
65
+ creds_dir: Path) -> ProfileEntry | None:
66
+ if manifest.get("cert_id") != cert_id:
67
+ return None
68
+ deadline = datetime.now(timezone.utc) + timedelta(days=RENEW_THRESHOLD_DAYS)
69
+ for raw in manifest.get("profiles", []):
70
+ if raw.get("bundle_id") != bundle_id:
71
+ continue
72
+ try:
73
+ expiration, uuid = _parse_iso(raw["expiration"]), raw["uuid"]
74
+ except (KeyError, ValueError):
75
+ return None
76
+ if expiration <= deadline or not profile_path(creds_dir, uuid).exists():
77
+ return None
78
+ return ProfileEntry(
79
+ bundle_id=bundle_id, name=raw.get("name", ""), uuid=uuid,
80
+ filename=raw.get("filename", f"{uuid}.mobileprovision"),
81
+ expiration=expiration,
82
+ )
83
+ return None
84
+
85
+
86
+ def write_cached_profile(creds_dir: Path, uuid: str, profile_der: bytes) -> None:
87
+ _atomic_write(profile_path(creds_dir, uuid), profile_der)
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env python3
2
+ """Fail-closed behavior for centrally managed distribution identities."""
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import json
8
+ import sys
9
+ import tempfile
10
+ import unittest
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from unittest import mock
14
+
15
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
16
+
17
+ import creds_store # noqa: E402
18
+ import prepare_signing # noqa: E402
19
+
20
+
21
+ class RegistryManagedSigningTests(unittest.TestCase):
22
+ def _managed_dir(self, state: str = "ready") -> tempfile.TemporaryDirectory:
23
+ temp = tempfile.TemporaryDirectory()
24
+ root = Path(temp.name)
25
+ p12, metadata = b"p12", b"metadata"
26
+ (root / "cert.p12").write_bytes(p12)
27
+ (root / "cert.meta.json").write_bytes(metadata)
28
+ marker = root / "cert.registry.json"
29
+ marker.write_text(json.dumps({
30
+ "schema_version": 1,
31
+ "managed_by": "app-robot",
32
+ "state": state,
33
+ "p12_sha256": hashlib.sha256(p12).hexdigest(),
34
+ "metadata_sha256": hashlib.sha256(metadata).hexdigest(),
35
+ }))
36
+ return temp
37
+
38
+ def test_missing_managed_identity_never_enters_creation_path(self):
39
+ with self._managed_dir() as raw:
40
+ creds_dir = Path(raw)
41
+ (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:
44
+ with mock.patch.object(prepare_signing.creds_store, "invalidate_cache") as invalidate:
45
+ with self.assertRaisesRegex(SystemExit, "bundle is incomplete"):
46
+ prepare_signing._load_or_create_cert("token", creds_dir)
47
+ generate.assert_not_called()
48
+ create.assert_not_called()
49
+ invalidate.assert_not_called()
50
+
51
+ def test_revoked_managed_identity_is_not_deleted_or_replaced(self):
52
+ cached = creds_store.CachedCert(
53
+ cert_id="CERT1",
54
+ not_after=datetime(2027, 8, 1, tzinfo=timezone.utc),
55
+ p12_bytes=b"p12",
56
+ password="secret",
57
+ certificate_sha256="a" * 64,
58
+ )
59
+ with self._managed_dir() as raw:
60
+ with mock.patch.object(prepare_signing.creds_store, "load_cached_cert", return_value=cached):
61
+ 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 self.assertRaisesRegex(SystemExit, "not active on Apple or does not match"):
64
+ prepare_signing._load_or_create_cert("token", Path(raw))
65
+ create.assert_not_called()
66
+
67
+ def test_active_managed_identity_is_reused(self):
68
+ cached = creds_store.CachedCert(
69
+ cert_id="CERT1",
70
+ not_after=datetime(2027, 8, 1, tzinfo=timezone.utc),
71
+ p12_bytes=b"p12",
72
+ password="secret",
73
+ certificate_sha256="a" * 64,
74
+ )
75
+ with self._managed_dir() as raw:
76
+ with mock.patch.object(prepare_signing.creds_store, "load_cached_cert", return_value=cached):
77
+ 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))
80
+ self.assertEqual(result, ("CERT1", b"p12", "secret", True))
81
+ create.assert_not_called()
82
+
83
+ def test_managed_liveness_binds_resource_id_to_certificate_bytes(self):
84
+ remote_der = b"apple-certificate-der"
85
+ response = mock.Mock(status_code=200)
86
+ response.json.return_value = {"data": {"attributes": {
87
+ "certificateContent": base64.b64encode(remote_der).decode(),
88
+ }}}
89
+ with mock.patch.object(creds_store, "request", return_value=response):
90
+ digest = hashlib.sha256(remote_der).hexdigest()
91
+ self.assertTrue(creds_store.verify_cert_alive("token", "CERT1", digest))
92
+ self.assertFalse(creds_store.verify_cert_alive("token", "CERT1", "b" * 64))
93
+
94
+ def test_updating_managed_bundle_fails_before_reading_identity(self):
95
+ with self._managed_dir(state="updating") as raw:
96
+ with mock.patch.object(prepare_signing.creds_store, "load_cached_cert") as load:
97
+ with self.assertRaisesRegex(SystemExit, "not committed and ready"):
98
+ prepare_signing._load_or_create_cert("token", Path(raw))
99
+ load.assert_not_called()
100
+
101
+ def test_ready_marker_rejects_tampered_bundle(self):
102
+ with self._managed_dir() as raw:
103
+ (Path(raw) / "cert.p12").write_bytes(b"tampered")
104
+ with self.assertRaisesRegex(SystemExit, "digest mismatch"):
105
+ prepare_signing._load_or_create_cert("token", Path(raw))
106
+
107
+ def test_metadata_without_ownership_marker_remains_legacy(self):
108
+ with tempfile.TemporaryDirectory() as raw:
109
+ meta = Path(raw) / "cert.meta.json"
110
+ meta.write_text(json.dumps({"managed_by": "app-robot"}))
111
+ self.assertFalse(creds_store.registry_managed(Path(raw)))
112
+
113
+
114
+ if __name__ == "__main__":
115
+ unittest.main()
@@ -1 +1 @@
1
- 1.0.39
1
+ 1.0.40
@@ -1 +1 @@
1
- 1.0.39
1
+ 1.0.40
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.39",
3
+ "version": "1.0.40",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {