gowalk-cicd 1.0.174 → 1.0.175

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/README.md CHANGED
@@ -154,6 +154,20 @@ the mobile workflow. No hosting provider is assumed: consumers can deploy the
154
154
  artifact to their chosen host without granting this package an unrelated cloud
155
155
  account or domain.
156
156
 
157
+ ## Delivery timing and cache cost
158
+
159
+ Keep routine push-to-verified-production delivery within about 2–5 minutes while preserving
160
+ checks and coverage. [CI performance receipts](docs/CI_PERFORMANCE.md) distinguish runner time,
161
+ queueing, retries and provider work. Count conditional dependency priming as auxiliary cost.
162
+
163
+ Promoted candidates now seed missing default-branch SDK/pub/Flutter Pods/Gradle caches through
164
+ verified key/path/compression identities and dependency-only work. They never promote PR cache
165
+ bytes or duplicate a compile. Default-branch macOS fallbacks transfer the IPA and leave Apple
166
+ processing to a required Linux verification job. Apple must positively confirm the exact upload,
167
+ file, eligible build and app before delivery completes; its processing time remains external.
168
+ The final complete upload receipt and verification proof are in `apple-verified-<run>-<attempt>`.
169
+ See [candidate artifacts](docs/CANDIDATE_ARTIFACTS.md) and [cache behavior](docs/GRADLE_CACHE.md).
170
+
157
171
  ## iOS credentials
158
172
 
159
173
  Use encrypted repository secrets for both the App Store Connect API key and
package/action/action.yml CHANGED
@@ -63,6 +63,10 @@ inputs:
63
63
  description: "Set to 'false' to stop after producing the IPA"
64
64
  required: false
65
65
  default: "true"
66
+ defer-provider-verification:
67
+ description: Transfer only; caller must verify the retained handoff on Linux before declaring delivery complete
68
+ required: false
69
+ default: "false"
66
70
  archive:
67
71
  description: >
68
72
  Set to 'false' to skip archive + export + upload entirely. Useful for
@@ -172,6 +176,20 @@ inputs:
172
176
  required: false
173
177
  default: "true"
174
178
 
179
+ outputs:
180
+ verification-deferred:
181
+ description: A successful transfer still requires the caller's Linux verification
182
+ value: ${{ inputs.defer-provider-verification == 'true' && inputs.upload == 'true' && inputs.archive == 'true' }}
183
+ handoff-path:
184
+ description: Exact deferred handoff file which the caller must retain before Linux verification
185
+ value: ${{ steps.upload.outputs.handoff-path }}
186
+ handoff-sha256:
187
+ description: Exact handoff content digest
188
+ value: ${{ steps.upload.outputs.handoff-sha256 }}
189
+ handoff-producer-attempt:
190
+ description: Attempt that performed the binary transfer
191
+ value: ${{ steps.upload.outputs.handoff-producer-attempt }}
192
+
175
193
  runs:
176
194
  using: composite
177
195
  steps:
@@ -776,13 +794,21 @@ runs:
776
794
  shell: bash
777
795
  env:
778
796
  APP_STORE_APPLE_ID: ${{ inputs.app-store-apple-id || env.CFG_APP_STORE_APPLE_ID }}
797
+ DEFER_PROVIDER_VERIFICATION: ${{ inputs.defer-provider-verification }}
798
+ APPLE_UPLOAD_MANAGE_VERSION: ${{ inputs.manage-app-store-version }}
799
+ INPUT_WHATS_NEW: ${{ inputs.app-store-whats-new }}
800
+ APP_STORE_LOCALE: ${{ inputs.app-store-locale || env.CFG_LOCALE }}
779
801
  run: |
