gowalk-cicd 1.0.138 → 1.0.139
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 +6 -0
- package/README.md +4 -0
- package/action/.daemux-version +1 -1
- package/action/action.yml +97 -24
- package/action/scripts/mmv_decide_create.py +5 -0
- package/action/scripts/mmv_floor_check.py +5 -2
- package/action/scripts/mobile_artifact_files.py +187 -0
- package/action/scripts/mobile_artifact_inventory.cjs +102 -0
- package/action/scripts/mobile_candidate_context.py +18 -0
- package/action/scripts/mobile_inputs.cjs +15 -1
- package/action/scripts/mobile_release.cjs +14 -1
- package/action/scripts/mobile_release_api.cjs +2 -2
- package/action/scripts/native_cache.py +45 -0
- package/action/scripts/prepare_crashlytics_dsyms.py +2 -1
- package/action/scripts/test_mobile_artifact_restore.py +93 -0
- package/action/scripts/test_mobile_artifacts.py +92 -0
- package/action/scripts/test_native_cache.py +37 -0
- package/android-action/.daemux-version +1 -1
- package/backend-action/.daemux-version +1 -1
- package/docs/CANDIDATE_ARTIFACTS.md +39 -0
- package/package.json +3 -2
- package/src/install.mjs +8 -6
- package/templates/deploy.yml +90 -32
- package/templates/mobile-candidate.yml +28 -0
package/CLAUDE.md
CHANGED
|
@@ -201,10 +201,16 @@ node /path/to/gowalk-cicd/bin/cli.mjs
|
|
|
201
201
|
resolve online. Missing or invalid metadata falls back to the required proxied GET; missing
|
|
202
202
|
account transport still refuses even with a warm cache. SDK keys and checksum checks stay unchanged.
|
|
203
203
|
- Gradle caches use checksum-pinned restore/save actions, each optional and bounded to two minutes.
|
|
204
|
+
Native Pods/Bundler caches use the committed locks and selected Ruby/Xcode/architecture in the existing job.
|
|
204
205
|
Preserve setup-java's complete keys, inputs and absolute paths; restore the same OS/architecture prefix.
|
|
205
206
|
Failed releases save distinct partial keys unless cancelled; completed keys require success. Builds remain required.
|
|
206
207
|
Only pinned cache entrypoints get cache transport; see [cache behavior](docs/GRADLE_CACHE.md) before adoption.
|
|
207
208
|
- GitHub artifact uploads have their own checksum-pinned `artifact_env.cjs` process scope.
|
|
209
|
+
`mobile-candidate.yml` builds only trusted ready task PRs, using the existing release workflow in
|
|
210
|
+
candidate mode. Candidate mode cannot publish or mutate a marketing-version slot. Main promotes
|
|
211
|
+
only verified matching binaries with current credentials and retains the original build/symbol identity;
|
|
212
|
+
a miss uses the normal build without waiting. Preserve successful sibling artifacts across reruns.
|
|
213
|
+
See `docs/CANDIDATE_ARTIFACTS.md`; never turn this optimization into a new factory stage or resource reservation.
|
|
208
214
|
It reuses the cache transport's approved HTTPS request guard, then removes inherited proxy
|
|
209
215
|
variables only inside the official `upload-artifact` process. Unknown programs/origins refuse;
|
|
210
216
|
later Apple/Google clients retain their proxies and artifact failures remain job failures.
|
package/README.md
CHANGED
|
@@ -26,6 +26,10 @@ are unaffected.
|
|
|
26
26
|
|
|
27
27
|
Re-run the same command anytime to pull the latest version.
|
|
28
28
|
|
|
29
|
+
Ready task PRs build shipping binaries alongside validation; the protected merge promotes matching
|
|
30
|
+
binaries through the existing store upload paths. See [candidate artifact reuse](docs/CANDIDATE_ARTIFACTS.md)
|
|
31
|
+
for identity checks, native dependency caches, version handling and adoption of custom workflows.
|
|
32
|
+
|
|
29
33
|
Mobile delivery requires an explicit frozen release intent. After source, signing, account transport,
|
|
30
34
|
and integration readiness pass, run on the task branch:
|
|
31
35
|
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.139
|
package/action/action.yml
CHANGED
|
@@ -15,6 +15,10 @@ description: >
|
|
|
15
15
|
consumers. App Store provisioning profiles are reconciled at runtime.
|
|
16
16
|
|
|
17
17
|
inputs:
|
|
18
|
+
candidate-binary:
|
|
19
|
+
description: Verified candidate artifact directory supplied by the package workflow
|
|
20
|
+
required: false
|
|
21
|
+
default: ''
|
|
18
22
|
project:
|
|
19
23
|
description: "Path to the .xcodeproj (or omit if using workspace)"
|
|
20
24
|
required: false
|
|
@@ -218,6 +222,15 @@ runs:
|
|
|
218
222
|
# that failure could not arrive in the repository that needed it.
|
|
219
223
|
# Preserving here rather than after signing is the same
|
|
220
224
|
# point: a red step later in this run must not take the update with it.
|
|
225
|
+
- name: Activate verified candidate binary
|
|
226
|
+
if: ${{ inputs.candidate-binary != '' }}
|
|
227
|
+
shell: bash
|
|
228
|
+
env:
|
|
229
|
+
CANDIDATE_DIRECTORY: ${{ inputs.candidate-binary }}
|
|
230
|
+
run: |
|
|
231
|
+
python3 -m pip install --quiet --break-system-packages PyJWT cryptography requests
|
|
232
|
+
node "$SWIFT_APP_ACTION/scripts/mobile_artifact_inventory.cjs" activate ios "$CANDIDATE_DIRECTORY"
|
|
233
|
+
|
|
221
234
|
- name: Stage bot-side scripts to runner temp
|
|
222
235
|
if: ${{ (inputs.archive == 'true' || inputs.auto-update == 'true')
|
|
223
236
|
&& github.event_name == 'push'
|
|
@@ -265,7 +278,42 @@ runs:
|
|
|
265
278
|
set -euo pipefail
|
|
266
279
|
bash "$BOT_SCRIPTS_DIR/commit_bot_changes.sh"
|
|
267
280
|
|
|
281
|
+
- name: Select latest Xcode
|
|
282
|
+
if: ${{ inputs.candidate-binary == '' }}
|
|
283
|
+
shell: bash
|
|
284
|
+
run: |
|
|
285
|
+
XCODE_PATH=$(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V | tail -1)
|
|
286
|
+
if [ -z "$XCODE_PATH" ]; then
|
|
287
|
+
XCODE_PATH=$(ls -d /Applications/Xcode*.app 2>/dev/null | sort -V | tail -1)
|
|
288
|
+
fi
|
|
289
|
+
echo "Selecting $XCODE_PATH"
|
|
290
|
+
sudo xcode-select -s "$XCODE_PATH"
|
|
291
|
+
xcodebuild -version
|
|
292
|
+
|
|
293
|
+
- name: Describe native dependency cache
|
|
294
|
+
if: ${{ inputs.candidate-binary == '' }}
|
|
295
|
+
id: native_cache_scope
|
|
296
|
+
continue-on-error: true
|
|
297
|
+
shell: bash
|
|
298
|
+
run: |
|
|
299
|
+
python3 "$SWIFT_APP_ACTION/scripts/native_cache.py"
|
|
300
|
+
node "$SWIFT_APP_ACTION/scripts/cache_scope.cjs" --snapshot
|
|
301
|
+
|
|
302
|
+
- name: Restore native dependencies
|
|
303
|
+
id: native_cache
|
|
304
|
+
if: ${{ steps.native_cache_scope.outputs.key != '' && inputs.candidate-binary == '' }}
|
|
305
|
+
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25
|
|
306
|
+
continue-on-error: true
|
|
307
|
+
timeout-minutes: 2
|
|
308
|
+
env:
|
|
309
|
+
NODE_OPTIONS: --require "${{ steps.native_cache_scope.outputs.preloader }}"
|
|
310
|
+
SEGMENT_DOWNLOAD_TIMEOUT_MINS: '1'
|
|
311
|
+
with:
|
|
312
|
+
key: ${{ steps.native_cache_scope.outputs.key }}
|
|
313
|
+
path: ${{ steps.native_cache_scope.outputs.paths }}
|
|
314
|
+
|
|
268
315
|
- name: Resolve credentials + auto-detect Xcode project
|
|
316
|
+
if: ${{ inputs.candidate-binary == '' }}
|
|
269
317
|
shell: bash
|
|
270
318
|
env:
|
|
271
319
|
INPUT_PROJECT: ${{ inputs.project }}
|
|
@@ -312,19 +360,21 @@ runs:
|
|
|
312
360
|
export INPUT_WORKSPACE
|
|
313
361
|
python3 "$SWIFT_APP_ACTION/scripts/read_config.py"
|
|
314
362
|
|
|
315
|
-
- name:
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
363
|
+
- name: Save native dependencies
|
|
364
|
+
if: >-
|
|
365
|
+
${{ steps.native_cache_scope.outputs.key != '' && steps.native_cache.outputs.cache-hit != 'true'
|
|
366
|
+
&& inputs.candidate-binary == '' }}
|
|
367
|
+
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25
|
|
368
|
+
continue-on-error: true
|
|
369
|
+
timeout-minutes: 2
|
|
370
|
+
env:
|
|
371
|
+
NODE_OPTIONS: --require "${{ steps.native_cache_scope.outputs.preloader }}"
|
|
372
|
+
with:
|
|
373
|
+
key: ${{ steps.native_cache_scope.outputs.key }}
|
|
374
|
+
path: ${{ steps.native_cache_scope.outputs.paths }}
|
|
325
375
|
|
|
326
376
|
- name: Run simulator tests
|
|
327
|
-
if: ${{ (inputs.run-tests || env.CFG_RUN_TESTS) == 'true' }}
|
|
377
|
+
if: ${{ (inputs.run-tests || env.CFG_RUN_TESTS) == 'true' && inputs.candidate-binary == '' }}
|
|
328
378
|
shell: bash
|
|
329
379
|
env:
|
|
330
380
|
PROJECT: ${{ inputs.project || env.CFG_PROJECT }}
|
|
@@ -387,7 +437,7 @@ runs:
|
|
|
387
437
|
echo "ASC_KEY_PATH=$RUNNER_TEMP/asc/AuthKey.p8" >> "$GITHUB_ENV"
|
|
388
438
|
|
|
389
439
|
- name: Resolve marketing version (project source of truth)
|
|
390
|
-
if: ${{ inputs.archive == 'true' }}
|
|
440
|
+
if: ${{ inputs.archive == 'true' && inputs.candidate-binary == '' }}
|
|
391
441
|
shell: bash
|
|
392
442
|
env:
|
|
393
443
|
PROJECT: ${{ inputs.project || env.CFG_PROJECT }}
|
|
@@ -404,7 +454,9 @@ runs:
|
|
|
404
454
|
- name: Resolve App Store version slot (REUSE or CREATE)
|
|
405
455
|
# A third answer, TESTFLIGHT_ONLY, is handled inside: see the
|
|
406
456
|
# STORE_VERSION_LOCKED branch of the script.
|
|
407
|
-
if:
|
|
457
|
+
if: >-
|
|
458
|
+
${{ inputs.archive == 'true'
|
|
459
|
+
&& (inputs.manage-app-store-version == 'true' || env.MOBILE_CANDIDATE_BUILD == 'true') }}
|
|
408
460
|
shell: bash
|
|
409
461
|
env:
|
|
410
462
|
APP_STORE_APPLE_ID: ${{ inputs.app-store-apple-id || env.CFG_APP_STORE_APPLE_ID }}
|
|
@@ -412,7 +464,7 @@ runs:
|
|
|
412
464
|
# mmv auto-bumps the project file (pbxproj or Info.plist) per this
|
|
413
465
|
# policy and continues. 'none' preserves the historical fail-the-
|
|
414
466
|
# build behavior.
|
|
415
|
-
MARKETING_VERSION_AUTO_BUMP: ${{ inputs.marketing-version-auto-bump }}
|
|
467
|
+
MARKETING_VERSION_AUTO_BUMP: ${{ inputs.candidate-binary != '' && 'none' || inputs.marketing-version-auto-bump }}
|
|
416
468
|
# Auto-bump persistence-context inputs. mmv_floor_check refuses to
|
|
417
469
|
# auto-bump unless this run is push-to-default-branch -- the same
|
|
418
470
|
# gate the commit-back step uses. Without this gate,
|
|
@@ -429,7 +481,8 @@ runs:
|
|
|
429
481
|
CONFIGURATION: ${{ inputs.configuration || env.CFG_CONFIGURATION }}
|
|
430
482
|
run: |
|
|
431
483
|
set -o pipefail
|
|
432
|
-
|
|
484
|
+
echo "SOURCE_MARKETING_VERSION=${SOURCE_MARKETING_VERSION:-$MARKETING_VERSION}" >> "$GITHUB_ENV"
|
|
485
|
+
BUILD_NUMBER=${REUSED_BUILD_NUMBER:-$(python3 "$SWIFT_APP_ACTION/scripts/next_build_number.py")}
|
|
433
486
|
DECISION_JSON=$(python3 "$SWIFT_APP_ACTION/scripts/manage_marketing_version.py")
|
|
434
487
|
APP_STORE_VERSION_ID=$(python3 -c \
|
|
435
488
|
"import json,sys; d=json.loads(sys.argv[1]); print(d.get('appStoreVersionId',''))" \
|
|
@@ -446,6 +499,20 @@ runs:
|
|
|
446
499
|
EFFECTIVE_MV=$(python3 -c \
|
|
447
500
|
"import json,sys; d=json.loads(sys.argv[1]); print(d.get('versionString',''))" \
|
|
448
501
|
"$DECISION_JSON")
|
|
502
|
+
if [ -n "${REUSED_BUILD_NUMBER:-}" ] && [ "$EFFECTIVE_MV" != "$MARKETING_VERSION" ]; then
|
|
503
|
+
echo '::error::mobile_artifact_store_version_changed'
|
|
504
|
+
exit 1
|
|
505
|
+
fi
|
|
506
|
+
if [ -n "${REUSED_BUILD_NUMBER:-}" ] && [ "$DECISION" != "TESTFLIGHT_ONLY" ] &&
|
|
507
|
+
[ "$SOURCE_MARKETING_VERSION" != "$MARKETING_VERSION" ]; then
|
|
508
|
+
python3 - <<'PY_PERSIST_VERSION'
|
|
509
|
+
import os, sys
|
|
510
|
+
sys.path.insert(0, os.environ["SWIFT_APP_ACTION"] + "/scripts")
|
|
511
|
+
from version_utils import write_marketing_version
|
|
512
|
+
if not write_marketing_version(os.environ["MARKETING_VERSION"]):
|
|
513
|
+
raise SystemExit("mobile_artifact_version_source_unresolved")
|
|
514
|
+
PY_PERSIST_VERSION
|
|
515
|
+
fi
|
|
449
516
|
if [ "$DECISION" = "TESTFLIGHT_ONLY" ]; then
|
|
450
517
|
# An App Store version is under review or awaiting the developer's
|
|
451
518
|
# release: App Store Connect refuses to create or edit ANY version
|
|
@@ -472,7 +539,9 @@ runs:
|
|
|
472
539
|
echo "Using marketing version $MARKETING_VERSION build $BUILD_NUMBER (decision=$DECISION_JSON)"
|
|
473
540
|
|
|
474
541
|
- name: Resolve TestFlight build number
|
|
475
|
-
if:
|
|
542
|
+
if: >-
|
|
543
|
+
${{ inputs.archive == 'true' && inputs.manage-app-store-version == 'false'
|
|
544
|
+
&& env.MOBILE_CANDIDATE_BUILD != 'true' && inputs.candidate-binary == '' }}
|
|
476
545
|
shell: bash
|
|
477
546
|
env:
|
|
478
547
|
APP_STORE_APPLE_ID: ${{ inputs.app-store-apple-id || env.CFG_APP_STORE_APPLE_ID }}
|
|
@@ -491,7 +560,7 @@ runs:
|
|
|
491
560
|
"Profile names are derived per-target as CI-<bundle_id>."
|
|
492
561
|
|
|
493
562
|
- name: Prepare signing (cert + keychain + per-target profiles + pbxproj)
|
|
494
|
-
if: ${{ inputs.archive == 'true' }}
|
|
563
|
+
if: ${{ inputs.archive == 'true' && inputs.candidate-binary == '' }}
|
|
495
564
|
shell: bash
|
|
496
565
|
env:
|
|
497
566
|
PROJECT: ${{ inputs.project || env.CFG_PROJECT }}
|
|
@@ -529,14 +598,14 @@ runs:
|
|
|
529
598
|
run: bash "$BOT_SCRIPTS_DIR/commit_bot_changes.sh"
|
|
530
599
|
|
|
531
600
|
- name: Re-raise signing preparation failure
|
|
532
|
-
if: ${{ inputs.archive == 'true' && env.SIGNING_PREP_EXIT != '0' }}
|
|
601
|
+
if: ${{ inputs.archive == 'true' && env.SIGNING_PREP_EXIT != '0' && inputs.candidate-binary == '' }}
|
|
533
602
|
shell: bash
|
|
534
603
|
run: |
|
|
535
604
|
echo "::error::Signing preparation failed with exit $SIGNING_PREP_EXIT."
|
|
536
605
|
exit "$SIGNING_PREP_EXIT"
|
|
537
606
|
|
|
538
607
|
- name: Stamp Info.plist versions (CFBundleVersion + CFBundleShortVersionString)
|
|
539
|
-
if: ${{ inputs.archive == 'true' }}
|
|
608
|
+
if: ${{ inputs.archive == 'true' && inputs.candidate-binary == '' }}
|
|
540
609
|
shell: bash
|
|
541
610
|
env:
|
|
542
611
|
PROJECT: ${{ inputs.project || env.CFG_PROJECT }}
|
|
@@ -562,7 +631,9 @@ runs:
|
|
|
562
631
|
echo "Stamped CFBundleVersion=$BUILD_NUMBER CFBundleShortVersionString=$MARKETING_VERSION in $INFOPLIST_PATH"
|
|
563
632
|
|
|
564
633
|
- name: Declare ITSAppUsesNonExemptEncryption in Info.plist
|
|
565
|
-
if:
|
|
634
|
+
if: >-
|
|
635
|
+
${{ inputs.archive == 'true' && (inputs.uses-non-exempt-encryption || env.CFG_USES_NON_EXEMPT) != ''
|
|
636
|
+
&& inputs.candidate-binary == '' }}
|
|
566
637
|
shell: bash
|
|
567
638
|
env:
|
|
568
639
|
PROJECT: ${{ inputs.project || env.CFG_PROJECT }}
|
|
@@ -587,7 +658,7 @@ runs:
|
|
|
587
658
|
echo "Set ITSAppUsesNonExemptEncryption=$VALUE in $INFOPLIST_PATH"
|
|
588
659
|
|
|
589
660
|
- name: Defer native Firebase upload phases to the proxied console
|
|
590
|
-
if: ${{ inputs.archive == 'true' }}
|
|
661
|
+
if: ${{ inputs.archive == 'true' && inputs.candidate-binary == '' }}
|
|
591
662
|
shell: bash
|
|
592
663
|
env:
|
|
593
664
|
PROJECT: ${{ inputs.project || env.CFG_PROJECT }}
|
|
@@ -597,7 +668,7 @@ runs:
|
|
|
597
668
|
--search-root "$(dirname "${WORKSPACE:-$PROJECT}")"
|
|
598
669
|
|
|
599
670
|
- name: Archive
|
|
600
|
-
if: ${{ inputs.archive == 'true' }}
|
|
671
|
+
if: ${{ inputs.archive == 'true' && inputs.candidate-binary == '' }}
|
|
601
672
|
shell: bash
|
|
602
673
|
env:
|
|
603
674
|
PROJECT: ${{ inputs.project || env.CFG_PROJECT }}
|
|
@@ -635,7 +706,7 @@ runs:
|
|
|
635
706
|
archive
|
|
636
707
|
|
|
637
708
|
- name: Export IPA
|
|
638
|
-
if: ${{ inputs.archive == 'true' }}
|
|
709
|
+
if: ${{ inputs.archive == 'true' && inputs.candidate-binary == '' }}
|
|
639
710
|
shell: bash
|
|
640
711
|
run: |
|
|
641
712
|
set -o pipefail
|
|
@@ -696,7 +767,9 @@ runs:
|
|
|
696
767
|
# cannot lose the exact build's symbols. The autonomous session completes
|
|
697
768
|
# the upload through the proxied Firebase console from this typed artifact.
|
|
698
769
|
- name: Retain dSYMs for the proxied Firebase console
|
|
699
|
-
if:
|
|
770
|
+
if: >-
|
|
771
|
+
${{ inputs.archive == 'true' && (inputs.upload == 'true' || env.MOBILE_CANDIDATE_BUILD == 'true')
|
|
772
|
+
&& inputs.candidate-binary == '' }}
|
|
700
773
|
shell: bash
|
|
701
774
|
env:
|
|
702
775
|
FIREBASE_APP_ID: ${{ inputs.firebase-app-id }}
|
|
@@ -181,6 +181,11 @@ def decide_create(
|
|
|
181
181
|
appstore_versions=versions, strict=True,
|
|
182
182
|
)
|
|
183
183
|
stale_id = _stale_editable_id(versions, target_version)
|
|
184
|
+
from mobile_candidate_context import active
|
|
185
|
+
|
|
186
|
+
if active():
|
|
187
|
+
# Match the shipping version decision without POSTing or renaming a store version before merge.
|
|
188
|
+
return mmv._result("PREPARE", target_version, "")
|
|
184
189
|
new_id = mmv.create_or_reuse_with_stale(
|
|
185
190
|
app_id, target_version, token, stale_editable_id=stale_id,
|
|
186
191
|
)
|
|
@@ -213,10 +213,13 @@ def maybe_auto_bump(target: str, floor: str) -> str | None:
|
|
|
213
213
|
file=sys.stderr,
|
|
214
214
|
)
|
|
215
215
|
return None
|
|
216
|
-
|
|
216
|
+
from mobile_candidate_context import active
|
|
217
|
+
|
|
218
|
+
candidate = active()
|
|
219
|
+
if not candidate and not _persist_context_ok(policy):
|
|
217
220
|
return None
|
|
218
221
|
new_version = version_utils.compute_next_version(target, floor, policy)
|
|
219
|
-
if not version_utils.write_marketing_version(new_version):
|
|
222
|
+
if not candidate and not version_utils.write_marketing_version(new_version):
|
|
220
223
|
print(
|
|
221
224
|
f"::warning::auto-bump could not locate a writable "
|
|
222
225
|
f"MARKETING_VERSION source (pbxproj or Info.plist); "
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Retain only release binaries, symbols and bounded non-secret build metadata."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
SCHEMA = "gowalk-cicd/mobile-binary.v1"
|
|
13
|
+
IOS_ENV = ("CFG_BUNDLE_ID", "CFG_TEAM_ID", "CFG_APP_STORE_APPLE_ID", "CFG_CONFIGURATION",
|
|
14
|
+
"CFG_PROJECT", "CFG_WORKSPACE", "CFG_SCHEME", "CFG_LOCALE", "CFG_USES_NON_EXEMPT",
|
|
15
|
+
"CFG_WHATS_NEW", "MARKETING_VERSION", "BUILD_NUMBER", "SOURCE_MARKETING_VERSION")
|
|
16
|
+
ANDROID_ENV = ("ANDROID_PACKAGE_NAME", "ANDROID_BUILD_NUMBER", "ANDROID_PROJECT_KIND")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def digest(path: Path) -> str:
|
|
20
|
+
with path.open("rb") as handle:
|
|
21
|
+
return hashlib.file_digest(handle, "sha256").hexdigest()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def android_config():
|
|
25
|
+
root = Path(__file__).resolve().parents[2]
|
|
26
|
+
folder = root / "android-app/scripts"
|
|
27
|
+
sys.path.insert(0, str(folder if folder.is_dir() else root / "android-action/scripts"))
|
|
28
|
+
import android_config as config
|
|
29
|
+
|
|
30
|
+
return config
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def credentials(platform: str) -> dict:
|
|
34
|
+
root = Path(os.environ["GITHUB_WORKSPACE"]) / "creds"
|
|
35
|
+
patterns = ("*.p8", "cert.p12", "cert.meta.json", "cert.registry.json") if platform == "ios" else (
|
|
36
|
+
"*.jks", "*.keystore")
|
|
37
|
+
paths = {path for pattern in patterns for path in root.glob(pattern)}
|
|
38
|
+
if platform == "android":
|
|
39
|
+
for path in root.glob("*.json"):
|
|
40
|
+
try:
|
|
41
|
+
if json.loads(path.read_text()).get("type") == "service_account":
|
|
42
|
+
paths.add(path)
|
|
43
|
+
except (OSError, ValueError, AttributeError):
|
|
44
|
+
continue
|
|
45
|
+
# Only digests of the app's existing signing/account material travel; never key bytes or passwords.
|
|
46
|
+
if any(path.is_symlink() or not path.is_file() for path in paths):
|
|
47
|
+
raise ValueError("mobile_artifact_credential_scope_invalid")
|
|
48
|
+
result = {path.name: digest(path) for path in sorted(paths)}
|
|
49
|
+
if platform == "android":
|
|
50
|
+
signing = android_config().find_signing_config(root.parent)
|
|
51
|
+
result["selected-keystore"] = digest(signing.keystore_path)
|
|
52
|
+
result["selected-alias"] = hashlib.sha256(signing.key_alias.encode()).hexdigest()
|
|
53
|
+
return result
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def files(root: Path) -> dict:
|
|
57
|
+
result = {}
|
|
58
|
+
for path in sorted(root.rglob("*")):
|
|
59
|
+
if path.is_symlink():
|
|
60
|
+
raise ValueError("mobile_artifact_symlink")
|
|
61
|
+
if path.is_file() and path.name != "mobile-binary.json":
|
|
62
|
+
name = path.relative_to(root).as_posix()
|
|
63
|
+
if "\n" in name or "\r" in name or len(result) >= 10000:
|
|
64
|
+
raise ValueError("mobile_artifact_path_invalid")
|
|
65
|
+
result[name] = digest(path)
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def metadata(platform: str) -> dict:
|
|
70
|
+
event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text())
|
|
71
|
+
keys = IOS_ENV if platform == "ios" else ANDROID_ENV
|
|
72
|
+
return {"schema": SCHEMA, "platform": platform, "repository": os.environ["GITHUB_REPOSITORY"],
|
|
73
|
+
"fingerprint": os.environ["MOBILE_ARTIFACT_FINGERPRINT"],
|
|
74
|
+
"run_id": int(os.environ["GITHUB_RUN_ID"]), "run_attempt": int(os.environ["GITHUB_RUN_ATTEMPT"]),
|
|
75
|
+
"source_sha": os.environ["GITHUB_SHA"],
|
|
76
|
+
"head_sha": event.get("pull_request", {}).get("head", {}).get("sha", os.environ["GITHUB_SHA"]),
|
|
77
|
+
"credential_fingerprints": credentials(platform),
|
|
78
|
+
"environment": {key: os.environ.get(key, "") for key in keys}}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def pack(platform: str, directory: Path) -> None:
|
|
82
|
+
temp = Path(os.environ["RUNNER_TEMP"])
|
|
83
|
+
if any(directory.iterdir()):
|
|
84
|
+
raise ValueError("mobile_artifact_output_not_empty")
|
|
85
|
+
if platform == "ios":
|
|
86
|
+
binaries = list((temp / "export").glob("*.ipa"))
|
|
87
|
+
if len(binaries) != 1:
|
|
88
|
+
raise ValueError("mobile_artifact_binary_ambiguous")
|
|
89
|
+
(directory / "export").mkdir()
|
|
90
|
+
shutil.copyfile(binaries[0], directory / "export" / "app.ipa")
|
|
91
|
+
symbols = ["firebase-symbols", "dart-symbols/ios"]
|
|
92
|
+
else:
|
|
93
|
+
binary = Path(os.environ["MOBILE_ARTIFACT_BINARY"])
|
|
94
|
+
if not binary.is_file() or binary.suffix != ".aab" or binary.is_symlink():
|
|
95
|
+
raise ValueError("mobile_artifact_binary_absent")
|
|
96
|
+
shutil.copyfile(binary, directory / "app.aab")
|
|
97
|
+
symbols = ["dart-symbols/android"]
|
|
98
|
+
for relative in symbols:
|
|
99
|
+
if (temp / relative).is_dir():
|
|
100
|
+
shutil.copytree(temp / relative, directory / relative, symlinks=True)
|
|
101
|
+
value = {**metadata(platform), "files": files(directory)}
|
|
102
|
+
(directory / "mobile-binary.json").write_text(json.dumps(value, indent=2) + "\n")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def verify(platform: str, directory: Path) -> dict:
|
|
106
|
+
receipt = directory / "mobile-binary.json"
|
|
107
|
+
if receipt.is_symlink() or receipt.stat().st_size > 2 * 1024 * 1024:
|
|
108
|
+
raise ValueError("mobile_artifact_receipt_invalid")
|
|
109
|
+
value = json.loads(receipt.read_text())
|
|
110
|
+
if (value.get("schema") != SCHEMA or value.get("platform") != platform
|
|
111
|
+
or value.get("repository") != os.environ["GITHUB_REPOSITORY"]
|
|
112
|
+
or value.get("fingerprint") != os.environ["MOBILE_ARTIFACT_FINGERPRINT"]
|
|
113
|
+
or value.get("credential_fingerprints") != credentials(platform)
|
|
114
|
+
or value.get("files") != files(directory)):
|
|
115
|
+
raise ValueError("mobile_artifact_inputs_mismatch")
|
|
116
|
+
expected = set(IOS_ENV if platform == "ios" else ANDROID_ENV)
|
|
117
|
+
environment = value.get("environment", {})
|
|
118
|
+
if set(environment) != expected or any(not isinstance(v, str) or len(v) > 16000 for v in environment.values()):
|
|
119
|
+
raise ValueError("mobile_artifact_environment_invalid")
|
|
120
|
+
number = environment["BUILD_NUMBER" if platform == "ios" else "ANDROID_BUILD_NUMBER"]
|
|
121
|
+
if not re.fullmatch(r"[1-9][0-9]{0,17}", number):
|
|
122
|
+
raise ValueError("mobile_artifact_version_invalid")
|
|
123
|
+
identifier = environment["CFG_BUNDLE_ID" if platform == "ios" else "ANDROID_PACKAGE_NAME"]
|
|
124
|
+
if not re.fullmatch(r"[A-Za-z0-9_.-]{3,200}", identifier):
|
|
125
|
+
raise ValueError("mobile_artifact_application_invalid")
|
|
126
|
+
if platform == "ios" and (not environment["CFG_APP_STORE_APPLE_ID"].isdigit()
|
|
127
|
+
or not re.fullmatch(r"[A-Z0-9]{10}", environment["CFG_TEAM_ID"])):
|
|
128
|
+
raise ValueError("mobile_artifact_signing_scope_invalid")
|
|
129
|
+
binary = "export/app.ipa" if platform == "ios" else "app.aab"
|
|
130
|
+
if binary not in value["files"]:
|
|
131
|
+
raise ValueError("mobile_artifact_binary_absent")
|
|
132
|
+
return value
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def emit(path: Path, name: str, value: str) -> None:
|
|
136
|
+
# A per-value delimiter keeps multiline What's New text inside a single environment value.
|
|
137
|
+
delimiter = "MOBILE_VALUE_" + hashlib.sha256(value.encode()).hexdigest()
|
|
138
|
+
with path.open("a") as handle:
|
|
139
|
+
handle.write(f"{name}<<{delimiter}\n{value}\n{delimiter}\n")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def restore_ios(directory: Path, value: dict) -> None:
|
|
143
|
+
from read_config import emit_credentials
|
|
144
|
+
|
|
145
|
+
temp, env = Path(os.environ["RUNNER_TEMP"]), Path(os.environ["GITHUB_ENV"])
|
|
146
|
+
emit_credentials(env, Path(os.environ["GITHUB_WORKSPACE"]))
|
|
147
|
+
for key, text in value["environment"].items():
|
|
148
|
+
emit(env, key, text)
|
|
149
|
+
emit(env, "REUSED_BUILD_NUMBER", value["environment"]["BUILD_NUMBER"])
|
|
150
|
+
notes = temp / "candidate-whats-new.txt"
|
|
151
|
+
notes.write_text(value["environment"]["CFG_WHATS_NEW"])
|
|
152
|
+
emit(env, "CFG_WHATS_NEW_FILE", str(notes))
|
|
153
|
+
for relative in ("export", "firebase-symbols", "dart-symbols/ios"):
|
|
154
|
+
if (directory / relative).is_dir():
|
|
155
|
+
shutil.copytree(directory / relative, temp / relative, dirs_exist_ok=True)
|
|
156
|
+
receipt = temp / "firebase-symbols/firebase-symbols.json"
|
|
157
|
+
if receipt.is_file():
|
|
158
|
+
firebase = json.loads(receipt.read_text())
|
|
159
|
+
name = f"ios-crashlytics-symbols-{os.environ['GITHUB_RUN_ID']}-{os.environ['GITHUB_RUN_ATTEMPT']}"
|
|
160
|
+
firebase.update(source_sha=os.environ["GITHUB_SHA"], artifact=name)
|
|
161
|
+
receipt.write_text(json.dumps(firebase, indent=2))
|
|
162
|
+
print("::notice title=firebase_symbols_pending::" + json.dumps(firebase, separators=(",", ":")))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def restore(platform: str, directory: Path) -> None:
|
|
166
|
+
value = verify(platform, directory)
|
|
167
|
+
if platform == "ios":
|
|
168
|
+
restore_ios(directory, value)
|
|
169
|
+
return
|
|
170
|
+
# Use the current checkout's account, never a service-account file retained in an artifact.
|
|
171
|
+
env = value["environment"]
|
|
172
|
+
values = {"package-name": env["ANDROID_PACKAGE_NAME"], "build-number": env["ANDROID_BUILD_NUMBER"],
|
|
173
|
+
"project-kind": env["ANDROID_PROJECT_KIND"], "bundle-path": str(directory / "app.aab"),
|
|
174
|
+
"play-service-account": str(android_config().find_play_service_account(
|
|
175
|
+
Path(os.environ["GITHUB_WORKSPACE"])))}
|
|
176
|
+
for key, text in values.items():
|
|
177
|
+
emit(Path(os.environ["GITHUB_OUTPUT"]), key, text)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
if __name__ == "__main__":
|
|
181
|
+
operation, selected, target = sys.argv[1:]
|
|
182
|
+
if selected not in {"ios", "android"} or operation not in {"pack", "restore", "verify"}:
|
|
183
|
+
raise SystemExit("mobile_artifact_command_invalid")
|
|
184
|
+
try:
|
|
185
|
+
{"pack": pack, "restore": restore, "verify": verify}[operation](selected, Path(target))
|
|
186
|
+
except Exception:
|
|
187
|
+
raise SystemExit("mobile_artifact_files_refused") from None
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/* Reuse completed candidate artifacts immediately; a miss never reserves or waits for a runner. */
|
|
2
|
+
'use strict';
|
|
3
|
+
const {execFileSync} = require('node:child_process');
|
|
4
|
+
const {mkdirSync, mkdtempSync, readFileSync, appendFileSync} = require('node:fs');
|
|
5
|
+
const {join} = require('node:path');
|
|
6
|
+
const {createHash} = require('node:crypto');
|
|
7
|
+
const inputs = require('./mobile_inputs.cjs');
|
|
8
|
+
const api = require('./mobile_release_api.cjs');
|
|
9
|
+
|
|
10
|
+
function identity(platform) {
|
|
11
|
+
const manifest = inputs.read(JSON.parse(process.env.MOBILE_RELEASE_VARIABLES || '{}'));
|
|
12
|
+
if (!manifest.platforms[platform]) throw Error('mobile_artifact_platform_absent');
|
|
13
|
+
return createHash('sha256').update(manifest.platforms[platform] + manifest.configuration_sha256).digest('hex');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function output(name, value) {
|
|
17
|
+
if (/[\r\n]/.test(String(value))) throw Error('mobile_artifact_output_invalid');
|
|
18
|
+
appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function candidate(artifact) {
|
|
22
|
+
if (artifact.expired !== false || !Number.isSafeInteger(artifact.workflow_run?.id)) return null;
|
|
23
|
+
const run = await api.request('GET', `/actions/runs/${artifact.workflow_run.id}`, undefined, 5000);
|
|
24
|
+
const repository = process.env.GITHUB_REPOSITORY;
|
|
25
|
+
if (run.status !== 'completed' || run.conclusion !== 'success' || run.event !== 'pull_request' ||
|
|
26
|
+
run.path !== '.github/workflows/mobile-candidate.yml' || !run.head_branch?.startsWith('task/') ||
|
|
27
|
+
run.repository?.full_name !== repository || run.head_repository?.full_name !== repository ||
|
|
28
|
+
!/^[a-f0-9]{40}$/.test(run.head_sha) || !Number.isSafeInteger(run.run_attempt)) return null;
|
|
29
|
+
const match = /^mobile-binary-(ios|android)-[a-f0-9]{64}-(\d+)-(\d+)$/.exec(artifact.name);
|
|
30
|
+
const attempt = Number(match?.[3]);
|
|
31
|
+
if (!match || Number(match[2]) !== run.id || !Number.isSafeInteger(attempt) ||
|
|
32
|
+
attempt < 1 || attempt > run.run_attempt) return null;
|
|
33
|
+
const jobs = await api.request('GET', `/actions/runs/${run.id}/attempts/${attempt}/jobs?per_page=100`, undefined, 5000);
|
|
34
|
+
const label = match[1] === 'ios' ? 'iOS TestFlight' : 'Android Google Play';
|
|
35
|
+
const producers = jobs.jobs?.filter(job => job.name === label || job.name?.endsWith(`/ ${label}`)) || [];
|
|
36
|
+
if (jobs.jobs?.length >= 100 || producers.length !== 1 || producers[0].conclusion !== 'success' ||
|
|
37
|
+
producers[0].head_sha !== run.head_sha || !producers[0].steps?.some(step =>
|
|
38
|
+
step.name === 'Retain candidate shipping binary' && step.conclusion === 'success')) return null;
|
|
39
|
+
// Re-running only a failed sibling retains a successful platform's artifact from its earlier attempt.
|
|
40
|
+
return {...run, artifact_attempt: attempt};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function restore(platform) {
|
|
44
|
+
const deadline = Date.now() + 120000;
|
|
45
|
+
const fingerprint = identity(platform);
|
|
46
|
+
const prefix = `mobile-binary-${platform}-${fingerprint}-`;
|
|
47
|
+
const inventory = await api.request('GET', '/actions/artifacts?per_page=100', undefined, 5000);
|
|
48
|
+
if (!Array.isArray(inventory.artifacts)) throw Error('mobile_artifact_inventory_invalid');
|
|
49
|
+
for (const artifact of inventory.artifacts.filter(row => row.name?.startsWith(prefix)).slice(0, 5)) {
|
|
50
|
+
if (Date.now() >= deadline) break;
|
|
51
|
+
const run = await candidate(artifact);
|
|
52
|
+
if (!run || artifact.name !== `${prefix}${run.id}-${run.artifact_attempt}`) continue;
|
|
53
|
+
const directory = mkdtempSync(join(process.env.RUNNER_TEMP, `mobile-binary-${platform}-`));
|
|
54
|
+
execFileSync('gh', ['run', 'download', String(run.id), '--repo', process.env.GITHUB_REPOSITORY,
|
|
55
|
+
'--name', artifact.name, '--dir', directory], {stdio: 'ignore', timeout: Math.max(1, deadline - Date.now())});
|
|
56
|
+
const receipt = JSON.parse(readFileSync(join(directory, 'mobile-binary.json'), 'utf8'));
|
|
57
|
+
if (receipt.run_id !== run.id || receipt.run_attempt !== run.artifact_attempt || receipt.head_sha !== run.head_sha) {
|
|
58
|
+
throw Error('mobile_artifact_run_mismatch');
|
|
59
|
+
}
|
|
60
|
+
const operation = platform === 'ios' ? 'verify' : 'restore';
|
|
61
|
+
execFileSync('python3', [join(__dirname, 'mobile_artifact_files.py'), operation, platform, directory],
|
|
62
|
+
{stdio: ['ignore', 'inherit', 'pipe'], env: {...process.env, MOBILE_ARTIFACT_FINGERPRINT: fingerprint}});
|
|
63
|
+
output('directory', directory);
|
|
64
|
+
output('restored', 'true');
|
|
65
|
+
console.log(`::notice::Reusing ${platform} binary from candidate run ${run.id}`);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
output('restored', 'false');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function pack(platform) {
|
|
72
|
+
const fingerprint = identity(platform);
|
|
73
|
+
const directory = join(process.env.RUNNER_TEMP, `mobile-binary-${platform}`);
|
|
74
|
+
mkdirSync(directory, {recursive: true});
|
|
75
|
+
execFileSync('python3', [join(__dirname, 'mobile_artifact_files.py'), 'pack', platform, directory],
|
|
76
|
+
{stdio: ['ignore', 'inherit', 'pipe'], env: {...process.env, MOBILE_ARTIFACT_FINGERPRINT: fingerprint}});
|
|
77
|
+
output('directory', directory);
|
|
78
|
+
const attempt = `${process.env.GITHUB_RUN_ID}-${process.env.GITHUB_RUN_ATTEMPT}`;
|
|
79
|
+
output('name', `mobile-binary-${platform}-${fingerprint}-${attempt}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function main([operation, platform, directory]) {
|
|
83
|
+
if (!['ios', 'android'].includes(platform)) throw Error('mobile_artifact_platform_invalid');
|
|
84
|
+
if (operation === 'pack') return pack(platform);
|
|
85
|
+
if (operation === 'activate' && platform === 'ios' && directory) {
|
|
86
|
+
return execFileSync('python3', [join(__dirname, 'mobile_artifact_files.py'), 'restore', platform, directory],
|
|
87
|
+
{stdio: ['ignore', 'inherit', 'pipe'], env: {...process.env, MOBILE_ARTIFACT_FINGERPRINT: identity(platform)}});
|
|
88
|
+
}
|
|
89
|
+
if (operation !== 'restore') throw Error('mobile_artifact_operation_invalid');
|
|
90
|
+
try { await restore(platform); }
|
|
91
|
+
catch {
|
|
92
|
+
// This is an optional optimization. No provider write has occurred; existing CI can build normally.
|
|
93
|
+
output('restored', 'false');
|
|
94
|
+
console.log('::notice::Candidate binary unavailable or mismatched; using the existing release build');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = {identity, candidate, restore, pack};
|
|
99
|
+
if (require.main === module) main(process.argv.slice(2)).catch(() => {
|
|
100
|
+
console.error('::error::mobile_artifact_pack_failed');
|
|
101
|
+
process.exitCode = 1;
|
|
102
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Candidate compilation may read store version state but cannot claim a store upload."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def active() -> bool:
|
|
8
|
+
if os.environ.get("MOBILE_CANDIDATE_BUILD") != "true":
|
|
9
|
+
return False
|
|
10
|
+
event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text())
|
|
11
|
+
pull = event.get("pull_request", {})
|
|
12
|
+
head, base = pull.get("head", {}), pull.get("base", {})
|
|
13
|
+
if (os.environ.get("GITHUB_EVENT_NAME") != "pull_request"
|
|
14
|
+
or head.get("repo", {}).get("full_name") != os.environ.get("GITHUB_REPOSITORY")
|
|
15
|
+
or not str(head.get("ref", "")).startswith("task/")
|
|
16
|
+
or base.get("ref") != event.get("repository", {}).get("default_branch")):
|
|
17
|
+
raise ValueError("mobile_release_candidate_scope_invalid")
|
|
18
|
+
return True
|
|
@@ -29,7 +29,21 @@ const BUILD_VARIABLES = [
|
|
|
29
29
|
];
|
|
30
30
|
|
|
31
31
|
function buildVariables(value = {}) {
|
|
32
|
-
|
|
32
|
+
const names = new Set(BUILD_VARIABLES);
|
|
33
|
+
for (const path of ['.github/workflows/deploy.yml', '.github/workflows/mobile-candidate.yml']) {
|
|
34
|
+
let source;
|
|
35
|
+
try { source = git('show', `HEAD:${path}`); } catch { continue; }
|
|
36
|
+
const named = /\bvars(?:\.([A-Za-z_][A-Za-z0-9_]*)|\[['"]([A-Za-z_][A-Za-z0-9_]*)['"]\])/g;
|
|
37
|
+
const consumed = [...source.matchAll(named)].map(match => match[1] || match[2]);
|
|
38
|
+
// A custom workflow may consume a variable the standard template does not know.
|
|
39
|
+
// Whole-object/dynamic use has no narrower provable scope, so conservatively include it.
|
|
40
|
+
const dynamic = /\bvars\[(?!['"])/.test(source) || source.split('\n').some(line =>
|
|
41
|
+
line.includes('toJSON(vars)') && !line.includes('MOBILE_RELEASE_VARIABLES:'));
|
|
42
|
+
for (const name of (dynamic ? Object.keys(value) : consumed).sort()) {
|
|
43
|
+
if (name !== 'APP_ROBOT_PLAY_EDIT_BINDING') names.add(name);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return Object.fromEntries([...names].filter(name => Object.hasOwn(value, name))
|
|
33
47
|
.map(name => [name, value[name]]));
|
|
34
48
|
}
|
|
35
49
|
|