gowalk-cicd 1.0.0

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.
Files changed (84) hide show
  1. package/CLAUDE.md +94 -0
  2. package/README.md +417 -0
  3. package/action/.daemux-version +1 -0
  4. package/action/MIGRATION.md +232 -0
  5. package/action/action.yml +973 -0
  6. package/action/prompts/generate_dependent_fields.prompt.yml +78 -0
  7. package/action/prompts/generate_descriptions.prompt.yml +59 -0
  8. package/action/scripts/app_context_scanner.py +239 -0
  9. package/action/scripts/asc_build_history.py +284 -0
  10. package/action/scripts/asc_common.py +246 -0
  11. package/action/scripts/asc_metadata_applier.py +368 -0
  12. package/action/scripts/asc_metadata_detector.py +250 -0
  13. package/action/scripts/asc_version_create.py +365 -0
  14. package/action/scripts/asc_version_fetch.py +274 -0
  15. package/action/scripts/asc_version_reuse.py +177 -0
  16. package/action/scripts/auto_detect.py +391 -0
  17. package/action/scripts/autoupdate_check.sh +118 -0
  18. package/action/scripts/cert_factory.py +190 -0
  19. package/action/scripts/cfg_io.py +27 -0
  20. package/action/scripts/cfg_resolve.py +147 -0
  21. package/action/scripts/commit_bot_changes.sh +82 -0
  22. package/action/scripts/creds_store.py +328 -0
  23. package/action/scripts/keychain.py +103 -0
  24. package/action/scripts/lookup_app_id.py +51 -0
  25. package/action/scripts/manage_marketing_version.py +337 -0
  26. package/action/scripts/metadata_constants.py +102 -0
  27. package/action/scripts/mmv_decide_create.py +181 -0
  28. package/action/scripts/mmv_floor_check.py +265 -0
  29. package/action/scripts/next_build_number.py +195 -0
  30. package/action/scripts/pbxproj_editor.py +220 -0
  31. package/action/scripts/prepare_signing.py +223 -0
  32. package/action/scripts/profile_io.py +64 -0
  33. package/action/scripts/profile_manager.py +307 -0
  34. package/action/scripts/read_config.py +315 -0
  35. package/action/scripts/resolve_marketing_version.py +173 -0
  36. package/action/scripts/set_app_store_whats_new.py +317 -0
  37. package/action/scripts/team_resolver.py +130 -0
  38. package/action/scripts/test_app_context_scanner.py +167 -0
  39. package/action/scripts/test_asc_build_history.py +221 -0
  40. package/action/scripts/test_asc_builds_prerelease.py +219 -0
  41. package/action/scripts/test_asc_common_timeouts.py +121 -0
  42. package/action/scripts/test_asc_metadata_applier.py +850 -0
  43. package/action/scripts/test_asc_metadata_detector.py +445 -0
  44. package/action/scripts/test_auto_detect.py +432 -0
  45. package/action/scripts/test_cfg_resolve_credentials.py +48 -0
  46. package/action/scripts/test_compute_next_version.py +95 -0
  47. package/action/scripts/test_compute_next_version_patch_backcompat.py +41 -0
  48. package/action/scripts/test_compute_next_version_strictness.py +67 -0
  49. package/action/scripts/test_fetch_versions.py +163 -0
  50. package/action/scripts/test_ground_truth_floor.py +104 -0
  51. package/action/scripts/test_manage_marketing_version.py +134 -0
  52. package/action/scripts/test_manage_marketing_version_autoroll.py +149 -0
  53. package/action/scripts/test_manage_marketing_version_floor.py +170 -0
  54. package/action/scripts/test_manage_marketing_version_match.py +187 -0
  55. package/action/scripts/test_mmv_409_classifier.py +231 -0
  56. package/action/scripts/test_mmv_autobump_persist_gate.py +131 -0
  57. package/action/scripts/test_mmv_decide_create.py +119 -0
  58. package/action/scripts/test_mmv_floor_check.py +158 -0
  59. package/action/scripts/test_mmv_floor_crosscheck.py +132 -0
  60. package/action/scripts/test_mmv_helpers.py +57 -0
  61. package/action/scripts/test_next_build_number.py +174 -0
  62. package/action/scripts/test_read_config_auto_detect.py +257 -0
  63. package/action/scripts/test_resolve_marketing_version.py +298 -0
  64. package/action/scripts/test_reuse_stale_editable.py +182 -0
  65. package/action/scripts/test_set_app_store_whats_new.py +71 -0
  66. package/action/scripts/test_team_fallback_wiring.py +157 -0
  67. package/action/scripts/test_team_resolver.py +249 -0
  68. package/action/scripts/tests_common.py +167 -0
  69. package/action/scripts/version_utils.py +373 -0
  70. package/android-action/.daemux-version +1 -0
  71. package/android-action/action.yml +92 -0
  72. package/android-action/scripts/android_config.py +130 -0
  73. package/android-action/scripts/bitrise_deploy.py +160 -0
  74. package/android-action/scripts/find_bundle.py +25 -0
  75. package/android-action/scripts/play_preflight.py +69 -0
  76. package/android-action/scripts/resolve_android.py +70 -0
  77. package/android-action/scripts/sign_bundle.py +70 -0
  78. package/android-action/scripts/test_android_config.py +97 -0
  79. package/android-action/scripts/test_bitrise_deploy.py +124 -0
  80. package/android-action/scripts/test_sign_bundle.py +82 -0
  81. package/bin/cli.mjs +63 -0
  82. package/package.json +57 -0
  83. package/src/install.mjs +208 -0
  84. package/templates/deploy.yml +150 -0
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env python3
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ import unittest
10
+ import zipfile
11
+ from pathlib import Path
12
+
13
+
14
+ @unittest.skipUnless(
15
+ shutil.which("keytool") and shutil.which("jarsigner") and shutil.which("zip"),
16
+ "Java signing tools are unavailable",
17
+ )
18
+ class SignBundleIntegrationTest(unittest.TestCase):
19
+ def test_signs_an_unsigned_bundle(self) -> None:
20
+ with tempfile.TemporaryDirectory() as directory:
21
+ root = Path(directory)
22
+ keystore = root / "upload.jks"
23
+ bundle = root / "app-release.aab"
24
+ properties = root / "android-signing.properties"
25
+ password = "test-password"
26
+ subprocess.run(
27
+ [
28
+ "keytool",
29
+ "-genkeypair",
30
+ "-noprompt",
31
+ "-keystore",
32
+ str(keystore),
33
+ "-storepass",
34
+ password,
35
+ "-keypass",
36
+ password,
37
+ "-alias",
38
+ "upload",
39
+ "-keyalg",
40
+ "RSA",
41
+ "-dname",
42
+ "CN=CI Test",
43
+ "-validity",
44
+ "1",
45
+ ],
46
+ check=True,
47
+ capture_output=True,
48
+ )
49
+ properties.write_text(
50
+ "storePassword=test-password\n"
51
+ "keyPassword=test-password\n"
52
+ "keyAlias=upload\n"
53
+ )
54
+ with zipfile.ZipFile(bundle, "w") as archive:
55
+ archive.writestr("base/manifest/AndroidManifest.xml", b"fixture")
56
+
57
+ subprocess.run(
58
+ [
59
+ sys.executable,
60
+ str(Path(__file__).with_name("sign_bundle.py")),
61
+ "--bundle",
62
+ str(bundle),
63
+ "--properties",
64
+ str(properties),
65
+ "--keystore",
66
+ str(keystore),
67
+ ],
68
+ check=True,
69
+ capture_output=True,
70
+ )
71
+
72
+ verified = subprocess.run(
73
+ ["jarsigner", "-verify", str(bundle)],
74
+ check=False,
75
+ capture_output=True,
76
+ text=True,
77
+ )
78
+ self.assertEqual(verified.returncode, 0, verified.stderr)
79
+
80
+
81
+ if __name__ == "__main__":
82
+ unittest.main()
package/bin/cli.mjs ADDED
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { dirname, join } from 'node:path';
6
+ import updateNotifier from 'update-notifier';
7
+
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = dirname(__filename);
10
+ const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
11
+
12
+ const notifier = updateNotifier({ pkg });
13
+
14
+ const args = process.argv.slice(2);
15
+ let dryRun = false;
16
+
17
+ for (const arg of args) {
18
+ switch (arg) {
19
+ case '-v':
20
+ case '--version':
21
+ console.log(pkg.version);
22
+ process.exit(0);
23
+ case '-h':
24
+ case '--help':
25
+ console.log(`Usage: npx gowalk-cicd [options]
26
+
27
+ Installs iOS TestFlight and Android Google Play actions plus one deploy
28
+ workflow into the current app project. Re-run anytime to update them.
29
+
30
+ What it writes:
31
+ .github/actions/swift-app/ - vendored composite action (action.yml + scripts/)
32
+ .github/actions/android-app/ - Flutter Android build/sign action
33
+ .github/workflows/deploy.yml - dual-platform workflow
34
+
35
+ Options:
36
+ --dry-run Print what would happen without writing files
37
+ -v, --version Show version number
38
+ -h, --help Show this help message
39
+
40
+ Examples:
41
+ cd my-app && npx --yes gowalk-cicd
42
+ npx --yes gowalk-cicd --dry-run
43
+
44
+ After install:
45
+ 1. Place your App Store Connect API key at creds/AuthKey_<KEY_ID>_Issuer_<UUID>.p8
46
+ 2. Add the Android upload keystore, signing properties, and Play service account
47
+ 3. Make the repo PRIVATE (credentials are read from creds/)
48
+ 4. Commit + push to main -- CI builds both platforms`);
49
+ process.exit(0);
50
+ case '--dry-run':
51
+ dryRun = true;
52
+ break;
53
+ default:
54
+ console.error(`Unknown option: ${arg}`);
55
+ console.error(`Run with --help for usage.`);
56
+ process.exit(2);
57
+ }
58
+ }
59
+
60
+ const { runInstall } = await import('../src/install.mjs');
61
+ await runInstall({ dryRun });
62
+
63
+ notifier.notify();
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "gowalk-cicd",
3
+ "version": "1.0.0",
4
+ "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
+ "type": "module",
6
+ "bin": {
7
+ "gowalk-cicd": "./bin/cli.mjs"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "src/",
12
+ "action/",
13
+ "!action/**/__pycache__/**",
14
+ "!action/**/*.py[cod]",
15
+ "android-action/",
16
+ "!android-action/**/__pycache__/**",
17
+ "!android-action/**/*.py[cod]",
18
+ "templates/",
19
+ "README.md",
20
+ "CLAUDE.md"
21
+ ],
22
+ "keywords": [
23
+ "ios",
24
+ "android",
25
+ "flutter",
26
+ "swift",
27
+ "testflight",
28
+ "google-play",
29
+ "github-actions",
30
+ "ci",
31
+ "app-store-connect",
32
+ "gowalk"
33
+ ],
34
+ "author": {
35
+ "name": "Gowalk"
36
+ },
37
+ "license": "MIT",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "https://github.com/gowalk-public/gowalk-cicd.git"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "engines": {
46
+ "node": ">=18"
47
+ },
48
+ "scripts": {
49
+ "test": "npm run test:installer && npm run test:android && npm run test:ios",
50
+ "test:installer": "node --test test/*.test.mjs",
51
+ "test:android": "python3 -m unittest discover -s android-action/scripts -p 'test_*.py'",
52
+ "test:ios": "python3 -m unittest discover -s action/scripts -p 'test_*.py'"
53
+ },
54
+ "dependencies": {
55
+ "update-notifier": "^7.3.1"
56
+ }
57
+ }
@@ -0,0 +1,208 @@
1
+ // Installer for gowalk-cicd.
2
+ //
3
+ // Copies the vendored composite action (action/) and the thin workflow
4
+ // template (templates/deploy.yml) into the consumer's repo so their
5
+ // app project can ship to TestFlight and Google Play from GitHub Actions.
6
+ //
7
+ // Intentionally simple: overwrite-on-update semantics, no scope flag,
8
+ // no uninstall. Re-running `npx gowalk-cicd` updates both actions.
9
+
10
+ import {
11
+ cpSync,
12
+ existsSync,
13
+ mkdirSync,
14
+ readFileSync,
15
+ readdirSync,
16
+ statSync,
17
+ writeFileSync,
18
+ } from 'node:fs';
19
+ import { join, relative, resolve } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { dirname } from 'node:path';
22
+
23
+ const __filename = fileURLToPath(import.meta.url);
24
+ const __dirname = dirname(__filename);
25
+ const PACKAGE_DIR = resolve(__dirname, '..');
26
+
27
+ const ACTION_SRC = join(PACKAGE_DIR, 'action');
28
+ const ANDROID_ACTION_SRC = join(PACKAGE_DIR, 'android-action');
29
+ const WORKFLOW_TEMPLATE = join(PACKAGE_DIR, 'templates', 'deploy.yml');
30
+ const PACKAGE_JSON = join(PACKAGE_DIR, 'package.json');
31
+
32
+ // Target paths inside the consumer repo. These are relative to the repo
33
+ // root we discover below.
34
+ const ACTION_DEST_REL = join('.github', 'actions', 'swift-app');
35
+ const ANDROID_ACTION_DEST_REL = join('.github', 'actions', 'android-app');
36
+ const WORKFLOW_DEST_REL = join('.github', 'workflows', 'deploy.yml');
37
+ const VERSION_MARKER_REL = join('.github', 'actions', 'swift-app', '.daemux-version');
38
+ const ANDROID_VERSION_MARKER_REL = join('.github', 'actions', 'android-app', '.daemux-version');
39
+
40
+ // Lines we treat as ignoring credentials. If any appears in the consumer's
41
+ // .gitignore, CI will not see one or both platform keys. We warn instead of
42
+ // auto-editing, because
43
+ // .gitignore is git-tracked and silent edits are the wrong default.
44
+ const CRED_IGNORE_PATTERNS = [
45
+ /^\s*creds\/?\s*$/,
46
+ /^\s*\/creds\/?\s*$/,
47
+ /^\s*\*\.p8\s*$/,
48
+ /^\s*\*\.(?:jks|keystore)\s*$/,
49
+ /^\s*creds\/\*\s*$/,
50
+ ];
51
+
52
+ function findRepoRoot(startDir) {
53
+ // Walk up until we find .git; fall back to startDir so the installer
54
+ // still works outside a git repo (e.g. dry-run in a scratch directory).
55
+ let dir = resolve(startDir);
56
+ while (true) {
57
+ if (existsSync(join(dir, '.git'))) return dir;
58
+ const parent = dirname(dir);
59
+ if (parent === dir) return resolve(startDir);
60
+ dir = parent;
61
+ }
62
+ }
63
+
64
+ function isDistributionFile(path) {
65
+ const parts = path.split(/[\\/]/);
66
+ return !parts.includes('__pycache__')
67
+ && !path.endsWith('.pyc')
68
+ && !path.endsWith('.DS_Store');
69
+ }
70
+
71
+ function countFiles(dir) {
72
+ let n = 0;
73
+ for (const entry of readdirSync(dir)) {
74
+ const p = join(dir, entry);
75
+ if (!isDistributionFile(p)) continue;
76
+ const st = statSync(p);
77
+ if (st.isDirectory()) n += countFiles(p);
78
+ else n += 1;
79
+ }
80
+ return n;
81
+ }
82
+
83
+ function ensureDir(p) {
84
+ mkdirSync(p, { recursive: true });
85
+ }
86
+
87
+ function copyAction(source, destination, repoRoot, dryRun) {
88
+ const dest = join(repoRoot, destination);
89
+ const fileCount = countFiles(source);
90
+ const existed = existsSync(dest);
91
+ if (dryRun) {
92
+ console.log(` [dry-run] ${existed ? 'update' : 'create'} ${destination}/ (${fileCount} files)`);
93
+ return { dest, existed, fileCount };
94
+ }
95
+ ensureDir(dirname(dest));
96
+ // force:true + overwrites existing files; we keep extra files the
97
+ // consumer may have added alongside so nothing vanishes unexpectedly.
98
+ cpSync(source, dest, { recursive: true, force: true, filter: isDistributionFile });
99
+ console.log(` ${existed ? 'Updated' : 'Created'} ${destination}/ (${fileCount} files)`);
100
+ return { dest, existed, fileCount };
101
+ }
102
+
103
+ function copyWorkflow(repoRoot, dryRun) {
104
+ const dest = join(repoRoot, WORKFLOW_DEST_REL);
105
+ const existed = existsSync(dest);
106
+ if (dryRun) {
107
+ console.log(` [dry-run] ${existed ? 'overwrite' : 'create'} ${WORKFLOW_DEST_REL}`);
108
+ return { dest, existed };
109
+ }
110
+ ensureDir(dirname(dest));
111
+ cpSync(WORKFLOW_TEMPLATE, dest, { force: true });
112
+ console.log(` ${existed ? 'Overwrote' : 'Created'} ${WORKFLOW_DEST_REL}`);
113
+ return { dest, existed };
114
+ }
115
+
116
+ function writeVersionMarkers(repoRoot, dryRun) {
117
+ // Reads this package's own version from package.json and stamps it
118
+ // into the consumer repo as the source of truth for autoupdate's
119
+ // "am I current?" check (see autoupdate_check.sh). Read + write are
120
+ // paired into one function — the marker exclusively reflects the
121
+ // just-installed package version, so there's no value in exposing
122
+ // the read separately.
123
+ const pkg = JSON.parse(readFileSync(PACKAGE_JSON, 'utf8'));
124
+ const version = String(pkg.version || '').trim();
125
+ const destinations = [VERSION_MARKER_REL, ANDROID_VERSION_MARKER_REL];
126
+ if (!version) {
127
+ console.log(' [warn] package.json has no version; skipping version markers');
128
+ return { version, written: false };
129
+ }
130
+ for (const destination of destinations) {
131
+ const dest = join(repoRoot, destination);
132
+ if (dryRun) {
133
+ console.log(` [dry-run] write ${destination} (${version})`);
134
+ continue;
135
+ }
136
+ ensureDir(dirname(dest));
137
+ writeFileSync(dest, version);
138
+ console.log(` Wrote ${destination} (${version})`);
139
+ }
140
+ return { version, written: !dryRun };
141
+ }
142
+
143
+ function checkGitignore(repoRoot) {
144
+ const gi = join(repoRoot, '.gitignore');
145
+ if (!existsSync(gi)) return { path: gi, exists: false, offenders: [] };
146
+ const lines = readFileSync(gi, 'utf8').split(/\r?\n/);
147
+ const offenders = [];
148
+ for (let i = 0; i < lines.length; i++) {
149
+ const line = lines[i];
150
+ if (CRED_IGNORE_PATTERNS.some((re) => re.test(line))) {
151
+ offenders.push({ lineNumber: i + 1, text: line });
152
+ }
153
+ }
154
+ return { path: gi, exists: true, offenders };
155
+ }
156
+
157
+ function printGitignoreWarning(result) {
158
+ if (!result.exists || result.offenders.length === 0) return;
159
+ console.log('');
160
+ console.log('WARNING: .gitignore excludes required CI credentials.');
161
+ console.log(` File: ${result.path}`);
162
+ for (const { lineNumber, text } of result.offenders) {
163
+ console.log(` Line ${lineNumber}: ${text}`);
164
+ }
165
+ console.log('');
166
+ console.log(' The actions read Apple and Android keys from creds/ in the checkout.');
167
+ console.log(' Remove those lines from .gitignore manually, then commit the .p8 file.');
168
+ console.log(' (Your repo MUST be private — never do this in a public repo.)');
169
+ }
170
+
171
+ function printSummary(repoRoot) {
172
+ console.log('');
173
+ console.log('Done.');
174
+ console.log('');
175
+ console.log('Next steps:');
176
+ console.log(` 1. Place your ASC API key:`);
177
+ console.log(` ${join(relative(process.cwd(), repoRoot) || '.', 'creds', 'AuthKey_<KEY_ID>_Issuer_<UUID>.p8')}`);
178
+ console.log(` (filename must include the 10-char KEY_ID and the issuer UUID)`);
179
+ console.log(` 2. Add creds/android-upload-key.jks and creds/android-signing.properties.`);
180
+ console.log(` 3. Add a Google Play service-account JSON anywhere under creds/.`);
181
+ console.log(` 4. Ensure the repo is PRIVATE; these long-lived credentials must remain private.`);
182
+ console.log(` 5. Commit and push to main. GitHub Actions builds both platforms.`);
183
+ console.log('');
184
+ console.log('Re-run this installer anytime to update the vendored action:');
185
+ console.log(' npx --yes gowalk-cicd');
186
+ }
187
+
188
+ export async function runInstall({ dryRun = false } = {}) {
189
+ const repoRoot = findRepoRoot(process.cwd());
190
+ console.log(`Installing gowalk-cicd into ${repoRoot}${dryRun ? ' (dry-run)' : ''}`);
191
+ console.log('');
192
+
193
+ copyAction(ACTION_SRC, ACTION_DEST_REL, repoRoot, dryRun);
194
+ copyAction(ANDROID_ACTION_SRC, ANDROID_ACTION_DEST_REL, repoRoot, dryRun);
195
+ writeVersionMarkers(repoRoot, dryRun);
196
+ copyWorkflow(repoRoot, dryRun);
197
+
198
+ const gi = checkGitignore(repoRoot);
199
+ printGitignoreWarning(gi);
200
+
201
+ if (dryRun) {
202
+ console.log('');
203
+ console.log('Dry-run complete. No files were written.');
204
+ return;
205
+ }
206
+
207
+ printSummary(repoRoot);
208
+ }
@@ -0,0 +1,150 @@
1
+ name: Mobile Deploy
2
+ on:
3
+ push:
4
+ branches: [main]
5
+ paths-ignore:
6
+ - '**/*.md'
7
+ - '.gitignore'
8
+ - 'creds/**'
9
+ - '.github/actions/swift-app/**'
10
+ - '.github/actions/android-app/**'
11
+ workflow_dispatch:
12
+
13
+ permissions:
14
+ contents: write
15
+ models: read
16
+
17
+ concurrency:
18
+ group: mobile-deploy-${{ github.ref }}
19
+ cancel-in-progress: true
20
+
21
+ jobs:
22
+ ios:
23
+ name: iOS TestFlight
24
+ runs-on: macos-15
25
+ timeout-minutes: 60
26
+ steps:
27
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
28
+ with:
29
+ fetch-depth: 0
30
+ persist-credentials: true
31
+ - name: Detect Flutter
32
+ id: flutter
33
+ shell: bash
34
+ run: |
35
+ if [ -f pubspec.yaml ]; then
36
+ echo "enabled=true" >> "$GITHUB_OUTPUT"
37
+ else
38
+ echo "enabled=false" >> "$GITHUB_OUTPUT"
39
+ fi
40
+ - name: Set up Flutter
41
+ if: ${{ steps.flutter.outputs.enabled == 'true' }}
42
+ uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
43
+ with:
44
+ channel: stable
45
+ cache: true
46
+ - name: Cache CocoaPods
47
+ if: ${{ steps.flutter.outputs.enabled == 'true' }}
48
+ uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
49
+ with:
50
+ path: |
51
+ ios/Pods
52
+ ~/Library/Caches/CocoaPods
53
+ key: pods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock', 'pubspec.lock') }}
54
+ restore-keys: pods-${{ runner.os }}-
55
+ - name: Prepare Flutter iOS project
56
+ if: ${{ steps.flutter.outputs.enabled == 'true' }}
57
+ shell: bash
58
+ run: |
59
+ flutter pub get
60
+ flutter build ios --release --no-codesign --config-only
61
+ - uses: ./.github/actions/swift-app
62
+ with:
63
+ project: ${{ steps.flutter.outputs.enabled == 'true' && 'ios/Runner.xcodeproj' || '' }}
64
+ workspace: ${{ steps.flutter.outputs.enabled == 'true' && 'ios/Runner.xcworkspace' || '' }}
65
+ scheme: ${{ steps.flutter.outputs.enabled == 'true' && 'Runner' || '' }}
66
+ run-tests: 'false' # tests belong to the CI gate, not the deploy path
67
+
68
+ android:
69
+ name: Android Google Play
70
+ runs-on: ubuntu-24.04
71
+ timeout-minutes: 45
72
+ steps:
73
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
74
+ # Two Android delivery modes:
75
+ # * bitrise — the upload key + Play service account live in Bitrise,
76
+ # not the repo, so the GitHub runner cannot sign. Drop a
77
+ # creds/bitrise.json ({"enabled":true,"app_slug":"...",...}) and this
78
+ # job triggers the Bitrise workflow (which holds the secrets) and
79
+ # waits for it. See android-action/scripts/bitrise_deploy.py.
80
+ # * local — default; build + sign + upload on the GitHub runner using
81
+ # creds/android-upload-key.jks + creds/android-signing.properties.
82
+ - name: Select Android deploy mode
83
+ id: mode
84
+ shell: bash
85
+ run: |
86
+ if [ -f creds/bitrise.json ] && \
87
+ [ "$(python3 -c "import json,sys;print(json.load(open('creds/bitrise.json')).get('enabled',False))" 2>/dev/null)" = "True" ]; then
88
+ echo "mode=bitrise" >> "$GITHUB_OUTPUT"
89
+ else
90
+ echo "mode=local" >> "$GITHUB_OUTPUT"
91
+ fi
92
+
93
+ # --- Bitrise mode ---------------------------------------------------
94
+ - name: Deploy Android via Bitrise
95
+ if: ${{ steps.mode.outputs.mode == 'bitrise' }}
96
+ shell: bash
97
+ env:
98
+ BITRISE_API_TOKEN: ${{ secrets.BITRISE_API_TOKEN }}
99
+ run: python3 .github/actions/android-app/scripts/bitrise_deploy.py
100
+
101
+ # --- Local mode -----------------------------------------------------
102
+ - name: Set up Java
103
+ if: ${{ steps.mode.outputs.mode == 'local' }}
104
+ uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5
105
+ with:
106
+ distribution: temurin
107
+ java-version: '17'
108
+ cache: gradle
109
+ - name: Set up Flutter
110
+ if: ${{ steps.mode.outputs.mode == 'local' }}
111
+ uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
112
+ with:
113
+ channel: stable
114
+ cache: true
115
+ - name: Build and sign Android release
116
+ id: android
117
+ if: ${{ steps.mode.outputs.mode == 'local' }}
118
+ uses: ./.github/actions/android-app
119
+ - name: Retain signed Android App Bundle
120
+ if: ${{ steps.mode.outputs.mode == 'local' }}
121
+ uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
122
+ with:
123
+ name: android-${{ steps.android.outputs.package-name }}-${{ steps.android.outputs.build-number }}
124
+ path: ${{ steps.android.outputs.bundle-path }}
125
+ if-no-files-found: error
126
+ retention-days: 30
127
+ - name: Install Google Play preflight dependencies
128
+ if: ${{ steps.mode.outputs.mode == 'local' }}
129
+ shell: bash
130
+ run: python3 -m pip install --disable-pip-version-check 'google-auth>=2.40,<3' 'requests>=2.32,<3'
131
+ - name: Check Google Play API readiness
132
+ id: play
133
+ if: ${{ steps.mode.outputs.mode == 'local' }}
134
+ shell: bash
135
+ env:
136
+ PACKAGE_NAME: ${{ steps.android.outputs.package-name }}
137
+ SERVICE_ACCOUNT: ${{ steps.android.outputs.play-service-account }}
138
+ run: |
139
+ python3 .github/actions/android-app/scripts/play_preflight.py \
140
+ --package "$PACKAGE_NAME" \
141
+ --service-account "$SERVICE_ACCOUNT"
142
+ - name: Upload Android release to Google Play
143
+ if: ${{ steps.mode.outputs.mode == 'local' && steps.play.outputs.ready == 'true' }}
144
+ uses: r0adkll/upload-google-play@e738b9dd8f2476ea806d921b64aacd24f34515a5 # v1.1.5
145
+ with:
146
+ serviceAccountJson: ${{ steps.android.outputs.play-service-account }}
147
+ packageName: ${{ steps.android.outputs.package-name }}
148
+ releaseFiles: ${{ steps.android.outputs.bundle-path }}
149
+ tracks: ${{ vars.GOOGLE_PLAY_TRACK || 'internal' }}
150
+ status: ${{ vars.GOOGLE_PLAY_STATUS || 'completed' }}