780
802
  IPA=$(ls "$RUNNER_TEMP"/export/*.ipa | head -1)
803
+ options=()
804
+ if [ "$DEFER_PROVIDER_VERIFICATION" = true ]; then
805
+ options+=(--transfer-only --handoff "$RUNNER_TEMP/apple-verification.json")
806
+ fi
781
807
  python3 "$SWIFT_APP_ACTION/scripts/upload_build.py" "$IPA" \
782
- --report "$RUNNER_TEMP/apple-build-upload.json"
808
+ --report "$RUNNER_TEMP/apple-build-upload.json" "${options[@]}"
783
809
 
784
810
  - name: Record confirmed iOS upload
785
- if: ${{ steps.upload.outcome == 'success' }}
811
+ if: ${{ steps.upload.outcome == 'success' && inputs.defer-provider-verification != 'true' }}
786
812
  shell: bash
787
813
  run: node "$SWIFT_APP_ACTION/scripts/mobile_release.cjs" finish-upload ios
788
814
 
@@ -800,13 +826,15 @@ runs:
800
826
  # continuation can resume or complete that same upload identity instead of
801
827
  # rebuilding different bytes under the number the receipt names.
802
828
  - name: Retain the exported IPA for upload recovery
803
- if: ${{ always() && inputs.archive == 'true' && inputs.upload == 'true' && steps.upload.outcome != 'success' }}
829
+ if: ${{ always() && inputs.archive == 'true' && inputs.upload == 'true'
830
+ && (steps.upload.outcome != 'success' || inputs.defer-provider-verification == 'true') }}
804
831
  uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
805
832
  env:
806
833
  NODE_OPTIONS: --require "${{ env.SWIFT_APP_ACTION }}/scripts/artifact_env.cjs"
807
834
  with:
808
835
  name: apple-ipa-${{ github.run_id }}-${{ github.run_attempt }}
809
836
  path: ${{ runner.temp }}/export/*.ipa
837
+ compression-level: 0 # An IPA is already a ZIP; avoid duplicate compression on the Mac.
810
838
  if-no-files-found: ignore
811
839
  retention-days: 7
812
840
 
@@ -818,6 +846,7 @@ runs:
818
846
  # TESTFLIGHT_ONLY decision): there is no editable version id to write to.
819
847
  - name: Set App Store "What's New"
820
848
  if: ${{ inputs.archive == 'true' && inputs.upload == 'true'
849
+ && inputs.defer-provider-verification != 'true'
821
850
  && inputs.manage-app-store-version == 'true'
822
851
  && env.STORE_VERSION_LOCKED == ''
823
852
  && (inputs.app-store-whats-new != '' || env.CFG_WHATS_NEW != '') }}
@@ -0,0 +1,103 @@
1
+ /* Admit the exact successful transfer and handoff, never an arbitrary receipt or cache. */
2
+ 'use strict';
3
+ const fs = require('node:fs');
4
+ const {createHash} = require('node:crypto');
5
+ const {join} = require('node:path');
6
+ const github = require('./mobile_release_api.cjs');
7
+ const SCHEMA = 'gowalk-cicd/apple-upload-handoff.v1';
8
+ const positive = value => Number.isSafeInteger(value) && value > 0;
9
+ const id = value => typeof value === 'string' && /^[A-Za-z0-9-]{1,64}$/.test(value);
10
+ const fail = code => { throw Error(`apple_upload_verify_${code}`); };
11
+
12
+ function read(path, digest, attempt, env = process.env) {
13
+ if (fs.statSync(path).size > 2 * 1024 * 1024) fail('handoff_size');
14
+ const bytes = fs.readFileSync(path);
15
+ if (bytes.length > 2 * 1024 * 1024 || !/^[a-f0-9]{64}$/.test(digest || '') ||
16
+ createHash('sha256').update(bytes).digest('hex') !== digest) fail('handoff_digest');
17
+ const value = JSON.parse(bytes), owner = value.owner || {}, r = value.receipt || {}, m = value.metadata || {};
18
+ const event = JSON.parse(fs.readFileSync(env.GITHUB_EVENT_PATH, 'utf8'));
19
+ if (value.schema !== SCHEMA || owner.repository !== env.GITHUB_REPOSITORY ||
20
+ !/^[\w.-]+\/[\w.-]+$/.test(owner.repository || '') || !/^[a-f0-9]{40}$/.test(owner.source_sha || '') ||
21
+ owner.source_sha !== env.GITHUB_SHA || owner.ref !== env.GITHUB_REF || !owner.ref?.startsWith('refs/heads/') ||
22
+ owner.ref !== `refs/heads/${event.repository?.default_branch}` ||
23
+ !['push', 'workflow_dispatch'].includes(owner.event) || owner.event !== env.GITHUB_EVENT_NAME ||
24
+ !positive(owner.run_id) || owner.run_id !== Number(env.GITHUB_RUN_ID) || !positive(owner.run_attempt) ||
25
+ !positive(attempt) || !positive(Number(env.GITHUB_RUN_ATTEMPT)) ||
26
+ owner.run_attempt !== attempt || attempt > Number(env.GITHUB_RUN_ATTEMPT) ||
27
+ !positive(owner.deployment_id) || owner.deployment_id !== Number(env.MOBILE_RELEASE_DEPLOYMENT_ID)) {
28
+ fail('handoff_owner');
29
+ }
30
+ if (r.schema !== 'gowalk-cicd/apple-build-upload-receipt.v1' || r.stage !== 'transferred' ||
31
+ !/^\d+$/.test(r.app_id || '') || !id(r.upload_id) || !id(r.file_id) ||
32
+ !/^[A-Za-z0-9.-]+$/.test(r.bundle_id || '') || !/^\d+$/.test(r.cfBundleVersion || '') ||
33
+ !/^\d+(\.\d+){0,2}$/.test(r.cfBundleShortVersionString || '') ||
34
+ !/^[a-f0-9]{64}$/.test(r.sha256 || '') || r.fileName !== `${r.sha256}.ipa` || !positive(r.fileSize)) {
35
+ fail('handoff_receipt');
36
+ }
37
+ if (typeof m.enabled !== 'boolean' || typeof m.only_if_empty !== 'boolean' ||
38
+ typeof m.notes !== 'string' || Buffer.byteLength(m.notes) > 1024 * 1024 || m.notes.includes('\0') ||
39
+ !/^[A-Za-z0-9-]{2,35}$/.test(m.locale || '') || m.enabled && !id(m.version_id)) fail('handoff_metadata');
40
+ return value;
41
+ }
42
+
43
+ function transferred(job) {
44
+ if (!Array.isArray(job.steps)) return false;
45
+ const selected = ['Build and transfer iOS binary', 'Retain Apple verification handoff'].map(name =>
46
+ job.steps.filter(step => step.name === name));
47
+ if (selected.some(steps => steps.length !== 1 || steps[0].status !== 'completed' ||
48
+ steps[0].conclusion !== 'success' || !positive(steps[0].number))) return false;
49
+ return selected[0][0].number < selected[1][0].number;
50
+ }
51
+
52
+ async function verifyProducer(value, artifactId, api = github) {
53
+ if (!positive(artifactId)) fail('artifact_identity');
54
+ const o = value.owner;
55
+ const run = await api.request('GET', `/actions/runs/${o.run_id}/attempts/${o.run_attempt}`);
56
+ if (run.id !== o.run_id || run.run_attempt !== o.run_attempt || run.head_sha !== o.source_sha ||
57
+ run.path !== '.github/workflows/deploy.yml' || run.event !== o.event ||
58
+ `refs/heads/${run.head_branch}` !== o.ref || run.repository?.full_name !== o.repository ||
59
+ run.head_repository?.full_name !== o.repository) fail('producer_run');
60
+ const result = await api.request('GET', `/actions/runs/${o.run_id}/attempts/${o.run_attempt}/jobs?per_page=100`);
61
+ if (!Array.isArray(result.jobs) || result.jobs.length >= 100) fail('producer_jobs');
62
+ const jobs = result.jobs.filter(job => job.name === 'iOS TestFlight' || job.name?.endsWith('/ iOS TestFlight'));
63
+ // A later symbol-retention failure does not undo these exact completed steps.
64
+ // The producer's original failure remains red; only Apple delivery is reconciled here.
65
+ if (jobs.length !== 1 || jobs[0].status !== 'completed' || !['success', 'failure'].includes(jobs[0].conclusion) ||
66
+ jobs[0].head_sha !== o.source_sha || !transferred(jobs[0])) fail('producer_job');
67
+ const artifact = await api.request('GET', `/actions/artifacts/${artifactId}`);
68
+ if (artifact.id !== artifactId || artifact.expired !== false || artifact.workflow_run?.id !== o.run_id ||
69
+ artifact.workflow_run?.head_sha !== o.source_sha ||
70
+ artifact.name !== `apple-verification-${o.run_id}-${o.run_attempt}`) fail('producer_artifact');
71
+ }
72
+
73
+ async function phase(value, api = github) {
74
+ const o = value.owner, record = await api.request('GET', `/deployments/${o.deployment_id}`);
75
+ const payload = record.payload || {};
76
+ if (record.id !== o.deployment_id || record.sha !== o.source_sha || record.task !== 'mobile-upload' ||
77
+ payload.schema !== 'gowalk-cicd/mobile-upload-owner.v1' || payload.platform !== 'ios' ||
78
+ !/^[a-f0-9]{64}$/.test(payload.fingerprint || '') ||
79
+ record.environment !== `mobile-ios-${payload.fingerprint}` || payload.run_id !== o.run_id ||
80
+ payload.source_sha !== o.source_sha || !positive(payload.run_attempt) ||
81
+ payload.run_attempt > o.run_attempt) fail('deployment_owner');
82
+ const rows = await api.request('GET', `/deployments/${o.deployment_id}/statuses?per_page=1`);
83
+ if (rows[0]?.state === 'success' && rows[0]?.description === 'uploaded') return 'uploaded';
84
+ if (rows[0]?.state !== 'in_progress' || rows[0]?.description !== 'uploading') fail('deployment_phase');
85
+ return 'uploading';
86
+ }
87
+
88
+ function metadata(value, env = process.env) {
89
+ if (!env.GITHUB_ENV) return;
90
+ const m = value.metadata;
91
+ if (!m.enabled) return fs.appendFileSync(env.GITHUB_ENV, 'APPLE_UPLOAD_METADATA_READY=false\n');
92
+ const notes = join(env.RUNNER_TEMP, 'verified-apple-whats-new.txt');
93
+ fs.writeFileSync(notes, m.notes, {mode: 0o600});
94
+ const fields = {APPLE_UPLOAD_METADATA_READY: 'true', APP_STORE_VERSION_ID: m.version_id,
95
+ MARKETING_VERSION: value.receipt.cfBundleShortVersionString, APP_STORE_WHATS_NEW_FILE: notes,
96
+ WHATS_NEW_ONLY_IF_EMPTY: String(m.only_if_empty), APP_STORE_LOCALE: m.locale};
97
+ for (const [name, content] of Object.entries(fields)) {
98
+ if (/[\r\n]/.test(content)) fail('metadata_environment');
99
+ fs.appendFileSync(env.GITHUB_ENV, `${name}=${content}\n`);
100
+ }
101
+ }
102
+
103
+ module.exports = {read, verifyProducer, phase, metadata, fail};
@@ -0,0 +1,65 @@
1
+ """Bind transferred IPA identity and metadata to one durable GitHub producer attempt."""
2
+ import hashlib
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import re
7
+
8
+ SCHEMA = "gowalk-cicd/apple-upload-handoff.v1"
9
+ FIELDS = ("schema", "stage", "app_id", "bundle_id", "cfBundleVersion", "cfBundleShortVersionString",
10
+ "sha256", "fileName", "fileSize", "upload_id", "file_id")
11
+
12
+
13
+ def positive(name):
14
+ value = os.environ.get(name, "")
15
+ if not re.fullmatch(r"[1-9][0-9]*", value) or int(value) > 2 ** 53 - 1:
16
+ raise ValueError("apple_handoff_owner_invalid")
17
+ return int(value)
18
+
19
+
20
+ def metadata():
21
+ env = os.environ
22
+ managed = env.get("APPLE_UPLOAD_MANAGE_VERSION", "false")
23
+ if managed not in ("true", "false"):
24
+ raise ValueError("apple_handoff_metadata_invalid")
25
+ explicit = env.get("INPUT_WHATS_NEW", "")
26
+ notes = explicit or env.get("CFG_WHATS_NEW", "")
27
+ enabled = managed == "true" and not env.get("STORE_VERSION_LOCKED") and bool(notes)
28
+ if enabled and not explicit and env.get("CFG_WHATS_NEW_FILE"):
29
+ notes = Path(env["CFG_WHATS_NEW_FILE"]).read_text()
30
+ version = env.get("APP_STORE_VERSION_ID", "") if enabled else ""
31
+ locale = env.get("APP_STORE_LOCALE", "") or "en-US"
32
+ if (len(notes.encode()) > 1024 * 1024 or "\0" in notes or not re.fullmatch(r"[A-Za-z0-9-]{2,35}", locale)
33
+ or enabled and not re.fullmatch(r"[A-Za-z0-9-]{1,64}", version)):
34
+ raise ValueError("apple_handoff_metadata_invalid")
35
+ return {"enabled": enabled, "version_id": version, "locale": locale,
36
+ "only_if_empty": not bool(explicit), "notes": notes if enabled else ""}
37
+
38
+
39
+ def prepare():
40
+ env = os.environ
41
+ repository, sha, ref = (env.get(name, "") for name in ("GITHUB_REPOSITORY", "GITHUB_SHA", "GITHUB_REF"))
42
+ event = env.get("GITHUB_EVENT_NAME", "")
43
+ context = json.loads(Path(env["GITHUB_EVENT_PATH"]).read_text())
44
+ if (event not in ("push", "workflow_dispatch") or not re.fullmatch(r"[\w.-]+/[\w.-]+", repository)
45
+ or not re.fullmatch(r"[a-f0-9]{40}", sha) or not ref.startswith("refs/heads/")
46
+ or ref != "refs/heads/" + context["repository"]["default_branch"]
47
+ or "\n" in ref or "\r" in ref or len(ref) > 300):
48
+ raise ValueError("apple_handoff_owner_invalid")
49
+ return {"schema": SCHEMA, "owner": {"repository": repository, "source_sha": sha, "ref": ref, "event": event,
50
+ "run_id": positive("GITHUB_RUN_ID"), "run_attempt": positive("GITHUB_RUN_ATTEMPT"),
51
+ "deployment_id": positive("MOBILE_RELEASE_DEPLOYMENT_ID")}, "metadata": metadata()}
52
+
53
+
54
+ def write(path, handoff, receipt):
55
+ if receipt.get("stage") != "transferred" or "\n" in str(path) or "\r" in str(path):
56
+ raise ValueError("apple_handoff_transfer_unconfirmed")
57
+ handoff = {**handoff, "receipt": {name: receipt[name] for name in FIELDS}}
58
+ data = (json.dumps(handoff, sort_keys=True, separators=(",", ":")) + "\n").encode()
59
+ path.write_bytes(data)
60
+ digest = hashlib.sha256(data).hexdigest()
61
+ if output := os.environ.get("GITHUB_OUTPUT"):
62
+ with open(output, "a") as stream:
63
+ stream.write(f"handoff-path={path}\nhandoff-sha256={digest}\n")
64
+ stream.write(f"handoff-producer-attempt={handoff['owner']['run_attempt']}\n")
65
+ return digest
@@ -0,0 +1,86 @@
1
+ /* Linux verifies the already-transferred IPA; it never reserves, uploads or rebuilds anything. */
2
+ 'use strict';
3
+ const fs = require('node:fs');
4
+ const {parseArgs} = require('node:util');
5
+ const {setTimeout: delay} = require('node:timers/promises');
6
+ const github = require('./mobile_release_api.cjs');
7
+ const apple = require('./reconcile_apple_upload.cjs');
8
+ const handoff = require('./apple_upload_handoff.cjs');
9
+
10
+ function credential(env = process.env) {
11
+ const pem = env.ASC_KEY_P8 || (env.ASC_KEY_PATH ? fs.readFileSync(env.ASC_KEY_PATH, 'utf8') : '');
12
+ if (!pem || !env.ASC_KEY_ID || !env.ASC_ISSUER_ID) handoff.fail('credential_missing');
13
+ return {pem, id: env.ASC_KEY_ID, issuer: env.ASC_ISSUER_ID};
14
+ }
15
+
16
+ async function observe(receipt, get) {
17
+ const path = `/buildUploads/${receipt.upload_id}?include=build`, response = await get(path);
18
+ const upload = response.data, attrs = upload?.attributes || {};
19
+ if (upload?.id !== receipt.upload_id || attrs.cfBundleVersion !== receipt.cfBundleVersion ||
20
+ attrs.cfBundleShortVersionString !== receipt.cfBundleShortVersionString) handoff.fail('upload_identity');
21
+ if (attrs.state?.state === 'FAILED') handoff.fail('processing_failed');
22
+ if (attrs.state?.state !== 'COMPLETE') return null;
23
+ const buildId = upload.relationships?.build?.data?.id;
24
+ if (!/^[A-Za-z0-9-]{1,64}$/.test(buildId || '')) handoff.fail('build_identity');
25
+ const buildPath = `/builds/${buildId}`, build = await get(buildPath);
26
+ if (['FAILED', 'INVALID'].includes(build.data?.attributes?.processingState)) handoff.fail('processing_failed');
27
+ if (build.data?.attributes?.processingState === 'PROCESSING') return null;
28
+ const files = await get(`/buildUploads/${receipt.upload_id}/buildUploadFiles`);
29
+ const assets = files.data?.filter(row => row.attributes?.assetType === 'ASSET');
30
+ if (!Array.isArray(assets) || files.links?.next || assets.length !== 1 || assets[0].id !== receipt.file_id) {
31
+ handoff.fail('file_membership');
32
+ }
33
+ // Preserve the existing independent complete-file, VALID/eligible build and actual app readbacks.
34
+ return apple.verifyApple(receipt, requested => requested === path ? response :
35
+ requested === buildPath ? build : get(requested));
36
+ }
37
+
38
+ async function verify(value, artifactId, timeout, dependencies = {}) {
39
+ if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 3600) handoff.fail('timeout_invalid');
40
+ const api = dependencies.api || github, now = dependencies.now || Date.now;
41
+ const sleep = dependencies.sleep || delay, started = now(), deadline = started + timeout * 1000;
42
+ const key = dependencies.get ? null : credential();
43
+ const get = dependencies.get || (path => apple.appleGet(path, key, Math.max(1, Math.min(30000, deadline - now()))));
44
+ await handoff.verifyProducer(value, artifactId, api);
45
+ await handoff.phase(value, api);
46
+ let proof;
47
+ do {
48
+ proof = await observe(value.receipt, get);
49
+ if (proof) break;
50
+ if (now() >= deadline) handoff.fail('processing_pending');
51
+ await sleep(Math.min(10000, deadline - now()));
52
+ } while (now() < deadline);
53
+ if (!proof) handoff.fail('processing_pending');
54
+ const prior = await handoff.phase(value, api);
55
+ if (prior === 'uploading') await api.status(value.owner.deployment_id, 'uploaded', 'success');
56
+ if (await handoff.phase(value, api) !== 'uploaded') handoff.fail('write_unconfirmed');
57
+ return {schema: 'gowalk-cicd/apple-upload-verified.v1', state: prior === 'uploaded' ? 'reused' : 'landed',
58
+ deployment_id: value.owner.deployment_id, producer_attempt: value.owner.run_attempt,
59
+ processing_seconds: (now() - started) / 1000, ...proof};
60
+ }
61
+
62
+ async function main() {
63
+ const {values, positionals} = parseArgs({allowPositionals: true, options: {
64
+ sha256: {type: 'string'}, 'producer-attempt': {type: 'string'}, timeout: {type: 'string', default: '900'}}});
65
+ if (positionals.length !== 1) handoff.fail('arguments');
66
+ const value = handoff.read(positionals[0], values.sha256, Number(values['producer-attempt']));
67
+ const proof = await verify(value, Number(process.env.APPLE_UPLOAD_HANDOFF_ARTIFACT_ID), Number(values.timeout));
68
+ handoff.metadata(value);
69
+ const receiptPath = `${positionals[0]}.upload-receipt.json`;
70
+ fs.writeFileSync(receiptPath, JSON.stringify({...value.receipt, stage: 'complete', state: 'COMPLETE',
71
+ build_id: proof.build_id, processing_seconds: proof.processing_seconds}, null, 2) + '\n');
72
+ if (/[\r\n]/.test(receiptPath)) handoff.fail('receipt_path');
73
+ if (process.env.GITHUB_OUTPUT) fs.appendFileSync(process.env.GITHUB_OUTPUT,
74
+ `verified=true\nbuild-id=${proof.build_id}\nproducer-attempt=${proof.producer_attempt}\n` +
75
+ `receipt-path=${receiptPath}\n`);
76
+ fs.writeFileSync(`${positionals[0]}.verified.json`, JSON.stringify(proof, null, 2) + '\n');
77
+ console.log(JSON.stringify(proof));
78
+ }
79
+
80
+ if (require.main === module) main().catch(error => {
81
+ const code = /^(apple_upload_verify|apple_upload_reconcile|mobile_release)_[a-z0-9_]+$/.test(error?.message || '') ?
82
+ error.message : 'apple_upload_verify_unavailable';
83
+ console.error('::error title=apple_upload_verification_failed::' + JSON.stringify({code}));
84
+ process.exitCode = 1;
85
+ });
86
+ module.exports = {credential, observe, verify, main};
@@ -39,7 +39,7 @@ async function candidate(artifact) {
39
39
  producers[0].head_sha !== run.head_sha || !producers[0].steps?.some(step =>
40
40
  step.name === 'Retain candidate shipping binary' && step.conclusion === 'success')) return null;
