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 +14 -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/mobile_artifact_inventory.cjs +2 -1
- package/action/scripts/mobile_cache_version.cjs +29 -0
- package/action/scripts/mobile_dependency_cache.cjs +123 -0
- package/action/scripts/prime_native_dependencies.py +47 -0
- package/action/scripts/upload_build.py +14 -2
- package/android-action/flutter-setup/action.yml +17 -0
- package/docs/CANDIDATE_ARTIFACTS.md +12 -4
- package/docs/CI_PERFORMANCE.md +25 -9
- package/docs/GRADLE_CACHE.md +23 -8
- package/package.json +1 -1
- package/src/config/candidate.mjs +12 -2
- package/src/config/workflows.mjs +2 -4
- package/templates/deploy.yml +91 -2
- package/templates/history.json.gz +0 -0
- package/templates/warm-android-cache.yml +231 -124
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'
|
|
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')
|