gowalk-cicd 1.0.3 → 1.0.4
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/CLAUDE.md +11 -4
- package/README.md +35 -8
- package/action/.daemux-version +1 -1
- package/action/scripts/test_write_marketing_version.py +101 -0
- package/action/scripts/version_utils.py +13 -3
- package/android-action/.daemux-version +1 -1
- package/android-action/action.yml +41 -8
- package/android-action/scripts/android_config.py +190 -14
- package/android-action/scripts/find_bundle.py +10 -1
- package/android-action/scripts/resolve_android.py +46 -4
- package/android-action/scripts/test_android_config.py +160 -0
- package/package.json +1 -1
- package/templates/deploy.yml +15 -3
package/CLAUDE.md
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
## Purpose
|
|
4
4
|
|
|
5
|
-
npm package that distributes iOS TestFlight and
|
|
6
|
-
|
|
5
|
+
npm package that distributes iOS TestFlight and Android Google Play actions.
|
|
6
|
+
Consumer runs `npx gowalk-cicd` and gets:
|
|
7
7
|
|
|
8
8
|
- `.github/actions/swift-app/` — vendored composite action
|
|
9
|
-
- `.github/actions/android-app/` — vendored Flutter
|
|
9
|
+
- `.github/actions/android-app/` — vendored Android action (Flutter or Gradle)
|
|
10
10
|
- `.github/workflows/deploy.yml` — dual-platform workflow
|
|
11
11
|
|
|
12
12
|
Re-running the command is both install and update. No scope flag, no
|
|
@@ -20,7 +20,7 @@ gowalk-cicd/ ← repo root IS the gowalk-cicd package
|
|
|
20
20
|
│ ├── action.yml
|
|
21
21
|
│ └── scripts/*.py
|
|
22
22
|
├── android-action/ ← Android action — canonical source of truth
|
|
23
|
-
│ ├── action.yml
|
|
23
|
+
│ ├── action.yml ← branches on project kind (flutter | gradle)
|
|
24
24
|
│ └── scripts/*.py
|
|
25
25
|
├── bin/cli.mjs ← CLI entrypoint (parses flags, calls runInstall)
|
|
26
26
|
├── src/install.mjs ← copy logic, gitignore-check, summary print
|
|
@@ -92,3 +92,10 @@ node /path/to/gowalk-cicd/bin/cli.mjs
|
|
|
92
92
|
Detect and warn, tell the user to remove lines manually.
|
|
93
93
|
- Store API calls belong in GitHub Actions. Local tests may build/sign but must
|
|
94
94
|
never connect to App Store Connect or Google Play.
|
|
95
|
+
- The Android action serves two build systems. `android_config.project_kind()`
|
|
96
|
+
is the only place that decides which; every downstream step branches on the
|
|
97
|
+
`project_kind` output rather than re-sniffing the repo. Flutter wins the tie
|
|
98
|
+
because a Flutter app also carries `android/settings.gradle`.
|
|
99
|
+
- Version stamping is per-build-system: Flutter takes `--build-number`, Gradle
|
|
100
|
+
has no equivalent, so `set_gradle_version()` rewrites the literal in the
|
|
101
|
+
checkout the way the iOS action patches the pbxproj. Never commit that edit.
|
package/README.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# gowalk-cicd
|
|
2
2
|
|
|
3
3
|
One installer for iOS TestFlight and Android Google Play delivery. It supports
|
|
4
|
-
native Swift/SwiftUI iOS projects
|
|
5
|
-
`creds/`, push to `main`, and both
|
|
4
|
+
native Swift/SwiftUI iOS projects, native Kotlin/Gradle Android projects, and
|
|
5
|
+
Flutter apps. Put the platform keys under `creds/`, push to `main`, and both
|
|
6
|
+
store builds run only on GitHub Actions.
|
|
6
7
|
|
|
7
8
|
## Install
|
|
8
9
|
|
|
@@ -15,7 +16,7 @@ npx --yes gowalk-cicd
|
|
|
15
16
|
Writes three things into your repo:
|
|
16
17
|
|
|
17
18
|
- `.github/actions/swift-app/` — the vendored composite action (action.yml + scripts)
|
|
18
|
-
- `.github/actions/android-app/` —
|
|
19
|
+
- `.github/actions/android-app/` — Android build/sign action (Flutter or Gradle)
|
|
19
20
|
- `.github/workflows/deploy.yml` — workflow that builds and deploys both platforms
|
|
20
21
|
|
|
21
22
|
Re-run the same command anytime to pull the latest version.
|
|
@@ -56,6 +57,9 @@ Place these files under `creds/`:
|
|
|
56
57
|
(`Gowalk.json` is valid); the action identifies it by the
|
|
57
58
|
`type: service_account` JSON fields.
|
|
58
59
|
|
|
60
|
+
These are the same three files whether the app is Flutter or native
|
|
61
|
+
Kotlin/Gradle — see [Android: Flutter or native Gradle](#android-flutter-or-native-gradle).
|
|
62
|
+
|
|
59
63
|
The service account must have Google Play Console access to the app and the
|
|
60
64
|
Android Publisher API must be enabled. Keep the repository private. The
|
|
61
65
|
workflow reads these files from the checkout, so they must be available to
|
|
@@ -129,11 +133,34 @@ create a fresh cert.
|
|
|
129
133
|
|
|
130
134
|
## How it works
|
|
131
135
|
|
|
132
|
-
On every push, iOS and Android run in parallel.
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
136
|
+
On every push, iOS and Android run in parallel. The Android job builds an AAB
|
|
137
|
+
with `GITHUB_RUN_NUMBER` as its monotonically increasing version code, replaces
|
|
138
|
+
any development signature with the upload key, and retains the signed bundle
|
|
139
|
+
before contacting Google Play.
|
|
140
|
+
|
|
141
|
+
### Android: Flutter or native Gradle
|
|
142
|
+
|
|
143
|
+
The Android action detects which build system to drive and needs no
|
|
144
|
+
configuration for either:
|
|
145
|
+
|
|
146
|
+
| | Flutter | Native Gradle |
|
|
147
|
+
|---|---|---|
|
|
148
|
+
| Detected by | `pubspec.yaml` at the repo root | `settings.gradle(.kts)` + `gradlew` at the root or under `android/` |
|
|
149
|
+
| App module | `android/app` | the one module applying `com.android.application` (version-catalog aliases are resolved; a module named `app` wins a tie against a wear/automotive sibling) |
|
|
150
|
+
| Tests (`run-tests`) | `flutter analyze` + `flutter test` | `<module>:testReleaseUnitTest` |
|
|
151
|
+
| Build | `flutter build appbundle --release` | `<module>:bundleRelease` |
|
|
152
|
+
| Toolchain installed | Flutter + JDK 17 | JDK 21 only |
|
|
153
|
+
|
|
154
|
+
A Flutter app also carries `android/settings.gradle`, so `pubspec.yaml` wins the
|
|
155
|
+
tie — Flutter apps must be built through the Flutter tool.
|
|
156
|
+
|
|
157
|
+
Gradle has no equivalent of `flutter build --build-number`, so the action
|
|
158
|
+
rewrites the literal `versionCode` (and `versionName`, when `build-name` is
|
|
159
|
+
set) in the app module's build file inside the CI checkout — the Gradle
|
|
160
|
+
counterpart to the iOS action patching the `.pbxproj`. The edit is never
|
|
161
|
+
committed back. If the module computes its `versionCode` instead of declaring a
|
|
162
|
+
literal, the action emits a `::warning::` and leaves the project's own value
|
|
163
|
+
alone.
|
|
137
164
|
|
|
138
165
|
### iOS delivery
|
|
139
166
|
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.4
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Where write_marketing_version() persists the bump.
|
|
3
|
+
|
|
4
|
+
The interesting case is a repo that keeps an xcodegen spec *and* commits the
|
|
5
|
+
generated .xcodeproj: auto_detect only runs `xcodegen generate` when no
|
|
6
|
+
.xcodeproj is present, so a bump written to the spec alone never reaches
|
|
7
|
+
xcodebuild and the archive ships the stale version.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import tempfile
|
|
14
|
+
import unittest
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from unittest import mock
|
|
17
|
+
|
|
18
|
+
import version_utils
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
PBXPROJ = """// !$*UTF8*$!
|
|
22
|
+
{
|
|
23
|
+
\t\t\t\tCURRENT_PROJECT_VERSION = 1;
|
|
24
|
+
\t\t\t\tMARKETING_VERSION = 0.1.0;
|
|
25
|
+
}
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
SPEC = """name: EmreForms
|
|
29
|
+
targets:
|
|
30
|
+
EmreForms:
|
|
31
|
+
settings:
|
|
32
|
+
base:
|
|
33
|
+
MARKETING_VERSION: 0.1.0
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class WriteMarketingVersionTest(unittest.TestCase):
|
|
38
|
+
def setUp(self) -> None:
|
|
39
|
+
self.temp = tempfile.TemporaryDirectory()
|
|
40
|
+
self.root = Path(self.temp.name)
|
|
41
|
+
self.project = self.root / "EmreForms.xcodeproj"
|
|
42
|
+
self.project.mkdir()
|
|
43
|
+
self.pbxproj = self.project / "project.pbxproj"
|
|
44
|
+
self.pbxproj.write_text(PBXPROJ, encoding="utf-8")
|
|
45
|
+
self.spec = self.root / "project.yml"
|
|
46
|
+
self.previous = Path.cwd()
|
|
47
|
+
os.chdir(self.root)
|
|
48
|
+
self.env = mock.patch.dict(
|
|
49
|
+
os.environ,
|
|
50
|
+
{"PROJECT": str(self.project), "WORKSPACE": "", "RUNNER_TEMP": ""},
|
|
51
|
+
)
|
|
52
|
+
self.env.start()
|
|
53
|
+
# _record_bumped_path shells out to `git add`; irrelevant here.
|
|
54
|
+
self.record = mock.patch.object(version_utils, "_record_bumped_path")
|
|
55
|
+
self.record.start()
|
|
56
|
+
|
|
57
|
+
def tearDown(self) -> None:
|
|
58
|
+
self.record.stop()
|
|
59
|
+
self.env.stop()
|
|
60
|
+
os.chdir(self.previous)
|
|
61
|
+
self.temp.cleanup()
|
|
62
|
+
|
|
63
|
+
def test_spec_and_committed_pbxproj_are_kept_in_sync(self) -> None:
|
|
64
|
+
self.spec.write_text(SPEC, encoding="utf-8")
|
|
65
|
+
|
|
66
|
+
self.assertTrue(version_utils.write_marketing_version("0.2.0"))
|
|
67
|
+
|
|
68
|
+
self.assertIn("MARKETING_VERSION: 0.2.0", self.spec.read_text())
|
|
69
|
+
self.assertIn("MARKETING_VERSION = 0.2.0;", self.pbxproj.read_text())
|
|
70
|
+
|
|
71
|
+
def test_pbxproj_alone_is_written_without_a_spec(self) -> None:
|
|
72
|
+
self.assertTrue(version_utils.write_marketing_version("0.2.0"))
|
|
73
|
+
|
|
74
|
+
self.assertIn("MARKETING_VERSION = 0.2.0;", self.pbxproj.read_text())
|
|
75
|
+
|
|
76
|
+
def test_spec_write_still_succeeds_without_a_generated_project(self) -> None:
|
|
77
|
+
# The usual xcodegen flow: the .xcodeproj is gitignored, so there is
|
|
78
|
+
# nothing to sync and the spec is the only source of truth.
|
|
79
|
+
self.spec.write_text(SPEC, encoding="utf-8")
|
|
80
|
+
self.pbxproj.unlink()
|
|
81
|
+
|
|
82
|
+
self.assertTrue(version_utils.write_marketing_version("0.2.0"))
|
|
83
|
+
|
|
84
|
+
self.assertIn("MARKETING_VERSION: 0.2.0", self.spec.read_text())
|
|
85
|
+
|
|
86
|
+
def test_refuses_the_pbxproj_when_the_spec_lacks_the_key(self) -> None:
|
|
87
|
+
# Unchanged behaviour: a spec that does not declare MARKETING_VERSION
|
|
88
|
+
# means we cannot tell where the value really lives, so we refuse
|
|
89
|
+
# rather than edit a file the next regenerate would overwrite.
|
|
90
|
+
self.spec.write_text("name: EmreForms\n", encoding="utf-8")
|
|
91
|
+
|
|
92
|
+
with mock.patch.object(
|
|
93
|
+
version_utils, "_write_xcconfig_marketing_version", return_value=False
|
|
94
|
+
):
|
|
95
|
+
self.assertFalse(version_utils.write_marketing_version("0.2.0"))
|
|
96
|
+
|
|
97
|
+
self.assertIn("MARKETING_VERSION = 0.1.0;", self.pbxproj.read_text())
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
if __name__ == "__main__":
|
|
101
|
+
unittest.main()
|
|
@@ -269,9 +269,11 @@ def _write_pbxproj_marketing_version(pbx: Path, new_version: str) -> bool:
|
|
|
269
269
|
``MARKETING_VERSION = <new_version>;``. Returns True when at least
|
|
270
270
|
one substitution happened, False otherwise.
|
|
271
271
|
|
|
272
|
-
SAFETY: caller MUST have
|
|
273
|
-
|
|
274
|
-
|
|
272
|
+
SAFETY: caller MUST have written the xcodegen spec first when one
|
|
273
|
+
exists. On its own this edit is futile for a generated pbxproj --
|
|
274
|
+
the next ``xcodegen generate`` wipes it -- so with a spec present
|
|
275
|
+
it is only ever a *sync* of a value already persisted in the real
|
|
276
|
+
source of truth, never a substitute for writing that source."""
|
|
275
277
|
text = pbx.read_text(encoding="utf-8")
|
|
276
278
|
new_text, count = _MARKETING_LINE.subn(
|
|
277
279
|
rf"\g<1>MARKETING_VERSION = {new_version};", text,
|
|
@@ -361,6 +363,14 @@ def write_marketing_version(new_version: str) -> bool:
|
|
|
361
363
|
if (d / name).is_file()]
|
|
362
364
|
for spec in specs:
|
|
363
365
|
if _write_yaml_marketing_version(spec, new_version):
|
|
366
|
+
# Repos that commit BOTH the spec and the generated .xcodeproj
|
|
367
|
+
# archive from the pbxproj -- auto_detect only runs `xcodegen
|
|
368
|
+
# generate` when no .xcodeproj is present -- so the spec write
|
|
369
|
+
# alone would never reach xcodebuild. Sync the pbxproj too; a
|
|
370
|
+
# later regenerate produces the same value either way.
|
|
371
|
+
pbx = _resolve_pbxproj_path()
|
|
372
|
+
if pbx is not None:
|
|
373
|
+
_write_pbxproj_marketing_version(pbx, new_version)
|
|
364
374
|
return True
|
|
365
375
|
if _write_xcconfig_marketing_version(new_version):
|
|
366
376
|
return True
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.4
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
name:
|
|
1
|
+
name: Android build and signing
|
|
2
2
|
description: >
|
|
3
|
-
Auto-detect
|
|
4
|
-
the upload key stored under creds/. Google Play upload is
|
|
5
|
-
package workflow so the signed artifact is always retained
|
|
3
|
+
Auto-detect an Android app — Flutter or native Gradle — build a release AAB,
|
|
4
|
+
and sign it with the upload key stored under creds/. Google Play upload is
|
|
5
|
+
handled by the package workflow so the signed artifact is always retained
|
|
6
|
+
first.
|
|
6
7
|
|
|
7
8
|
inputs:
|
|
8
9
|
package-name:
|
|
@@ -17,11 +18,15 @@ inputs:
|
|
|
17
18
|
required: false
|
|
18
19
|
default: ""
|
|
19
20
|
build-name:
|
|
20
|
-
description:
|
|
21
|
+
description: >-
|
|
22
|
+
versionName override; defaults to pubspec.yaml (Flutter) or the value
|
|
23
|
+
declared in the module's build.gradle (native Gradle)
|
|
21
24
|
required: false
|
|
22
25
|
default: ""
|
|
23
26
|
run-tests:
|
|
24
|
-
description:
|
|
27
|
+
description: >-
|
|
28
|
+
Run the project's analyzer and unit tests before the release build
|
|
29
|
+
(flutter analyze + flutter test, or the Gradle module's unit tests)
|
|
25
30
|
required: false
|
|
26
31
|
default: "true"
|
|
27
32
|
|
|
@@ -38,6 +43,9 @@ outputs:
|
|
|
38
43
|
play-service-account:
|
|
39
44
|
description: Path to the discovered Google Play service-account JSON
|
|
40
45
|
value: ${{ steps.config.outputs.service_account }}
|
|
46
|
+
project-kind:
|
|
47
|
+
description: Detected build system — `flutter` or `gradle`
|
|
48
|
+
value: ${{ steps.config.outputs.project_kind }}
|
|
41
49
|
|
|
42
50
|
runs:
|
|
43
51
|
using: composite
|
|
@@ -55,21 +63,24 @@ runs:
|
|
|
55
63
|
env:
|
|
56
64
|
INPUT_PACKAGE_NAME: ${{ inputs.package-name }}
|
|
57
65
|
INPUT_BUILD_NUMBER: ${{ inputs.build-number }}
|
|
66
|
+
INPUT_BUILD_NAME: ${{ inputs.build-name }}
|
|
58
67
|
run: python3 "${{ github.action_path }}/scripts/resolve_android.py"
|
|
59
68
|
|
|
69
|
+
# --- Flutter --------------------------------------------------------
|
|
60
70
|
- name: Get Flutter dependencies
|
|
71
|
+
if: ${{ steps.config.outputs.project_kind == 'flutter' }}
|
|
61
72
|
shell: bash
|
|
62
73
|
run: flutter pub get
|
|
63
74
|
|
|
64
75
|
- name: Analyze Flutter app
|
|
65
|
-
if: ${{ inputs.run-tests == 'true' }}
|
|
76
|
+
if: ${{ steps.config.outputs.project_kind == 'flutter' && inputs.run-tests == 'true' }}
|
|
66
77
|
shell: bash
|
|
67
78
|
run: flutter analyze
|
|
68
79
|
|
|
69
80
|
# `flutter test` hard-fails with "Test directory \"test\" not found" when a repo
|
|
70
81
|
# has no tests, which is not a real failure. Skip instead of blowing up the deploy.
|
|
71
82
|
- name: Test Flutter app
|
|
72
|
-
if: ${{ inputs.run-tests == 'true' }}
|
|
83
|
+
if: ${{ steps.config.outputs.project_kind == 'flutter' && inputs.run-tests == 'true' }}
|
|
73
84
|
shell: bash
|
|
74
85
|
run: |
|
|
75
86
|
if [ -d test ]; then
|
|
@@ -79,6 +90,7 @@ runs:
|
|
|
79
90
|
fi
|
|
80
91
|
|
|
81
92
|
- name: Build release Android App Bundle
|
|
93
|
+
if: ${{ steps.config.outputs.project_kind == 'flutter' }}
|
|
82
94
|
shell: bash
|
|
83
95
|
env:
|
|
84
96
|
BUILD_NAME: ${{ inputs.build-name }}
|
|
@@ -93,6 +105,27 @@ runs:
|
|
|
93
105
|
fi
|
|
94
106
|
flutter "${args[@]}"
|
|
95
107
|
|
|
108
|
+
# --- Native Gradle --------------------------------------------------
|
|
109
|
+
# resolve_android.py has already rewritten versionCode/versionName in the
|
|
110
|
+
# module build file, so these are plain Gradle invocations with no extra
|
|
111
|
+
# properties for the project to have to opt into reading.
|
|
112
|
+
- name: Test Android app
|
|
113
|
+
if: ${{ steps.config.outputs.project_kind == 'gradle' && inputs.run-tests == 'true' }}
|
|
114
|
+
shell: bash
|
|
115
|
+
working-directory: ${{ steps.config.outputs.gradle_root || '.' }}
|
|
116
|
+
run: |
|
|
117
|
+
chmod +x ./gradlew
|
|
118
|
+
./gradlew "${ANDROID_GRADLE_MODULE}:testReleaseUnitTest" --console=plain --stacktrace
|
|
119
|
+
|
|
120
|
+
- name: Build release Android App Bundle with Gradle
|
|
121
|
+
if: ${{ steps.config.outputs.project_kind == 'gradle' }}
|
|
122
|
+
shell: bash
|
|
123
|
+
working-directory: ${{ steps.config.outputs.gradle_root || '.' }}
|
|
124
|
+
run: |
|
|
125
|
+
chmod +x ./gradlew
|
|
126
|
+
./gradlew "${ANDROID_GRADLE_MODULE}:bundleRelease" --console=plain --stacktrace
|
|
127
|
+
|
|
128
|
+
# --- Shared ---------------------------------------------------------
|
|
96
129
|
- name: Locate Android App Bundle
|
|
97
130
|
id: bundle
|
|
98
131
|
shell: bash
|
|
@@ -22,6 +22,28 @@ class SigningConfig:
|
|
|
22
22
|
key_alias: str
|
|
23
23
|
|
|
24
24
|
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class GradleProject:
|
|
27
|
+
"""A native (non-Flutter) Android project and its application module.
|
|
28
|
+
|
|
29
|
+
``root`` is the directory holding ``settings.gradle(.kts)`` and ``gradlew``
|
|
30
|
+
— the repo root for a standalone Android repo, ``android/`` for a repo that
|
|
31
|
+
keeps the Android app beside another platform's sources.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
root: Path
|
|
35
|
+
module: str
|
|
36
|
+
build_file: Path
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def module_dir(self) -> Path:
|
|
40
|
+
return self.build_file.parent
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def gradlew(self) -> Path:
|
|
44
|
+
return self.root / "gradlew"
|
|
45
|
+
|
|
46
|
+
|
|
25
47
|
def parse_properties(path: Path) -> dict[str, str]:
|
|
26
48
|
values: dict[str, str] = {}
|
|
27
49
|
for line_number, raw_line in enumerate(path.read_text().splitlines(), start=1):
|
|
@@ -97,34 +119,188 @@ def find_play_service_account(workspace: Path) -> Path:
|
|
|
97
119
|
return _single(matches, "Google Play service-account JSON")
|
|
98
120
|
|
|
99
121
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
122
|
+
APPLICATION_ID_PATTERNS = (
|
|
123
|
+
re.compile(r"\bapplicationId\s*=\s*[\"']([^\"']+)[\"']"),
|
|
124
|
+
re.compile(r"\bapplicationId\s+[\"']([^\"']+)[\"']"),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
SETTINGS_FILES = ("settings.gradle.kts", "settings.gradle")
|
|
128
|
+
BUILD_FILES = ("build.gradle.kts", "build.gradle")
|
|
129
|
+
|
|
130
|
+
# Where the Android Gradle build can live relative to the repo root. A repo
|
|
131
|
+
# that is nothing but an Android app puts it at the root; a repo that also
|
|
132
|
+
# holds an iOS app (or any other platform) conventionally nests it in android/.
|
|
133
|
+
GRADLE_ROOT_CANDIDATES = (".", "android")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def project_kind(workspace: Path) -> str:
|
|
137
|
+
"""``flutter`` when a pubspec.yaml drives the build, else ``gradle``.
|
|
138
|
+
|
|
139
|
+
Flutter wins the tie deliberately: a Flutter app also carries
|
|
140
|
+
``android/settings.gradle``, but it must be built through the Flutter tool,
|
|
141
|
+
not by invoking Gradle directly.
|
|
142
|
+
"""
|
|
143
|
+
return "flutter" if (workspace / "pubspec.yaml").is_file() else "gradle"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _first_existing(directory: Path, names: tuple[str, ...]) -> Path | None:
|
|
147
|
+
for name in names:
|
|
148
|
+
candidate = directory / name
|
|
149
|
+
if candidate.is_file():
|
|
150
|
+
return candidate
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def find_gradle_root(workspace: Path) -> Path:
|
|
155
|
+
for relative in GRADLE_ROOT_CANDIDATES:
|
|
156
|
+
candidate = (workspace / relative).resolve()
|
|
157
|
+
if _first_existing(candidate, SETTINGS_FILES) and (candidate / "gradlew").is_file():
|
|
158
|
+
return candidate
|
|
159
|
+
raise ConfigError(
|
|
160
|
+
"no Gradle build found: expected settings.gradle(.kts) and gradlew "
|
|
161
|
+
"at the repo root or under android/"
|
|
104
162
|
)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _catalog_application_aliases(gradle_root: Path) -> set[str]:
|
|
166
|
+
"""Version-catalog plugin aliases that resolve to com.android.application.
|
|
167
|
+
|
|
168
|
+
Modules increasingly declare the plugin as ``alias(libs.plugins.foo)``,
|
|
169
|
+
which says nothing about the plugin id on its own — that lives in
|
|
170
|
+
``gradle/libs.versions.toml``. Without resolving it we cannot tell the
|
|
171
|
+
application module from a library module.
|
|
172
|
+
"""
|
|
173
|
+
catalog = gradle_root / "gradle" / "libs.versions.toml"
|
|
174
|
+
if not catalog.is_file():
|
|
175
|
+
return set()
|
|
176
|
+
aliases: set[str] = set()
|
|
177
|
+
in_plugins = False
|
|
178
|
+
for raw_line in catalog.read_text().splitlines():
|
|
179
|
+
line = raw_line.strip()
|
|
180
|
+
if line.startswith("["):
|
|
181
|
+
in_plugins = line.replace(" ", "") == "[plugins]"
|
|
182
|
+
continue
|
|
183
|
+
if not in_plugins or "=" not in line:
|
|
184
|
+
continue
|
|
185
|
+
alias, _, definition = line.partition("=")
|
|
186
|
+
if re.search(r"""id\s*=\s*["']com\.android\.application["']""", definition):
|
|
187
|
+
# Gradle exposes `my-plugin` / `my_plugin` as `libs.plugins.my.plugin`.
|
|
188
|
+
aliases.add(re.sub(r"[-_]", ".", alias.strip()))
|
|
189
|
+
return aliases
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _declares_application_plugin(build_file: Path, aliases: set[str]) -> bool:
|
|
193
|
+
for raw_line in build_file.read_text().splitlines():
|
|
194
|
+
line = raw_line.split("//", 1)[0]
|
|
195
|
+
# `apply false` in a root build file only declares the plugin's version
|
|
196
|
+
# for subprojects; it does not make that project an Android application.
|
|
197
|
+
if "apply false" in line:
|
|
198
|
+
continue
|
|
199
|
+
if "com.android.application" in line:
|
|
200
|
+
return True
|
|
201
|
+
match = re.search(r"alias\s*\(\s*libs\.plugins\.([A-Za-z0-9_.]+)\s*\)", line)
|
|
202
|
+
if match and match.group(1) in aliases:
|
|
203
|
+
return True
|
|
204
|
+
return False
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _module_candidates(gradle_root: Path) -> list[Path]:
|
|
208
|
+
directories = sorted(
|
|
209
|
+
path
|
|
210
|
+
for path in gradle_root.iterdir()
|
|
211
|
+
if path.is_dir() and not path.name.startswith(".") and path.name != "buildSrc"
|
|
108
212
|
)
|
|
213
|
+
return [path for path in directories if _first_existing(path, BUILD_FILES)]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def find_gradle_project(workspace: Path) -> GradleProject:
|
|
217
|
+
root = find_gradle_root(workspace)
|
|
218
|
+
aliases = _catalog_application_aliases(root)
|
|
219
|
+
matches: list[Path] = []
|
|
220
|
+
for directory in _module_candidates(root):
|
|
221
|
+
build_file = _first_existing(directory, BUILD_FILES)
|
|
222
|
+
if build_file and _declares_application_plugin(build_file, aliases):
|
|
223
|
+
matches.append(build_file)
|
|
224
|
+
if not matches:
|
|
225
|
+
raise ConfigError(
|
|
226
|
+
f"no module under {root.name}/ applies com.android.application; "
|
|
227
|
+
"cannot tell which module is the app"
|
|
228
|
+
)
|
|
229
|
+
if len(matches) > 1:
|
|
230
|
+
preferred = [path for path in matches if path.parent.name == "app"]
|
|
231
|
+
if len(preferred) != 1:
|
|
232
|
+
names = ", ".join(sorted(path.parent.name for path in matches))
|
|
233
|
+
raise ConfigError(f"multiple Android application modules found: {names}")
|
|
234
|
+
matches = preferred
|
|
235
|
+
build_file = matches[0].resolve()
|
|
236
|
+
return GradleProject(
|
|
237
|
+
root=root, module=f":{build_file.parent.name}", build_file=build_file
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def detect_package_name(workspace: Path, build_file: Path | None = None) -> str:
|
|
242
|
+
if build_file is not None:
|
|
243
|
+
gradle_files: tuple[Path, ...] = (build_file,)
|
|
244
|
+
manifest = build_file.parent / "src" / "main" / "AndroidManifest.xml"
|
|
245
|
+
else:
|
|
246
|
+
gradle_files = (
|
|
247
|
+
workspace / "android" / "app" / "build.gradle.kts",
|
|
248
|
+
workspace / "android" / "app" / "build.gradle",
|
|
249
|
+
)
|
|
250
|
+
manifest = workspace / "android" / "app" / "src" / "main" / "AndroidManifest.xml"
|
|
109
251
|
for path in gradle_files:
|
|
110
252
|
if not path.is_file():
|
|
111
253
|
continue
|
|
112
254
|
text = path.read_text()
|
|
113
|
-
for pattern in
|
|
255
|
+
for pattern in APPLICATION_ID_PATTERNS:
|
|
114
256
|
match = pattern.search(text)
|
|
115
257
|
if match:
|
|
116
258
|
return match.group(1)
|
|
117
259
|
|
|
118
|
-
manifest = workspace / "android" / "app" / "src" / "main" / "AndroidManifest.xml"
|
|
119
260
|
if manifest.is_file():
|
|
120
261
|
match = re.search(r"\bpackage\s*=\s*[\"']([^\"']+)[\"']", manifest.read_text())
|
|
121
262
|
if match:
|
|
122
263
|
return match.group(1)
|
|
123
|
-
raise ConfigError("cannot detect Android applicationId from
|
|
264
|
+
raise ConfigError(f"cannot detect Android applicationId from {gradle_files[0].parent}")
|
|
124
265
|
|
|
125
266
|
|
|
126
|
-
def detect_bundle(workspace: Path) -> Path:
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
267
|
+
def detect_bundle(workspace: Path, search_root: Path | None = None) -> Path:
|
|
268
|
+
# Flutter collects every module's outputs under build/app/; a plain Gradle
|
|
269
|
+
# build leaves them in the module's own build/outputs/bundle/.
|
|
270
|
+
base = search_root if search_root is not None else workspace / "build" / "app"
|
|
271
|
+
candidates = sorted((base / "outputs" / "bundle").glob("**/*.aab"))
|
|
130
272
|
return _single(candidates, "release Android App Bundle")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
VERSION_CODE_RE = re.compile(r"(?m)^(?P<pre>\s*versionCode\s*=?\s*)(?P<value>\d+)")
|
|
276
|
+
VERSION_NAME_RE = re.compile(
|
|
277
|
+
r"(?m)^(?P<pre>\s*versionName\s*=?\s*)(?P<quote>[\"'])(?P<value>[^\"']*)(?P=quote)"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def set_gradle_version(
|
|
282
|
+
build_file: Path, version_code: int, version_name: str | None = None
|
|
283
|
+
) -> dict[str, bool]:
|
|
284
|
+
"""Pin versionCode (and optionally versionName) in a module build file.
|
|
285
|
+
|
|
286
|
+
Gradle has no equivalent of `flutter build --build-number`, and a literal
|
|
287
|
+
`versionCode = 1` checked into the repo means every run after the first
|
|
288
|
+
is rejected by Play as a duplicate. Rewriting the literal in the checkout
|
|
289
|
+
is the Gradle counterpart to the iOS action patching the pbxproj.
|
|
290
|
+
"""
|
|
291
|
+
original = build_file.read_text()
|
|
292
|
+
patched, code_hits = VERSION_CODE_RE.subn(
|
|
293
|
+
lambda match: f"{match.group('pre')}{version_code}", original
|
|
294
|
+
)
|
|
295
|
+
name_hits = 0
|
|
296
|
+
if version_name:
|
|
297
|
+
patched, name_hits = VERSION_NAME_RE.subn(
|
|
298
|
+
lambda match: (
|
|
299
|
+
f"{match.group('pre')}{match.group('quote')}"
|
|
300
|
+
f"{version_name}{match.group('quote')}"
|
|
301
|
+
),
|
|
302
|
+
patched,
|
|
303
|
+
)
|
|
304
|
+
if patched != original:
|
|
305
|
+
build_file.write_text(patched)
|
|
306
|
+
return {"version_code": bool(code_hits), "version_name": bool(name_hits)}
|
|
@@ -9,10 +9,19 @@ from pathlib import Path
|
|
|
9
9
|
from android_config import ConfigError, detect_bundle
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
def gradle_module_dir() -> Path | None:
|
|
13
|
+
"""The Gradle application module's directory, when this is a native build."""
|
|
14
|
+
root = os.environ.get("ANDROID_GRADLE_ROOT", "")
|
|
15
|
+
module = os.environ.get("ANDROID_GRADLE_MODULE", "")
|
|
16
|
+
if not root or not module:
|
|
17
|
+
return None
|
|
18
|
+
return Path(root) / module.lstrip(":").replace(":", "/") / "build"
|
|
19
|
+
|
|
20
|
+
|
|
12
21
|
def main() -> None:
|
|
13
22
|
workspace = Path(os.environ.get("GITHUB_WORKSPACE", ".")).resolve()
|
|
14
23
|
try:
|
|
15
|
-
bundle = detect_bundle(workspace).resolve()
|
|
24
|
+
bundle = detect_bundle(workspace, gradle_module_dir()).resolve()
|
|
16
25
|
except ConfigError as exc:
|
|
17
26
|
print(f"::error::{exc}")
|
|
18
27
|
raise SystemExit(1) from exc
|
|
@@ -10,8 +10,11 @@ from pathlib import Path
|
|
|
10
10
|
from android_config import (
|
|
11
11
|
ConfigError,
|
|
12
12
|
detect_package_name,
|
|
13
|
+
find_gradle_project,
|
|
13
14
|
find_play_service_account,
|
|
14
15
|
find_signing_config,
|
|
16
|
+
project_kind,
|
|
17
|
+
set_gradle_version,
|
|
15
18
|
)
|
|
16
19
|
|
|
17
20
|
|
|
@@ -100,21 +103,54 @@ def auto_build_number(package_name: str, service_account: Path) -> int:
|
|
|
100
103
|
return candidate
|
|
101
104
|
|
|
102
105
|
|
|
106
|
+
def apply_gradle_version(project, build_number: int, build_name: str) -> None:
|
|
107
|
+
"""Write the resolved versionCode/versionName into the Gradle module.
|
|
108
|
+
|
|
109
|
+
A miss is a warning rather than an error: the declared versionCode is still
|
|
110
|
+
valid for a first upload, and Play rejects a genuine duplicate with a far
|
|
111
|
+
clearer message than anything this script could guess at here.
|
|
112
|
+
"""
|
|
113
|
+
applied = set_gradle_version(project.build_file, build_number, build_name or None)
|
|
114
|
+
relative = project.build_file.name
|
|
115
|
+
if applied["version_code"]:
|
|
116
|
+
print(f"Pinned versionCode {build_number} in {project.module}/{relative}")
|
|
117
|
+
else:
|
|
118
|
+
print(
|
|
119
|
+
f"::warning::No literal versionCode found in {project.module}/{relative}; "
|
|
120
|
+
f"leaving the project's own value in place"
|
|
121
|
+
)
|
|
122
|
+
if build_name and not applied["version_name"]:
|
|
123
|
+
print(
|
|
124
|
+
f"::warning::No literal versionName found in {project.module}/{relative}; "
|
|
125
|
+
f"build-name {build_name} was not applied"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
103
129
|
def main() -> None:
|
|
104
130
|
workspace = Path(os.environ.get("GITHUB_WORKSPACE", ".")).resolve()
|
|
105
131
|
env_path = Path(os.environ["GITHUB_ENV"])
|
|
106
132
|
output_path = Path(os.environ["GITHUB_OUTPUT"])
|
|
133
|
+
gradle_root = ""
|
|
134
|
+
gradle_module = ""
|
|
107
135
|
try:
|
|
108
|
-
|
|
109
|
-
|
|
136
|
+
kind = project_kind(workspace)
|
|
137
|
+
project = None if kind == "flutter" else find_gradle_project(workspace)
|
|
110
138
|
signing = find_signing_config(workspace)
|
|
111
139
|
service_account = find_play_service_account(workspace)
|
|
112
|
-
package_name = os.environ.get("INPUT_PACKAGE_NAME") or detect_package_name(
|
|
140
|
+
package_name = os.environ.get("INPUT_PACKAGE_NAME") or detect_package_name(
|
|
141
|
+
workspace, project.build_file if project else None
|
|
142
|
+
)
|
|
113
143
|
pinned = os.environ.get("INPUT_BUILD_NUMBER") or ""
|
|
114
144
|
if pinned:
|
|
115
145
|
build_number = resolve_build_number(pinned)
|
|
116
146
|
else:
|
|
117
147
|
build_number = auto_build_number(package_name, service_account)
|
|
148
|
+
if project is not None:
|
|
149
|
+
gradle_root = str(project.root)
|
|
150
|
+
gradle_module = project.module
|
|
151
|
+
apply_gradle_version(
|
|
152
|
+
project, build_number, os.environ.get("INPUT_BUILD_NAME") or ""
|
|
153
|
+
)
|
|
118
154
|
except (ConfigError, KeyError, OSError) as exc:
|
|
119
155
|
print(f"::error::{exc}")
|
|
120
156
|
raise SystemExit(1) from exc
|
|
@@ -127,6 +163,9 @@ def main() -> None:
|
|
|
127
163
|
"ANDROID_SIGNING_PROPERTIES": str(signing.properties_path),
|
|
128
164
|
"ANDROID_KEYSTORE_PATH": str(signing.keystore_path),
|
|
129
165
|
"ANDROID_PLAY_SERVICE_ACCOUNT": str(service_account),
|
|
166
|
+
"ANDROID_PROJECT_KIND": kind,
|
|
167
|
+
"ANDROID_GRADLE_ROOT": gradle_root,
|
|
168
|
+
"ANDROID_GRADLE_MODULE": gradle_module,
|
|
130
169
|
}
|
|
131
170
|
for name, value in values.items():
|
|
132
171
|
append_value(env_path, name, value)
|
|
@@ -134,9 +173,12 @@ def main() -> None:
|
|
|
134
173
|
("package_name", package_name),
|
|
135
174
|
("service_account", str(service_account)),
|
|
136
175
|
("build_number", str(build_number)),
|
|
176
|
+
("project_kind", kind),
|
|
177
|
+
("gradle_root", gradle_root),
|
|
178
|
+
("gradle_module", gradle_module),
|
|
137
179
|
):
|
|
138
180
|
append_value(output_path, name, value)
|
|
139
|
-
print(f"Resolved Android package {package_name}, build {build_number}")
|
|
181
|
+
print(f"Resolved {kind} Android package {package_name}, build {build_number}")
|
|
140
182
|
|
|
141
183
|
|
|
142
184
|
if __name__ == "__main__":
|
|
@@ -13,8 +13,11 @@ from android_config import (
|
|
|
13
13
|
ConfigError,
|
|
14
14
|
detect_bundle,
|
|
15
15
|
detect_package_name,
|
|
16
|
+
find_gradle_project,
|
|
16
17
|
find_play_service_account,
|
|
17
18
|
find_signing_config,
|
|
19
|
+
project_kind,
|
|
20
|
+
set_gradle_version,
|
|
18
21
|
)
|
|
19
22
|
|
|
20
23
|
|
|
@@ -95,6 +98,163 @@ class AndroidConfigTest(unittest.TestCase):
|
|
|
95
98
|
self.assertEqual(detect_bundle(self.workspace), bundle)
|
|
96
99
|
|
|
97
100
|
|
|
101
|
+
class NativeGradleProjectTest(unittest.TestCase):
|
|
102
|
+
"""Native Kotlin/Gradle apps ship through the same action as Flutter ones."""
|
|
103
|
+
|
|
104
|
+
def setUp(self) -> None:
|
|
105
|
+
self.temp = tempfile.TemporaryDirectory()
|
|
106
|
+
self.workspace = Path(self.temp.name)
|
|
107
|
+
|
|
108
|
+
def tearDown(self) -> None:
|
|
109
|
+
self.temp.cleanup()
|
|
110
|
+
|
|
111
|
+
def scaffold(self, base: str = "android", *, alias: bool = False) -> Path:
|
|
112
|
+
root = self.workspace / base if base != "." else self.workspace
|
|
113
|
+
(root / "app").mkdir(parents=True)
|
|
114
|
+
(root / "settings.gradle.kts").write_text('include(":app")')
|
|
115
|
+
(root / "gradlew").write_text("#!/bin/sh\n")
|
|
116
|
+
(root / "build.gradle.kts").write_text(
|
|
117
|
+
"plugins { alias(libs.plugins.android.application) apply false }"
|
|
118
|
+
)
|
|
119
|
+
if alias:
|
|
120
|
+
(root / "gradle").mkdir()
|
|
121
|
+
(root / "gradle" / "libs.versions.toml").write_text(
|
|
122
|
+
'[plugins]\nandroid-application = { id = "com.android.application" }\n'
|
|
123
|
+
)
|
|
124
|
+
plugins = "plugins { alias(libs.plugins.android.application) }"
|
|
125
|
+
else:
|
|
126
|
+
plugins = 'plugins { id("com.android.application") }'
|
|
127
|
+
(root / "app" / "build.gradle.kts").write_text(
|
|
128
|
+
f"{plugins}\n"
|
|
129
|
+
"android {\n"
|
|
130
|
+
" defaultConfig {\n"
|
|
131
|
+
' applicationId = "com.gowalk.form"\n'
|
|
132
|
+
" versionCode = 1\n"
|
|
133
|
+
' versionName = "1.0.0"\n'
|
|
134
|
+
" }\n"
|
|
135
|
+
"}\n"
|
|
136
|
+
)
|
|
137
|
+
return root
|
|
138
|
+
|
|
139
|
+
def test_flutter_wins_over_a_nested_gradle_build(self) -> None:
|
|
140
|
+
self.scaffold()
|
|
141
|
+
self.assertEqual(project_kind(self.workspace), "gradle")
|
|
142
|
+
(self.workspace / "pubspec.yaml").write_text("name: app\n")
|
|
143
|
+
self.assertEqual(project_kind(self.workspace), "flutter")
|
|
144
|
+
|
|
145
|
+
def test_finds_app_module_under_android(self) -> None:
|
|
146
|
+
root = self.scaffold()
|
|
147
|
+
|
|
148
|
+
project = find_gradle_project(self.workspace)
|
|
149
|
+
|
|
150
|
+
self.assertEqual(project.root, root.resolve())
|
|
151
|
+
self.assertEqual(project.module, ":app")
|
|
152
|
+
self.assertEqual(project.build_file, (root / "app" / "build.gradle.kts").resolve())
|
|
153
|
+
|
|
154
|
+
def test_finds_app_module_at_the_repo_root(self) -> None:
|
|
155
|
+
self.scaffold(".")
|
|
156
|
+
|
|
157
|
+
self.assertEqual(find_gradle_project(self.workspace).module, ":app")
|
|
158
|
+
|
|
159
|
+
def test_resolves_version_catalog_plugin_aliases(self) -> None:
|
|
160
|
+
# `alias(libs.plugins.android.application)` says nothing about the plugin
|
|
161
|
+
# id without the catalog, and the root build file declares the same alias
|
|
162
|
+
# with `apply false`.
|
|
163
|
+
self.scaffold(alias=True)
|
|
164
|
+
|
|
165
|
+
self.assertEqual(find_gradle_project(self.workspace).module, ":app")
|
|
166
|
+
|
|
167
|
+
def test_ignores_library_modules(self) -> None:
|
|
168
|
+
root = self.scaffold()
|
|
169
|
+
(root / "network").mkdir()
|
|
170
|
+
(root / "network" / "build.gradle.kts").write_text(
|
|
171
|
+
'plugins { id("com.android.library") }'
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
self.assertEqual(find_gradle_project(self.workspace).module, ":app")
|
|
175
|
+
|
|
176
|
+
def test_prefers_the_app_module_over_a_sibling_application(self) -> None:
|
|
177
|
+
# A wear/automotive companion is also `com.android.application`; the
|
|
178
|
+
# module named `app` is the one that ships to Play.
|
|
179
|
+
root = self.scaffold()
|
|
180
|
+
(root / "wear").mkdir()
|
|
181
|
+
(root / "wear" / "build.gradle.kts").write_text(
|
|
182
|
+
'plugins { id("com.android.application") }'
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
self.assertEqual(find_gradle_project(self.workspace).module, ":app")
|
|
186
|
+
|
|
187
|
+
def test_rejects_ambiguous_application_modules(self) -> None:
|
|
188
|
+
root = self.scaffold()
|
|
189
|
+
(root / "app").rename(root / "phone")
|
|
190
|
+
(root / "wear").mkdir()
|
|
191
|
+
(root / "wear" / "build.gradle.kts").write_text(
|
|
192
|
+
'plugins { id("com.android.application") }'
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
with self.assertRaisesRegex(ConfigError, "multiple Android application"):
|
|
196
|
+
find_gradle_project(self.workspace)
|
|
197
|
+
|
|
198
|
+
def test_reports_a_missing_gradle_build(self) -> None:
|
|
199
|
+
with self.assertRaisesRegex(ConfigError, "no Gradle build found"):
|
|
200
|
+
find_gradle_project(self.workspace)
|
|
201
|
+
|
|
202
|
+
def test_detects_package_name_from_the_resolved_module(self) -> None:
|
|
203
|
+
project = find_gradle_project(self.scaffold("."))
|
|
204
|
+
|
|
205
|
+
self.assertEqual(
|
|
206
|
+
detect_package_name(self.workspace, project.build_file), "com.gowalk.form"
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def test_detects_bundle_in_the_gradle_module_output(self) -> None:
|
|
210
|
+
root = self.scaffold()
|
|
211
|
+
bundle = root / "app" / "build" / "outputs" / "bundle" / "release" / "app.aab"
|
|
212
|
+
bundle.parent.mkdir(parents=True)
|
|
213
|
+
bundle.write_bytes(b"bundle")
|
|
214
|
+
|
|
215
|
+
self.assertEqual(
|
|
216
|
+
detect_bundle(self.workspace, root / "app" / "build"), bundle
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def test_pins_version_code_and_name(self) -> None:
|
|
220
|
+
project = find_gradle_project(self.scaffold())
|
|
221
|
+
|
|
222
|
+
applied = set_gradle_version(project.build_file, 214, "1.2.3")
|
|
223
|
+
|
|
224
|
+
self.assertEqual(applied, {"version_code": True, "version_name": True})
|
|
225
|
+
text = project.build_file.read_text()
|
|
226
|
+
self.assertIn("versionCode = 214", text)
|
|
227
|
+
self.assertIn('versionName = "1.2.3"', text)
|
|
228
|
+
|
|
229
|
+
def test_leaves_version_name_alone_when_not_overridden(self) -> None:
|
|
230
|
+
project = find_gradle_project(self.scaffold())
|
|
231
|
+
|
|
232
|
+
applied = set_gradle_version(project.build_file, 9)
|
|
233
|
+
|
|
234
|
+
self.assertFalse(applied["version_name"])
|
|
235
|
+
self.assertIn('versionName = "1.0.0"', project.build_file.read_text())
|
|
236
|
+
|
|
237
|
+
def test_reports_a_non_literal_version_code_instead_of_guessing(self) -> None:
|
|
238
|
+
project = find_gradle_project(self.scaffold())
|
|
239
|
+
project.build_file.write_text(
|
|
240
|
+
"android { defaultConfig { versionCode = computeVersionCode() } }"
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
applied = set_gradle_version(project.build_file, 12)
|
|
244
|
+
|
|
245
|
+
self.assertFalse(applied["version_code"])
|
|
246
|
+
self.assertIn("computeVersionCode()", project.build_file.read_text())
|
|
247
|
+
|
|
248
|
+
def test_groovy_version_code_syntax(self) -> None:
|
|
249
|
+
project = find_gradle_project(self.scaffold())
|
|
250
|
+
project.build_file.write_text(
|
|
251
|
+
"android {\n defaultConfig {\n versionCode 7\n }\n}\n"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
self.assertTrue(set_gradle_version(project.build_file, 88)["version_code"])
|
|
255
|
+
self.assertIn("versionCode 88", project.build_file.read_text())
|
|
256
|
+
|
|
257
|
+
|
|
98
258
|
if __name__ == "__main__":
|
|
99
259
|
unittest.main()
|
|
100
260
|
|
package/package.json
CHANGED
package/templates/deploy.yml
CHANGED
|
@@ -110,15 +110,27 @@ jobs:
|
|
|
110
110
|
run: python3 .github/actions/android-app/scripts/bitrise_deploy.py
|
|
111
111
|
|
|
112
112
|
# --- Local mode -----------------------------------------------------
|
|
113
|
+
# Native Gradle apps must not pay for a Flutter install, and their AGP
|
|
114
|
+
# version generally wants a newer JDK than a Flutter app's does.
|
|
115
|
+
- name: Detect Flutter
|
|
116
|
+
id: flutter
|
|
117
|
+
if: ${{ steps.mode.outputs.mode == 'local' }}
|
|
118
|
+
shell: bash
|
|
119
|
+
run: |
|
|
120
|
+
if [ -f pubspec.yaml ]; then
|
|
121
|
+
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
|
122
|
+
else
|
|
123
|
+
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
|
124
|
+
fi
|
|
113
125
|
- name: Set up Java
|
|
114
126
|
if: ${{ steps.mode.outputs.mode == 'local' }}
|
|
115
127
|
uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5
|
|
116
128
|
with:
|
|
117
129
|
distribution: temurin
|
|
118
|
-
java-version: '17'
|
|
130
|
+
java-version: ${{ steps.flutter.outputs.enabled == 'true' && '17' || '21' }}
|
|
119
131
|
cache: gradle
|
|
120
132
|
- name: Set up Flutter
|
|
121
|
-
if: ${{ steps.mode.outputs.mode == 'local' }}
|
|
133
|
+
if: ${{ steps.mode.outputs.mode == 'local' && steps.flutter.outputs.enabled == 'true' }}
|
|
122
134
|
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
|
|
123
135
|
with:
|
|
124
136
|
channel: stable
|
|
@@ -126,7 +138,7 @@ jobs:
|
|
|
126
138
|
# Same reason as the iOS job: apps using AppLocalizations must generate it
|
|
127
139
|
# before anything analyses or compiles them. No-op for apps without l10n.
|
|
128
140
|
- name: Generate localizations
|
|
129
|
-
if: ${{ steps.mode.outputs.mode == 'local' }}
|
|
141
|
+
if: ${{ steps.mode.outputs.mode == 'local' && steps.flutter.outputs.enabled == 'true' }}
|
|
130
142
|
shell: bash
|
|
131
143
|
run: |
|
|
132
144
|
if [ -f l10n.yaml ]; then
|