41
41
  // Re-running only a failed sibling retains a successful platform's artifact from its earlier attempt.
42
- return {...run, artifact_attempt: attempt};
42
+ return {...run, artifact_attempt: attempt, cache_runner_labels: producers[0].labels};
43
43
  }
44
44
 
45
45
  // The newest reusable candidate for this platform's current inputs, or null; reads only.
@@ -190,6 +190,7 @@ function pack(platform) {
190
190
  mkdirSync(directory, {recursive: true});
191
191
  execFileSync('python3', [join(__dirname, 'mobile_artifact_files.py'), 'pack', platform, directory],
192
192
  {stdio: ['ignore', 'inherit', 'pipe'], env: {...process.env, MOBILE_ARTIFACT_FINGERPRINT: fingerprint}});
193
+ require('./mobile_dependency_cache.cjs').record(directory, platform);
193
194
  output('directory', directory);
194
195
  const attempt = `${process.env.GITHUB_RUN_ID}-${process.env.GITHUB_RUN_ATTEMPT}`;
195
196
  output('name', `mobile-binary-${platform}-${fingerprint}-${attempt}`);
@@ -0,0 +1,29 @@
1
+ /* actions/cache@caa2961 bundles @actions/cache 5.1.0: raw paths, compression, salt 1.0. */
2
+ 'use strict';
3
+ const {createHash} = require('node:crypto');
4
+ const {spawnSync} = require('node:child_process');
5
+ const {homedir} = require('node:os');
6
+
7
+ function compression(run = spawnSync) {
8
+ const result = run('zstd', ['--quiet', '--version'], {encoding: 'utf8', timeout: 3000, maxBuffer: 4096});
9
+ if (result.error?.code === 'ENOENT') return 'gzip';
10
+ if (result.error || result.status !== 0) throw Error('dependency_cache_compression_unknown');
11
+ return `${result.stdout || ''}${result.stderr || ''}`.trim() ? 'zstd-without-long' : 'gzip';
12
+ }
13
+
14
+ function version(paths, method) {
15
+ if (!Array.isArray(paths) || !paths.length || paths.some(path => typeof path !== 'string' ||
16
+ !path || /[\r\n|]/.test(path)) || !['gzip', 'zstd-without-long'].includes(method)) {
17
+ throw Error('dependency_cache_paths_invalid');
18
+ }
19
+ return createHash('sha256').update([...paths, method, '1.0'].join('|')).digest('hex');
20
+ }
21
+
22
+ function versions(keys, flutter, method = compression()) {
23
+ const paths = {sdk: [flutter.sdk_path], pub: [flutter.pub_path],
24
+ pods: ['ios/Pods', '~/Library/Caches/CocoaPods'],
25
+ gradle: [`${homedir()}/.gradle/caches`, `${homedir()}/.gradle/wrapper`]};
26
+ return Object.fromEntries(Object.keys(keys).map(name => [name, version(paths[name], method)]));
27
+ }
28
+
29
+ module.exports = {compression, version, versions};
@@ -0,0 +1,123 @@
1
+ /* Prime missing default-branch dependencies using keys, never bytes, from a verified candidate. */
2
+ 'use strict';
3
+ const {appendFileSync, readFileSync, writeFileSync, existsSync} = require('node:fs');
4
+ const {join, relative, resolve, isAbsolute} = require('node:path');
5
+ const {execFileSync} = require('node:child_process');
6
+ const inventory = require('./mobile_artifact_inventory.cjs');
7
+ const api = require('./mobile_release_api.cjs');
8
+ const cache = require('./mobile_cache_version.cjs');
9
+ const PREFIX = {sdk: 'flutter-', pub: 'flutter-pub-', pods: 'pods-macOS-',
10
+ gradle: 'setup-java-Linux-'};
11
+
12
+ function valid(value, platform) {
13
+ if (value?.schema !== 1 || value.os !== (platform === 'ios' ? 'macOS' : 'Linux') ||
14
+ !['ARM64', 'X64'].includes(value.arch) || !value.keys || typeof value.keys !== 'object') return false;
15
+ const entries = Object.entries(value.keys);
16
+ if (!entries.length || entries.length > 4 || entries.some(([name, key]) => !PREFIX[name] ||
17
+ typeof key !== 'string' || !key.startsWith(PREFIX[name]) || !/^[\w.-]{1,500}$/.test(key))) return false;
18
+ if (!value.versions || entries.some(([name]) => !/^[a-f0-9]{64}$/.test(value.versions[name] || ''))) return false;
19
+ if (Boolean(value.keys.sdk) !== Boolean(value.keys.pub)) return false;
20
+ if (value.keys.sdk && !/^\d+\.\d+\.\d+$/.test(value.flutter_version || '')) return false;
21
+ if (platform === 'ios' && value.keys.gradle || platform === 'android' && value.keys.pods) {
22
+ return false;
23
+ }
24
+ return typeof value.container === 'string' && !isAbsolute(value.container) &&
25
+ !value.container.split(/[\\/]/).includes('..') && !/[\r\n]/.test(value.container);
26
+ }
27
+
28
+ function record(directory, platform) {
29
+ const env = process.env, keys = {};
30
+ let flutter;
31
+ try { flutter = JSON.parse(env.MOBILE_CACHE_FLUTTER_IDENTITY || '{}'); } catch { return; }
32
+ for (const name of Object.keys(PREFIX)) {
33
+ const key = env[`MOBILE_CACHE_${name.toUpperCase()}_KEY`];
34
+ if (key) keys[name] = key;
35
+ }
36
+ if (flutter.sdk && flutter.pub) Object.assign(keys, {sdk: flutter.sdk, pub: flutter.pub});
37
+ if (keys.pub && !keys.sdk) delete keys.pub;
38
+ if (keys.pods && !existsSync(join(env.GITHUB_WORKSPACE, 'ios/Podfile.lock'))) delete keys.pods;
39
+ if (!Object.keys(keys).length) return;
40
+ const container = env.CFG_WORKSPACE || env.CFG_PROJECT || '';
41
+ let versions;
42
+ try { versions = cache.versions(keys, flutter); } catch { return; }
43
+ const value = {schema: 1, keys, versions, os: env.RUNNER_OS, arch: env.RUNNER_ARCH,
44
+ flutter_version: flutter.version || '',
45
+ container: container ? relative(env.GITHUB_WORKSPACE, resolve(env.GITHUB_WORKSPACE, container)) : ''};
46
+ if (!valid(value, platform)) return;
47
+ const path = join(directory, 'mobile-binary.json');
48
+ const receipt = JSON.parse(readFileSync(path, 'utf8'));
49
+ receipt.dependency_caches = value;
50
+ writeFileSync(path, JSON.stringify(receipt, null, 2) + '\n');
51
+ }
52
+
53
+ async function missing(value, branch, read = api.request) {
54
+ const absent = await Promise.all(Object.entries(value.keys).map(async ([name, key]) => {
55
+ const query = new URLSearchParams({ref: `refs/heads/${branch}`, key, per_page: '100'});
56
+ const response = await read('GET', `/actions/caches?${query}`, undefined, 5000);
57
+ if (!Array.isArray(response.actions_caches)) throw Error('dependency_cache_inventory_unknown');
58
+ // A partial Gradle download is useful, but cannot certify completed dependency priming.
59
+ const found = response.actions_caches.some(row => row.ref === `refs/heads/${branch}` &&
60
+ row.size_in_bytes > 0 && row.version === value.versions[name] &&
61
+ (row.key === key || name === 'gradle' && row.key.startsWith(`${key}-warm-`)));
62
+ return found ? null : name;
63
+ }));
64
+ return absent.filter(Boolean);
65
+ }
66
+
67
+ async function plan(platform, branch, dependencies = {inventory, missing}) {
68
+ const deadline = Date.now() + 30000;
69
+ const found = await dependencies.inventory.find(platform, deadline);
70
+ if (!found) return null;
71
+ const receipt = await dependencies.inventory.remoteReceipt(found.artifact, deadline);
72
+ if (receipt.run_id !== found.run.id || receipt.run_attempt !== found.run.artifact_attempt ||
73
+ receipt.head_sha !== found.run.head_sha || receipt.repository !== process.env.GITHUB_REPOSITORY ||
74
+ receipt.platform !== platform || receipt.fingerprint !== found.fingerprint ||
75
+ !valid(receipt.dependency_caches, platform)) return null;
76
+ const labels = found.run.cache_runner_labels || [];
77
+ const runner = platform === 'ios' ? labels.find(label => /^macos-(15|26)(-intel)?$/.test(label)) : '';
78
+ if (platform === 'ios' && (!runner || labels.includes('self-hosted'))) return null;
79
+ const absent = await dependencies.missing(receipt.dependency_caches, branch);
80
+ return absent.length ? {...receipt.dependency_caches, runner, missing: absent} : null;
81
+ }
82
+
83
+ async function main() {
84
+ const event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8'));
85
+ const run = event.workflow_run, branch = event.repository?.default_branch;
86
+ if (run?.conclusion !== 'success' || run.event !== 'push' || run.head_branch !== branch ||
87
+ run.head_repository?.full_name !== process.env.GITHUB_REPOSITORY) return;
88
+ if (execFileSync('git', ['rev-parse', 'HEAD'], {encoding: 'utf8'}).trim() !== run.head_sha) return;
89
+ const intent = JSON.parse(readFileSync('.factory/releases/mobile.json', 'utf8'));
90
+ for (const platform of ['ios', 'android']) {
91
+ if (!intent.platforms?.[platform]) continue;
92
+ try {
93
+ const value = await plan(platform, branch);
94
+ if (value) appendFileSync(process.env.GITHUB_OUTPUT, `${platform}=${JSON.stringify(value)}\n`);
95
+ console.log(`Dependency cache ${platform}: ${value ? value.missing.join(',') : 'no missing verified keys'}`);
96
+ } catch { console.log(`::notice::Dependency cache ${platform}: inventory unavailable; skipping optional priming`); }
97
+ }
98
+ }
99
+
100
+ function verify(hostOnly = false) {
101
+ const value = JSON.parse(process.env.MOBILE_CACHE_PLAN || '{}');
102
+ const platform = process.env.RUNNER_OS === 'macOS' ? 'ios' : 'android';
103
+ if (!valid(value, platform) || value.os !== process.env.RUNNER_OS || value.arch !== process.env.RUNNER_ARCH) {
104
+ throw Error('dependency_cache_host_changed');
105
+ }
106
+ if (hostOnly) return;
107
+ for (const [name, key] of Object.entries(value.keys)) {
108
+ let actual = process.env[`MOBILE_CACHE_${name.toUpperCase()}_KEY`];
109
+ if (name === 'pub') actual += `-${process.env.MOBILE_CACHE_PUB_LOCK || ''}`;
110
+ if (actual !== key) throw Error('dependency_cache_key_changed');
111
+ }
112
+ const versions = cache.versions(value.keys, {sdk_path: process.env.MOBILE_CACHE_SDK_PATH,
113
+ pub_path: process.env.MOBILE_CACHE_PUB_PATH});
114
+ if (Object.keys(value.keys).some(name => versions[name] !== value.versions[name])) {
115
+ throw Error('dependency_cache_version_changed');
116
+ }
117
+ }
118
+
119
+ module.exports = {valid, record, missing, plan, verify, main};
120
+ if (require.main === module && process.argv[2] === 'verify') verify(process.argv[3] === '--host-only');
121
+ else if (require.main === module) main().catch(() => {
122
+ console.log('::notice::Dependency cache plan unavailable; required builds retain dependency resolution');
123
+ });
@@ -0,0 +1,47 @@
1
+ """Resolve committed CocoaPods/pub inputs on a trusted CI cache miss; never build or sign."""
2
+ import json
3
+ import os
4
+ from pathlib import Path
5
+ import signal
6
+ import subprocess
7
+
8
+ import native_pods
9
+
10
+
11
+ def flutter(*arguments):
12
+ child = subprocess.Popen(["flutter", *arguments], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
13
+ start_new_session=True)
14
+ try:
15
+ if child.wait(timeout=120):
16
+ raise RuntimeError("dependency_priming_failed")
17
+ finally:
18
+ if child.poll() is None:
19
+ os.killpg(child.pid, signal.SIGTERM)
20
+ try:
21
+ child.wait(timeout=5)
22
+ except subprocess.TimeoutExpired:
23
+ os.killpg(child.pid, signal.SIGKILL)
24
+ child.wait(timeout=5)
25
+
26
+
27
+ def prime(value, root):
28
+ if os.environ.get("GITHUB_ACTIONS") != "true":
29
+ raise RuntimeError("dependency_priming_requires_ci")
30
+ if value.get("keys", {}).get("sdk") and set(value["missing"]) & {"pub", "pods"}:
31
+ flutter("pub", "get")
32
+ if "pods" in value["missing"]:
33
+ flutter("precache", "--ios")
34
+ if "pods" in value["missing"]:
35
+ native_pods.prepare(root, value["container"])
36
+
37
+
38
+ def interrupted(*_args):
39
+ raise KeyboardInterrupt
40
+
41
+
42
+ if __name__ == "__main__":
43
+ signal.signal(signal.SIGTERM, interrupted)
44
+ try:
45
+ prime(json.loads(os.environ["MOBILE_CACHE_PLAN"]), Path(os.environ["GITHUB_WORKSPACE"]))
46
+ except (OSError, ValueError, RuntimeError, native_pods.guard.Refused, subprocess.SubprocessError):
47
+ raise SystemExit("Dependency priming unavailable; no completed Pods cache was saved") from None
@@ -17,6 +17,7 @@ import asc_common
17
17
  import requests
18
18
  import upload_checksums
19
19
  import upload_receipt
20
+ import apple_upload_handoff
20
21
  from upload_receipt import Receipt, UploadError
21
22
 
22
23
  # Provider refusals the uploader classifies itself instead of letting the shared
@@ -149,7 +150,11 @@ def transfer_all(ipa: Path, file: dict, info: dict) -> None:
149
150
  }})
