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.
- package/CLAUDE.md +94 -0
- package/README.md +417 -0
- package/action/.daemux-version +1 -0
- package/action/MIGRATION.md +232 -0
- package/action/action.yml +973 -0
- package/action/prompts/generate_dependent_fields.prompt.yml +78 -0
- package/action/prompts/generate_descriptions.prompt.yml +59 -0
- package/action/scripts/app_context_scanner.py +239 -0
- package/action/scripts/asc_build_history.py +284 -0
- package/action/scripts/asc_common.py +246 -0
- package/action/scripts/asc_metadata_applier.py +368 -0
- package/action/scripts/asc_metadata_detector.py +250 -0
- package/action/scripts/asc_version_create.py +365 -0
- package/action/scripts/asc_version_fetch.py +274 -0
- package/action/scripts/asc_version_reuse.py +177 -0
- package/action/scripts/auto_detect.py +391 -0
- package/action/scripts/autoupdate_check.sh +118 -0
- package/action/scripts/cert_factory.py +190 -0
- package/action/scripts/cfg_io.py +27 -0
- package/action/scripts/cfg_resolve.py +147 -0
- package/action/scripts/commit_bot_changes.sh +82 -0
- package/action/scripts/creds_store.py +328 -0
- package/action/scripts/keychain.py +103 -0
- package/action/scripts/lookup_app_id.py +51 -0
- package/action/scripts/manage_marketing_version.py +337 -0
- package/action/scripts/metadata_constants.py +102 -0
- package/action/scripts/mmv_decide_create.py +181 -0
- package/action/scripts/mmv_floor_check.py +265 -0
- package/action/scripts/next_build_number.py +195 -0
- package/action/scripts/pbxproj_editor.py +220 -0
- package/action/scripts/prepare_signing.py +223 -0
- package/action/scripts/profile_io.py +64 -0
- package/action/scripts/profile_manager.py +307 -0
- package/action/scripts/read_config.py +315 -0
- package/action/scripts/resolve_marketing_version.py +173 -0
- package/action/scripts/set_app_store_whats_new.py +317 -0
- package/action/scripts/team_resolver.py +130 -0
- package/action/scripts/test_app_context_scanner.py +167 -0
- package/action/scripts/test_asc_build_history.py +221 -0
- package/action/scripts/test_asc_builds_prerelease.py +219 -0
- package/action/scripts/test_asc_common_timeouts.py +121 -0
- package/action/scripts/test_asc_metadata_applier.py +850 -0
- package/action/scripts/test_asc_metadata_detector.py +445 -0
- package/action/scripts/test_auto_detect.py +432 -0
- package/action/scripts/test_cfg_resolve_credentials.py +48 -0
- package/action/scripts/test_compute_next_version.py +95 -0
- package/action/scripts/test_compute_next_version_patch_backcompat.py +41 -0
- package/action/scripts/test_compute_next_version_strictness.py +67 -0
- package/action/scripts/test_fetch_versions.py +163 -0
- package/action/scripts/test_ground_truth_floor.py +104 -0
- package/action/scripts/test_manage_marketing_version.py +134 -0
- package/action/scripts/test_manage_marketing_version_autoroll.py +149 -0
- package/action/scripts/test_manage_marketing_version_floor.py +170 -0
- package/action/scripts/test_manage_marketing_version_match.py +187 -0
- package/action/scripts/test_mmv_409_classifier.py +231 -0
- package/action/scripts/test_mmv_autobump_persist_gate.py +131 -0
- package/action/scripts/test_mmv_decide_create.py +119 -0
- package/action/scripts/test_mmv_floor_check.py +158 -0
- package/action/scripts/test_mmv_floor_crosscheck.py +132 -0
- package/action/scripts/test_mmv_helpers.py +57 -0
- package/action/scripts/test_next_build_number.py +174 -0
- package/action/scripts/test_read_config_auto_detect.py +257 -0
- package/action/scripts/test_resolve_marketing_version.py +298 -0
- package/action/scripts/test_reuse_stale_editable.py +182 -0
- package/action/scripts/test_set_app_store_whats_new.py +71 -0
- package/action/scripts/test_team_fallback_wiring.py +157 -0
- package/action/scripts/test_team_resolver.py +249 -0
- package/action/scripts/tests_common.py +167 -0
- package/action/scripts/version_utils.py +373 -0
- package/android-action/.daemux-version +1 -0
- package/android-action/action.yml +92 -0
- package/android-action/scripts/android_config.py +130 -0
- package/android-action/scripts/bitrise_deploy.py +160 -0
- package/android-action/scripts/find_bundle.py +25 -0
- package/android-action/scripts/play_preflight.py +69 -0
- package/android-action/scripts/resolve_android.py +70 -0
- package/android-action/scripts/sign_bundle.py +70 -0
- package/android-action/scripts/test_android_config.py +97 -0
- package/android-action/scripts/test_bitrise_deploy.py +124 -0
- package/android-action/scripts/test_sign_bundle.py +82 -0
- package/bin/cli.mjs +63 -0
- package/package.json +57 -0
- package/src/install.mjs +208 -0
- 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
|
+
}
|
package/src/install.mjs
ADDED
|
@@ -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' }}
|