gowalk-cicd 1.0.173 → 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 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:
@@ -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
+ });
@@ -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
- const inventory = await api.request('GET', '/actions/artifacts?per_page=100', undefined, 5000);
50
- if (!Array.isArray(inventory.artifacts)) throw Error('mobile_artifact_inventory_invalid');
51
- for (const artifact of inventory.artifacts.filter(row => row.name?.startsWith(prefix)).slice(0, 5)) {
52
- if (Date.now() >= deadline) break;
53
- const run = await candidate(artifact);
54
- if (run && artifact.name === `${prefix}${run.id}-${run.artifact_attempt}`) return {artifact, run, fingerprint};
49
+ let checked = 0;
50
+ for (let page = 1; page <= 10 && Date.now() < deadline; page++) {
51
+ const inventory = await api.request('GET', `/actions/artifacts?per_page=100&page=${page}`, undefined,
52
+ Math.max(1, Math.min(5000, deadline - Date.now())));
53
+ if (!Array.isArray(inventory.artifacts)) throw Error('mobile_artifact_inventory_invalid');
54
+ for (const artifact of inventory.artifacts.filter(row => row.name?.startsWith(prefix))) {
55
+ if (Date.now() >= deadline || checked++ >= 5) return null;
56
+ const run = await candidate(artifact);
57
+ if (run && artifact.name === `${prefix}${run.id}-${run.artifact_attempt}`) return {artifact, run, fingerprint};
58
+ }
59
+ if (inventory.artifacts.length < 100) break;
55
60
  }
56
61
  return null;
57
62
  }
@@ -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};
@@ -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
- deadline = time.monotonic() + timeout
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
- receipt.update("processing", state=state, build_id=build_id)
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.
@@ -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.
@@ -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, the installer adds `warm-android-cache.yml`: after this app's own successful
27
- `Mobile Deploy` push run on the default branch whose release intent names Android, it looks up the
28
- android job's exact Gradle key on the android job's runner (Linux only). GitHub also matches the key
29
- as a prefix, so earlier `-warm-`/`-partial-` saves count. A lookup that times out stops; one the
30
- cache service rejects reads as a miss. Only on a miss does it compile the unsigned bundle once, with
31
- no credentials other than `GIT_PRIVATE_TOKEN`, no store call and no upload task. A finished build,
32
- or one stopped at 30 minutes, saves `<key>-warm-<run_id>-<attempt>`; an earlier failure saves nothing
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.173",
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",
@@ -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);
@@ -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'],
@@ -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 compiles the
6
- # Android bundle once, unsigned, when main has no entry for the android job's Gradle key. It runs on
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: 60
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: 5
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: 10
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
- - name: Generate localizations
191
- if: ${{ steps.flutter.outputs.enabled == 'true' }}
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
- timeout -k 30 30m "${build[@]}" || status=$?
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
- # Only a finished build or one stopped at 30 minutes saves: an early failure (the candidate
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() && (steps.build.outputs.status == '0' || steps.build.outputs.status == '124') }}
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