150
151
 
151
152
 
152
- def deliver(ipa: Path, report: Path, timeout: int) -> dict:
153
+ def deliver(ipa: Path, report: Path, timeout: int, handoff: Path | None = None) -> dict:
154
+ if handoff and (any(char in str(handoff) for char in "\r\n")
155
+ or not handoff.parent.is_dir() or handoff.is_dir()):
156
+ raise ValueError("apple_handoff_path_invalid")
157
+ deferred = apple_upload_handoff.prepare() if handoff else None
153
158
  receipt = Receipt(report)
154
159
  receipt.update("identity", **identity(ipa))
155
160
  app_id = os.environ.get("APP_STORE_APPLE_ID") or ""
@@ -165,6 +170,9 @@ def deliver(ipa: Path, report: Path, timeout: int) -> dict:
165
170
  receipt.update("file_reserved", file_id=file["id"])
166
171
  transfer_all(ipa, file, info)
167
172
  receipt.update("transferred")
173
+ if handoff:
174
+ apple_upload_handoff.write(handoff, deferred, receipt.data)
175
+ return dict(receipt.data)
168
176
  processing_started = time.monotonic()
169
177
  deadline = processing_started + timeout
170
178
  while True:
@@ -190,9 +198,13 @@ def main() -> None:
190
198
  parser.add_argument("ipa", type=Path)
