gowalk-cicd 1.0.138 → 1.0.140

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -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
 
@@ -1 +1 @@
1
- 1.0.138
1
+ 1.0.140
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: Select latest Xcode
316
- shell: bash
317
- run: |
318
- XCODE_PATH=$(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V | tail -1)
319
- if [ -z "$XCODE_PATH" ]; then
320
- XCODE_PATH=$(ls -d /Applications/Xcode*.app 2>/dev/null | sort -V | tail -1)
321
- fi
322
- echo "Selecting $XCODE_PATH"
323
- sudo xcode-select -s "$XCODE_PATH"
324
- xcodebuild -version
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: ${{ inputs.archive == 'true' && inputs.manage-app-store-version == 'true' }}
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
- BUILD_NUMBER=$(python3 "$SWIFT_APP_ACTION/scripts/next_build_number.py")
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: ${{ inputs.archive == 'true' && inputs.manage-app-store-version == 'false' }}
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: ${{ inputs.archive == 'true' && (inputs.uses-non-exempt-encryption || env.CFG_USES_NON_EXEMPT) != '' }}
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: ${{ inputs.archive == 'true' && inputs.upload == 'true' }}
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 }}
@@ -0,0 +1,10 @@
1
+ // NODE_OPTIONS applies this only to the cache step, including its official post/save action.
2
+ try {
3
+ const scope = require('./cache_scope.cjs');
4
+ scope.authorize(process.argv[1], process.env);
5
+ scope.guardRequests();
6
+ for (const key of scope.proxyKeys) delete process.env[key];
7
+ } catch {
8
+ process.stderr.write('github_cache_transport_scope_refused\n');
9
+ process.exit(1);
10
+ }
@@ -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
- if not _persist_context_ok(policy):
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
- return Object.fromEntries(BUILD_VARIABLES.filter(name => Object.hasOwn(value, name))
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