gowalk-cicd 1.0.169 → 1.0.171
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 +8 -0
- package/android-action/scripts/android_config.py +37 -0
- package/bin/cli.mjs +1 -0
- package/docs/CANDIDATE_ARTIFACTS.md +5 -3
- package/docs/CONFIGURATION.md +13 -2
- package/docs/GRADLE_CACHE.md +14 -1
- package/package.json +1 -1
- package/src/config/merge.mjs +45 -1
- package/src/config/workflows.mjs +23 -12
- package/templates/history.json.gz +0 -0
- package/templates/warm-android-cache.yml +256 -0
package/README.md
CHANGED
|
@@ -23,6 +23,14 @@ Writes three core things into your repo:
|
|
|
23
23
|
- `.github/actions/android-app/` — Android build/sign action (Flutter or Gradle)
|
|
24
24
|
- `.github/workflows/deploy.yml` — workflow that builds and deploys both platforms
|
|
25
25
|
|
|
26
|
+
It also writes:
|
|
27
|
+
|
|
28
|
+
- `.github/workflows/mobile-candidate.yml` — shipping binaries for ready task PRs
|
|
29
|
+
(dispatch-only while candidate artifacts are off)
|
|
30
|
+
- `.github/workflows/warm-android-cache.yml` — only while Android candidates are enabled: after this
|
|
31
|
+
app's successful default-branch release that includes Android, compiles the Android bundle once,
|
|
32
|
+
unsigned, when main has no Gradle cache for it ([Gradle dependency retention](docs/GRADLE_CACHE.md))
|
|
33
|
+
|
|
26
34
|
The installer no longer adds `.github/workflows/deploy-web.yml`. On re-adoption it
|
|
27
35
|
removes a `deploy-web.yml` that is still byte-for-byte the generated file and has no
|
|
28
36
|
overrides in `.gowalk-cicd.yml`; a customized one keeps upgrading (see
|
|
@@ -264,6 +264,21 @@ def detect_package_name(workspace: Path, build_file: Path | None = None) -> str:
|
|
|
264
264
|
raise ConfigError(f"cannot detect Android applicationId from {gradle_files[0].parent}")
|
|
265
265
|
|
|
266
266
|
|
|
267
|
+
def build_inputs(workspace: Path) -> dict[str, str]:
|
|
268
|
+
"""What the release build compiles, read from the checkout alone.
|
|
269
|
+
|
|
270
|
+
The default branch's Gradle cache warm-up (`templates/warm-android-cache.yml`) compiles
|
|
271
|
+
what a candidate compiles without creds/, a Play read or a version write, so it never runs
|
|
272
|
+
resolve_android.py (a Play edit). Raises ConfigError when there is no Android project.
|
|
273
|
+
"""
|
|
274
|
+
if project_kind(workspace) == "flutter":
|
|
275
|
+
if not _first_existing(workspace / "android", SETTINGS_FILES):
|
|
276
|
+
raise ConfigError("no Android project: android/settings.gradle(.kts) is missing")
|
|
277
|
+
return {"project_kind": "flutter", "gradle_root": "", "gradle_module": ""}
|
|
278
|
+
project = find_gradle_project(workspace)
|
|
279
|
+
return {"project_kind": "gradle", "gradle_root": str(project.root), "gradle_module": project.module}
|
|
280
|
+
|
|
281
|
+
|
|
267
282
|
def detect_bundle(workspace: Path, search_root: Path | None = None) -> Path:
|
|
268
283
|
# Flutter collects every module's outputs under build/app/; a plain Gradle
|
|
269
284
|
# build leaves them in the module's own build/outputs/bundle/.
|
|
@@ -304,3 +319,25 @@ def set_gradle_version(
|
|
|
304
319
|
if patched != original:
|
|
305
320
|
build_file.write_text(patched)
|
|
306
321
|
return {"version_code": bool(code_hits), "version_name": bool(name_hits)}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
if __name__ == "__main__":
|
|
325
|
+
import os
|
|
326
|
+
import sys
|
|
327
|
+
|
|
328
|
+
root = Path(os.environ.get("GITHUB_WORKSPACE", ".")).resolve()
|
|
329
|
+
if sys.argv[1:] == ["--flutter-toolchain"]:
|
|
330
|
+
import gradle_wrapper
|
|
331
|
+
|
|
332
|
+
# The same toolchain repair resolve_android.py applies before a Flutter release build.
|
|
333
|
+
for line in gradle_wrapper.ensure_toolchain(root, Path(os.environ["FLUTTER_ROOT"])):
|
|
334
|
+
print(line)
|
|
335
|
+
sys.exit(0)
|
|
336
|
+
try:
|
|
337
|
+
values = build_inputs(root)
|
|
338
|
+
except ConfigError as exc:
|
|
339
|
+
# Nothing to warm; an empty kind skips the rest without failing anything.
|
|
340
|
+
print(f"Nothing to compile: {exc}")
|
|
341
|
+
values = {"project_kind": "", "gradle_root": "", "gradle_module": ""}
|
|
342
|
+
with open(os.environ["GITHUB_OUTPUT"], "a") as output:
|
|
343
|
+
output.writelines(f"{name}={value}\n" for name, value in values.items())
|
package/bin/cli.mjs
CHANGED
|
@@ -47,6 +47,7 @@ What it writes:
|
|
|
47
47
|
.github/actions/android-app/ - Flutter Android build/sign action
|
|
48
48
|
.github/workflows/deploy.yml - dual-platform workflow
|
|
49
49
|
.github/workflows/mobile-candidate.yml - candidate binaries for ready task PRs
|
|
50
|
+
.github/workflows/warm-android-cache.yml - main's Gradle cache after a release (with candidates)
|
|
50
51
|
|
|
51
52
|
Options:
|
|
52
53
|
release [ios|android|both] Prepare a mobile release intent on the task branch
|
|
@@ -17,8 +17,9 @@ After protected merge, each existing platform job looks for its matching candida
|
|
|
17
17
|
lookup never waits for a build to finish. A missing, expired, failed or mismatched candidate uses
|
|
18
18
|
the normal release build. Reads have short timeouts and downloading gets the remainder of a
|
|
19
19
|
two-minute lookup budget. Dependency caches are optional: candidate runs restore Gradle, CocoaPods and
|
|
20
|
-
Flutter caches but never save them, and only default-branch runs save
|
|
21
|
-
|
|
20
|
+
Flutter caches but never save them, and only default-branch runs save. After a successful
|
|
21
|
+
default-branch release that includes Android, `warm-android-cache.yml` fills main's Android Gradle
|
|
22
|
+
key when it is missing (see [Gradle dependency retention](GRADLE_CACHE.md)).
|
|
22
23
|
|
|
23
24
|
Reuse requires the same repository, platform, source/build inputs, current signing/account material,
|
|
24
25
|
successful producer job and retained file hashes. A successful platform's earlier artifact survives
|
|
@@ -50,4 +51,5 @@ When adopting, review the installer’s replaced-binding receipt and retain app-
|
|
|
50
51
|
configuration and validation. Native Xcode validation can use the same `native_cache.py` helper
|
|
51
52
|
and pinned cache programs as release builds. Cache Pods and `.factory/toolchain/gems` with the
|
|
52
53
|
helper's lockfile/Ruby/Xcode/architecture identity; `pod install --deployment` and committed locks
|
|
53
|
-
remain required.
|
|
54
|
+
remain required. Beyond that Android Gradle warm-up, no warm-up job, machine affinity, reservation
|
|
55
|
+
or factory wait protocol is added.
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -62,8 +62,10 @@ Compose service must pass `BACKEND_PUBLIC_BASE_URL` into its container and use i
|
|
|
62
62
|
The same named repository inputs reach the feature-branch backend preview in `deploy.yml`.
|
|
63
63
|
|
|
64
64
|
The supported managed files are `deploy.yml`, `mobile-candidate.yml`,
|
|
65
|
-
`deploy-web.yml` and `deploy-backend.yml`. The installer no longer adds
|
|
66
|
-
|
|
65
|
+
`warm-android-cache.yml`, `deploy-web.yml` and `deploy-backend.yml`. The installer no longer adds
|
|
66
|
+
`deploy-web.yml`, and adds `warm-android-cache.yml` only while candidate artifacts are enabled
|
|
67
|
+
and `mobile.platforms` is not `ios`;
|
|
68
|
+
it removes such a file that is still the unmodified generated one with no overrides,
|
|
67
69
|
and keeps upgrading one this configuration customizes. App-specific values outside the named
|
|
68
70
|
mobile/backend inputs, and custom jobs or steps embedded in those workflows,
|
|
69
71
|
are retained as sparse `workflows.<file>.overrides` entries. An override identifies
|
|
@@ -97,3 +99,12 @@ An unknown layout or overlapping custom code edit returns a JSON
|
|
|
97
99
|
`gowalk-cicd/migration-repair.v1` receipt with its code, file and YAML path. The
|
|
98
100
|
receipt never includes the conflicting value. Resolve that specific customization
|
|
99
101
|
in the same repository; unrelated repositories can continue upgrading.
|
|
102
|
+
|
|
103
|
+
1.0.168 deleted the Android `mode` step (Bitrise delivery). No app enabled Bitrise, so it
|
|
104
|
+
always answered `local`; a deleted step answers null. An upgrade therefore drops a
|
|
105
|
+
`steps.mode.outputs.mode == 'local'` (or `!= 'bitrise'`) conjunct from an app's
|
|
106
|
+
`jobs.android` step `if`, and an `if` left empty goes. Any other use of `steps.mode` in a
|
|
107
|
+
`jobs.android` step `if` returns `android_mode_condition_requires_edit`: a Bitrise-only step
|
|
108
|
+
never ran (delete it), and beside `always()`, `failure()` or `cancelled()` the conjunct also
|
|
109
|
+
meant that the steps before `mode` succeeded (restate that). `env`, `run` and job outputs are
|
|
110
|
+
not checked; there `steps.mode` renders empty.
|
package/docs/GRADLE_CACHE.md
CHANGED
|
@@ -20,7 +20,20 @@ The pinned cache preloader remains confined to the official cache processes.
|
|
|
20
20
|
GitHub caches are branch-scoped: task branches cannot restore sibling branches' caches, while
|
|
21
21
|
default-branch caches can serve subsequent task branches. The normal default-branch release
|
|
22
22
|
therefore retains useful downloads after failure as well as after successful delivery. It
|
|
23
|
-
adds no
|
|
23
|
+
adds no extra main validation, upload retry or release dispatch.
|
|
24
|
+
|
|
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.
|
|
24
37
|
|
|
25
38
|
On adoption, preserve app-specific dependency inputs, platforms and existing restore identities.
|
|
26
39
|
Candidate and release workflows need compatible keys, paths and compression versions to share
|
package/package.json
CHANGED
package/src/config/merge.mjs
CHANGED
|
@@ -86,9 +86,53 @@ function iosChange(file, before, after, change) {
|
|
|
86
86
|
return change;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
// 1.0.168 deleted the Android step `mode`, which answered 'local' on every app (none enabled
|
|
90
|
+
// Bitrise). A missing step answers null, so an app step still guarded by
|
|
91
|
+
// `steps.mode.outputs.mode == 'local'` silently stopped running. That always-true conjunct is
|
|
92
|
+
// dropped (&& binds tighter than ||, so any isolated conjunct can go); any other use refuses.
|
|
93
|
+
// Beside always(), failure() or cancelled() it also meant "nothing before mode failed", so it refuses.
|
|
94
|
+
const MODE = /^steps\.mode\.outputs\.mode\s*(?:==\s*'local'|!=\s*'bitrise')$/;
|
|
95
|
+
const USES_MODE = /\bsteps\.mode(?![\w-])/, STATUS = /\b(?:always|failure|cancelled)\(/;
|
|
96
|
+
|
|
97
|
+
function conjuncts(body) {
|
|
98
|
+
const terms = [];
|
|
99
|
+
let depth = 0, quoted = false, start = 0;
|
|
100
|
+
for (let index = 0; index < body.length; index++) {
|
|
101
|
+
const char = body[index];
|
|
102
|
+
if (char === "'") quoted = !quoted;
|
|
103
|
+
else if (!quoted && (char === '(' || char === ')')) depth += char === '(' ? 1 : -1;
|
|
104
|
+
else if (!quoted && !depth && body.startsWith('&&', index)) {
|
|
105
|
+
terms.push(body.slice(start, index).trim());
|
|
106
|
+
start = ++index + 1;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return [...terms, body.slice(start).trim()];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function modeChange(file, change, ownMode) {
|
|
113
|
+
const [jobs, job, steps, items, name, key] = change.path;
|
|
114
|
+
if (file !== 'deploy.yml' || jobs !== 'jobs' || job !== 'android' || steps !== 'steps' || items !== 'items'
|
|
115
|
+
|| ownMode) return change;
|
|
116
|
+
const step = change.path.length === 5 && change.value && typeof change.value === 'object';
|
|
117
|
+
const condition = step ? change.value.if : change.path.length === 6 && key === 'if' ? change.value : null;
|
|
118
|
+
if (typeof condition !== 'string' || !USES_MODE.test(condition)) return change;
|
|
119
|
+
const terms = conjuncts(condition.trim().replace(/^\$\{\{([\s\S]*)\}\}$/, '$1').trim())
|
|
120
|
+
.filter(term => !MODE.test(term));
|
|
121
|
+
if (terms.some(term => USES_MODE.test(term) || STATUS.test(term))) {
|
|
122
|
+
throw new MigrationRepair('android_mode_condition_requires_edit', file, change.path);
|
|
123
|
+
}
|
|
124
|
+
console.log(` Migrated ${file} android step ${name}: dropped steps.mode (step deleted in 1.0.168)`);
|
|
125
|
+
const migrated = terms.length ? `\${{ ${terms.join(' && ')} }}` : undefined;
|
|
126
|
+
if (!step) return migrated === undefined ? { path: change.path, remove: true } : { ...change, value: migrated };
|
|
127
|
+
return { ...change, value: Object.fromEntries(Object.entries(change.value).flatMap(([field, value]) =>
|
|
128
|
+
field !== 'if' ? [[field, value]] : migrated === undefined ? [] : [[field, migrated]])) };
|
|
129
|
+
}
|
|
130
|
+
|
|
89
131
|
export function mergeChanges(base, incoming, changes, file) {
|
|
90
132
|
const result = structuredClone(incoming);
|
|
91
|
-
|
|
133
|
+
const ownMode = changes.some(change => equal(change.path, ['jobs', 'android', 'steps', 'items', 'id:mode']));
|
|
134
|
+
for (const raw of changes) {
|
|
135
|
+
const change = modeChange(file, raw, ownMode);
|
|
92
136
|
const before = at(base, change.path), after = at(incoming, change.path);
|
|
93
137
|
if (change.path.at(-1) === 'order' && Array.isArray(before) && Array.isArray(after)) {
|
|
94
138
|
assign(result, { path: change.path, value: mergeOrder(before, change.value, after, file, change.path) });
|
package/src/config/workflows.mjs
CHANGED
|
@@ -35,13 +35,16 @@ function render(source, normalized, file) {
|
|
|
35
35
|
return doc.toString({ lineWidth: 120 });
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
const WARM = 'warm-android-cache.yml';
|
|
39
|
+
const MANAGED = ['deploy.yml', 'mobile-candidate.yml', 'deploy-web.yml', 'deploy-backend.yml', WARM];
|
|
40
|
+
|
|
41
|
+
// A generated file this install no longer wants (deploy-web.yml always; the warm-up without
|
|
42
|
+
// candidates) is removed while it is still what the lock says was generated, with no overrides.
|
|
43
|
+
// Anything customized stays and keeps upgrading.
|
|
40
44
|
function retired(root, file, state) {
|
|
41
|
-
if (
|
|
45
|
+
if (state.migrating || state.config.workflows[file]?.overrides?.length) return false;
|
|
42
46
|
const previous = load(root, `.github/workflows/${file}`);
|
|
43
|
-
return
|
|
44
|
-
&& (previous === null || digest(previous) === state.lock.workflows[file]?.generated);
|
|
47
|
+
return previous === null || digest(previous) === state.lock.workflows[file]?.generated;
|
|
45
48
|
}
|
|
46
49
|
|
|
47
50
|
function upgradeWorkflow(root, file, templateDir, state) {
|
|
@@ -81,23 +84,31 @@ export function planWorkflows(root, templateDir, files, version) {
|
|
|
81
84
|
const state = configState(root);
|
|
82
85
|
const selected = [...new Set([...files, ...Object.keys(state.config.workflows)])];
|
|
83
86
|
for (const file of selected) {
|
|
84
|
-
if (!
|
|
85
|
-
|
|
86
|
-
|
|
87
|
+
if (!MANAGED.includes(file)) throw new MigrationRepair('unknown_managed_workflow', CONFIG);
|
|
88
|
+
}
|
|
89
|
+
const removed = selected.filter(file => file === 'deploy-web.yml' && retired(root, file, state));
|
|
90
|
+
const writes = selected.filter(file => !removed.includes(file) && file !== WARM)
|
|
91
|
+
.map(file => upgradeWorkflow(root, file, templateDir, state));
|
|
92
|
+
// Main's warm Android build cache serves Android candidates, so it follows them; deploy.yml has
|
|
93
|
+
// just settled whether this app builds candidates at all. An iOS-only app builds no Android one.
|
|
94
|
+
const candidates = selected.includes('mobile-candidate.yml')
|
|
95
|
+
&& state.config.mobile?.['candidate-artifacts'] !== false
|
|
96
|
+
&& String(state.config.mobile?.platforms ?? '').trim() !== 'ios';
|
|
97
|
+
if (candidates || (selected.includes(WARM) && !retired(root, WARM, state))) {
|
|
98
|
+
writes.push(upgradeWorkflow(root, WARM, templateDir, state));
|
|
99
|
+
} else if (selected.includes(WARM)) {
|
|
100
|
+
removed.push(WARM);
|
|
87
101
|
}
|
|
88
|
-
const removed = selected.filter(file => retired(root, file, state));
|
|
89
102
|
for (const file of removed) {
|
|
90
103
|
delete state.config.workflows[file];
|
|
91
104
|
delete state.lock.workflows[file];
|
|
92
105
|
}
|
|
93
|
-
const writes = selected.filter(file => !removed.includes(file))
|
|
94
|
-
.map(file => upgradeWorkflow(root, file, templateDir, state));
|
|
95
106
|
writes.push(...removed.map(file => ({ file: `.github/workflows/${file}`, content: null })));
|
|
96
107
|
assertReferences(state.config, CONFIG);
|
|
97
108
|
for (const [file, entry] of Object.entries(state.config.workflows)) {
|
|
98
109
|
for (const change of entry.overrides) assertReferences(change.value, file, change.path);
|
|
99
110
|
}
|
|
100
|
-
writes.push(...planValidation(root, selected));
|
|
111
|
+
writes.push(...planValidation(root, [...selected, WARM]));
|
|
101
112
|
state.lock.package = version;
|
|
102
113
|
const config = state.text && equal(readYaml(state.text, CONFIG).value, state.config)
|
|
103
114
|
? state.text
|
|
Binary file
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
name: Warm Android build cache
|
|
2
|
+
|
|
3
|
+
# Candidates restore only what the default branch saved, and a release that promotes its
|
|
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
|
|
7
|
+
# no other event: never on a candidate, a dispatch or a schedule. Installed only with candidates.
|
|
8
|
+
on:
|
|
9
|
+
workflow_run:
|
|
10
|
+
workflows: [Mobile Deploy]
|
|
11
|
+
types: [completed]
|
|
12
|
+
|
|
13
|
+
permissions:
|
|
14
|
+
contents: read
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
warm:
|
|
18
|
+
name: Warm Android build cache
|
|
19
|
+
if: >-
|
|
20
|
+
${{ github.event.workflow_run.conclusion == 'success'
|
|
21
|
+
&& github.event.workflow_run.event == 'push'
|
|
22
|
+
&& github.event.workflow_run.head_branch == github.event.repository.default_branch
|
|
23
|
+
&& contains(fromJSON('["both", "android"]'), vars.DEPLOY_PLATFORMS || 'both') }}
|
|
24
|
+
# The android job's runner, so the Gradle key's OS/architecture and cache paths match it.
|
|
25
|
+
runs-on: ${{ vars.ANDROID_RUNNER || 'ubuntu-24.04' }}
|
|
26
|
+
# workflow_run gets a read-only cache token unless the job asks for write. This job runs
|
|
27
|
+
# only for the repository's own default-branch push and builds that exact commit.
|
|
28
|
+
cache-mode: write
|
|
29
|
+
concurrency:
|
|
30
|
+
group: warm-android-cache
|
|
31
|
+
cancel-in-progress: false
|
|
32
|
+
# A backstop only: each long step is bounded below, so a cold build never reaches it.
|
|
33
|
+
timeout-minutes: 60
|
|
34
|
+
steps:
|
|
35
|
+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
|
36
|
+
continue-on-error: true
|
|
37
|
+
with:
|
|
38
|
+
ref: ${{ github.event.workflow_run.head_sha }}
|
|
39
|
+
# Only a release whose intent names Android built or promoted an Android binary.
|
|
40
|
+
- name: Check that this release included Android
|
|
41
|
+
id: intent
|
|
42
|
+
continue-on-error: true
|
|
43
|
+
shell: bash
|
|
44
|
+
run: |
|
|
45
|
+
if node -e "process.exit(require('./.factory/releases/mobile.json').platforms?.android ? 0 : 1)" 2>/dev/null; then
|
|
46
|
+
echo "android=true" >> "$GITHUB_OUTPUT"
|
|
47
|
+
else
|
|
48
|
+
echo "This release built no Android binary: nothing to warm."
|
|
49
|
+
fi
|
|
50
|
+
# Linux only, like the android job's disk and heap steps (GNU timeout, df, /proc).
|
|
51
|
+
- name: Retain Gradle cache scope
|
|
52
|
+
id: gradle_scope
|
|
53
|
+
if: ${{ steps.intent.outputs.android == 'true' && runner.os == 'Linux' }}
|
|
54
|
+
continue-on-error: true
|
|
55
|
+
shell: bash
|
|
56
|
+
run: |
|
|
57
|
+
node .github/actions/android-app/scripts/cache_scope.cjs --snapshot
|
|
58
|
+
node - <<'NODE'
|
|
59
|
+
require('fs').appendFileSync(process.env.GITHUB_OUTPUT,
|
|
60
|
+
`home=${require('os').homedir()}\narch=${process.arch}\n`);
|
|
61
|
+
NODE
|
|
62
|
+
# The android job's exact key. GitHub also matches it as a prefix, so an earlier warm-up
|
|
63
|
+
# (`-warm-`) or failed release (`-partial-`) of the same inputs counts as present. A lookup
|
|
64
|
+
# that times out stops the warm-up; one the cache service rejects reads as a miss (the
|
|
65
|
+
# pinned action logs the error and reports no match), costing one build and a duplicate entry.
|
|
66
|
+
- name: Look up the default branch's Gradle cache
|
|
67
|
+
id: gradle_lookup
|
|
68
|
+
if: ${{ steps.gradle_scope.outcome == 'success' }}
|
|
69
|
+
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25
|
|
70
|
+
continue-on-error: true
|
|
71
|
+
timeout-minutes: 2
|
|
72
|
+
env:
|
|
73
|
+
NODE_OPTIONS: --require "${{ steps.gradle_scope.outputs.preloader }}"
|
|
74
|
+
SEGMENT_DOWNLOAD_TIMEOUT_MINS: '1'
|
|
75
|
+
with:
|
|
76
|
+
lookup-only: true
|
|
77
|
+
path: |
|
|
78
|
+
${{ steps.gradle_scope.outputs.home }}/.gradle/caches
|
|
79
|
+
${{ steps.gradle_scope.outputs.home }}/.gradle/wrapper
|
|
80
|
+
key: >-
|
|
81
|
+
${{ format('setup-java-{0}-{1}-gradle-{2}', runner.os, steps.gradle_scope.outputs.arch,
|
|
82
|
+
hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'buildSrc/**/Versions.kt',
|
|
83
|
+
'buildSrc/**/Dependencies.kt', 'gradle/*.versions.toml', '**/versions.properties')) }}
|
|
84
|
+
# Only a lookup that ended without a match goes on. An empty kind: no Android project.
|
|
85
|
+
- name: Resolve the Android build inputs
|
|
86
|
+
id: build_inputs
|
|
87
|
+
if: >-
|
|
88
|
+
${{ steps.gradle_lookup.outcome == 'success'
|
|
89
|
+
&& steps.gradle_lookup.outputs.cache-matched-key == '' }}
|
|
90
|
+
continue-on-error: true
|
|
91
|
+
shell: bash
|
|
92
|
+
run: python3 .github/actions/android-app/scripts/android_config.py
|
|
93
|
+
# The android job's own setup steps follow; only `if`, ids and failure bounds differ.
|
|
94
|
+
- name: Detect Flutter
|
|
95
|
+
id: flutter
|
|
96
|
+
if: ${{ steps.build_inputs.outputs.project_kind != '' }}
|
|
97
|
+
shell: bash
|
|
98
|
+
run: |
|
|
99
|
+
if [ -f pubspec.yaml ]; then
|
|
100
|
+
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
|
101
|
+
else
|
|
102
|
+
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
|
103
|
+
fi
|
|
104
|
+
- name: Free runner disk space
|
|
105
|
+
if: ${{ steps.build_inputs.outputs.project_kind != '' }}
|
|
106
|
+
shell: bash
|
|
107
|
+
run: |
|
|
108
|
+
df -h / | tail -1
|
|
109
|
+
if [ "$(df --output=avail -BG / | tail -1 | tr -dc 0-9)" -ge 40 ]; then exit 0; fi
|
|
110
|
+
# NOT /usr/local/lib/node_modules: npm and npx live there, and the
|
|
111
|
+
# Android action's Crashlytics symbol upload shells out to npx.
|
|
112
|
+
# Keep installed Android SDK/NDKs: removing them makes AGP download them again.
|
|
113
|
+
unused="/usr/share/dotnet /opt/ghc /usr/local/.ghcup /usr/local/share/powershell"
|
|
114
|
+
unused="$unused /usr/share/swift /usr/local/share/chromium"
|
|
115
|
+
nohup sudo sh -c "rm -rf $unused; docker image prune --all --force" >/dev/null 2>&1 </dev/null &
|
|
116
|
+
- name: Choose the JDK for this build
|
|
117
|
+
id: jdk
|
|
118
|
+
if: ${{ steps.build_inputs.outputs.project_kind != '' }}
|
|
119
|
+
shell: bash
|
|
120
|
+
env:
|
|
121
|
+
JAVA_VERSION: ${{ vars.JAVA_VERSION }}
|
|
122
|
+
FLUTTER_ENABLED: ${{ steps.flutter.outputs.enabled }}
|
|
123
|
+
run: |
|
|
124
|
+
# Never let a chooser bug take down every Android job: fall back to the
|
|
125
|
+
# JDK this workflow used before the chooser existed.
|
|
126
|
+
if ! python3 .github/actions/android-app/scripts/select_jdk.py; then
|
|
127
|
+
echo "::warning::select_jdk.py failed; falling back"
|
|
128
|
+
if [ "${FLUTTER_ENABLED}" = "true" ]; then fallback=17; else fallback=21; fi
|
|
129
|
+
echo "version=${fallback}" >> "$GITHUB_OUTPUT"
|
|
130
|
+
fi
|
|
131
|
+
- name: Set up Java
|
|
132
|
+
id: java
|
|
133
|
+
if: ${{ steps.build_inputs.outputs.project_kind != '' }}
|
|
134
|
+
uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5
|
|
135
|
+
continue-on-error: true
|
|
136
|
+
timeout-minutes: 5
|
|
137
|
+
with:
|
|
138
|
+
distribution: temurin
|
|
139
|
+
java-version: ${{ steps.jdk.outputs.version }}
|
|
140
|
+
- name: Set up Flutter
|
|
141
|
+
id: flutter_setup
|
|
142
|
+
if: ${{ steps.flutter.outputs.enabled == 'true' }}
|
|
143
|
+
uses: ./.github/actions/android-app/flutter-setup
|
|
144
|
+
continue-on-error: true
|
|
145
|
+
timeout-minutes: 10
|
|
146
|
+
with:
|
|
147
|
+
channel: stable
|
|
148
|
+
cache: true
|
|
149
|
+
- name: Authenticate private git dependencies
|
|
150
|
+
if: ${{ steps.flutter.outputs.enabled == 'true' }}
|
|
151
|
+
shell: bash
|
|
152
|
+
env:
|
|
153
|
+
GIT_PRIVATE_TOKEN: ${{ secrets.GIT_PRIVATE_TOKEN }}
|
|
154
|
+
run: |
|
|
155
|
+
if [ -z "${GIT_PRIVATE_TOKEN:-}" ]; then
|
|
156
|
+
echo "No GIT_PRIVATE_TOKEN secret; private git dependencies must carry their own credentials."
|
|
157
|
+
exit 0
|
|
158
|
+
fi
|
|
159
|
+
echo "::add-mask::$GIT_PRIVATE_TOKEN"
|
|
160
|
+
git config --global \
|
|
161
|
+
url."https://x-access-token:${GIT_PRIVATE_TOKEN}@github.com/".insteadOf "https://github.com/"
|
|
162
|
+
echo "Configured authenticated github.com remotes for pub."
|
|
163
|
+
- name: Cap the Gradle daemon heap for this runner
|
|
164
|
+
if: ${{ steps.build_inputs.outputs.project_kind != '' }}
|
|
165
|
+
shell: bash
|
|
166
|
+
env:
|
|
167
|
+
MAX_HEAP_GB: '4'
|
|
168
|
+
MAX_META_GB: '2'
|
|
169
|
+
run: |
|
|
170
|
+
if awk '/^MemTotal:/ { kb = $2 } END { exit !(kb >= 16777216) }' /proc/meminfo; then exit 0; fi
|
|
171
|
+
shopt -s nullglob
|
|
172
|
+
for f in android/gradle.properties gradle.properties; do
|
|
173
|
+
[ -f "$f" ] || continue
|
|
174
|
+
before=$(grep -m1 '^org.gradle.jvmargs' "$f" || true)
|
|
175
|
+
[ -n "$before" ] || continue
|
|
176
|
+
heap=$(sed -nE 's/.*-Xmx([0-9]+)[Gg].*/\1/p' <<<"$before")
|
|
177
|
+
meta=$(sed -nE 's/.*MaxMetaspaceSize=([0-9]+)[Gg].*/\1/p' <<<"$before")
|
|
178
|
+
changed=""
|
|
179
|
+
if [ -n "$heap" ] && [ "$heap" -gt "$MAX_HEAP_GB" ]; then
|
|
180
|
+
sed -i -E "s/-Xmx${heap}[Gg]/-Xmx${MAX_HEAP_GB}G/" "$f"; changed=1
|
|
181
|
+
fi
|
|
182
|
+
if [ -n "$meta" ] && [ "$meta" -gt "$MAX_META_GB" ]; then
|
|
183
|
+
sed -i -E "s/MaxMetaspaceSize=${meta}[Gg]/MaxMetaspaceSize=${MAX_META_GB}G/" "$f"; changed=1
|
|
184
|
+
fi
|
|
185
|
+
if [ -n "$changed" ]; then
|
|
186
|
+
echo "::warning::$f asked for more heap than this runner has; capped it for this build only ($before)"
|
|
187
|
+
grep -m1 '^org.gradle.jvmargs' "$f"
|
|
188
|
+
fi
|
|
189
|
+
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
|
|
204
|
+
id: build
|
|
205
|
+
if: >-
|
|
206
|
+
${{ steps.java.outcome == 'success'
|
|
207
|
+
&& (steps.flutter.outputs.enabled != 'true' || steps.flutter_setup.outcome == 'success') }}
|
|
208
|
+
continue-on-error: true
|
|
209
|
+
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
|
+
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
|
+
status=0
|
|
234
|
+
timeout -k 30 30m "${build[@]}" || status=$?
|
|
235
|
+
echo "::$token::"
|
|
236
|
+
echo "status=$status" >> "$GITHUB_OUTPUT"
|
|
237
|
+
echo "Build exit status $status (124: stopped at 30 minutes)."
|
|
238
|
+
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.
|
|
243
|
+
- name: Save Gradle dependencies
|
|
244
|
+
if: ${{ always() && (steps.build.outputs.status == '0' || steps.build.outputs.status == '124') }}
|
|
245
|
+
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25
|
|
246
|
+
continue-on-error: true
|
|
247
|
+
timeout-minutes: 2
|
|
248
|
+
env:
|
|
249
|
+
NODE_OPTIONS: --require "${{ steps.gradle_scope.outputs.preloader }}"
|
|
250
|
+
with:
|
|
251
|
+
path: |
|
|
252
|
+
${{ steps.gradle_scope.outputs.home }}/.gradle/caches
|
|
253
|
+
${{ steps.gradle_scope.outputs.home }}/.gradle/wrapper
|
|
254
|
+
key: >-
|
|
255
|
+
${{ format('{0}-warm-{1}-{2}', steps.gradle_lookup.outputs.cache-primary-key,
|
|
256
|
+
github.run_id, github.run_attempt) }}
|