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 +5 -1
- package/README.md +37 -13
- package/action/.daemux-version +1 -1
- package/action/action.yml +1 -0
- package/action/scripts/creds_store.py +78 -111
- package/action/scripts/prepare_signing.py +30 -22
- package/action/scripts/profile_store.py +87 -0
- package/action/scripts/test_registry_managed_signing.py +115 -0
- package/android-action/.daemux-version +1 -1
- package/backend-action/.daemux-version +1 -1
- package/bin/cli.mjs +4 -2
- package/package.json +1 -1
- package/src/install.mjs +16 -0
- package/templates/deploy-web.yml +58 -0
package/CLAUDE.md
CHANGED
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
## Purpose
|
|
4
4
|
|
|
5
|
-
npm package that distributes iOS TestFlight
|
|
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
|
|
4
|
-
native Swift/SwiftUI iOS projects, native Kotlin/Gradle
|
|
5
|
-
Flutter apps. Put the platform keys under `creds/`, push
|
|
6
|
-
|
|
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**.
|
|
334
|
-
`creds/cert.p12` + `creds/cert.meta.json` into every repo of
|
|
335
|
-
Distribution cert is team-scoped, not app-scoped, so one
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
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
|
-
|
|
124
|
-
# --------------------------------------------------------------------------- #
|
|
126
|
+
def registry_managed(creds_dir: Path) -> bool:
|
|
127
|
+
"""Return whether the identity is owned by the app-robot registry.
|
|
125
128
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
"""
|
|
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});
|
|
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});
|
|
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);
|
|
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
|
|
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
|
-
|
|
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
|
|
14
|
-
|
|
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
|
-
"""
|
|
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
|
|
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
|
-
|
|
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.
|
|
1
|
+
1.0.41
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
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
|
|
37
|
-
workflow into the current app project.
|
|
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
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
|