gowalk-cicd 1.0.39 → 1.0.41

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
@@ -2,12 +2,15 @@
2
2
 
3
3
  ## Purpose
4
4
 
5
- npm package that distributes iOS TestFlight and Android Google Play actions.
5
+ npm package that distributes iOS TestFlight, Android Google Play, and Flutter
6
+ web release workflows.
6
7
  Consumer runs `npx gowalk-cicd` and gets:
7
8
 
8
9
  - `.github/actions/swift-app/` — vendored composite action
9
10
  - `.github/actions/android-app/` — vendored Android action (Flutter or Gradle)
10
11
  - `.github/workflows/deploy.yml` — dual-platform workflow
12
+ - `.github/workflows/deploy-web.yml` — Flutter web release artifact workflow
13
+ (only when `web/index.html` exists)
11
14
 
12
15
  Re-running the command is both install and update. No scope flag, no
13
16
  uninstall. Keep it simple.
@@ -25,6 +28,7 @@ gowalk-cicd/ ← repo root IS the gowalk-cicd package
25
28
  ├── bin/cli.mjs ← CLI entrypoint (parses flags, calls runInstall)
26
29
  ├── src/install.mjs ← copy logic, gitignore-check, summary print
27
30
  ├── templates/deploy.yml ← dual-platform workflow copied verbatim
31
+ ├── templates/deploy-web.yml ← Flutter web workflow copied conditionally
28
32
  ├── backend-action/ ← backend deploy action + remote host script
29
33
  ├── templates/deploy-backend.yml ← backend workflow copied when compose exists
30
34
  ├── scripts/auto-version.mjs ← patch-bump-from-npm used by the publish workflow
package/README.md CHANGED
@@ -1,9 +1,9 @@
1
1
  # gowalk-cicd
2
2
 
3
- One installer for iOS TestFlight and Android Google Play delivery. It supports
4
- native Swift/SwiftUI iOS projects, native Kotlin/Gradle Android projects, and
5
- Flutter apps. Put the platform keys under `creds/`, push to `main`, and both
6
- store builds run only on GitHub Actions.
3
+ One installer for iOS TestFlight, Android Google Play, and Flutter web release
4
+ artifacts. It supports native Swift/SwiftUI iOS projects, native Kotlin/Gradle
5
+ Android projects, and Flutter apps. Put the platform keys under `creds/`, push
6
+ to `main`, and the applicable builds run only on GitHub Actions.
7
7
 
8
8
  ## Install
9
9
 
@@ -13,14 +13,31 @@ From the root of your app repo:
13
13
  npx --yes gowalk-cicd
