mkit-cli 0.3.1__py3-none-any.whl
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.
- mkit/__init__.py +5 -0
- mkit/art.py +1220 -0
- mkit/aso.py +359 -0
- mkit/cli.py +176 -0
- mkit/cloud/__init__.py +1 -0
- mkit/cloud/auth.py +130 -0
- mkit/cloud/fbproject.py +180 -0
- mkit/cloud/firebase.py +164 -0
- mkit/cloud/insight.py +286 -0
- mkit/cloud/money.py +676 -0
- mkit/cloud/play.py +497 -0
- mkit/cloud/store.py +390 -0
- mkit/commands/__init__.py +2 -0
- mkit/commands/art.py +116 -0
- mkit/commands/aso.py +130 -0
- mkit/commands/dev.py +256 -0
- mkit/commands/doctor.py +191 -0
- mkit/commands/firebase_cmds.py +194 -0
- mkit/commands/init.py +213 -0
- mkit/commands/insight.py +110 -0
- mkit/commands/meta.py +84 -0
- mkit/commands/money.py +152 -0
- mkit/commands/release.py +430 -0
- mkit/commands/selfcmd.py +167 -0
- mkit/commands/store.py +263 -0
- mkit/config.py +146 -0
- mkit/data/art/art.toml +119 -0
- mkit/data/art/en.json +38 -0
- mkit/data/datasafety-questions.csv +783 -0
- mkit/datasafety.py +302 -0
- mkit/device.py +246 -0
- mkit/docgen.py +100 -0
- mkit/errors.py +78 -0
- mkit/git.py +95 -0
- mkit/hooks.py +32 -0
- mkit/kinds.py +350 -0
- mkit/notify.py +49 -0
- mkit/output.py +377 -0
- mkit/proc.py +154 -0
- mkit/registry.py +99 -0
- mkit/safety.py +60 -0
- mkit/secrets.py +148 -0
- mkit/toolchain.py +210 -0
- mkit_cli-0.3.1.dist-info/METADATA +179 -0
- mkit_cli-0.3.1.dist-info/RECORD +48 -0
- mkit_cli-0.3.1.dist-info/WHEEL +4 -0
- mkit_cli-0.3.1.dist-info/entry_points.txt +2 -0
- mkit_cli-0.3.1.dist-info/licenses/LICENSE +21 -0
mkit/errors.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Errors and exit codes.
|
|
2
|
+
|
|
3
|
+
Every failure mkit reports is an MkitError: a stable machine code, a sentence a
|
|
4
|
+
human can read, and a hint that says what to do about it. The exit code is part
|
|
5
|
+
of the contract, so an agent can branch on it without parsing text.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
OK = 0
|
|
9
|
+
FAILED = 1
|
|
10
|
+
USAGE = 2
|
|
11
|
+
AUTH = 3
|
|
12
|
+
NOT_FOUND = 4
|
|
13
|
+
CONFLICT = 5
|
|
14
|
+
NEEDS_CONFIRM = 6
|
|
15
|
+
PARTIAL = 7
|
|
16
|
+
DRIFT = 10
|
|
17
|
+
INTERRUPTED = 130
|
|
18
|
+
|
|
19
|
+
EXIT_CODES = {
|
|
20
|
+
OK: 'ok',
|
|
21
|
+
FAILED: 'failed',
|
|
22
|
+
USAGE: 'usage error',
|
|
23
|
+
AUTH: 'credential missing or rejected',
|
|
24
|
+
NOT_FOUND: 'not found',
|
|
25
|
+
CONFLICT: 'conflict: remote changed, edit expired, or already in review',
|
|
26
|
+
NEEDS_CONFIRM: 'needs confirmation (--yes --confirm <package>)',
|
|
27
|
+
PARTIAL: 'done, with skipped steps',
|
|
28
|
+
DRIFT: 'plan found differences',
|
|
29
|
+
INTERRUPTED: 'interrupted',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class MkitError(Exception):
|
|
34
|
+
exit_code = FAILED
|
|
35
|
+
code = 'FAILED'
|
|
36
|
+
|
|
37
|
+
def __init__(self, message, hint=None, code=None, retryable=False, lines=None):
|
|
38
|
+
super().__init__(message)
|
|
39
|
+
self.message = message
|
|
40
|
+
self.hint = hint
|
|
41
|
+
self.retryable = retryable
|
|
42
|
+
# The captured tail of a failed tool, shown as-is under the headline.
|
|
43
|
+
self.lines = lines or []
|
|
44
|
+
if code:
|
|
45
|
+
self.code = code
|
|
46
|
+
|
|
47
|
+
def as_dict(self):
|
|
48
|
+
out = {'code': self.code, 'message': self.message, 'retryable': self.retryable}
|
|
49
|
+
if self.hint:
|
|
50
|
+
out['hint'] = self.hint
|
|
51
|
+
if self.lines:
|
|
52
|
+
out['lines'] = self.lines
|
|
53
|
+
return out
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class UsageError(MkitError):
|
|
57
|
+
exit_code = USAGE
|
|
58
|
+
code = 'USAGE'
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class CredentialMissing(MkitError):
|
|
62
|
+
exit_code = AUTH
|
|
63
|
+
code = 'CREDENTIAL_MISSING'
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class NotFound(MkitError):
|
|
67
|
+
exit_code = NOT_FOUND
|
|
68
|
+
code = 'NOT_FOUND'
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Conflict(MkitError):
|
|
72
|
+
exit_code = CONFLICT
|
|
73
|
+
code = 'CONFLICT'
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class NeedsConfirm(MkitError):
|
|
77
|
+
exit_code = NEEDS_CONFIRM
|
|
78
|
+
code = 'NEEDS_CONFIRM'
|
mkit/git.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""The git safety net around a release.
|
|
2
|
+
|
|
3
|
+
deploy commits in two steps before it touches Play: whatever was already
|
|
4
|
+
uncommitted gets a commit of its own (so it is never at risk), then the
|
|
5
|
+
version bump gets its own commit. If anything fails before Play accepts the
|
|
6
|
+
release, exactly that one bump commit is undone -- and only if it is still the
|
|
7
|
+
commit at HEAD, so a failure can never drop someone else's work.
|
|
8
|
+
"""
|
|
9
|
+
from . import proc
|
|
10
|
+
from .toolchain import tool
|
|
11
|
+
|
|
12
|
+
SNAPSHOT_MESSAGE = 'chore: snapshot pending changes before release'
|
|
13
|
+
# How deploy names its version bump, so the last release can be found again.
|
|
14
|
+
BUMP_PREFIX = 'chore: bump version to '
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Repo:
|
|
18
|
+
def __init__(self, root):
|
|
19
|
+
self.root = root
|
|
20
|
+
|
|
21
|
+
def _git(self, *args, check=True):
|
|
22
|
+
return proc.run([tool('git'), *args], cwd=self.root, check=check)
|
|
23
|
+
|
|
24
|
+
def read(self, *args):
|
|
25
|
+
try:
|
|
26
|
+
return self._git(*args).strip()
|
|
27
|
+
except Exception:
|
|
28
|
+
return ''
|
|
29
|
+
|
|
30
|
+
def is_repo(self):
|
|
31
|
+
return self.read('rev-parse', '--is-inside-work-tree') == 'true'
|
|
32
|
+
|
|
33
|
+
def branch(self):
|
|
34
|
+
return self.read('rev-parse', '--abbrev-ref', 'HEAD')
|
|
35
|
+
|
|
36
|
+
def head(self, short=True):
|
|
37
|
+
return self.read('rev-parse', '--short' if short else '--verify', 'HEAD')
|
|
38
|
+
|
|
39
|
+
def dirty(self):
|
|
40
|
+
return [l for l in self._git('status', '--porcelain').splitlines() if l.strip()]
|
|
41
|
+
|
|
42
|
+
def snapshot(self):
|
|
43
|
+
"""Commit everything already dirty on its own. Returns how many paths."""
|
|
44
|
+
dirty = self.dirty()
|
|
45
|
+
if dirty:
|
|
46
|
+
self._git('add', '-A')
|
|
47
|
+
self._git('commit', '-m', SNAPSHOT_MESSAGE)
|
|
48
|
+
return len(dirty)
|
|
49
|
+
|
|
50
|
+
def commit_files(self, files, message):
|
|
51
|
+
self._git('add', '--', *[str(f) for f in files])
|
|
52
|
+
self._git('commit', '-m', message)
|
|
53
|
+
return self.head(short=False)
|
|
54
|
+
|
|
55
|
+
def undo(self, expected_head):
|
|
56
|
+
"""Drop HEAD only if it is still the commit we made."""
|
|
57
|
+
if self.head(short=False) != expected_head:
|
|
58
|
+
return False
|
|
59
|
+
self._git('reset', '--hard', 'HEAD~1')
|
|
60
|
+
return True
|
|
61
|
+
|
|
62
|
+
def last_release(self):
|
|
63
|
+
"""The commit of the last version bump deploy made, or "" before the first."""
|
|
64
|
+
return self.read('log', '-1', '--format=%H', f'--grep=^{BUMP_PREFIX}')
|
|
65
|
+
|
|
66
|
+
def changed_since(self, commit, path):
|
|
67
|
+
"""Whether path differs from commit, counting uncommitted and untracked files."""
|
|
68
|
+
return bool(self.read('diff', '--name-only', commit, '--', str(path))
|
|
69
|
+
or self.read('ls-files', '--others', '--exclude-standard', '--', str(path)))
|
|
70
|
+
|
|
71
|
+
def upstream(self):
|
|
72
|
+
"""The branch this one pushes to, or "" when it has none."""
|
|
73
|
+
return self.read('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}')
|
|
74
|
+
|
|
75
|
+
def push(self):
|
|
76
|
+
"""Push the current branch. Returns (ok, detail); never raises.
|
|
77
|
+
|
|
78
|
+
By the time a release pushes, the build is already on Play. A push that
|
|
79
|
+
cannot go - no remote, no upstream, a branch someone else moved - is a
|
|
80
|
+
thing to tell the user about, not a reason to end a finished release in
|
|
81
|
+
a traceback. The detail is what to say next, not a diagnosis.
|
|
82
|
+
"""
|
|
83
|
+
upstream = self.upstream()
|
|
84
|
+
if not upstream:
|
|
85
|
+
return False, f'no upstream; run: git push -u origin {self.branch()}'
|
|
86
|
+
try:
|
|
87
|
+
self._git('push')
|
|
88
|
+
except Exception as e:
|
|
89
|
+
lines = [l for l in getattr(e, 'lines', []) if l.strip()]
|
|
90
|
+
return False, (lines[-1] if lines else str(e))[:200]
|
|
91
|
+
return True, upstream
|
|
92
|
+
|
|
93
|
+
def restore(self, files):
|
|
94
|
+
"""Put files back to HEAD (a dry run's temporary bump)."""
|
|
95
|
+
self._git('checkout', '--', *[str(f) for f in files])
|
mkit/hooks.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Project hooks: a project's own scripts, run at fixed points of a pipeline.
|
|
2
|
+
|
|
3
|
+
[hooks]
|
|
4
|
+
pre_deploy = "python console/check.py"
|
|
5
|
+
post_build = "console/sign_extra.sh"
|
|
6
|
+
post_deploy = "python console/deploy_backend.py"
|
|
7
|
+
|
|
8
|
+
A hook runs in the project root with MKIT_VERSION_NAME / MKIT_VERSION_CODE /
|
|
9
|
+
MKIT_DRY_RUN set. A non-zero exit fails the step it belongs to, like any other
|
|
10
|
+
step. The command is split like a shell would, but runs without a shell.
|
|
11
|
+
"""
|
|
12
|
+
import os
|
|
13
|
+
import shlex
|
|
14
|
+
|
|
15
|
+
from . import proc
|
|
16
|
+
|
|
17
|
+
POINTS = ('pre_deploy', 'post_build', 'post_deploy', 'post_promote')
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get(ctx, point):
|
|
21
|
+
return ctx.project.section('hooks').get(point)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def run(ctx, point, **env_values):
|
|
25
|
+
command = get(ctx, point)
|
|
26
|
+
if not command:
|
|
27
|
+
return None
|
|
28
|
+
argv = shlex.split(command, posix=os.name != 'nt')
|
|
29
|
+
env = dict(os.environ, MKIT_DRY_RUN='1' if ctx.dry_run else '0',
|
|
30
|
+
**{f'MKIT_{k.upper()}': str(v) for k, v in env_values.items()})
|
|
31
|
+
proc.stream(argv, f'hook {point}', lambda line: bool(line.strip()), cwd=ctx.project.root, env=env)
|
|
32
|
+
return command
|
mkit/kinds.py
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""The three kinds of app, and the only things that differ between them:
|
|
2
|
+
where the version lives, how a build is run, and where its outputs land.
|
|
3
|
+
|
|
4
|
+
flutter version: x.y.z+n in pubspec.yaml; Gradle project in android/
|
|
5
|
+
android versionCode/versionName in <module>/build.gradle(.kts), or
|
|
6
|
+
version.properties at the root if the project keeps it there
|
|
7
|
+
android-wear android, plus a `wear` module shipped on Play's Wear track
|
|
8
|
+
|
|
9
|
+
Every bump moves the name AND the code, and the code by exactly 1 whatever the
|
|
10
|
+
level: Play only needs it to increase, users only ever see the name, and moving
|
|
11
|
+
one without the other ships a build Play takes but users read as the old one.
|
|
12
|
+
|
|
13
|
+
Files are read and written with newline='' so a bump changes the version and
|
|
14
|
+
nothing else. (Reading in text mode turns CRLF into LF, and the whole file
|
|
15
|
+
would go back out rewritten in the bump commit.)
|
|
16
|
+
"""
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from . import proc
|
|
23
|
+
from .errors import MkitError, UsageError
|
|
24
|
+
from .toolchain import gradlew, tool
|
|
25
|
+
|
|
26
|
+
LEVELS = ('major', 'minor', 'patch')
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Module:
|
|
31
|
+
label: str # "phone" / "watch": what a person calls it
|
|
32
|
+
factor: str # "phone" / "wear": which Play track family it ships on
|
|
33
|
+
gradle: str # Gradle module name
|
|
34
|
+
aab: Path
|
|
35
|
+
mapping: Path
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def bump_name(name, level):
|
|
39
|
+
if level not in LEVELS:
|
|
40
|
+
raise UsageError(f'bump must be one of {", ".join(LEVELS)}, not "{level}"')
|
|
41
|
+
parts = [int(p) for p in re.findall(r'\d+', name)[:3]]
|
|
42
|
+
while len(parts) < 3:
|
|
43
|
+
parts.append(0)
|
|
44
|
+
major, minor, patch = parts
|
|
45
|
+
if level == 'major':
|
|
46
|
+
return f'{major + 1}.0.0'
|
|
47
|
+
if level == 'minor':
|
|
48
|
+
return f'{major}.{minor + 1}.0'
|
|
49
|
+
return f'{major}.{minor}.{patch + 1}'
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _read(path):
|
|
53
|
+
try:
|
|
54
|
+
with open(path, encoding='utf-8', newline='') as f:
|
|
55
|
+
return f.read()
|
|
56
|
+
except FileNotFoundError:
|
|
57
|
+
raise MkitError(f'{path} not found', code='NO_VERSION_FILE') from None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _write(path, text):
|
|
61
|
+
with open(path, 'w', encoding='utf-8', newline='') as f:
|
|
62
|
+
f.write(text)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# A lookahead for the line end, never a match: trailing spaces and the CR of a
|
|
66
|
+
# CRLF file stay exactly where they were.
|
|
67
|
+
_EOL = r'(?=[ \t]*\r?$)'
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class VersionSource:
|
|
71
|
+
"""One file holding versionName and versionCode."""
|
|
72
|
+
def __init__(self, path):
|
|
73
|
+
self.path = Path(path)
|
|
74
|
+
|
|
75
|
+
def read(self):
|
|
76
|
+
raise NotImplementedError
|
|
77
|
+
|
|
78
|
+
def write(self, name, code):
|
|
79
|
+
raise NotImplementedError
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class PubspecVersion(VersionSource):
|
|
83
|
+
RE = re.compile(r'^(version:[ \t]*)(\d+\.\d+\.\d+)\+(\d+)' + _EOL, re.M)
|
|
84
|
+
|
|
85
|
+
def read(self):
|
|
86
|
+
m = self.RE.search(_read(self.path))
|
|
87
|
+
if not m:
|
|
88
|
+
raise MkitError(f'no "version: x.y.z+n" line in {self.path}', code='NO_VERSION')
|
|
89
|
+
return m.group(2), int(m.group(3))
|
|
90
|
+
|
|
91
|
+
def write(self, name, code):
|
|
92
|
+
text = self.RE.sub(lambda m: f'{m.group(1)}{name}+{code}', _read(self.path), count=1)
|
|
93
|
+
_write(self.path, text)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class GradleVersion(VersionSource):
|
|
97
|
+
CODE = re.compile(r'^([ \t]*versionCode[ \t]*=?[ \t]*)(\d+)' + _EOL, re.M)
|
|
98
|
+
NAME = re.compile(r'''^([ \t]*versionName[ \t]*=?[ \t]*["'])([^"']+)(["'])''' + _EOL, re.M)
|
|
99
|
+
|
|
100
|
+
def read(self):
|
|
101
|
+
text = _read(self.path)
|
|
102
|
+
code, name = self.CODE.search(text), self.NAME.search(text)
|
|
103
|
+
if not code or not name:
|
|
104
|
+
raise MkitError(f'no literal versionCode/versionName in {self.path}', code='NO_VERSION',
|
|
105
|
+
hint='Set version_file under [app] if the version lives elsewhere.')
|
|
106
|
+
return name.group(2), int(code.group(2))
|
|
107
|
+
|
|
108
|
+
def write(self, name, code):
|
|
109
|
+
text = _read(self.path)
|
|
110
|
+
text = self.CODE.sub(lambda m: f'{m.group(1)}{code}', text, count=1)
|
|
111
|
+
text = self.NAME.sub(lambda m: f'{m.group(1)}{name}{m.group(3)}', text, count=1)
|
|
112
|
+
_write(self.path, text)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class PropertiesVersion(VersionSource):
|
|
116
|
+
CODE = re.compile(r'^([ \t]*versionCode[ \t]*=[ \t]*)(\d+)' + _EOL, re.M)
|
|
117
|
+
NAME = re.compile(r'^([ \t]*versionName[ \t]*=[ \t]*)([^\s#]+)' + _EOL, re.M)
|
|
118
|
+
|
|
119
|
+
def read(self):
|
|
120
|
+
text = _read(self.path)
|
|
121
|
+
code, name = self.CODE.search(text), self.NAME.search(text)
|
|
122
|
+
if not code or not name:
|
|
123
|
+
raise MkitError(f'no versionCode/versionName in {self.path}', code='NO_VERSION')
|
|
124
|
+
return name.group(2), int(code.group(2))
|
|
125
|
+
|
|
126
|
+
def write(self, name, code):
|
|
127
|
+
text = _read(self.path)
|
|
128
|
+
text = self.CODE.sub(lambda m: f'{m.group(1)}{code}', text, count=1)
|
|
129
|
+
text = self.NAME.sub(lambda m: f'{m.group(1)}{name}', text, count=1)
|
|
130
|
+
_write(self.path, text)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def version_source(path):
|
|
134
|
+
path = Path(path)
|
|
135
|
+
if path.name == 'pubspec.yaml':
|
|
136
|
+
return PubspecVersion(path)
|
|
137
|
+
if path.suffix == '.properties':
|
|
138
|
+
return PropertiesVersion(path)
|
|
139
|
+
return GradleVersion(path)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
_DEBUG_BLOCK = re.compile(r'(?:\bdebug\s*|getByName\(\s*"debug"\s*\)\s*|named\(\s*"debug"\s*\)\s*)\{')
|
|
143
|
+
_ID_SUFFIX = re.compile(r'''applicationIdSuffix\s*=?\s*["']([^"']*)["']''')
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def gradle_debug_suffix(text):
|
|
147
|
+
"""The debug build type's applicationIdSuffix in a module's Gradle file
|
|
148
|
+
(Groovy or Kotlin DSL), or '' when it sets none.
|
|
149
|
+
|
|
150
|
+
Only the debug block counts: a suffix on another build type or a flavour
|
|
151
|
+
says nothing about where a debug build installs.
|
|
152
|
+
"""
|
|
153
|
+
for match in _DEBUG_BLOCK.finditer(text):
|
|
154
|
+
depth, i = 1, match.end()
|
|
155
|
+
while i < len(text) and depth:
|
|
156
|
+
depth += {'{': 1, '}': -1}.get(text[i], 0)
|
|
157
|
+
i += 1
|
|
158
|
+
found = _ID_SUFFIX.search(text, match.end(), i)
|
|
159
|
+
if found:
|
|
160
|
+
return found.group(1)
|
|
161
|
+
return ''
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class Kind:
|
|
165
|
+
name = ''
|
|
166
|
+
|
|
167
|
+
def __init__(self, project):
|
|
168
|
+
self.project = project
|
|
169
|
+
self.root = project.root
|
|
170
|
+
self.module = project.get('app.module', 'app')
|
|
171
|
+
|
|
172
|
+
# ---------------------------------------------------------------- version
|
|
173
|
+
|
|
174
|
+
def default_version_file(self):
|
|
175
|
+
raise NotImplementedError
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def version(self):
|
|
179
|
+
custom = self.project.get('app.version_file')
|
|
180
|
+
return version_source(self.root / custom if custom else self.default_version_file())
|
|
181
|
+
|
|
182
|
+
def current_version(self):
|
|
183
|
+
return self.version.read()
|
|
184
|
+
|
|
185
|
+
def bump(self, level='patch'):
|
|
186
|
+
"""Rewrite the version file. Returns (old_name, old_code, new_name, new_code)."""
|
|
187
|
+
old_name, old_code = self.current_version()
|
|
188
|
+
new_name, new_code = bump_name(old_name, level), old_code + 1
|
|
189
|
+
self.version.write(new_name, new_code)
|
|
190
|
+
return old_name, old_code, new_name, new_code
|
|
191
|
+
|
|
192
|
+
# ---------------------------------------------------------------- packages
|
|
193
|
+
|
|
194
|
+
def module_gradle_file(self):
|
|
195
|
+
for name in ('build.gradle.kts', 'build.gradle'):
|
|
196
|
+
path = self.android_dir / self.module / name
|
|
197
|
+
if path.is_file():
|
|
198
|
+
return path
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
def debug_package(self):
|
|
202
|
+
"""Where a debug build installs.
|
|
203
|
+
|
|
204
|
+
`app.debug_suffix` in mkit.toml wins; otherwise it is read from the
|
|
205
|
+
debug build type in Gradle. It used to be assumed to be ".debug", so on
|
|
206
|
+
an app without a suffix `run -c` cleaned a package that did not exist
|
|
207
|
+
and left the real one alone.
|
|
208
|
+
"""
|
|
209
|
+
suffix = self.project.get('app.debug_suffix')
|
|
210
|
+
if suffix is None:
|
|
211
|
+
gradle = self.module_gradle_file()
|
|
212
|
+
suffix = gradle_debug_suffix(gradle.read_text(encoding='utf-8')) if gradle else ''
|
|
213
|
+
return self.project.package + suffix
|
|
214
|
+
|
|
215
|
+
def installed_package(self, release=False):
|
|
216
|
+
"""The package a run of that variant puts on the device."""
|
|
217
|
+
return self.project.package if release else self.debug_package()
|
|
218
|
+
|
|
219
|
+
# ---------------------------------------------------------------- build
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def android_dir(self):
|
|
223
|
+
return self.root
|
|
224
|
+
|
|
225
|
+
def signing_candidates(self):
|
|
226
|
+
return [self.android_dir / 'key.properties', self.root / 'keystore.properties',
|
|
227
|
+
self.root / 'key.properties']
|
|
228
|
+
|
|
229
|
+
def signing_file(self):
|
|
230
|
+
custom = self.project.get('app.signing')
|
|
231
|
+
if custom:
|
|
232
|
+
path = self.root / custom
|
|
233
|
+
return path if path.is_file() else None
|
|
234
|
+
return next((p for p in self.signing_candidates() if p.is_file()), None)
|
|
235
|
+
|
|
236
|
+
def modules(self):
|
|
237
|
+
base = self.root / self.module / 'build' / 'outputs'
|
|
238
|
+
return [Module('phone', 'phone', self.module,
|
|
239
|
+
base / 'bundle' / 'release' / f'{self.module}-release.aab',
|
|
240
|
+
base / 'mapping' / 'release' / 'mapping.txt')]
|
|
241
|
+
|
|
242
|
+
def prepare(self, variant, env):
|
|
243
|
+
"""Anything that must run before Gradle for this variant."""
|
|
244
|
+
|
|
245
|
+
def bundle(self, env):
|
|
246
|
+
"""Build every module's release bundle. Returns the modules built."""
|
|
247
|
+
self.prepare('release', env)
|
|
248
|
+
tasks = [f':{m.gradle}:bundleRelease' for m in self.modules()]
|
|
249
|
+
proc.stream([gradlew(self.android_dir), *tasks], 'gradlew ' + ' '.join(tasks),
|
|
250
|
+
cwd=self.android_dir, env=env)
|
|
251
|
+
return self.modules()
|
|
252
|
+
|
|
253
|
+
def apk_dir(self, variant):
|
|
254
|
+
return self.root / self.module / 'build' / 'outputs' / 'apk' / variant
|
|
255
|
+
|
|
256
|
+
def assemble(self, variant, env):
|
|
257
|
+
"""Build the phone APK for `variant`; return (applicationId, apk path)."""
|
|
258
|
+
self.prepare(variant, env)
|
|
259
|
+
task = f':{self.module}:assemble{variant.capitalize()}'
|
|
260
|
+
proc.stream([gradlew(self.android_dir), task], f'gradlew {task}',
|
|
261
|
+
cwd=self.android_dir, env=env)
|
|
262
|
+
directory = self.apk_dir(variant)
|
|
263
|
+
meta_file = directory / 'output-metadata.json'
|
|
264
|
+
if not meta_file.is_file():
|
|
265
|
+
raise MkitError(f'no output-metadata.json in {directory}', code='NO_APK')
|
|
266
|
+
meta = json.loads(meta_file.read_text(encoding='utf-8'))
|
|
267
|
+
elements = meta.get('elements', [])
|
|
268
|
+
if len(elements) != 1:
|
|
269
|
+
raise MkitError('expected one APK for this variant; split APKs are not supported by run',
|
|
270
|
+
code='NO_APK')
|
|
271
|
+
return meta['applicationId'], directory / elements[0]['outputFile']
|
|
272
|
+
|
|
273
|
+
def test(self, device, env):
|
|
274
|
+
"""Run the project's tests against `device`. Returns the exit code."""
|
|
275
|
+
task = f':{self.module}:connectedDebugAndroidTest'
|
|
276
|
+
return proc.interactive([gradlew(self.android_dir), task], cwd=self.android_dir, env=env)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class Flutter(Kind):
|
|
280
|
+
name = 'flutter'
|
|
281
|
+
|
|
282
|
+
def default_version_file(self):
|
|
283
|
+
return self.root / 'pubspec.yaml'
|
|
284
|
+
|
|
285
|
+
@property
|
|
286
|
+
def android_dir(self):
|
|
287
|
+
return self.root / 'android'
|
|
288
|
+
|
|
289
|
+
def modules(self):
|
|
290
|
+
base = self.root / 'build' / 'app' / 'outputs'
|
|
291
|
+
return [Module('phone', 'phone', 'app',
|
|
292
|
+
base / 'bundle' / 'release' / 'app-release.aab',
|
|
293
|
+
base / 'mapping' / 'release' / 'mapping.txt')]
|
|
294
|
+
|
|
295
|
+
def apk_dir(self, variant):
|
|
296
|
+
return self.root / 'build' / 'app' / 'outputs' / 'apk' / variant
|
|
297
|
+
|
|
298
|
+
def prepare(self, variant, env):
|
|
299
|
+
"""`flutter build apk --<variant> --config-only` regenerates the plugin
|
|
300
|
+
registrant and Gradle config for this mode. A registrant last written by
|
|
301
|
+
`flutter test` still names integration_test, which release does not
|
|
302
|
+
compile in. Needs no JDK; the Gradle wrapper then builds with ours."""
|
|
303
|
+
proc.stream([tool('flutter'), 'build', 'apk', f'--{variant}', '--config-only'],
|
|
304
|
+
f'flutter build apk --{variant} --config-only', cwd=self.root, env=env,
|
|
305
|
+
quiet=True)
|
|
306
|
+
|
|
307
|
+
def test(self, device, env):
|
|
308
|
+
flutter = tool('flutter')
|
|
309
|
+
code = proc.interactive([flutter, 'test'], cwd=self.root, env=env)
|
|
310
|
+
if code != 0 or not (self.root / 'integration_test').is_dir():
|
|
311
|
+
return code
|
|
312
|
+
return proc.interactive([flutter, 'test', 'integration_test', '-d', device],
|
|
313
|
+
cwd=self.root, env=env)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class Android(Kind):
|
|
317
|
+
name = 'android'
|
|
318
|
+
|
|
319
|
+
def default_version_file(self):
|
|
320
|
+
if (self.root / 'version.properties').is_file():
|
|
321
|
+
return self.root / 'version.properties'
|
|
322
|
+
for f in ('build.gradle.kts', 'build.gradle'):
|
|
323
|
+
if (self.root / self.module / f).is_file():
|
|
324
|
+
return self.root / self.module / f
|
|
325
|
+
return self.root / self.module / 'build.gradle.kts'
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class AndroidWear(Android):
|
|
329
|
+
"""A phone module plus a watch module. Play has required the watch to ship on
|
|
330
|
+
its own Wear OS track since August 2023; the upload step resolves that."""
|
|
331
|
+
name = 'android-wear'
|
|
332
|
+
|
|
333
|
+
def modules(self):
|
|
334
|
+
wear = self.project.get('app.wear_module', 'wear')
|
|
335
|
+
base = self.root / wear / 'build' / 'outputs'
|
|
336
|
+
return super().modules() + [
|
|
337
|
+
Module('watch', 'wear', wear,
|
|
338
|
+
base / 'bundle' / 'release' / f'{wear}-release.aab',
|
|
339
|
+
base / 'mapping' / 'release' / 'mapping.txt')]
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
KINDS = {k.name: k for k in (Flutter, Android, AndroidWear)}
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def for_project(project):
|
|
346
|
+
kind = project.kind
|
|
347
|
+
if kind not in KINDS:
|
|
348
|
+
raise UsageError(f'app.kind is "{kind}"; set it to one of {", ".join(KINDS)} in mkit.toml',
|
|
349
|
+
code='BAD_CONFIG')
|
|
350
|
+
return KINDS[kind](project)
|
mkit/notify.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Telegram release notifications. Never fails a release.
|
|
2
|
+
|
|
3
|
+
Credential: TELEGRAM = {"bot_token": "...", "chat_id": "..."}. On only when
|
|
4
|
+
[notify.telegram] is in mkit.toml. Messages are plain HTML: the version and
|
|
5
|
+
what happened first, labelled detail underneath, no decoration.
|
|
6
|
+
"""
|
|
7
|
+
import html
|
|
8
|
+
import json
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
|
|
12
|
+
from . import output
|
|
13
|
+
|
|
14
|
+
API = 'https://api.telegram.org/bot{token}/sendMessage'
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def esc(value):
|
|
18
|
+
return html.escape(str(value), quote=False)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def enabled(ctx):
|
|
22
|
+
return ctx.project.has('notify.telegram') and ctx.secrets.has('TELEGRAM')
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def message(ctx, subject, headline, fields=()):
|
|
26
|
+
lines = [f'<b>{esc(ctx.project.name)} {esc(subject)}</b>', headline]
|
|
27
|
+
detail = [f'{label}: {esc(value)}' for label, value in fields if value]
|
|
28
|
+
if detail:
|
|
29
|
+
lines += [''] + detail
|
|
30
|
+
return '\n'.join(lines)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def send(ctx, text):
|
|
34
|
+
"""True if delivered. Problems only warn: the success path runs when a build
|
|
35
|
+
is already live, and a failure path must not swap its real error for a
|
|
36
|
+
chat-delivery one."""
|
|
37
|
+
if not enabled(ctx):
|
|
38
|
+
return False
|
|
39
|
+
creds = ctx.secrets.json('TELEGRAM')
|
|
40
|
+
fields = {'chat_id': creds['chat_id'], 'text': text, 'parse_mode': 'HTML',
|
|
41
|
+
'link_preview_options': json.dumps({'is_disabled': True})}
|
|
42
|
+
request = urllib.request.Request(API.format(token=creds['bot_token']),
|
|
43
|
+
data=urllib.parse.urlencode(fields).encode('utf-8'))
|
|
44
|
+
try:
|
|
45
|
+
urllib.request.urlopen(request, timeout=30)
|
|
46
|
+
return True
|
|
47
|
+
except Exception as e:
|
|
48
|
+
output.out.warn(f'Telegram notification failed: {type(e).__name__}')
|
|
49
|
+
return False
|