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,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()
|