14
14
  ```
15
15
 
16
- Writes three things into your repo:
16
+ Writes three core things into your repo:
17
17
 
18
18
  - `.github/actions/swift-app/` — the vendored composite action (action.yml + scripts)
19
19
  - `.github/actions/android-app/` — Android build/sign action (Flutter or Gradle)
20
20
  - `.github/workflows/deploy.yml` — workflow that builds and deploys both platforms
21
21
 
22
+ Flutter projects that contain `web/index.html` also get
23
+ `.github/workflows/deploy-web.yml`. It creates a version-stamped release build
24
+ and retains `build/web` as a 30-day workflow artifact. Native-only repositories
25
+ are unaffected.
26
+
22
27
  Re-run the same command anytime to pull the latest version.
23
28
 
29
+ ## Flutter web release
30
+
31
+ The conditional `deploy-web.yml` workflow runs on changes to Flutter source,
32
+ web assets, package metadata, localization config, or the workflow itself. It
33
+ also supports manual dispatch. The build uses `GITHUB_RUN_NUMBER` as its build
34
+ number and uploads the complete `build/web` directory as a release artifact.
35
+
36
+ Private git dependencies use the same optional `GIT_PRIVATE_TOKEN` secret as
37
+ the mobile workflow. No hosting provider is assumed: consumers can deploy the
38
+ artifact to their chosen host without granting this package an unrelated cloud
39
+ account or domain.
40
+
24
41
  ## iOS credentials
25
42
 
26
43
  1. Create an **App Store Connect API key** with the **App Manager** role
@@ -330,14 +347,21 @@ its cached cert already revoked by a sibling, mints a new one, and revokes
330
347
  another. The symptom is a "Your Certificate Has Been Revoked" email on nearly
331
348
  every deploy.
332
349
 
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.
350
+ Fix: apps in the same Apple team should **share one cert**. A fleet registry can
351
+ provision the same `creds/cert.p12` + `creds/cert.meta.json` into every repo of
352
+ that team (an Apple Distribution cert is team-scoped, not app-scoped, so one
353
+ cert signs them all). Registry provisioning must also write
354
+ `creds/cert.registry.json` with `managed_by: app-robot`, `state: ready`, and
355
+ SHA-256 commit digests for both `cert.p12` and `cert.meta.json`. It must include
356
+ the same `managed_by` field plus the DER certificate digest in
357
+ `cert.meta.json`.
358
+
359
+ The marker is a fail-closed ownership boundary: the action may reuse the
360
+ identity and create per-app provisioning profiles, but it never creates,
361
+ replaces, or revokes a registry-managed distribution certificate. Missing,
362
+ partially written, corrupt, expired, Apple-revoked, or resource-ID/P12-mismatched
363
+ managed material aborts with a reconciliation error. Legacy repos without the
364
+ marker keep the historical cache lifecycle.
341
365
 
342
366
  ## How it works
343
367
 
@@ -1 +1 @@
1
- 1.0.39
1
+ 1.0.41
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.41
@@ -1 +1 @@
1
- 1.0.39
1
+ 1.0.41
package/bin/cli.mjs CHANGED
@@ -33,13 +33,15 @@ for (const arg of args) {
33
33
  case '--help':
34
34
  console.log(`Usage: npx gowalk-cicd [options]
35
35
 
36
- Installs iOS TestFlight and Android Google Play actions plus one deploy
37
- workflow into the current app project. Re-run anytime to update them.
36
+ Installs iOS TestFlight and Android Google Play actions plus their deploy
37
+ workflow into the current app project. Flutter web apps also get a release
38
+ artifact workflow. Re-run anytime to update them.
38
39
 
39
40
  What it writes:
40
41
  .github/actions/swift-app/ - vendored composite action (action.yml + scripts/)
41
42
  .github/actions/android-app/ - Flutter Android build/sign action
42
43
  .github/workflows/deploy.yml - dual-platform workflow
44
+ .github/workflows/deploy-web.yml - Flutter web release workflow when web/ exists
43
45
 
44
46
  Options:
45
47
  --dry-run Print what would happen without writing files
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.39",
3
+ "version": "1.0.41",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/install.mjs CHANGED
@@ -28,6 +28,7 @@ const ACTION_SRC = join(PACKAGE_DIR, 'action');
28
28
  const ANDROID_ACTION_SRC = join(PACKAGE_DIR, 'android-action');
29
29
  const BACKEND_ACTION_SRC = join(PACKAGE_DIR, 'backend-action');
30
30
  const WORKFLOW_TEMPLATE = join(PACKAGE_DIR, 'templates', 'deploy.yml');
31
+ const WEB_WORKFLOW_TEMPLATE = join(PACKAGE_DIR, 'templates', 'deploy-web.yml');
31
32
  const BACKEND_WORKFLOW_TEMPLATE = join(PACKAGE_DIR, 'templates', 'deploy-backend.yml');
32
33
  const PACKAGE_JSON = join(PACKAGE_DIR, 'package.json');
33
34
 
@@ -37,6 +38,7 @@ const ACTION_DEST_REL = join('.github', 'actions', 'swift-app');
37
38
  const ANDROID_ACTION_DEST_REL = join('.github', 'actions', 'android-app');
38
39
  const BACKEND_ACTION_DEST_REL = join('.github', 'actions', 'backend-app');
39
40
  const WORKFLOW_DEST_REL = join('.github', 'workflows', 'deploy.yml');
