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