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,160 @@
1
+ #!/usr/bin/env python3
2
+ """Deploy Android through Bitrise instead of building locally.
3
+
4
+ Enabled by dropping a ``creds/bitrise.json`` config in the consumer repo:
5
+
6
+ {
7
+ "enabled": true,
8
+ "app_slug": "8b2a7a24-....", // Bitrise app slug
9
+ "workflow": "android-deploy", // Bitrise workflow to run
10
+ "branch": "main", // optional; defaults to the pushed ref
11
+ "api_token": "bitpat_...." // optional; else env BITRISE_API_TOKEN
12
+ }
13
+
14
+ Use this when the Google Play upload key / signing secrets live in Bitrise
15
+ (not in the repo) — the GitHub Actions runner cannot sign the AAB, so it
16
+ triggers the Bitrise ``android-deploy`` workflow (which holds the keystore
17
+ and service-account secrets) and waits for it to finish, mirroring the
18
+ build's success/failure back into the GitHub Actions job.
19
+
20
+ The Bitrise API token is a repo-scoped secret; committing it to a PRIVATE
21
+ repo's ``creds/`` follows the same trust model as the ASC ``.p8`` key that
22
+ already lives there. Prefer the ``BITRISE_API_TOKEN`` env (from an Actions
23
+ secret) when the repo is not private.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import os
30
+ import sys
31
+ import time
32
+ import urllib.error
33
+ import urllib.request
34
+ from pathlib import Path
35
+
36
+ API = "https://api.bitrise.io/v0.1"
37
+ POLL_SECONDS = int(os.getenv("BITRISE_POLL_SECONDS", "20"))
38
+ # Bitrise build status codes: 0 = not finished, 1 = success, 2 = failed,
39
+ # 3 = aborted, 4 = aborted-with-success (skipped).
40
+ STATUS_TEXT = {0: "in-progress", 1: "success", 2: "failed", 3: "aborted",
41
+ 4: "aborted (success)"}
42
+
43
+
44
+ def fail(msg: str) -> "None":
45
+ print(f"::error::{msg}")
46
+ raise SystemExit(1)
47
+
48
+
49
+ def load_config() -> dict:
50
+ workspace = Path(os.getenv("GITHUB_WORKSPACE", os.getcwd()))
51
+ cfg_path = workspace / "creds" / "bitrise.json"
52
+ if not cfg_path.is_file():
53
+ fail("creds/bitrise.json not found but Bitrise mode was selected")
54
+ try:
55
+ cfg = json.loads(cfg_path.read_text())
56
+ except ValueError as exc:
57
+ fail(f"creds/bitrise.json is not valid JSON: {exc}")
58
+ if not cfg.get("app_slug"):
59
+ fail("creds/bitrise.json missing required 'app_slug'")
60
+ return cfg
61
+
62
+
63
+ def resolve_token(cfg: dict) -> str:
64
+ token = os.getenv("BITRISE_API_TOKEN") or cfg.get("api_token")
65
+ if not token:
66
+ fail("No Bitrise API token: set creds/bitrise.json 'api_token' or "
67
+ "the BITRISE_API_TOKEN env/secret")
68
+ return token
69
+
70
+
71
+ def resolve_branch(cfg: dict) -> str:
72
+ if cfg.get("branch"):
73
+ return cfg["branch"]
74
+ ref = os.getenv("GITHUB_REF", "")
75
+ if ref.startswith("refs/heads/"):
76
+ return ref[len("refs/heads/"):]
77
+ return "main"
78
+
79
+
80
+ def api(method: str, path: str, token: str, body: dict | None = None) -> dict:
81
+ data = json.dumps(body).encode() if body is not None else None
82
+ req = urllib.request.Request(
83
+ API + path, data=data, method=method,
84
+ headers={"Authorization": token, "Content-Type": "application/json"},
85
+ )
86
+ try:
87
+ with urllib.request.urlopen(req, timeout=60) as resp:
88
+ return json.loads(resp.read().decode())
89
+ except urllib.error.HTTPError as exc:
90
+ detail = exc.read().decode()[:400]
91
+ fail(f"Bitrise API {method} {path} -> HTTP {exc.code}: {detail}")
92
+ except urllib.error.URLError as exc:
93
+ fail(f"Bitrise API {method} {path} unreachable: {exc}")
94
+ return {} # unreachable (fail raises)
95
+
96
+
97
+ def trigger(cfg: dict, token: str, branch: str) -> tuple[str, int, str]:
98
+ workflow = cfg.get("workflow", "android-deploy")
99
+ body = {
100
+ "hook_info": {"type": "bitrise"},
101
+ "build_params": {"branch": branch, "workflow_id": workflow},
102
+ }
103
+ commit = os.getenv("GITHUB_SHA")
104
+ if commit:
105
+ body["build_params"]["commit_hash"] = commit
106
+ resp = api("POST", f"/apps/{cfg['app_slug']}/builds", token, body)
107
+ if resp.get("status") != "ok":
108
+ fail(f"Bitrise refused the build trigger: {resp}")
109
+ return resp["build_slug"], resp["build_number"], workflow
110
+
111
+
112
+ def poll(app_slug: str, build_slug: str, token: str) -> int:
113
+ last = None
114
+ while True:
115
+ data = api("GET", f"/apps/{app_slug}/builds/{build_slug}", token)["data"]
116
+ status = data.get("status", 0)
117
+ text = data.get("status_text", STATUS_TEXT.get(status, "?"))
118
+ if text != last:
119
+ print(f" Bitrise build status: {text}")
120
+ last = text
121
+ if status != 0:
122
+ return status
123
+ time.sleep(POLL_SECONDS)
124
+
125
+
126
+ def print_tail_log(app_slug: str, build_slug: str, token: str) -> None:
127
+ try:
128
+ data = api("GET", f"/apps/{app_slug}/builds/{build_slug}/log", token)
129
+ except SystemExit:
130
+ return
131
+ chunks = "".join(c.get("chunk", "") for c in data.get("log_chunks", []))
132
+ if chunks:
133
+ print("::group::Bitrise build log (tail)")
134
+ print("\n".join(chunks.splitlines()[-60:]))
135
+ print("::endgroup::")
136
+
137
+
138
+ def main() -> None:
139
+ cfg = load_config()
140
+ token = resolve_token(cfg)
141
+ branch = resolve_branch(cfg)
142
+ app_slug = cfg["app_slug"]
143
+
144
+ build_slug, build_number, workflow = trigger(cfg, token, branch)
145
+ url = f"https://app.bitrise.io/build/{build_slug}"
146
+ print(f"Triggered Bitrise workflow '{workflow}' build #{build_number} "
147
+ f"on branch '{branch}'")
148
+ print(f" {url}")
149
+
150
+ status = poll(app_slug, build_slug, token)
151
+ if status == 1:
152
+ print(f"::notice::Bitrise build #{build_number} succeeded ({url})")
153
+ return
154
+ print_tail_log(app_slug, build_slug, token)
155
+ fail(f"Bitrise build #{build_number} finished with status "
156
+ f"'{STATUS_TEXT.get(status, status)}' ({url})")
157
+
158
+
159
+ if __name__ == "__main__":
160
+ main()
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env python3
2
+ """Publish the single generated Android App Bundle as an action output."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ from pathlib import Path
8
+
9
+ from android_config import ConfigError, detect_bundle
10
+
11
+
12
+ def main() -> None:
13
+ workspace = Path(os.environ.get("GITHUB_WORKSPACE", ".")).resolve()
14
+ try:
15
+ bundle = detect_bundle(workspace).resolve()
16
+ except ConfigError as exc:
17
+ print(f"::error::{exc}")
18
+ raise SystemExit(1) from exc
19
+ with Path(os.environ["GITHUB_OUTPUT"]).open("a") as stream:
20
+ stream.write(f"bundle_path={bundle}\n")
21
+ print(f"Built Android App Bundle: {bundle}")
22
+
23
+
24
+ if __name__ == "__main__":
25
+ main()
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env python3
2
+ """Check whether Google Play accepts API edits for this package."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+
11
+ import google.auth.transport.requests
12
+ import requests
13
+ from google.oauth2 import service_account
14
+
15
+
16
+ SCOPE = "https://www.googleapis.com/auth/androidpublisher"
17
+ BASE_URL = "https://androidpublisher.googleapis.com/androidpublisher/v3"
18
+
19
+
20
+ def write_ready(value: bool) -> None:
21
+ with Path(os.environ["GITHUB_OUTPUT"]).open("a") as stream:
22
+ stream.write(f"ready={'true' if value else 'false'}\n")
23
+
24
+
25
+ def response_message(response: requests.Response) -> str:
26
+ try:
27
+ payload = response.json()
28
+ return str(payload.get("error", {}).get("message", "unknown API error"))[:500]
29
+ except (ValueError, AttributeError):
30
+ return "unknown API error"
31
+
32
+
33
+ def main() -> None:
34
+ parser = argparse.ArgumentParser()
35
+ parser.add_argument("--package", required=True)
36
+ parser.add_argument("--service-account", required=True, type=Path)
37
+ args = parser.parse_args()
38
+
39
+ credentials = service_account.Credentials.from_service_account_file(
40
+ args.service_account, scopes=[SCOPE]
41
+ )
42
+ credentials.refresh(google.auth.transport.requests.Request())
43
+ headers = {"Authorization": f"Bearer {credentials.token}"}
44
+ url = f"{BASE_URL}/applications/{args.package}/edits"
45
+ response = requests.post(url, headers=headers, json={}, timeout=30)
46
+ if response.status_code == 404:
47
+ write_ready(False)
48
+ print(
49
+ "::warning::Google Play package is not API-ready. Download the "
50
+ "android-first-release artifact and upload it once in Play Console; "
51
+ "later runs will deploy automatically."
52
+ )
53
+ return
54
+ if response.status_code != 200:
55
+ message = response_message(response)
56
+ raise SystemExit(f"Google Play preflight failed ({response.status_code}): {message}")
57
+
58
+ edit_id = json.loads(response.text)["id"]
59
+ delete_url = f"{url}/{edit_id}"
60
+ deleted = requests.delete(delete_url, headers=headers, timeout=30)
61
+ if deleted.status_code not in (200, 204):
62
+ message = response_message(deleted)
63
+ raise SystemExit(f"cannot close Google Play preflight edit: {message}")
64
+ write_ready(True)
65
+ print("Google Play package is ready for automated uploads")
66
+
67
+
68
+ if __name__ == "__main__":
69
+ main()
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env python3
2
+ """Resolve Android credentials and app metadata without exposing secrets."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from android_config import (
11
+ ConfigError,
12
+ detect_package_name,
13
+ find_play_service_account,
14
+ find_signing_config,
15
+ )
16
+
17
+
18
+ def append_value(path: Path, name: str, value: str) -> None:
19
+ with path.open("a") as stream:
20
+ stream.write(f"{name}={value}\n")
21
+
22
+
23
+ def resolve_build_number(raw: str) -> int:
24
+ try:
25
+ number = int(raw)
26
+ except ValueError as exc:
27
+ raise ConfigError(f"Android build number must be an integer: {raw!r}") from exc
28
+ if not 1 <= number <= 2_100_000_000:
29
+ raise ConfigError("Android build number must be between 1 and 2100000000")
30
+ return number
31
+
32
+
33
+ def main() -> None:
34
+ workspace = Path(os.environ.get("GITHUB_WORKSPACE", ".")).resolve()
35
+ env_path = Path(os.environ["GITHUB_ENV"])
36
+ output_path = Path(os.environ["GITHUB_OUTPUT"])
37
+ try:
38
+ if not (workspace / "pubspec.yaml").is_file():
39
+ raise ConfigError("pubspec.yaml not found; Android action requires Flutter")
40
+ signing = find_signing_config(workspace)
41
+ 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)
45
+ except (ConfigError, KeyError, OSError) as exc:
46
+ print(f"::error::{exc}")
47
+ raise SystemExit(1) from exc
48
+
49
+ for secret in (signing.store_password, signing.key_password):
50
+ print(f"::add-mask::{secret}")
51
+ values = {
52
+ "ANDROID_PACKAGE_NAME": package_name,
53
+ "ANDROID_BUILD_NUMBER": str(build_number),
54
+ "ANDROID_SIGNING_PROPERTIES": str(signing.properties_path),
55
+ "ANDROID_KEYSTORE_PATH": str(signing.keystore_path),
56
+ "ANDROID_PLAY_SERVICE_ACCOUNT": str(service_account),
57
+ }
58
+ for name, value in values.items():
59
+ append_value(env_path, name, value)
60
+ for name, value in (
61
+ ("package_name", package_name),
62
+ ("service_account", str(service_account)),
63
+ ("build_number", str(build_number)),
64
+ ):
65
+ append_value(output_path, name, value)
66
+ print(f"Resolved Android package {package_name}, build {build_number}")
67
+
68
+
69
+ if __name__ == "__main__":
70
+ main()
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env python3
2
+ """Replace any development signature on an AAB with the upload key."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+ from android_config import ConfigError, parse_properties
11
+
12
+
13
+ def run(command: list[str], *, allowed: set[int] | None = None) -> None:
14
+ completed = subprocess.run(command, check=False)
15
+ accepted = allowed or {0}
16
+ if completed.returncode not in accepted:
17
+ raise ConfigError(f"command failed with exit code {completed.returncode}: {command[0]}")
18
+
19
+
20
+ def main() -> None:
21
+ parser = argparse.ArgumentParser()
22
+ parser.add_argument("--bundle", required=True, type=Path)
23
+ parser.add_argument("--properties", required=True, type=Path)
24
+ parser.add_argument("--keystore", required=True, type=Path)
25
+ args = parser.parse_args()
26
+
27
+ try:
28
+ values = parse_properties(args.properties)
29
+ store_password = values["storePassword"]
30
+ key_password = values["keyPassword"]
31
+ key_alias = values["keyAlias"]
32
+ if not args.bundle.is_file():
33
+ raise ConfigError(f"bundle does not exist: {args.bundle}")
34
+ for secret in (store_password, key_password):
35
+ print(f"::add-mask::{secret}")
36
+ run(
37
+ [
38
+ "zip",
39
+ "-d",
40
+ str(args.bundle),
41
+ "META-INF/*.SF",
42
+ "META-INF/*.RSA",
43
+ "META-INF/*.DSA",
44
+ "META-INF/*.EC",
45
+ "META-INF/SIG-*",
46
+ ],
47
+ allowed={0, 12},
48
+ )
49
+ run(
50
+ [
51
+ "jarsigner",
52
+ "-keystore",
53
+ str(args.keystore),
54
+ "-storepass",
55
+ store_password,
56
+ "-keypass",
57
+ key_password,
58
+ str(args.bundle),
59
+ key_alias,
60
+ ]
61
+ )
62
+ run(["jarsigner", "-verify", str(args.bundle)])
63
+ except (ConfigError, KeyError, OSError) as exc:
64
+ print(f"::error::{exc}")
65
+ raise SystemExit(1) from exc
66
+ print(f"Signed Android App Bundle: {args.bundle}")
67
+
68
+
69
+ if __name__ == "__main__":
70
+ main()
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env python3
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import tempfile
7
+ import unittest
8
+ from pathlib import Path
9
+
10
+ from android_config import (
11
+ ConfigError,
12
+ detect_bundle,
13
+ detect_package_name,
14
+ find_play_service_account,
15
+ find_signing_config,
16
+ )
17
+
18
+
19
+ class AndroidConfigTest(unittest.TestCase):
20
+ def setUp(self) -> None:
21
+ self.temp = tempfile.TemporaryDirectory()
22
+ self.workspace = Path(self.temp.name)
23
+ (self.workspace / "creds").mkdir()
24
+
25
+ def tearDown(self) -> None:
26
+ self.temp.cleanup()
27
+
28
+ def test_finds_arbitrarily_named_service_account(self) -> None:
29
+ path = self.workspace / "creds" / "Gowalk.json"
30
+ path.write_text(
31
+ json.dumps(
32
+ {
33
+ "type": "service_account",
34
+ "client_email": "ci@example.invalid",
35
+ "private_key": "secret",
36
+ }
37
+ )
38
+ )
39
+ (self.workspace / "creds" / "metadata.json").write_text("{}")
40
+
41
+ self.assertEqual(find_play_service_account(self.workspace), path.resolve())
42
+
43
+ def test_rejects_multiple_service_accounts(self) -> None:
44
+ payload = json.dumps(
45
+ {
46
+ "type": "service_account",
47
+ "client_email": "ci@example.invalid",
48
+ "private_key": "secret",
49
+ }
50
+ )
51
+ (self.workspace / "creds" / "one.json").write_text(payload)
52
+ (self.workspace / "creds" / "two.json").write_text(payload)
53
+
54
+ with self.assertRaisesRegex(ConfigError, "multiple Google Play"):
55
+ find_play_service_account(self.workspace)
56
+
57
+ def test_loads_signing_config_next_to_keystore(self) -> None:
58
+ properties = self.workspace / "creds" / "android-signing.properties"
59
+ properties.write_text(
60
+ "storeFile=android-upload-key.jks\n"
61
+ "storePassword=store-secret\n"
62
+ "keyPassword=key-secret\n"
63
+ "keyAlias=upload\n"
64
+ )
65
+ keystore = self.workspace / "creds" / "android-upload-key.jks"
66
+ keystore.write_bytes(b"keystore")
67
+
68
+ result = find_signing_config(self.workspace)
69
+
70
+ self.assertEqual(result.keystore_path, keystore.resolve())
71
+ self.assertEqual(result.key_alias, "upload")
72
+
73
+ def test_detects_kotlin_application_id(self) -> None:
74
+ gradle = self.workspace / "android" / "app" / "build.gradle.kts"
75
+ gradle.parent.mkdir(parents=True)
76
+ gradle.write_text('android { defaultConfig { applicationId = "benz.gowalk.com" } }')
77
+
78
+ self.assertEqual(detect_package_name(self.workspace), "benz.gowalk.com")
79
+
80
+ def test_detects_only_bundle(self) -> None:
81
+ bundle = (
82
+ self.workspace
83
+ / "build"
84
+ / "app"
85
+ / "outputs"
86
+ / "bundle"
87
+ / "release"
88
+ / "app-release.aab"
89
+ )
90
+ bundle.parent.mkdir(parents=True)
91
+ bundle.write_bytes(b"bundle")
92
+
93
+ self.assertEqual(detect_bundle(self.workspace), bundle)
94
+
95
+
96
+ if __name__ == "__main__":
97
+ unittest.main()
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env python3
2
+ """Tests for bitrise_deploy.py config/token/branch resolution."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ import sys
9
+ import tempfile
10
+ import unittest
11
+ from pathlib import Path
12
+ from unittest import mock
13
+
14
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
15
+
16
+ import bitrise_deploy as bd # noqa: E402
17
+
18
+
19
+ class _Env:
20
+ def __init__(self, mapping):
21
+ self._mapping = mapping
22
+ self._prev = {}
23
+
24
+ def __enter__(self):
25
+ for k, v in self._mapping.items():
26
+ self._prev[k] = os.environ.get(k)
27
+ if v is None:
28
+ os.environ.pop(k, None)
29
+ else:
30
+ os.environ[k] = v
31
+ return self
32
+
33
+ def __exit__(self, *_exc):
34
+ for k, prev in self._prev.items():
35
+ if prev is None:
36
+ os.environ.pop(k, None)
37
+ else:
38
+ os.environ[k] = prev
39
+
40
+
41
+ class LoadConfigTests(unittest.TestCase):
42
+ def test_loads_valid_config(self):
43
+ with tempfile.TemporaryDirectory() as tmp:
44
+ creds = Path(tmp) / "creds"
45
+ creds.mkdir()
46
+ (creds / "bitrise.json").write_text(json.dumps(
47
+ {"enabled": True, "app_slug": "abc123", "workflow": "android-deploy"}))
48
+ with _Env({"GITHUB_WORKSPACE": tmp}):
49
+ cfg = bd.load_config()
50
+ self.assertEqual(cfg["app_slug"], "abc123")
51
+
52
+ def test_missing_file_exits(self):
53
+ with tempfile.TemporaryDirectory() as tmp, _Env({"GITHUB_WORKSPACE": tmp}):
54
+ with self.assertRaises(SystemExit):
55
+ bd.load_config()
56
+
57
+ def test_missing_app_slug_exits(self):
58
+ with tempfile.TemporaryDirectory() as tmp:
59
+ creds = Path(tmp) / "creds"
60
+ creds.mkdir()
61
+ (creds / "bitrise.json").write_text(json.dumps({"enabled": True}))
62
+ with _Env({"GITHUB_WORKSPACE": tmp}):
63
+ with self.assertRaises(SystemExit):
64
+ bd.load_config()
65
+
66
+
67
+ class ResolveTokenTests(unittest.TestCase):
68
+ def test_env_token_wins(self):
69
+ with _Env({"BITRISE_API_TOKEN": "env-tok"}):
70
+ self.assertEqual(bd.resolve_token({"api_token": "cfg-tok"}), "env-tok")
71
+
72
+ def test_falls_back_to_config_token(self):
73
+ with _Env({"BITRISE_API_TOKEN": None}):
74
+ self.assertEqual(bd.resolve_token({"api_token": "cfg-tok"}), "cfg-tok")
75
+
76
+ def test_no_token_exits(self):
77
+ with _Env({"BITRISE_API_TOKEN": None}):
78
+ with self.assertRaises(SystemExit):
79
+ bd.resolve_token({})
80
+
81
+
82
+ class ResolveBranchTests(unittest.TestCase):
83
+ def test_config_branch_wins(self):
84
+ with _Env({"GITHUB_REF": "refs/heads/feature"}):
85
+ self.assertEqual(bd.resolve_branch({"branch": "release"}), "release")
86
+
87
+ def test_derives_from_github_ref(self):
88
+ with _Env({"GITHUB_REF": "refs/heads/main"}):
89
+ self.assertEqual(bd.resolve_branch({}), "main")
90
+
91
+ def test_defaults_to_main(self):
92
+ with _Env({"GITHUB_REF": None}):
93
+ self.assertEqual(bd.resolve_branch({}), "main")
94
+
95
+
96
+ class TriggerTests(unittest.TestCase):
97
+ def test_trigger_posts_expected_body(self):
98
+ captured = {}
99
+
100
+ def fake_api(method, path, token, body=None):
101
+ captured["method"] = method
102
+ captured["path"] = path
103
+ captured["body"] = body
104
+ return {"status": "ok", "build_slug": "bs1", "build_number": 7}
105
+
106
+ with _Env({"GITHUB_SHA": "deadbeef"}), \
107
+ mock.patch.object(bd, "api", side_effect=fake_api):
108
+ slug, num, wf = bd.trigger(
109
+ {"app_slug": "abc", "workflow": "android-deploy"}, "tok", "main")
110
+ self.assertEqual((slug, num, wf), ("bs1", 7, "android-deploy"))
111
+ self.assertEqual(captured["method"], "POST")
112
+ self.assertEqual(captured["path"], "/apps/abc/builds")
113
+ self.assertEqual(captured["body"]["build_params"]["branch"], "main")
114
+ self.assertEqual(captured["body"]["build_params"]["workflow_id"], "android-deploy")
115
+ self.assertEqual(captured["body"]["build_params"]["commit_hash"], "deadbeef")
116
+
117
+ def test_trigger_rejects_non_ok(self):
118
+ with mock.patch.object(bd, "api", return_value={"status": "error", "message": "bad"}):
119
+ with self.assertRaises(SystemExit):
120
+ bd.trigger({"app_slug": "abc"}, "tok", "main")
121
+
122
+
123
+ if __name__ == "__main__":
124
+ unittest.main()