41
+ const WEB_WORKFLOW_DEST_REL = join('.github', 'workflows', 'deploy-web.yml');
40
42
  const BACKEND_WORKFLOW_DEST_REL = join('.github', 'workflows', 'deploy-backend.yml');
41
43
  const VERSION_MARKER_REL = join('.github', 'actions', 'swift-app', '.daemux-version');
42
44
  const ANDROID_VERSION_MARKER_REL = join('.github', 'actions', 'android-app', '.daemux-version');
@@ -219,6 +221,20 @@ export async function runInstall({ dryRun = false } = {}) {
219
221
  writeVersionMarkers(repoRoot, dryRun);
220
222
  copyWorkflow(repoRoot, dryRun);
221
223
 
224
+ if (existsSync(join(repoRoot, 'pubspec.yaml'))
225
+ && existsSync(join(repoRoot, 'web', 'index.html'))) {
226
+ console.log(' Flutter web detected (web/index.html) — installing web release workflow');
227
+ const webWorkflowDest = join(repoRoot, WEB_WORKFLOW_DEST_REL);
228
+ const webWorkflowExisted = existsSync(webWorkflowDest);
229
+ if (dryRun) {
230
+ console.log(` [dry-run] ${webWorkflowExisted ? 'overwrite' : 'create'} ${WEB_WORKFLOW_DEST_REL}`);
231
+ } else {
232
+ ensureDir(dirname(webWorkflowDest));
233
+ cpSync(WEB_WORKFLOW_TEMPLATE, webWorkflowDest, { force: true });
234
+ console.log(` ${webWorkflowExisted ? 'Overwrote' : 'Created'} ${WEB_WORKFLOW_DEST_REL}`);
235
+ }
236
+ }
237
+
222
238
  // Backend deploy path — installed only when the repo has a Dockerized
223
239
  // backend (backend/ or server/ with a docker-compose.yml). Mobile-only
224
240
  // repos are unaffected, so this is backward compatible.
@@ -0,0 +1,58 @@
1
+ name: Web Release
2
+ on:
3
+ push:
4
+ branches: [main]
5
+ paths:
6
+ - 'lib/**'
7
+ - 'web/**'
8
+ - 'assets/**'
9
+ - 'pubspec.yaml'
10
+ - 'pubspec.lock'
11
+ - 'l10n.yaml'
12
+ - 'analysis_options.yaml'
13
+ - '.github/workflows/deploy-web.yml'
14
+ workflow_dispatch:
15
+
16
+ permissions:
17
+ contents: read
18
+
19
+ concurrency:
20
+ group: web-release-${{ github.ref }}
21
+ cancel-in-progress: true
22
+
23
+ jobs:
24
+ web:
25
+ name: Flutter Web Artifact
26
+ runs-on: ubuntu-24.04
27
+ timeout-minutes: 20
28
+ steps:
29
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
30
+ - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
31
+ with:
32
+ channel: stable
33
+ cache: true
34
+ - name: Authenticate private git dependencies
35
+ shell: bash
36
+ env:
37
+ GIT_PRIVATE_TOKEN: ${{ secrets.GIT_PRIVATE_TOKEN }}
38
+ run: |
39
+ if [ -z "${GIT_PRIVATE_TOKEN:-}" ]; then
40
+ echo "No GIT_PRIVATE_TOKEN secret; private git dependencies must carry their own credentials."
41
+ exit 0
42
+ fi
43
+ echo "::add-mask::$GIT_PRIVATE_TOKEN"
44
+ git config --global \
45
+ url."https://x-access-token:${GIT_PRIVATE_TOKEN}@github.com/".insteadOf "https://github.com/"
46
+ - name: Build Flutter web release
47
+ shell: bash
48
+ run: |
49
+ flutter pub get
50
+ if [ -f l10n.yaml ]; then flutter gen-l10n; fi
51
+ flutter build web --release --build-number="${GITHUB_RUN_NUMBER}"
52
+ - name: Retain Flutter web release
53
+ uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
54
+ with:
55
+ name: web-${{ github.event.repository.name }}-${{ github.run_number }}
56
+ path: build/web
57
+ if-no-files-found: error
58
+ retention-days: 30