191
199
  parser.add_argument("--report", required=True, type=Path)
192
200
  parser.add_argument("--timeout", type=int, default=900)
201
+ parser.add_argument("--transfer-only", action="store_true")
202
+ parser.add_argument("--handoff", type=Path)
193
203
  args = parser.parse_args()
204
+ if args.transfer_only != bool(args.handoff):
205
+ parser.error("--transfer-only and --handoff must be supplied together")
194
206
  try:
195
- receipt = deliver(args.ipa, args.report, args.timeout)
207
+ receipt = deliver(args.ipa, args.report, args.timeout, args.handoff)
196
208
  except (OSError, ValueError, KeyError, RuntimeError, SystemExit) as exc:
197
209
  # The receipt and annotation carry the classified category and identifiers only;
198
210
  # exception text can quote provider bodies or transport details and stays out.
@@ -280,3 +280,20 @@ runs:
280
280
  with:
281
281
  path: ${{ steps.flutter-action.outputs.CACHE-PATH }}
282
282
  key: ${{ steps.flutter-action.outputs.CACHE-KEY }}
283
+ - name: Retain the resolved dependency cache identities
284
+ if: ${{ inputs.cache == 'true' && inputs.dry-run != 'true' && steps.install.outcome == 'success' }}
285
+ continue-on-error: true
286
+ shell: python3 {0}
287
+ env:
288
+ CACHE_SDK: ${{ steps.flutter-action.outputs.CACHE-KEY }}
289
+ CACHE_PUB: ${{ steps.flutter-action.outputs.PUB-CACHE-KEY }}-${{ hashFiles('**/pubspec.lock') }}
290
+ CACHE_SDK_PATH: ${{ steps.flutter-action.outputs.CACHE-PATH }}
291
+ CACHE_PUB_PATH: ${{ steps.flutter-action.outputs.PUB-CACHE-PATH }}
292
+ CACHE_VERSION: ${{ steps.flutter-action.outputs.VERSION }}
293
+ run: |
294
+ import json, os
295
+ value = {key: os.environ['CACHE_' + key.upper()] for key in ('sdk', 'pub', 'version', 'sdk_path', 'pub_path')}
296
+ if any(not text or len(text) > 500 or '\n' in text or '\r' in text for text in value.values()):
297
+ raise SystemExit('Dependency cache identity unavailable')
298
+ with open(os.environ['GITHUB_ENV'], 'a') as output:
299
+ output.write('MOBILE_CACHE_FLUTTER_IDENTITY=' + json.dumps(value, separators=(',', ':')) + '\n')