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 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 (see
21
- [Gradle dependency retention](GRADLE_CACHE.md)).
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. No warm-up job, machine affinity, reservation or factory wait protocol is added.
54
+ remain required. Beyond that Android Gradle warm-up, no warm-up job, machine affinity, reservation
55
+ or factory wait protocol is added.
@@ -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 `deploy-web.yml`;
66
- it removes an existing one that is still the unmodified generated file with no overrides,
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.
@@ -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 warming workflow, extra main validation, upload retry or release dispatch.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.169",
3
+ "version": "1.0.171",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- for (const change of changes) {
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) });
@@ -35,13 +35,16 @@ function render(source, normalized, file) {
35
35
  return doc.toString({ lineWidth: 120 });
36
36
  }
37
37
 
38
- // deploy-web.yml is no longer installed: a copy still identical to what the lock
39
- // says was generated, with no overrides, is removed. Anything customized stays.
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 (file !== 'deploy-web.yml' || state.migrating) return false;
45
+ if (state.migrating || state.config.workflows[file]?.overrides?.length) return false;
42
46
  const previous = load(root, `.github/workflows/${file}`);
43
- return !state.config.workflows[file]?.overrides?.length
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 (!['deploy.yml', 'mobile-candidate.yml', 'deploy-web.yml', 'deploy-backend.yml'].includes(file)) {
85
- throw new MigrationRepair('unknown_managed_workflow', CONFIG);
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) }}