gowalk-cicd 1.0.173 → 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
@@ -47,6 +47,8 @@ workflow the package does not manage that fails to parse is skipped with
47
47
  Ready task PRs build shipping binaries alongside validation; the protected merge promotes matching
48
48
  binaries through the existing store upload paths. See [candidate artifact reuse](docs/CANDIDATE_ARTIFACTS.md)
49
49
  for identity checks, native dependency caches, version handling and adoption of custom workflows.
50
+ For the 2–5 minute delivery target, immutable native bootstrap, verified native-check reuse and
51
+ runner-time receipts, see [CI performance](docs/CI_PERFORMANCE.md).
50
52
 
51
53
  Mobile delivery requires an explicit release intent. After source, signing, account transport,
52
54
  and integration readiness pass, run on the task branch:
@@ -152,6 +154,20 @@ the mobile workflow. No hosting provider is assumed: consumers can deploy the
152
154
  artifact to their chosen host without granting this package an unrelated cloud
153
155
  account or domain.
154
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
+
155
171
  ## iOS credentials
156
172
 
157
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};
@@ -0,0 +1,80 @@
1
+ /* Safe Actions timing receipts. Estimates use rounded job time, never claim to be an invoice. */
2
+ 'use strict';
3
+ const fs = require('node:fs');
4
+ const {join} = require('node:path');
5
+ const api = require('./mobile_release_api.cjs');
6
+
7
+ function seconds(start, end) {
8
+ const value = (Date.parse(end) - Date.parse(start)) / 1000;
9
+ return Number.isFinite(value) ? Math.max(0, Math.round(value)) : null;
10
+ }
11
+
12
+ function category(name) {
13
+ if (/process|poll|wait/i.test(name)) return 'provider_or_workflow_wait';
14
+ if (/upload|TestFlight|Google Play/i.test(name)) return 'provider_operation';
15
+ if (/cache|dependencies|Flutter|Java|Xcode|install/i.test(name)) return 'setup_or_cache';
16
+ if (/test|coverage|validation/i.test(name)) return 'validation';
17
+ if (/build|archive|compile|export/i.test(name)) return 'build';
18
+ return 'other';
19
+ }
20
+
21
+ function summarize(run, jobs, reuse = {}) {
22
+ const completed = jobs.filter(job => job.started_at && job.completed_at);
23
+ const estimates = {};
24
+ const rows = completed.map(job => {
25
+ const labels = (job.labels || []).join(' ').toLowerCase();
26
+ const os = /macos/.test(labels) ? 'macos' : /ubuntu|linux/.test(labels) ? 'linux' : 'unknown';
27
+ const duration = seconds(job.started_at, job.completed_at);
28
+ estimates[os] = (estimates[os] || 0) + Math.ceil((duration || 0) / 60);
29
+ return {id: job.id, name: job.name, conclusion: job.conclusion, os, execution_seconds: duration,
30
+ steps: (job.steps || []).map(step => ({name: step.name, conclusion: step.conclusion,
31
+ category: category(step.name), seconds: seconds(step.started_at, step.completed_at)}))};
32
+ });
33
+ const last = completed.map(job => job.completed_at).sort().at(-1);
34
+ return {schema: 'gowalk-cicd/ci-performance.v1', run_id: run.id, run_attempt: run.run_attempt,
35
+ source_sha: run.head_sha, queue_seconds: run.run_attempt === 1 ? seconds(run.created_at, run.run_started_at) : null,
36
+ retry_elapsed_before_attempt_seconds: run.run_attempt > 1 ? seconds(run.created_at, run.run_started_at) : 0,
37
+ delivery_execution_seconds: seconds(run.run_started_at, last),
38
+ delivery_wall_seconds: seconds(run.created_at, last),
39
+ estimated_rounded_runner_minutes: estimates, incomplete_jobs: jobs.length - completed.length,
40
+ reuse, jobs: rows, billing_note: 'Job time estimates only; excludes unfinished jobs and account credits'};
41
+ }
42
+
43
+ async function collect() {
44
+ const id = Number(process.env.GITHUB_RUN_ID), attempt = Number(process.env.GITHUB_RUN_ATTEMPT);
45
+ if (!Number.isSafeInteger(id) || !Number.isSafeInteger(attempt)) throw Error('ci_performance_run_invalid');
46
+ const run = await api.request('GET', `/actions/runs/${id}/attempts/${attempt}`, undefined, 10000);
47
+ const jobs = [];
48
+ for (let page = 1; page <= 5; page++) {
49
+ const result = await api.request('GET', `/actions/runs/${id}/attempts/${attempt}/jobs?per_page=100&page=${page}`,
50
+ undefined, 10000);
51
+ if (!Array.isArray(result.jobs)) throw Error('ci_performance_jobs_invalid');
52
+ jobs.push(...result.jobs);
53
+ if (result.jobs.length < 100) break;
54
+ }
55
+ const reuse = JSON.parse(process.env.CI_BINARY_REUSE || '{}');
56
+ return summarize(run, jobs, Object.fromEntries(Object.entries(reuse)
57
+ .filter(([key, value]) => ['ios', 'android'].includes(key) && ['true', 'false', ''].includes(value))));
58
+ }
59
+
60
+ async function main() {
61
+ const receipt = await collect(), target = Number(process.env.CI_DELIVERY_TARGET_SECONDS || 300);
62
+ const file = join(process.env.RUNNER_TEMP, 'ci-performance.json');
63
+ fs.writeFileSync(file, JSON.stringify(receipt, null, 2) + '\n');
64
+ if (process.env.GITHUB_OUTPUT) fs.appendFileSync(process.env.GITHUB_OUTPUT, `receipt-path=${file}\n`);
65
+ if (process.env.GITHUB_STEP_SUMMARY) {
66
+ const minutes = JSON.stringify(receipt.estimated_rounded_runner_minutes);
67
+ fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY,
68
+ `Delivery: ${receipt.delivery_wall_seconds}s; queue ${receipt.queue_seconds ?? 'unknown on retry'}; ` +
69
+ `attempt ${receipt.run_attempt}; rounded completed runner minutes ${minutes}.\n`);
70
+ }
71
+ if (receipt.delivery_wall_seconds > target) {
72
+ console.log(`::warning title=ci_delivery_latency::Delivery ${receipt.delivery_wall_seconds}s exceeds ${target}s; ` +
73
+ 'inspect ci-performance.json and repair the slow stage without reducing coverage');
74
+ }
75
+ }
76
+
77
+ module.exports = {seconds, category, summarize, collect, main};
78
+ if (require.main === module) main().catch(() => {
79
+ console.log('::notice title=ci_performance_unavailable::Timing metadata unavailable; validation remains required');
80
+ });
@@ -39,19 +39,24 @@ 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.
46
46
  async function find(platform, deadline) {
47
47
  const fingerprint = identity(platform);
48
48
  const prefix = `mobile-binary-${platform}-${fingerprint}-`;
49
- const inventory = await api.request('GET', '/actions/artifacts?per_page=100', undefined, 5000);
50
- if (!Array.isArray(inventory.artifacts)) throw Error('mobile_artifact_inventory_invalid');
51
- for (const artifact of inventory.artifacts.filter(row => row.name?.startsWith(prefix)).slice(0, 5)) {
52
- if (Date.now() >= deadline) break;
53
- const run = await candidate(artifact);
54
- if (run && artifact.name === `${prefix}${run.id}-${run.artifact_attempt}`) return {artifact, run, fingerprint};
49
+ let checked = 0;
50
+ for (let page = 1; page <= 10 && Date.now() < deadline; page++) {
51
+ const inventory = await api.request('GET', `/actions/artifacts?per_page=100&page=${page}`, undefined,
52
+ Math.max(1, Math.min(5000, deadline - Date.now())));
53
+ if (!Array.isArray(inventory.artifacts)) throw Error('mobile_artifact_inventory_invalid');
54
+ for (const artifact of inventory.artifacts.filter(row => row.name?.startsWith(prefix))) {
55
+ if (Date.now() >= deadline || checked++ >= 5) return null;
56
+ const run = await candidate(artifact);
57
+ if (run && artifact.name === `${prefix}${run.id}-${run.artifact_attempt}`) return {artifact, run, fingerprint};
58
+ }
59
+ if (inventory.artifacts.length < 100) break;
55
60
  }
56
61
  return null;
57
62
  }
@@ -185,6 +190,7 @@ function pack(platform) {
185
190
  mkdirSync(directory, {recursive: true});
186
191
  execFileSync('python3', [join(__dirname, 'mobile_artifact_files.py'), 'pack', platform, directory],
187
192
  {stdio: ['ignore', 'inherit', 'pipe'], env: {...process.env, MOBILE_ARTIFACT_FINGERPRINT: fingerprint}});
193
+ require('./mobile_dependency_cache.cjs').record(directory, platform);
188
194
  output('directory', directory);
189
195
  const attempt = `${process.env.GITHUB_RUN_ID}-${process.env.GITHUB_RUN_ATTEMPT}`;
190
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
+ });