gowalk-cicd 1.0.2 → 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 CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
  ## Purpose
4
4
 
5
- npm package that distributes iOS TestFlight and Flutter Android Google Play
6
- actions. Consumer runs `npx gowalk-cicd` and gets:
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 Android action
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 and Flutter apps. Put the platform keys under
5
- `creds/`, push to `main`, and both store builds run only on GitHub Actions.
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/` — Flutter Android build/sign action
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. Flutter is installed only on
133
- the GitHub-hosted runners. The Android job runs `flutter analyze`,
134
- `flutter test`, builds an AAB with `GITHUB_RUN_NUMBER` as its monotonically
135
- increasing version code, replaces any development signature with the upload
136
- key, and retains the signed bundle before contacting Google Play.
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
 
@@ -1 +1 @@
1
- 1.0.2
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 ruled out xcodegen first. Editing a
273
- generated pbxproj is futile when ``project.yml`` is the source of
274
- truth -- the next ``xcodegen generate`` would wipe the bump."""
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.2
1
+ 1.0.4
@@ -1,8 +1,9 @@
1
- name: Flutter Android build and signing
1
+ name: Android build and signing
2
2
  description: >
3
- Auto-detect a Flutter Android app, build a release AAB, and sign it with
4
- the upload key stored under creds/. Google Play upload is handled by the
5
- package workflow so the signed artifact is always retained first.
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:
@@ -10,15 +11,22 @@ inputs:
10
11
  required: false
11
12
  default: ""
12
13
  build-number:
13
- description: Android versionCode override; defaults to GITHUB_RUN_NUMBER
14
+ description: >-
15
+ Android versionCode override. When unset, the action uses
16
+ max(GITHUB_RUN_NUMBER, highest versionCode already on Play + 1), so a repo whose
17
+ CI run numbers start at 1 cannot collide with codes Play already holds.
14
18
  required: false
15
19
  default: ""
16
20
  build-name:
17
- description: Flutter versionName override; defaults to pubspec.yaml
21
+ description: >-
22
+ versionName override; defaults to pubspec.yaml (Flutter) or the value
23
+ declared in the module's build.gradle (native Gradle)
18
24
  required: false
19
25
  default: ""
20
26
  run-tests:
21
- description: Run flutter analyze and flutter test before the release build
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)
22
30
  required: false
23
31
  default: "true"
24
32
 
@@ -35,33 +43,54 @@ outputs:
35
43
  play-service-account:
36
44
  description: Path to the discovered Google Play service-account JSON
37
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 }}
38
49
 
39
50
  runs:
40
51
  using: composite
41
52
  steps:
53
+ # resolve_android.py asks Google Play for the highest versionCode already
54
+ # uploaded, so it needs these. Installing them here (rather than relying on the
55
+ # caller) keeps the action self-contained.
56
+ - name: Install Play API dependencies
57
+ shell: bash
58
+ run: python3 -m pip install --disable-pip-version-check -q 'google-auth>=2.40,<3' 'requests>=2.32,<3' || true
59
+
42
60
  - name: Resolve Android app and credentials
43
61
  id: config
44
62
  shell: bash
45
63
  env:
46
64
  INPUT_PACKAGE_NAME: ${{ inputs.package-name }}
47
65
  INPUT_BUILD_NUMBER: ${{ inputs.build-number }}
66
+ INPUT_BUILD_NAME: ${{ inputs.build-name }}
48
67
  run: python3 "${{ github.action_path }}/scripts/resolve_android.py"
49
68
 
69
+ # --- Flutter --------------------------------------------------------
50
70
  - name: Get Flutter dependencies
71
+ if: ${{ steps.config.outputs.project_kind == 'flutter' }}
51
72
  shell: bash
52
73
  run: flutter pub get
53
74
 
54
75
  - name: Analyze Flutter app
55
- if: ${{ inputs.run-tests == 'true' }}
76
+ if: ${{ steps.config.outputs.project_kind == 'flutter' && inputs.run-tests == 'true' }}
56
77
  shell: bash
57
78
  run: flutter analyze
58
79
 
80
+ # `flutter test` hard-fails with "Test directory \"test\" not found" when a repo
81
+ # has no tests, which is not a real failure. Skip instead of blowing up the deploy.
59
82
  - name: Test Flutter app
60
- if: ${{ inputs.run-tests == 'true' }}
83
+ if: ${{ steps.config.outputs.project_kind == 'flutter' && inputs.run-tests == 'true' }}
61
84
  shell: bash
62
- run: flutter test
85
+ run: |
86
+ if [ -d test ]; then
87
+ flutter test
88
+ else
89
+ echo "No test/ directory; skipping flutter test."
90
+ fi
63
91
 
64
92
  - name: Build release Android App Bundle
93
+ if: ${{ steps.config.outputs.project_kind == 'flutter' }}
65
94
  shell: bash
66
95
  env:
67
96
  BUILD_NAME: ${{ inputs.build-name }}
@@ -76,6 +105,27 @@ runs:
76
105
  fi
77
106
  flutter "${args[@]}"
78
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 ---------------------------------------------------------
79
129
  - name: Locate Android App Bundle
80
130
  id: bundle
81
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
- def detect_package_name(workspace: Path) -> str:
101
- gradle_files = (
102
- workspace / "android" / "app" / "build.gradle.kts",
103
- workspace / "android" / "app" / "build.gradle",
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
- patterns = (
106
- re.compile(r"\bapplicationId\s*=\s*[\"']([^\"']+)[\"']"),
107
- re.compile(r"\bapplicationId\s+[\"']([^\"']+)[\"']"),
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 patterns:
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 android/app")
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
- candidates = sorted(
128
- (workspace / "build" / "app" / "outputs" / "bundle").glob("**/*.aab")
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
 
@@ -30,18 +33,124 @@ def resolve_build_number(raw: str) -> int:
30
33
  return number
31
34
 
32
35
 
36
+ def highest_play_version_code(package_name: str, service_account: Path) -> int | None:
37
+ """Highest versionCode Google Play already holds for this package, or None.
38
+
39
+ Best-effort: any failure (offline, no permission, app not yet created) returns
40
+ None so the caller falls back to GITHUB_RUN_NUMBER.
41
+ """
42
+ try:
43
+ import google.auth.transport.requests # noqa: PLC0415
44
+ import requests # noqa: PLC0415
45
+ from google.oauth2 import service_account as gsa # noqa: PLC0415
46
+
47
+ creds = gsa.Credentials.from_service_account_file(
48
+ str(service_account),
49
+ scopes=["https://www.googleapis.com/auth/androidpublisher"],
50
+ )
51
+ creds.refresh(google.auth.transport.requests.Request())
52
+ base = "https://androidpublisher.googleapis.com/androidpublisher/v3"
53
+ headers = {"Authorization": f"Bearer {creds.token}"}
54
+
55
+ edit = requests.post(
56
+ f"{base}/applications/{package_name}/edits", headers=headers, timeout=60
57
+ )
58
+ edit.raise_for_status()
59
+ edit_id = edit.json()["id"]
60
+ try:
61
+ listed = requests.get(
62
+ f"{base}/applications/{package_name}/edits/{edit_id}/bundles",
63
+ headers=headers,
64
+ timeout=60,
65
+ )
66
+ listed.raise_for_status()
67
+ codes = [
68
+ int(b["versionCode"])
69
+ for b in listed.json().get("bundles", [])
70
+ if b.get("versionCode") is not None
71
+ ]
72
+ finally:
73
+ requests.delete(
74
+ f"{base}/applications/{package_name}/edits/{edit_id}",
75
+ headers=headers,
76
+ timeout=60,
77
+ )
78
+ return max(codes) if codes else None
79
+ except Exception as exc: # noqa: BLE001 - never fail the build over this
80
+ print(f"::warning::Could not read existing Play versionCodes ({exc}); "
81
+ f"falling back to GITHUB_RUN_NUMBER")
82
+ return None
83
+
84
+
85
+ def auto_build_number(package_name: str, service_account: Path) -> int:
86
+ """versionCode to use when the caller did not pin one.
87
+
88
+ GITHUB_RUN_NUMBER alone is wrong: it restarts at 1 on a freshly-onboarded repo,
89
+ so it collides with versionCodes Play already holds and every upload is rejected
90
+ ("Version code N has already been used"). Take whichever is higher: the run
91
+ number, or one past the highest code Play knows about.
92
+ """
93
+ run_number = resolve_build_number(os.environ.get("GITHUB_RUN_NUMBER", "1"))
94
+ highest = highest_play_version_code(package_name, service_account)
95
+ if highest is None:
96
+ return run_number
97
+ candidate = max(run_number, highest + 1)
98
+ if candidate != run_number:
99
+ print(
100
+ f"Play already holds versionCode {highest}; using {candidate} "
101
+ f"instead of GITHUB_RUN_NUMBER {run_number}"
102
+ )
103
+ return candidate
104
+
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
+
33
129
  def main() -> None:
34
130
  workspace = Path(os.environ.get("GITHUB_WORKSPACE", ".")).resolve()
35
131
  env_path = Path(os.environ["GITHUB_ENV"])
36
132
  output_path = Path(os.environ["GITHUB_OUTPUT"])
133
+ gradle_root = ""
134
+ gradle_module = ""
37
135
  try:
38
- if not (workspace / "pubspec.yaml").is_file():
39
- raise ConfigError("pubspec.yaml not found; Android action requires Flutter")
136
+ kind = project_kind(workspace)
137
+ project = None if kind == "flutter" else find_gradle_project(workspace)
40
138
  signing = find_signing_config(workspace)
41
139
  service_account = find_play_service_account(workspace)
42
- package_name = os.environ.get("INPUT_PACKAGE_NAME") or detect_package_name(workspace)
43
- raw_number = os.environ.get("INPUT_BUILD_NUMBER") or os.environ.get("GITHUB_RUN_NUMBER", "")
44
- build_number = resolve_build_number(raw_number)
140
+ package_name = os.environ.get("INPUT_PACKAGE_NAME") or detect_package_name(
141
+ workspace, project.build_file if project else None
142
+ )
143
+ pinned = os.environ.get("INPUT_BUILD_NUMBER") or ""
144
+ if pinned:
145
+ build_number = resolve_build_number(pinned)
146
+ else:
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
+ )
45
154
  except (ConfigError, KeyError, OSError) as exc:
46
155
  print(f"::error::{exc}")
47
156
  raise SystemExit(1) from exc
@@ -54,6 +163,9 @@ def main() -> None:
54
163
  "ANDROID_SIGNING_PROPERTIES": str(signing.properties_path),
55
164
  "ANDROID_KEYSTORE_PATH": str(signing.keystore_path),
56
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,
57
169
  }
58
170
  for name, value in values.items():
59
171
  append_value(env_path, name, value)
@@ -61,9 +173,12 @@ def main() -> None:
61
173
  ("package_name", package_name),
62
174
  ("service_account", str(service_account)),
63
175
  ("build_number", str(build_number)),
176
+ ("project_kind", kind),
177
+ ("gradle_root", gradle_root),
178
+ ("gradle_module", gradle_module),
64
179
  ):
65
180
  append_value(output_path, name, value)
66
- print(f"Resolved Android package {package_name}, build {build_number}")
181
+ print(f"Resolved {kind} Android package {package_name}, build {build_number}")
67
182
 
68
183
 
69
184
  if __name__ == "__main__":
@@ -3,16 +3,21 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import os
6
7
  import tempfile
7
8
  import unittest
8
9
  from pathlib import Path
10
+ from unittest import mock
9
11
 
10
12
  from android_config import (
11
13
  ConfigError,
12
14
  detect_bundle,
13
15
  detect_package_name,
16
+ find_gradle_project,
14
17
  find_play_service_account,
15
18
  find_signing_config,
19
+ project_kind,
20
+ set_gradle_version,
16
21
  )
17
22
 
18
23
 
@@ -93,5 +98,197 @@ class AndroidConfigTest(unittest.TestCase):
93
98
  self.assertEqual(detect_bundle(self.workspace), bundle)
94
99
 
95
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
+
96
258
  if __name__ == "__main__":
97
259
  unittest.main()
260
+
261
+
262
+ class AutoBuildNumberTest(unittest.TestCase):
263
+ """versionCode must never collide with what Play already holds."""
264
+
265
+ def test_uses_play_high_water_mark_when_run_number_is_lower(self) -> None:
266
+ import resolve_android
267
+
268
+ with mock.patch.object(resolve_android, "highest_play_version_code", return_value=173):
269
+ with mock.patch.dict(os.environ, {"GITHUB_RUN_NUMBER": "1"}):
270
+ # A freshly-onboarded repo starts at run 1; Play already has 173.
271
+ self.assertEqual(
272
+ resolve_android.auto_build_number("com.example.app", Path("sa.json")),
273
+ 174,
274
+ )
275
+
276
+ def test_uses_run_number_when_it_is_already_higher(self) -> None:
277
+ import resolve_android
278
+
279
+ with mock.patch.object(resolve_android, "highest_play_version_code", return_value=5):
280
+ with mock.patch.dict(os.environ, {"GITHUB_RUN_NUMBER": "42"}):
281
+ self.assertEqual(
282
+ resolve_android.auto_build_number("com.example.app", Path("sa.json")),
283
+ 42,
284
+ )
285
+
286
+ def test_falls_back_to_run_number_when_play_is_unreachable(self) -> None:
287
+ import resolve_android
288
+
289
+ with mock.patch.object(resolve_android, "highest_play_version_code", return_value=None):
290
+ with mock.patch.dict(os.environ, {"GITHUB_RUN_NUMBER": "7"}):
291
+ self.assertEqual(
292
+ resolve_android.auto_build_number("com.example.app", Path("sa.json")),
293
+ 7,
294
+ )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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