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 +16 -0
- package/action/action.yml +32 -3
- package/action/scripts/apple_upload_handoff.cjs +103 -0
- package/action/scripts/apple_upload_handoff.py +65 -0
- package/action/scripts/apple_upload_verify.cjs +86 -0
- package/action/scripts/ci_performance.cjs +80 -0
- package/action/scripts/mobile_artifact_inventory.cjs +13 -7
- package/action/scripts/mobile_cache_version.cjs +29 -0
- package/action/scripts/mobile_dependency_cache.cjs +123 -0
- package/action/scripts/native_bootstrap.py +136 -0
- package/action/scripts/native_validation.cjs +82 -0
- package/action/scripts/native_validation_github.cjs +73 -0
- package/action/scripts/native_validation_inputs.cjs +68 -0
- package/action/scripts/prime_native_dependencies.py +47 -0
- package/action/scripts/upload_build.py +20 -5
- package/android-action/flutter-setup/action.yml +17 -0
- package/android-action/scripts/prime_gradle.py +63 -0
- package/docs/CANDIDATE_ARTIFACTS.md +12 -4
- package/docs/CI_PERFORMANCE.md +123 -0
- package/docs/CONFIGURATION.md +4 -0
- package/docs/GRADLE_CACHE.md +23 -12
- package/package.json +3 -2
- package/src/config/candidate.mjs +12 -2
- package/src/config/schema.mjs +7 -1
- package/src/config/settings.mjs +4 -0
- package/src/config/workflows.mjs +2 -4
- package/templates/deploy.yml +123 -2
- package/templates/history.json.gz +0 -0
- package/templates/warm-android-cache.yml +233 -163
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'
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
+
});
|