gowalk-cicd 1.0.172 → 1.0.174
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 +6 -3
- package/action/scripts/asc_common.py +15 -3
- package/action/scripts/ci_performance.cjs +80 -0
- package/action/scripts/manage_marketing_version.py +2 -1
- package/action/scripts/mobile_artifact_inventory.cjs +11 -6
- 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/set_app_store_whats_new.py +5 -1
- package/action/scripts/upload_build.py +6 -3
- package/android-action/scripts/prime_gradle.py +63 -0
- package/docs/CI_PERFORMANCE.md +107 -0
- package/docs/CONFIGURATION.md +4 -0
- package/docs/GRADLE_CACHE.md +7 -11
- package/package.json +3 -2
- package/src/config/schema.mjs +7 -1
- package/src/config/settings.mjs +4 -0
- package/templates/deploy.yml +33 -1
- package/templates/history.json.gz +0 -0
- package/templates/warm-android-cache.yml +13 -50
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:
|
|
@@ -887,9 +889,10 @@ preserving each app's custom checks and artifact retention settings. An isolated
|
|
|
887
889
|
override via action inputs only if auto-detection fails.
|
|
888
890
|
2. **Reads the ASC key** from `creds/AuthKey_*.p8` and uses it to authenticate
|
|
889
891
|
to App Store Connect via JWT.
|
|
890
|
-
3. **Decides the marketing version**: either reuses the current
|
|
891
|
-
version on App Store Connect
|
|
892
|
-
|
|
892
|
+
3. **Decides the marketing version**: either reuses the current editable
|
|
893
|
+
version on App Store Connect (`PREPARE_FOR_SUBMISSION`, a `READY_FOR_REVIEW`
|
|
894
|
+
draft you have not submitted yet, or a rejected state), or creates a new
|
|
895
|
+
version if the highest declared version is already live.
|
|
893
896
|
4. **Computes the next build number** by querying ASC for the latest uploaded
|
|
894
897
|
build and incrementing.
|
|
895
898
|
5. **Provisions signing** at runtime: reconciles the App ID's capabilities with
|
|
@@ -33,9 +33,13 @@ ASC_BASE = "https://api.appstoreconnect.apple.com/v1"
|
|
|
33
33
|
# developer's manual release click). Auto-roll is forbidden:
|
|
34
34
|
# advancing past either state would race the review process or
|
|
35
35
|
# bypass the developer's release decision.
|
|
36
|
-
# editable -- PREPARE_FOR_SUBMISSION /
|
|
37
|
-
# DEVELOPER_REJECTED / INVALID_BINARY.
|
|
38
|
-
# (developer-actionable; mutating is safe).
|
|
36
|
+
# editable -- PREPARE_FOR_SUBMISSION / READY_FOR_REVIEW / REJECTED /
|
|
37
|
+
# METADATA_REJECTED / DEVELOPER_REJECTED / INVALID_BINARY.
|
|
38
|
+
# REUSE the row's id (developer-actionable; mutating is safe).
|
|
39
|
+
# READY_FOR_REVIEW is App Store Connect's "indicated that you
|
|
40
|
+
# intend to submit it for review, but haven't submitted it
|
|
41
|
+
# yet": the developer still holds the row (only images and
|
|
42
|
+
# videos are locked), so a build for that version joins it.
|
|
39
43
|
# unknown -- any state in NONE of the above. Round-12 fail-closed: the
|
|
40
44
|
# classifier rejects rather than guessing, so a future ASC
|
|
41
45
|
# state cannot silently 409 on CREATE or interfere with
|
|
@@ -53,6 +57,7 @@ ASC_BASE = "https://api.appstoreconnect.apple.com/v1"
|
|
|
53
57
|
# App Store version states that allow editing the current draft.
|
|
54
58
|
EDITABLE_STATES = {
|
|
55
59
|
"PREPARE_FOR_SUBMISSION",
|
|
60
|
+
"READY_FOR_REVIEW",
|
|
56
61
|
"WAITING_FOR_REVIEW",
|
|
57
62
|
"IN_REVIEW",
|
|
58
63
|
"REJECTED",
|
|
@@ -68,8 +73,15 @@ EDITABLE_STATES = {
|
|
|
68
73
|
# such a row would silently interfere with App Review. REJECTED /
|
|
69
74
|
# METADATA_REJECTED / INVALID_BINARY come back from Apple Review and are
|
|
70
75
|
# explicitly developer-actionable, so mutating them is safe.
|
|
76
|
+
# READY_FOR_REVIEW sits in a review submission the developer has not sent
|
|
77
|
+
# (App Store Connect: "haven't submitted it yet"; editable except images and
|
|
78
|
+
# videos). Apple is not reviewing it, and a new build for the same version is
|
|
79
|
+
# exactly what its owner uploads before submitting; the fail-closed
|
|
80
|
+
# "unrecognized state" exit refused that build before compilation
|
|
81
|
+
# (CI run 37116771417).
|
|
71
82
|
REUSABLE_STATES = {
|
|
72
83
|
"PREPARE_FOR_SUBMISSION",
|
|
84
|
+
"READY_FOR_REVIEW",
|
|
73
85
|
"DEVELOPER_REJECTED",
|
|
74
86
|
"REJECTED",
|
|
75
87
|
"METADATA_REJECTED",
|
|
@@ -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
|
+
});
|
|
@@ -25,7 +25,8 @@ Algorithm:
|
|
|
25
25
|
4. If the target row already exists in /appStoreVersions:
|
|
26
26
|
- any terminal match -> auto-roll or SystemExit(2); the slot is
|
|
27
27
|
taken by a shipped row.
|
|
28
|
-
- editable-only match
|
|
28
|
+
- editable-only match (PREPARE_FOR_SUBMISSION, READY_FOR_REVIEW,
|
|
29
|
+
the rejected states) -> REUSE its id (steady-state TestFlight
|
|
29
30
|
iteration at unchanged MARKETING_VERSION).
|
|
30
31
|
5. CREATE path: floor-check (strict, target > floor); PATCH-rename any
|
|
31
32
|
stale editable to target; or POST a new row.
|
|
@@ -46,12 +46,17 @@ async function candidate(artifact) {
|
|
|
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
|
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Resolve the latest public package once; install only its verified immutable native helpers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import hashlib
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path, PurePosixPath
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
import tarfile
|
|
13
|
+
from urllib.parse import urlparse
|
|
14
|
+
from urllib.request import urlopen
|
|
15
|
+
|
|
16
|
+
REGISTRY = "https://registry.npmjs.org/gowalk-cicd"
|
|
17
|
+
SCHEMA = "gowalk-cicd/native-tooling.v1"
|
|
18
|
+
LIMIT = 8 * 1024 * 1024
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def read(url: str) -> bytes:
|
|
22
|
+
parsed = urlparse(url)
|
|
23
|
+
if parsed.scheme != "https" or parsed.netloc != "registry.npmjs.org" or parsed.username:
|
|
24
|
+
raise ValueError("native_tooling_origin_invalid")
|
|
25
|
+
with urlopen(url, timeout=20) as response:
|
|
26
|
+
if urlparse(response.url).netloc != "registry.npmjs.org":
|
|
27
|
+
raise ValueError("native_tooling_origin_invalid")
|
|
28
|
+
data = response.read(LIMIT + 1)
|
|
29
|
+
if len(data) > LIMIT:
|
|
30
|
+
raise ValueError("native_tooling_response_too_large")
|
|
31
|
+
return data
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def metadata(version: str) -> dict:
|
|
35
|
+
if version != "latest" and not re.fullmatch(r"\d+\.\d+\.\d+", version):
|
|
36
|
+
raise ValueError("native_tooling_version_invalid")
|
|
37
|
+
value = json.loads(read(f"{REGISTRY}/{version}"))
|
|
38
|
+
selected, dist = value.get("version", ""), value.get("dist", {})
|
|
39
|
+
if (value.get("name") != "gowalk-cicd" or not re.fullmatch(r"\d+\.\d+\.\d+", selected)
|
|
40
|
+
or (version != "latest" and version != selected)
|
|
41
|
+
or dist.get("tarball") != f"{REGISTRY}/-/gowalk-cicd-{selected}.tgz"
|
|
42
|
+
or not re.fullmatch(r"sha512-[A-Za-z0-9+/]{86}==", dist.get("integrity", ""))):
|
|
43
|
+
raise ValueError("native_tooling_metadata_invalid")
|
|
44
|
+
return {"schema": SCHEMA, "version": selected, "integrity": dist["integrity"], "tarball": dist["tarball"]}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def archive(release: dict) -> dict[str, bytes]:
|
|
48
|
+
content = read(release["tarball"])
|
|
49
|
+
digest = "sha512-" + base64.b64encode(hashlib.sha512(content).digest()).decode()
|
|
50
|
+
if digest != release["integrity"]:
|
|
51
|
+
raise ValueError("native_tooling_integrity_mismatch")
|
|
52
|
+
files = {}
|
|
53
|
+
with tarfile.open(fileobj=io.BytesIO(content), mode="r:gz") as package:
|
|
54
|
+
for member in package.getmembers():
|
|
55
|
+
path = PurePosixPath(member.name)
|
|
56
|
+
if not (member.name.startswith("package/action/scripts/")
|
|
57
|
+
or member.name.startswith("package/android-action/scripts/")):
|
|
58
|
+
continue
|
|
59
|
+
if member.isdir():
|
|
60
|
+
continue
|
|
61
|
+
if not member.isfile() or ".." in path.parts or member.size > 2 * 1024 * 1024:
|
|
62
|
+
raise ValueError("native_tooling_archive_invalid")
|
|
63
|
+
name = str(path.relative_to("package"))
|
|
64
|
+
if name in files:
|
|
65
|
+
raise ValueError("native_tooling_archive_invalid")
|
|
66
|
+
files[name] = package.extractfile(member).read()
|
|
67
|
+
if "action/scripts/native_validation.cjs" not in files:
|
|
68
|
+
raise ValueError("native_tooling_release_missing_validation")
|
|
69
|
+
return files
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def hashes(files: dict[str, bytes]) -> dict:
|
|
73
|
+
return {name: hashlib.sha256(data).hexdigest() for name, data in sorted(files.items())}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def output(values: dict) -> None:
|
|
77
|
+
destination = os.environ.get("GITHUB_OUTPUT")
|
|
78
|
+
if destination:
|
|
79
|
+
with open(destination, "a") as stream:
|
|
80
|
+
for key, value in values.items():
|
|
81
|
+
if "\n" in str(value) or "\r" in str(value):
|
|
82
|
+
raise ValueError("native_tooling_output_invalid")
|
|
83
|
+
stream.write(f"{key}={value}\n")
|
|
84
|
+
else:
|
|
85
|
+
print(json.dumps(values))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def resolve(receipt: Path) -> None:
|
|
89
|
+
release = metadata("latest")
|
|
90
|
+
release["files"] = hashes(archive(release))
|
|
91
|
+
receipt.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
receipt.write_text(json.dumps(release, sort_keys=True) + "\n")
|
|
93
|
+
output({"version": release["version"], "integrity": release["integrity"],
|
|
94
|
+
"receipt-path": str(receipt), "release-json": json.dumps(release, separators=(",", ":"))})
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def install(receipt: Path, destination: Path) -> None:
|
|
98
|
+
if destination.is_symlink():
|
|
99
|
+
raise ValueError("native_tooling_destination_invalid")
|
|
100
|
+
destination = destination.resolve()
|
|
101
|
+
release = json.loads(receipt.read_text())
|
|
102
|
+
expected = metadata(release.get("version", ""))
|
|
103
|
+
if any(release.get(key) != value for key, value in expected.items()):
|
|
104
|
+
raise ValueError("native_tooling_release_changed")
|
|
105
|
+
files = archive(expected)
|
|
106
|
+
if release.get("files") != hashes(files):
|
|
107
|
+
raise ValueError("native_tooling_helper_mismatch")
|
|
108
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
for name, data in files.items():
|
|
110
|
+
target = destination / name
|
|
111
|
+
if target.is_symlink() or any(parent.is_symlink() for parent in target.parents):
|
|
112
|
+
raise ValueError("native_tooling_destination_invalid")
|
|
113
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
target.write_bytes(data)
|
|
115
|
+
(destination / "release.json").write_text(json.dumps(release, sort_keys=True) + "\n")
|
|
116
|
+
output({"version": release["version"], "integrity": release["integrity"],
|
|
117
|
+
"scripts": str(destination / "action/scripts"),
|
|
118
|
+
"android-scripts": str(destination / "android-action/scripts")})
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def main() -> None:
|
|
122
|
+
if len(sys.argv) == 3 and sys.argv[1] == "resolve":
|
|
123
|
+
resolve(Path(sys.argv[2]))
|
|
124
|
+
elif len(sys.argv) == 4 and sys.argv[1] == "install":
|
|
125
|
+
install(Path(sys.argv[2]), Path(sys.argv[3]))
|
|
126
|
+
else:
|
|
127
|
+
raise ValueError("native_tooling_command_invalid")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if __name__ == "__main__":
|
|
131
|
+
try:
|
|
132
|
+
main()
|
|
133
|
+
except Exception as error:
|
|
134
|
+
code = str(error) if re.fullmatch(r"native_tooling_[a-z_]+", str(error)) else "native_tooling_unavailable"
|
|
135
|
+
print(f"::error title=native_tooling_refused::{code}", file=sys.stderr)
|
|
136
|
+
raise SystemExit(1) from None
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/* Reuse completed native coverage only when every declared source, workflow and execution input matches. */
|
|
2
|
+
'use strict';
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const {join} = require('node:path');
|
|
5
|
+
const inputs = require('./native_validation_inputs.cjs');
|
|
6
|
+
const github = require('./native_validation_github.cjs');
|
|
7
|
+
const SCHEMA = 'gowalk-cicd/native-validation.v1';
|
|
8
|
+
|
|
9
|
+
function output(values) {
|
|
10
|
+
if (!process.env.GITHUB_OUTPUT) return console.log(JSON.stringify(values));
|
|
11
|
+
for (const [name, value] of Object.entries(values)) {
|
|
12
|
+
if (/[\r\n]/.test(String(value))) throw Error('native_validation_output_invalid');
|
|
13
|
+
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function lookup(config, values, fingerprint) {
|
|
18
|
+
const deadline = Date.now() + 30000;
|
|
19
|
+
const name = `native-validation-${fingerprint}`;
|
|
20
|
+
const inventory = await github.request(`/actions/artifacts?name=${name}&per_page=20`, deadline);
|
|
21
|
+
if (!Array.isArray(inventory.artifacts)) throw Error('native_validation_inventory_invalid');
|
|
22
|
+
for (const artifact of inventory.artifacts.slice(0, 5)) {
|
|
23
|
+
if (artifact.name !== name || artifact.expired !== false || Date.now() >= deadline) continue;
|
|
24
|
+
const receipt = await github.download(artifact, deadline);
|
|
25
|
+
if (receipt.schema !== SCHEMA || receipt.fingerprint !== fingerprint ||
|
|
26
|
+
!Number.isSafeInteger(receipt.run_id) || !Number.isSafeInteger(receipt.run_attempt)) continue;
|
|
27
|
+
if (!await github.producer(artifact, receipt, config, deadline)) continue;
|
|
28
|
+
// A present local commit gives an independent tree proof; absent commits are a safe miss.
|
|
29
|
+
if (inputs.fingerprint(config, values, receipt.source_sha) !== fingerprint) continue;
|
|
30
|
+
return {'source-run': receipt.run_id, 'source-sha': receipt.source_sha,
|
|
31
|
+
reused: 'true', 'reuse-reason': 'verified_identical_inputs'};
|
|
32
|
+
}
|
|
33
|
+
return {reused: 'false', 'reuse-reason': 'no_matching_success'};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function record(config, fingerprint) {
|
|
37
|
+
const receipt = {schema: SCHEMA, fingerprint, source_sha: inputs.git('rev-parse', 'HEAD').trim(),
|
|
38
|
+
run_id: Number(process.env.GITHUB_RUN_ID), run_attempt: Number(process.env.GITHUB_RUN_ATTEMPT),
|
|
39
|
+
run_head_sha: process.env.GITHUB_SHA};
|
|
40
|
+
if (!Number.isSafeInteger(receipt.run_id) || receipt.run_id <= 0 ||
|
|
41
|
+
!Number.isSafeInteger(receipt.run_attempt) || receipt.run_attempt <= 0 ||
|
|
42
|
+
!/^[a-f0-9]{40}$/.test(receipt.run_head_sha || '')) throw Error('native_validation_run_invalid');
|
|
43
|
+
const directory = join(process.env.RUNNER_TEMP, 'gowalk-native-validation');
|
|
44
|
+
fs.mkdirSync(directory, {recursive: true});
|
|
45
|
+
const file = join(directory, 'native-validation.json');
|
|
46
|
+
fs.writeFileSync(file, JSON.stringify(receipt) + '\n');
|
|
47
|
+
return {'receipt-path': file};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function main([operation, file]) {
|
|
51
|
+
if (!['lookup', 'record'].includes(operation) || !file) throw Error('native_validation_command_invalid');
|
|
52
|
+
const config = inputs.configuration(file);
|
|
53
|
+
let values;
|
|
54
|
+
try { values = inputs.context(config); }
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (operation !== 'lookup') throw error;
|
|
57
|
+
return output({reused: 'false', 'reuse-reason': 'external_or_execution_context_unavailable'});
|
|
58
|
+
}
|
|
59
|
+
let fingerprint;
|
|
60
|
+
try { fingerprint = inputs.fingerprint(config, values); }
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (operation !== 'lookup') throw error;
|
|
63
|
+
return output({reused: 'false', 'reuse-reason': 'inputs_unverifiable'});
|
|
64
|
+
}
|
|
65
|
+
let result;
|
|
66
|
+
if (operation === 'record') result = record(config, fingerprint);
|
|
67
|
+
else {
|
|
68
|
+
try { result = await lookup(config, values, fingerprint); }
|
|
69
|
+
catch {
|
|
70
|
+
result = {reused: 'false', 'reuse-reason': 'proof_unavailable'};
|
|
71
|
+
console.log('::notice::Native validation proof unavailable; run full coverage');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
output({fingerprint, 'artifact-name': `native-validation-${fingerprint}`, ...result});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = {SCHEMA, output, lookup, record, main};
|
|
78
|
+
if (require.main === module) main(process.argv.slice(2)).catch(error => {
|
|
79
|
+
const code = /^native_validation_[a-z_]+$/.test(error.message) ? error.message : 'native_validation_unavailable';
|
|
80
|
+
console.error(`::error title=native_validation_refused::${code}`);
|
|
81
|
+
process.exitCode = 1;
|
|
82
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/* Bounded read-only GitHub proof; artifact names alone never establish successful validation. */
|
|
2
|
+
'use strict';
|
|
3
|
+
const {inflateRawSync} = require('node:zlib');
|
|
4
|
+
const {createHash} = require('node:crypto');
|
|
5
|
+
const api = require('./mobile_release_api.cjs');
|
|
6
|
+
|
|
7
|
+
async function request(path, deadline) {
|
|
8
|
+
const remaining = deadline - Date.now();
|
|
9
|
+
if (remaining <= 0) throw Error('native_validation_lookup_deadline');
|
|
10
|
+
return api.request('GET', path, undefined, Math.min(5000, remaining));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function producer(artifact, receipt, config, deadline) {
|
|
14
|
+
const run = await request(`/actions/runs/${receipt.run_id}/attempts/${receipt.run_attempt}`, deadline);
|
|
15
|
+
const repo = process.env.GITHUB_REPOSITORY;
|
|
16
|
+
if (artifact.workflow_run?.id !== run.id || run.id !== receipt.run_id ||
|
|
17
|
+
run.run_attempt !== receipt.run_attempt || run.status !== 'completed' || run.conclusion !== 'success' ||
|
|
18
|
+
!['push', 'pull_request'].includes(run.event) || run.path !== config.workflow ||
|
|
19
|
+
run.repository?.full_name !== repo || run.head_repository?.full_name !== repo ||
|
|
20
|
+
run.head_sha !== receipt.source_sha || !/^[a-f0-9]{40}$/.test(receipt.run_head_sha || '') ||
|
|
21
|
+
(run.event === 'push' && receipt.run_head_sha !== receipt.source_sha)) return false;
|
|
22
|
+
const jobs = await request(`/actions/runs/${run.id}/attempts/${run.run_attempt}/jobs?per_page=100`, deadline);
|
|
23
|
+
if (!Array.isArray(jobs.jobs) || jobs.total_count > 100 || jobs.jobs.length >= 100) return false;
|
|
24
|
+
return config.jobs.every(required => {
|
|
25
|
+
const matches = jobs.jobs.filter(job => job.name === required.name);
|
|
26
|
+
return matches.length === 1 && matches[0].head_sha === run.head_sha && matches[0].conclusion === 'success' &&
|
|
27
|
+
required.steps.every(name => matches[0].steps?.some(step => step.name === name && step.conclusion === 'success'));
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function unpack(bytes) {
|
|
32
|
+
const end = bytes.lastIndexOf(Buffer.from('504b0506', 'hex'));
|
|
33
|
+
if (end < 0 || bytes.readUInt16LE(end + 10) !== 1) throw Error('native_validation_artifact_invalid');
|
|
34
|
+
const at = bytes.readUInt32LE(end + 16);
|
|
35
|
+
if (bytes.readUInt32LE(at) !== 0x02014b50) throw Error('native_validation_artifact_invalid');
|
|
36
|
+
const length = bytes.readUInt16LE(at + 28), method = bytes.readUInt16LE(at + 10);
|
|
37
|
+
const name = bytes.toString('utf8', at + 46, at + 46 + length);
|
|
38
|
+
if (name !== 'native-validation.json' || ![0, 8].includes(method)) throw Error('native_validation_artifact_invalid');
|
|
39
|
+
const local = bytes.readUInt32LE(at + 42), packed = bytes.readUInt32LE(at + 20);
|
|
40
|
+
const start = local + 30 + bytes.readUInt16LE(local + 26) + bytes.readUInt16LE(local + 28);
|
|
41
|
+
const content = bytes.subarray(start, start + packed);
|
|
42
|
+
return JSON.parse((method ? inflateRawSync(content, {maxOutputLength: 65536}) : content).toString('utf8'));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function download(artifact, deadline) {
|
|
46
|
+
if (!Number.isSafeInteger(artifact.id) || !/^sha256:[a-f0-9]{64}$/.test(artifact.digest || '') ||
|
|
47
|
+
artifact.size_in_bytes > 131072) throw Error('native_validation_artifact_invalid');
|
|
48
|
+
const signal = () => AbortSignal.timeout(Math.max(1, Math.min(5000, deadline - Date.now())));
|
|
49
|
+
const url = `https://api.github.com/repos/${process.env.GITHUB_REPOSITORY}/actions/artifacts/${artifact.id}/zip`;
|
|
50
|
+
const response = await fetch(url, {redirect: 'manual', signal: signal(), headers: {
|
|
51
|
+
Authorization: `Bearer ${process.env.GITHUB_TOKEN || ''}`, 'User-Agent': 'gowalk-cicd'}});
|
|
52
|
+
const location = new URL(response.headers.get('location') || 'https://invalid.invalid');
|
|
53
|
+
if (response.status !== 302 || location.protocol !== 'https:' || location.username || location.password ||
|
|
54
|
+
!/(?:\.blob\.core\.windows\.net|\.githubusercontent\.com)$/.test(location.hostname)) {
|
|
55
|
+
throw Error('native_validation_artifact_unavailable');
|
|
56
|
+
}
|
|
57
|
+
const body = await fetch(location, {redirect: 'error', signal: signal()});
|
|
58
|
+
if (!body.ok) throw Error('native_validation_artifact_unavailable');
|
|
59
|
+
const chunks = [];
|
|
60
|
+
let size = 0;
|
|
61
|
+
for await (const chunk of body.body) {
|
|
62
|
+
size += chunk.length;
|
|
63
|
+
if (size > 131072) throw Error('native_validation_artifact_too_large');
|
|
64
|
+
chunks.push(chunk);
|
|
65
|
+
}
|
|
66
|
+
const bytes = Buffer.concat(chunks);
|
|
67
|
+
if ('sha256:' + createHash('sha256').update(bytes).digest('hex') !== artifact.digest) {
|
|
68
|
+
throw Error('native_validation_artifact_digest_mismatch');
|
|
69
|
+
}
|
|
70
|
+
return unpack(bytes);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = {request, producer, unpack, download};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/* Exact native validation inputs. Unknown projects use input_paths: ['.']. */
|
|
2
|
+
'use strict';
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const {execFileSync} = require('node:child_process');
|
|
5
|
+
const {createHash} = require('node:crypto');
|
|
6
|
+
const {join} = require('node:path');
|
|
7
|
+
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
8
|
+
const git = (...args) => execFileSync('git', args, {encoding: 'utf8', timeout: 10000,
|
|
9
|
+
stdio: ['ignore', 'pipe', 'ignore']});
|
|
10
|
+
const HELPERS = ['native_validation.cjs', 'native_validation_inputs.cjs', 'native_validation_github.cjs',
|
|
11
|
+
'mobile_release_api.cjs'];
|
|
12
|
+
|
|
13
|
+
function canonical(value) {
|
|
14
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
15
|
+
if (!value || typeof value !== 'object') return value;
|
|
16
|
+
return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])]));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function configuration(file) {
|
|
20
|
+
if (fs.lstatSync(file).isSymbolicLink()) throw Error('native_validation_config_invalid');
|
|
21
|
+
const config = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
22
|
+
const list = value => Array.isArray(value) && value.length > 0 && value.length <= 100;
|
|
23
|
+
const path = value => typeof value === 'string' && value.length > 0 && value.length < 300 &&
|
|
24
|
+
!value.startsWith('/') && !/[\r\n\\*?\[\]]/.test(value) && !value.split('/').includes('..');
|
|
25
|
+
const fields = ['schema', 'input_paths', 'workflow', 'jobs', 'context_keys', 'external_context_keys'];
|
|
26
|
+
if (Object.keys(config).some(key => !fields.includes(key)) ||
|
|
27
|
+
config.schema !== 1 || !list(config.input_paths) || !config.input_paths.every(path) ||
|
|
28
|
+
!/^\.github\/workflows\/[\w.-]+\.ya?ml$/.test(config.workflow || '') || !list(config.jobs) ||
|
|
29
|
+
!config.jobs.every(job => typeof job.name === 'string' && job.name.length > 0 &&
|
|
30
|
+
list(job.steps) && job.steps.every(step => typeof step === 'string' && step.length > 0)) ||
|
|
31
|
+
!list(config.context_keys) || !config.context_keys.every(key => /^[a-zA-Z_][\w.-]*$/.test(key)) ||
|
|
32
|
+
!['package_version', 'package_integrity'].every(key => config.context_keys.includes(key)) ||
|
|
33
|
+
!Array.isArray(config.external_context_keys) ||
|
|
34
|
+
config.external_context_keys.some(key => !config.context_keys.includes(key))) {
|
|
35
|
+
throw Error('native_validation_config_invalid');
|
|
36
|
+
}
|
|
37
|
+
return config;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function context(config) {
|
|
41
|
+
const values = JSON.parse(process.env.NATIVE_VALIDATION_CONTEXT || '{}');
|
|
42
|
+
if (config.context_keys.some(key => typeof values[key] !== 'string' || !values[key].trim())) {
|
|
43
|
+
throw Error('native_validation_context_missing');
|
|
44
|
+
}
|
|
45
|
+
if (config.external_context_keys.some(key => !/^sha256:[a-f0-9]{64}$/.test(values[key]))) {
|
|
46
|
+
throw Error('native_validation_external_witness_missing');
|
|
47
|
+
}
|
|
48
|
+
return Object.fromEntries(config.context_keys.map(key => [key, values[key]]));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function selected(name, config) {
|
|
52
|
+
return name.startsWith('.github/') || config.input_paths.some(path =>
|
|
53
|
+
path === '.' || name === path.replace(/\/$/, '') || name.startsWith(path.replace(/\/$/, '') + '/'));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function fingerprint(config, values, ref = 'HEAD') {
|
|
57
|
+
const rows = git('ls-tree', '-r', '-z', ref).split('\0').filter(Boolean).filter(row =>
|
|
58
|
+
selected(row.slice(row.indexOf('\t') + 1), config));
|
|
59
|
+
if (!rows.length || rows.some(row => !/^100(?:644|755) blob [a-f0-9]{40}\t/.test(row))) {
|
|
60
|
+
throw Error('native_validation_source_unverifiable');
|
|
61
|
+
}
|
|
62
|
+
if (ref === 'HEAD' && git('diff', '--name-only', '-z', 'HEAD').split('\0')
|
|
63
|
+
.some(name => name && selected(name, config))) throw Error('native_validation_inputs_dirty');
|
|
64
|
+
const helper = HELPERS.map(file => [file, hash(fs.readFileSync(join(__dirname, file)))]);
|
|
65
|
+
return hash(JSON.stringify(canonical({config, context: values, source: rows.sort(), helper})));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = {canonical, configuration, context, selected, fingerprint, git, hash};
|
|
@@ -52,9 +52,13 @@ SKIP_VERSIONS = {"1.0", "1.0.0", "0.0", "0.0.0"}
|
|
|
52
52
|
|
|
53
53
|
# Apple permits editing appStoreVersionLocalizations.whatsNew only while the
|
|
54
54
|
# version is in an editable state. WAITING_FOR_REVIEW / IN_REVIEW /
|
|
55
|
-
# READY_FOR_SALE etc. are not editable -- skip non-fatally.
|
|
55
|
+
# READY_FOR_SALE etc. are not editable -- skip non-fatally. READY_FOR_REVIEW
|
|
56
|
+
# (added to a review submission the developer has not sent) keeps text
|
|
57
|
+
# metadata editable; a localization Apple still locks answers 409
|
|
58
|
+
# STATE_ERROR, which _patch_localization skips.
|
|
56
59
|
WHATSNEW_EDITABLE_STATES = {
|
|
57
60
|
"PREPARE_FOR_SUBMISSION",
|
|
61
|
+
"READY_FOR_REVIEW",
|
|
58
62
|
"REJECTED",
|
|
59
63
|
"METADATA_REJECTED",
|
|
60
64
|
"DEVELOPER_REJECTED",
|
|
@@ -165,14 +165,17 @@ def deliver(ipa: Path, report: Path, timeout: int) -> dict:
|
|
|
165
165
|
receipt.update("file_reserved", file_id=file["id"])
|
|
166
166
|
transfer_all(ipa, file, info)
|
|
167
167
|
receipt.update("transferred")
|
|
168
|
-
|
|
168
|
+
processing_started = time.monotonic()
|
|
169
|
+
deadline = processing_started + timeout
|
|
169
170
|
while True:
|
|
170
171
|
current = api("GET", f"/buildUploads/{record['id']}", params={"include": "build"})["data"]
|
|
171
172
|
state = ((current.get("attributes") or {}).get("state") or {}).get("state")
|
|
172
173
|
build_id = (((current.get("relationships") or {}).get("build") or {}).get("data") or {}).get("id")
|
|
173
174
|
if state == "COMPLETE":
|
|
174
|
-
return receipt.update("complete", state=state, build_id=build_id
|
|
175
|
-
|
|
175
|
+
return receipt.update("complete", state=state, build_id=build_id,
|
|
176
|
+
processing_seconds=round(time.monotonic() - processing_started, 3))
|
|
177
|
+
receipt.update("processing", state=state, build_id=build_id,
|
|
178
|
+
processing_seconds=round(time.monotonic() - processing_started, 3))
|
|
176
179
|
if state == "FAILED":
|
|
177
180
|
raise UploadError("processing_failed",
|
|
178
181
|
"Apple build upload failed processing; inspect the upload ID's provider errors")
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Prime release dependency artifacts without compiling, signing, testing or uploading an app."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
8
|
+
|
|
9
|
+
from android_config import find_gradle_root, project_kind
|
|
10
|
+
from gradle_wrapper import ensure_toolchain
|
|
11
|
+
|
|
12
|
+
INIT = """
|
|
13
|
+
gradle.projectsEvaluated {
|
|
14
|
+
rootProject.tasks.register('gowalkPrimeDependencies') {
|
|
15
|
+
doLast {
|
|
16
|
+
int resolved = 0
|
|
17
|
+
rootProject.allprojects.each { target ->
|
|
18
|
+
target.configurations.findAll { config ->
|
|
19
|
+
config.canBeResolved && config.name.toLowerCase().contains('release') &&
|
|
20
|
+
config.name.toLowerCase().endsWith('classpath')
|
|
21
|
+
}.each { config ->
|
|
22
|
+
config.resolvedConfiguration.resolvedArtifacts.each { artifact -> artifact.file.length() }
|
|
23
|
+
resolved++
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (resolved == 0) throw new GradleException('No release dependency configuration')
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def prepare(root: Path, run=subprocess.run) -> Path:
|
|
34
|
+
if project_kind(root) == "flutter":
|
|
35
|
+
run(["flutter", "pub", "get"], cwd=root, check=True, timeout=60,
|
|
36
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
37
|
+
sdk = Path(os.environ["FLUTTER_ROOT"])
|
|
38
|
+
ensure_toolchain(root, sdk)
|
|
39
|
+
properties = root / "android/local.properties"
|
|
40
|
+
lines = properties.read_text().splitlines() if properties.exists() else []
|
|
41
|
+
lines = [line for line in lines if not line.startswith(("flutter.sdk=", "sdk.dir="))]
|
|
42
|
+
lines.extend([f"flutter.sdk={sdk}", f"sdk.dir={os.environ['ANDROID_HOME']}"])
|
|
43
|
+
properties.write_text("\n".join(lines) + "\n")
|
|
44
|
+
return find_gradle_root(root)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def prime(root: Path, run=subprocess.run) -> None:
|
|
48
|
+
directory = prepare(root, run)
|
|
49
|
+
wrapper = directory / "gradlew"
|
|
50
|
+
wrapper.chmod(wrapper.stat().st_mode | 0o100)
|
|
51
|
+
with tempfile.TemporaryDirectory(prefix="gowalk-prime-", dir=os.environ.get("RUNNER_TEMP")) as scratch:
|
|
52
|
+
init = Path(scratch) / "dependencies.gradle"
|
|
53
|
+
init.write_text(INIT)
|
|
54
|
+
run([str(wrapper), "--no-daemon", "--console=plain", "--init-script", str(init),
|
|
55
|
+
"gowalkPrimeDependencies"], cwd=directory, check=True, timeout=180,
|
|
56
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
if __name__ == "__main__":
|
|
60
|
+
try:
|
|
61
|
+
prime(Path(os.environ.get("GITHUB_WORKSPACE", ".")).resolve())
|
|
62
|
+
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError):
|
|
63
|
+
raise SystemExit("Dependency priming unavailable; the required build resolves its dependencies") from None
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Native validation and delivery time
|
|
2
|
+
|
|
3
|
+
Keep routine push-to-verified delivery within 2–5 minutes. Preserve every required check and
|
|
4
|
+
coverage assertion. Measure regressions and fix the slow stage in the same task; lowering a
|
|
5
|
+
timeout, skipping a failed test or calling a transferred binary production-ready is not a fix.
|
|
6
|
+
Cold toolchain setup and provider processing are recorded separately; neither is hidden from
|
|
7
|
+
total wall time or billed runner time. Compile one frozen candidate and promote its verified
|
|
8
|
+
binary. REST-only iOS promotion runs on Linux; do not disable candidate reuse to repair an
|
|
9
|
+
unrelated custom workflow setting. A full-build fallback still uploads and waits for provider
|
|
10
|
+
processing on its Mac; the receipt reports that cost and never declares transfer alone verified.
|
|
11
|
+
|
|
12
|
+
`deploy.yml` retains `ci-performance-<run>-<attempt>` with queue time, completed-job execution,
|
|
13
|
+
step categories, run attempt, binary reuse and rounded runner-minute estimates by OS. These are
|
|
14
|
+
time estimates, not billing invoices. `incomplete_jobs` counts the reporting job and any other
|
|
15
|
+
still-running jobs; their unfinished time is excluded. Retry receipts separate elapsed time before
|
|
16
|
+
the latest attempt from queue time, which is unavailable for a retry. Apple upload receipts separately retain exact
|
|
17
|
+
`processing_seconds`; provider-operation steps include transfer and API work as well as waiting.
|
|
18
|
+
An optional `.gowalk-cicd.yml` setting `performance: {target-seconds: 300}` accepts 60–300 seconds;
|
|
19
|
+
the report warns on slower delivery without turning failed coverage or upload green.
|
|
20
|
+
|
|
21
|
+
## Resolve once, install the same public tooling everywhere
|
|
22
|
+
|
|
23
|
+
The repository adopts this helper through the normal released package installer. At execution,
|
|
24
|
+
the helper resolves **latest**, then freezes the version, SHA-512 archive integrity and SHA-256
|
|
25
|
+
of every runtime helper. It does not freeze future tasks to that version.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
python3 .github/actions/swift-app/scripts/native_bootstrap.py resolve "$RUNNER_TEMP/release.json"
|
|
29
|
+
python3 .github/actions/swift-app/scripts/native_bootstrap.py install \
|
|
30
|
+
"$RUNNER_TEMP/release.json" "$RUNNER_TEMP/gowalk-ci-transport"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`resolve` writes GitHub outputs `version`, `integrity`, `receipt-path`, and compact `release-json`.
|
|
34
|
+
Pass `release-json` unchanged between jobs through a structured environment value written to a
|
|
35
|
+
file; never interpolate JSON into shell source. `install` independently verifies the exact npm
|
|
36
|
+
version and all helper hashes, writes `DEST/release.json`, and outputs `scripts`, `android-scripts`,
|
|
37
|
+
`version`, and `integrity`. Scripts retain their package-relative directory layout. Neither mode
|
|
38
|
+
reads credentials or connects to a store. The receipt schema is `gowalk-cicd/native-tooling.v1`.
|
|
39
|
+
|
|
40
|
+
## Reuse native checks only with positive proof
|
|
41
|
+
|
|
42
|
+
The application owns a reviewed JSON configuration, normally `.factory/native-validation.json`:
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"schema": 1,
|
|
47
|
+
"input_paths": ["Sources", "Resources", "Tests", "UITests", "project.yml", "App.xcodeproj"],
|
|
48
|
+
"workflow": ".github/workflows/ios-ci.yml",
|
|
49
|
+
"jobs": [{"name": "Native tests", "steps": ["Run all native tests", "Verify full coverage"]}],
|
|
50
|
+
"context_keys": ["package_version", "package_integrity", "xcode", "sdk", "devices", "backend"],
|
|
51
|
+
"external_context_keys": ["backend"]
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Use `input_paths: ["."]` for unknown project layouts; include every source, test, asset, script,
|
|
56
|
+
lockfile, configuration and resource consumed by a reviewed narrower suite. `.github/`, the
|
|
57
|
+
validated configuration and receipt helper bytes always participate. Symlinks, submodules and
|
|
58
|
+
dirty selected inputs cannot establish reusable proof. Generated temporary configurations are
|
|
59
|
+
supported; their canonical contents participate just like a committed configuration.
|
|
60
|
+
|
|
61
|
+
Pass `NATIVE_VALIDATION_CONTEXT` as a JSON object whose keys match `context_keys`, with nonempty
|
|
62
|
+
string values read from actual execution state. Include the resolved package version/integrity,
|
|
63
|
+
full selected Xcode build, SDK/runtime, device matrix and consumed variables. A floating runner
|
|
64
|
+
label is not a toolchain identity; describe the actual Mac before lookup when necessary.
|
|
65
|
+
|
|
66
|
+
Every external key requires a fresh `sha256:<64 hex characters>` witness for the exact backend
|
|
67
|
+
deployment/flow revision or canonical relevant response. An origin URL is not an external-state
|
|
68
|
+
witness. Missing proof means full validation. For suites with inherently changing external data,
|
|
69
|
+
run those integration checks afresh outside the reusable portion. Unknown custom scripts or
|
|
70
|
+
undeclared network dependencies keep full coverage; never guess that they are unchanged.
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
node "$SCRIPTS/native_validation.cjs" lookup .factory/native-validation.json
|
|
74
|
+
# After every declared test and coverage check succeeded:
|
|
75
|
+
node "$SCRIPTS/native_validation.cjs" record .factory/native-validation.json
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`lookup` needs repository `actions:read`, `GITHUB_TOKEN`, and a checkout containing the prior
|
|
79
|
+
producer commit. Its outputs are `reused`, `reuse-reason`, `fingerprint`, `artifact-name` and,
|
|
80
|
+
on a hit, `source-run`/`source-sha`. Missing external context returns a miss without a fingerprint.
|
|
81
|
+
No completed match or unavailable API proof runs full coverage immediately; it never waits for
|
|
82
|
+
another workflow. Missing local producer history is also a safe miss.
|
|
83
|
+
|
|
84
|
+
`record` runs inside the producing job after tests, writing `receipt-path` and `artifact-name`.
|
|
85
|
+
Retain that single `native-validation.json` with the pinned `actions/upload-artifact`, seven-day
|
|
86
|
+
retention and the existing artifact process scope. For matrix suites, a collector records only
|
|
87
|
+
after verifying every shard; configure every exact required job and successful step. A later
|
|
88
|
+
lookup verifies the completed successful same-repository run, matching workflow, every named
|
|
89
|
+
job/step, immutable artifact digest and an independently recomputed producer source tree. The
|
|
90
|
+
receipt contains hashes and run identity, never context values or credentials.
|
|
91
|
+
|
|
92
|
+
Keep the existing required `task/**` push check. A cheap admission may reuse prior success only
|
|
93
|
+
under this proof; its required final check must require either that proof or successful fresh
|
|
94
|
+
coverage. Do not replace the required push check with an unrelated PR merge-ref status.
|
|
95
|
+
|
|
96
|
+
## Cache work is part of delivery cost
|
|
97
|
+
|
|
98
|
+
Android keys include Gradle files, wrapper, properties, Flutter lockfiles and the Flutter pin.
|
|
99
|
+
Only trusted default-branch jobs save shared caches; candidates restore them. After a successful
|
|
100
|
+
Android release, a missing default-branch key primes release dependency artifacts without
|
|
101
|
+
compilation, signing, test execution or upload. Priming has a five-minute step budget and saves
|
|
102
|
+
only a complete graph. It replaces the earlier thirty-minute unsigned full build. A failure is
|
|
103
|
+
optional: the next required candidate still resolves and validates its own dependencies.
|
|
104
|
+
|
|
105
|
+
Observe actual cache ref/key/hit and runner minutes. Do not promote arbitrary PR cache bytes
|
|
106
|
+
into default-branch scope or start unconditional warm-up builds. Native Pods/gems caches keep
|
|
107
|
+
their existing lockfile, Ruby, Xcode and architecture identities.
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -108,3 +108,7 @@ always answered `local`; a deleted step answers null. An upgrade therefore drops
|
|
|
108
108
|
never ran (delete it), and beside `always()`, `failure()` or `cancelled()` the conjunct also
|
|
109
109
|
meant that the steps before `mode` succeeded (restate that). `env`, `run` and job outputs are
|
|
110
110
|
not checked; there `steps.mode` renders empty.
|
|
111
|
+
|
|
112
|
+
Routine delivery targets and exact native validation proof are documented in
|
|
113
|
+
[CI_PERFORMANCE.md](CI_PERFORMANCE.md). The optional top-level
|
|
114
|
+
`performance: {target-seconds: 300}` setting accepts 60–300 seconds and controls latency reporting.
|
package/docs/GRADLE_CACHE.md
CHANGED
|
@@ -23,17 +23,13 @@ therefore retains useful downloads after failure as well as after successful del
|
|
|
23
23
|
adds no extra main validation, upload retry or release dispatch.
|
|
24
24
|
|
|
25
25
|
A release that promotes its candidate compiles nothing, so it saves nothing. While Android
|
|
26
|
-
candidates are enabled,
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
and the next release retries. A later real main build still saves the exact key. It never fails or
|
|
34
|
-
delays a release; a queued warm-up that a newer one replaces shows as cancelled. It declares
|
|
35
|
-
`cache-mode: write` because a `workflow_run` job otherwise gets a read-only cache token. No dispatch,
|
|
36
|
-
schedule or pre-warm; android job overrides do not reach it.
|
|
26
|
+
candidates are enabled, `warm-android-cache.yml` checks the default branch's exact Gradle key
|
|
27
|
+
after a successful main Android release. On a miss it resolves release dependency artifacts
|
|
28
|
+
without compiling, signing or uploading. The step is bounded to five minutes and only a complete
|
|
29
|
+
graph is saved under a distinct `-warm-` key. Failed priming leaves the next required build to
|
|
30
|
+
resolve its dependencies. Keys include Flutter lockfiles and the Flutter version pin as well as
|
|
31
|
+
Gradle configuration. This replaces the old unsigned full-bundle warm-up, which could run thirty
|
|
32
|
+
minutes. Default-branch cache access and matching compatible restore prefixes are preserved.
|
|
37
33
|
|
|
38
34
|
On adoption, preserve app-specific dependency inputs, platforms and existing restore identities.
|
|
39
35
|
Candidate and release workflows need compatible keys, paths and compression versions to share
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gowalk-cicd",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.174",
|
|
4
4
|
"description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -39,7 +39,8 @@
|
|
|
39
39
|
"docs/GRADLE_CACHE.md",
|
|
40
40
|
"docs/CANDIDATE_ARTIFACTS.md",
|
|
41
41
|
"docs/BACKEND_EDGE.md",
|
|
42
|
-
"docs/CONFIGURATION.md"
|
|
42
|
+
"docs/CONFIGURATION.md",
|
|
43
|
+
"docs/CI_PERFORMANCE.md"
|
|
43
44
|
],
|
|
44
45
|
"keywords": [
|
|
45
46
|
"ios",
|
package/src/config/schema.mjs
CHANGED
|
@@ -6,10 +6,16 @@ export function validateConfig(config, lock) {
|
|
|
6
6
|
if (config.schema !== 1 || lock.schema !== 1 || !mapping(config.workflows) || !mapping(lock.workflows)) {
|
|
7
7
|
throw new MigrationRepair('unsupported_config_version', '.gowalk-cicd.yml');
|
|
8
8
|
}
|
|
9
|
-
if (Object.keys(config).some(key => !['schema', 'mobile', 'backend', 'workflows'].includes(key))) {
|
|
9
|
+
if (Object.keys(config).some(key => !['schema', 'mobile', 'backend', 'workflows', 'performance'].includes(key))) {
|
|
10
10
|
throw new MigrationRepair('unknown_config_setting', '.gowalk-cicd.yml');
|
|
11
11
|
}
|
|
12
12
|
validateNamedSettings(config);
|
|
13
|
+
if (config.performance !== undefined && (!mapping(config.performance) ||
|
|
14
|
+
Object.keys(config.performance).some(key => key !== 'target-seconds') ||
|
|
15
|
+
!Number.isInteger(config.performance['target-seconds']) ||
|
|
16
|
+
config.performance['target-seconds'] < 60 || config.performance['target-seconds'] > 300)) {
|
|
17
|
+
throw new MigrationRepair('invalid_delivery_target', '.gowalk-cicd.yml');
|
|
18
|
+
}
|
|
13
19
|
for (const [file, entry] of Object.entries(config.workflows)) {
|
|
14
20
|
if (!mapping(entry) || Object.keys(entry).some(key => key !== 'overrides') || !Array.isArray(entry.overrides)) {
|
|
15
21
|
throw new MigrationRepair('invalid_workflow_overrides', file);
|
package/src/config/settings.mjs
CHANGED
|
@@ -36,6 +36,10 @@ export function applySettings(config, file, workflow) {
|
|
|
36
36
|
const value = at(config, name);
|
|
37
37
|
if (value !== undefined) assign(workflow, { path, value });
|
|
38
38
|
}
|
|
39
|
+
if (file === 'deploy.yml' && config.performance?.['target-seconds'] !== undefined) {
|
|
40
|
+
assign(workflow, {path: ['jobs', 'timing', 'env', 'CI_DELIVERY_TARGET_SECONDS'],
|
|
41
|
+
value: String(config.performance['target-seconds'])});
|
|
42
|
+
}
|
|
39
43
|
const job = file === 'deploy.yml' ? 'backend-preview' : file === 'deploy-backend.yml' ? 'deploy' : null;
|
|
40
44
|
if (job) {
|
|
41
45
|
for (const [key, output, variable] of [['backend-dir', 'path', 'BACKEND_DIR_INPUT'],
|
package/templates/deploy.yml
CHANGED
|
@@ -66,6 +66,8 @@ jobs:
|
|
|
66
66
|
ios:
|
|
67
67
|
needs: release
|
|
68
68
|
name: iOS TestFlight
|
|
69
|
+
outputs:
|
|
70
|
+
reused: ${{ steps.reuse.outputs.restored }}
|
|
69
71
|
env:
|
|
70
72
|
MOBILE_CANDIDATE_BUILD: ${{ inputs.candidate-build && 'true' || 'false' }}
|
|
71
73
|
MOBILE_RELEASE_VARIABLES: ${{ toJSON(vars) }}
|
|
@@ -331,6 +333,8 @@ jobs:
|
|
|
331
333
|
android:
|
|
332
334
|
needs: release
|
|
333
335
|
name: Android Google Play
|
|
336
|
+
outputs:
|
|
337
|
+
reused: ${{ steps.reuse.outputs.restored }}
|
|
334
338
|
env:
|
|
335
339
|
MOBILE_CANDIDATE_BUILD: ${{ inputs.candidate-build && 'true' || 'false' }}
|
|
336
340
|
MOBILE_RELEASE_VARIABLES: ${{ toJSON(vars) }}
|
|
@@ -454,7 +458,8 @@ jobs:
|
|
|
454
458
|
key: >-
|
|
455
459
|
${{ format('setup-java-{0}-{1}-gradle-{2}', runner.os, steps.gradle_scope.outputs.arch,
|
|
456
460
|
hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'buildSrc/**/Versions.kt',
|
|
457
|
-
'buildSrc/**/Dependencies.kt', 'gradle/*.versions.toml', '**/versions.properties'
|
|
461
|
+
'buildSrc/**/Dependencies.kt', 'gradle/*.versions.toml', '**/versions.properties',
|
|
462
|
+
'**/pubspec.lock', '.flutter-version', '**/gradle.properties')) }}
|
|
458
463
|
restore-keys: >-
|
|
459
464
|
${{ format('setup-java-{0}-{1}-gradle-', runner.os, steps.gradle_scope.outputs.arch) }}
|
|
460
465
|
- name: Set up Flutter
|
|
@@ -685,6 +690,33 @@ jobs:
|
|
|
685
690
|
|| format('{0}-partial-{1}-{2}', steps.gradle_cache.outputs.cache-primary-key,
|
|
686
691
|
github.run_id, github.run_attempt) }}
|
|
687
692
|
|
|
693
|
+
timing:
|
|
694
|
+
name: Delivery timing
|
|
695
|
+
needs: [release, ios, android]
|
|
696
|
+
if: ${{ always() }}
|
|
697
|
+
runs-on: ubuntu-24.04
|
|
698
|
+
timeout-minutes: 2
|
|
699
|
+
env:
|
|
700
|
+
GITHUB_TOKEN: ${{ github.token }}
|
|
701
|
+
CI_DELIVERY_TARGET_SECONDS: '300'
|
|
702
|
+
CI_BINARY_REUSE: >-
|
|
703
|
+
{"ios":"${{ needs.ios.outputs.reused }}","android":"${{ needs.android.outputs.reused }}"}
|
|
704
|
+
steps:
|
|
705
|
+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
|
|
706
|
+
- name: Record delivery time and runner minutes
|
|
707
|
+
id: timing
|
|
708
|
+
continue-on-error: true
|
|
709
|
+
run: node .github/actions/swift-app/scripts/ci_performance.cjs
|
|
710
|
+
- name: Retain delivery timing
|
|
711
|
+
if: ${{ steps.timing.outputs.receipt-path != '' }}
|
|
712
|
+
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
|
|
713
|
+
env:
|
|
714
|
+
NODE_OPTIONS: --require "${{ github.workspace }}/.github/actions/android-app/scripts/artifact_env.cjs"
|
|
715
|
+
with:
|
|
716
|
+
name: ci-performance-${{ github.run_id }}-${{ github.run_attempt }}
|
|
717
|
+
path: ${{ steps.timing.outputs.receipt-path }}
|
|
718
|
+
retention-days: 7
|
|
719
|
+
|
|
688
720
|
# GitHub only registers a newly added workflow after it reaches the default
|
|
689
721
|
# branch. A brand-new app's feature branch therefore cannot dispatch the
|
|
690
722
|
# separate Backend Deploy workflow yet. Reuse this already-registered mobile
|
|
Binary file
|
|
@@ -2,8 +2,8 @@ name: Warm Android build cache
|
|
|
2
2
|
|
|
3
3
|
# Candidates restore only what the default branch saved, and a release that promotes its
|
|
4
4
|
# candidate compiles nothing, so main may hold no Gradle cache for the inputs candidates build.
|
|
5
|
-
# After this app's own default-branch release that included Android succeeds, this
|
|
6
|
-
#
|
|
5
|
+
# After this app's own default-branch release that included Android succeeds, this resolves
|
|
6
|
+
# release dependencies without compiling when main has no entry for the android job's Gradle key. It runs on
|
|
7
7
|
# no other event: never on a candidate, a dispatch or a schedule. Installed only with candidates.
|
|
8
8
|
on:
|
|
9
9
|
workflow_run:
|
|
@@ -30,7 +30,7 @@ jobs:
|
|
|
30
30
|
group: warm-android-cache
|
|
31
31
|
cancel-in-progress: false
|
|
32
32
|
# A backstop only: each long step is bounded below, so a cold build never reaches it.
|
|
33
|
-
timeout-minutes:
|
|
33
|
+
timeout-minutes: 20
|
|
34
34
|
steps:
|
|
35
35
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
|
36
36
|
continue-on-error: true
|
|
@@ -80,7 +80,8 @@ jobs:
|
|
|
80
80
|
key: >-
|
|
81
81
|
${{ format('setup-java-{0}-{1}-gradle-{2}', runner.os, steps.gradle_scope.outputs.arch,
|
|
82
82
|
hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'buildSrc/**/Versions.kt',
|
|
83
|
-
'buildSrc/**/Dependencies.kt', 'gradle/*.versions.toml', '**/versions.properties'
|
|
83
|
+
'buildSrc/**/Dependencies.kt', 'gradle/*.versions.toml', '**/versions.properties',
|
|
84
|
+
'**/pubspec.lock', '.flutter-version', '**/gradle.properties')) }}
|
|
84
85
|
# Only a lookup that ended without a match goes on. An empty kind: no Android project.
|
|
85
86
|
- name: Resolve the Android build inputs
|
|
86
87
|
id: build_inputs
|
|
@@ -133,7 +134,7 @@ jobs:
|
|
|
133
134
|
if: ${{ steps.build_inputs.outputs.project_kind != '' }}
|
|
134
135
|
uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5
|
|
135
136
|
continue-on-error: true
|
|
136
|
-
timeout-minutes:
|
|
137
|
+
timeout-minutes: 2
|
|
137
138
|
with:
|
|
138
139
|
distribution: temurin
|
|
139
140
|
java-version: ${{ steps.jdk.outputs.version }}
|
|
@@ -142,7 +143,7 @@ jobs:
|
|
|
142
143
|
if: ${{ steps.flutter.outputs.enabled == 'true' }}
|
|
143
144
|
uses: ./.github/actions/android-app/flutter-setup
|
|
144
145
|
continue-on-error: true
|
|
145
|
-
timeout-minutes:
|
|
146
|
+
timeout-minutes: 6
|
|
146
147
|
with:
|
|
147
148
|
channel: stable
|
|
148
149
|
cache: true
|
|
@@ -187,61 +188,23 @@ jobs:
|
|
|
187
188
|
grep -m1 '^org.gradle.jvmargs' "$f"
|
|
188
189
|
fi
|
|
189
190
|
done
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
continue-on-error: true
|
|
193
|
-
timeout-minutes: 5
|
|
194
|
-
shell: bash
|
|
195
|
-
run: |
|
|
196
|
-
if [ -f l10n.yaml ]; then
|
|
197
|
-
flutter pub get
|
|
198
|
-
flutter gen-l10n
|
|
199
|
-
fi
|
|
200
|
-
# What a candidate compiles, with no credentials, signing, version write, store read or
|
|
201
|
-
# upload: an init script outside the cached paths disables every upload task. The compiler's
|
|
202
|
-
# output stays inert and it stops within 30 minutes.
|
|
203
|
-
- name: Build the release bundle without signing it
|
|
191
|
+
# Resolve release dependency artifacts only. No app compiler, signing or upload task runs.
|
|
192
|
+
- name: Prime release dependencies without compiling
|
|
204
193
|
id: build
|
|
205
194
|
if: >-
|
|
206
195
|
${{ steps.java.outcome == 'success'
|
|
207
196
|
&& (steps.flutter.outputs.enabled != 'true' || steps.flutter_setup.outcome == 'success') }}
|
|
208
197
|
continue-on-error: true
|
|
198
|
+
timeout-minutes: 5
|
|
209
199
|
shell: bash
|
|
210
|
-
working-directory: ${{ steps.build_inputs.outputs.gradle_root || '.' }}
|
|
211
|
-
env:
|
|
212
|
-
PROJECT_KIND: ${{ steps.build_inputs.outputs.project_kind }}
|
|
213
|
-
GRADLE_MODULE: ${{ steps.build_inputs.outputs.gradle_module }}
|
|
214
200
|
run: |
|
|
215
|
-
mkdir -p "$HOME/.gradle/init.d"
|
|
216
|
-
cat > "$HOME/.gradle/init.d/gowalk-no-uploads.gradle" <<'GRADLE'
|
|
217
|
-
gradle.beforeProject { project ->
|
|
218
|
-
project.tasks.configureEach { task ->
|
|
219
|
-
def name = task.name.toLowerCase()
|
|
220
|
-
if (name.contains('upload') || name.startsWith('bugsnagrelease')) task.enabled = false
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
GRADLE
|
|
224
|
-
if [ "$PROJECT_KIND" = flutter ]; then
|
|
225
|
-
python3 .github/actions/android-app/scripts/android_config.py --flutter-toolchain
|
|
226
|
-
build=(flutter build appbundle --release --obfuscate --split-debug-info "$RUNNER_TEMP/dart-symbols/android")
|
|
227
|
-
else
|
|
228
|
-
chmod +x ./gradlew
|
|
229
|
-
build=(./gradlew "${GRADLE_MODULE}:bundleRelease" --console=plain)
|
|
230
|
-
fi
|
|
231
|
-
token=$(openssl rand -hex 16)
|
|
232
|
-
echo "::stop-commands::$token"
|
|
233
201
|
status=0
|
|
234
|
-
|
|
235
|
-
echo "::$token::"
|
|
202
|
+
python3 .github/actions/android-app/scripts/prime_gradle.py || status=$?
|
|
236
203
|
echo "status=$status" >> "$GITHUB_OUTPUT"
|
|
237
|
-
echo "Build exit status $status (124: stopped at 30 minutes)."
|
|
238
204
|
exit "$status"
|
|
239
|
-
#
|
|
240
|
-
# compiled this commit, so a transient fault) saves nothing, and the next release retries.
|
|
241
|
-
# Never the android job's exact key: a real default-branch build still saves that one,
|
|
242
|
-
# which replaces whatever this warm-up compiled differently.
|
|
205
|
+
# A complete dependency graph can serve later candidates; failed/partial priming saves nothing.
|
|
243
206
|
- name: Save Gradle dependencies
|
|
244
|
-
if: ${{ always() &&
|
|
207
|
+
if: ${{ always() && steps.build.outputs.status == '0' }}
|
|
245
208
|
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25
|
|
246
209
|
continue-on-error: true
|
|
247
210
|
timeout-minutes: 2
|