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/commands/release.py
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
"""Releasing: deploy, status, promote, rollout, Firestore rules, update doc.
|
|
2
|
+
|
|
3
|
+
deploy is one pipeline for every app. Each step runs only if this app uses it
|
|
4
|
+
(its table is in mkit.toml) and its credential is present:
|
|
5
|
+
|
|
6
|
+
1 Preflight tools, JDK, signing, Play access, what will be skipped
|
|
7
|
+
2 Snapshot commit whatever was already uncommitted, on its own
|
|
8
|
+
3 Bump version name + code, committed on its own
|
|
9
|
+
4 Firestore rules [firebase] rules = true
|
|
10
|
+
5 Build release bundle(s); [hooks] post_build
|
|
11
|
+
6 Upload bundle, mapping, what's new -> track, commit
|
|
12
|
+
7 Update doc [firebase] update_doc
|
|
13
|
+
8 Notify [notify.telegram]; [hooks] post_deploy
|
|
14
|
+
9 Push the snapshot and bump commits, to the branch's upstream
|
|
15
|
+
|
|
16
|
+
A failure before step 6 commits undoes the bump commit (only if it is still
|
|
17
|
+
HEAD) and nothing ships. After step 6 the build is live, so later failures warn
|
|
18
|
+
and the bump stays: the repo must keep tracking what actually shipped.
|
|
19
|
+
|
|
20
|
+
--dry-run does the real build and a real upload into a Play edit, validates
|
|
21
|
+
it, and discards it. No git commits (the version file is put back), no rules,
|
|
22
|
+
no Firestore write, no message.
|
|
23
|
+
"""
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
|
|
27
|
+
from .. import hooks, kinds, notify, safety, toolchain
|
|
28
|
+
from ..cloud import auth, firebase, play
|
|
29
|
+
from ..errors import PARTIAL, MkitError, UsageError
|
|
30
|
+
from ..git import BUMP_PREFIX, Repo
|
|
31
|
+
from ..registry import Arg, command, group
|
|
32
|
+
|
|
33
|
+
TRACK_CHOICES = ('internal', 'closed', 'open', 'prod')
|
|
34
|
+
STEPS = 9
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def feature_ready(ctx, feature, secret=None, extra=None):
|
|
38
|
+
"""(ready, reason). Not ready + switched on = a skip with a warning."""
|
|
39
|
+
if not ctx.project.has(feature):
|
|
40
|
+
return False, None
|
|
41
|
+
if secret and not ctx.secrets.has(secret):
|
|
42
|
+
return False, f'{secret} is not set (add MKIT_{secret}_JSON_BASE64 to .env)'
|
|
43
|
+
if extra:
|
|
44
|
+
return extra()
|
|
45
|
+
return True, None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def skip_or_fail(ctx, step, reason):
|
|
49
|
+
"""A switched-on step that cannot run: warn and go on, or fail if --strict."""
|
|
50
|
+
if ctx.strict:
|
|
51
|
+
raise MkitError(f'{step}: {reason}', code='STEP_SKIPPED',
|
|
52
|
+
hint='Without --strict this step is skipped with a warning.')
|
|
53
|
+
ctx.out.skip(step, reason)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_percent(text):
|
|
57
|
+
if text is None:
|
|
58
|
+
return None
|
|
59
|
+
value = float(str(text).rstrip('%'))
|
|
60
|
+
if not 0 < value < 100:
|
|
61
|
+
raise UsageError('rollout must be a percentage between 0 and 100 (exclusive)')
|
|
62
|
+
return round(value / 100, 4)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _rules_extra(ctx):
|
|
66
|
+
if not ctx.project.section('firebase').get('rules'):
|
|
67
|
+
return False, None
|
|
68
|
+
try:
|
|
69
|
+
toolchain.tool('firebase')
|
|
70
|
+
except MkitError:
|
|
71
|
+
return False, 'firebase CLI not found on PATH'
|
|
72
|
+
return True, None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _listing_url(ctx):
|
|
76
|
+
return f'https://play.google.com/store/apps/details?id={ctx.project.package}'
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def version_label(kind, moved):
|
|
80
|
+
""""1.2.2 (8)" for what a promote moved, as deploy writes it.
|
|
81
|
+
|
|
82
|
+
The name is the Play release name deploy sets (the version name), or Play's
|
|
83
|
+
own default "8 (1.2.2)" for a release uploaded some other way; failing both,
|
|
84
|
+
this checkout's version name when its code is the one that moved.
|
|
85
|
+
"""
|
|
86
|
+
codes = [c for m in moved for c in m['versionCodes']]
|
|
87
|
+
names = {m['name'] for m in moved if m.get('name')}
|
|
88
|
+
name = names.pop() if len(names) == 1 else None
|
|
89
|
+
if name and (default := re.fullmatch(r'\d+ \((.+)\)', name)):
|
|
90
|
+
name = default.group(1)
|
|
91
|
+
if not name:
|
|
92
|
+
local_name, local_code = kind.current_version()
|
|
93
|
+
if str(local_code) in codes:
|
|
94
|
+
name = local_name
|
|
95
|
+
joined = ', '.join(codes)
|
|
96
|
+
return f'{name} ({joined})' if name else f'versionCode {joined}'
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _notes_written_for_this_release(repo, project):
|
|
101
|
+
"""A note left as it was at the last release describes that release, not this one."""
|
|
102
|
+
last = repo.last_release()
|
|
103
|
+
if last and not repo.changed_since(last, project.path('store.whatsnew_dir')):
|
|
104
|
+
raise UsageError(f"the what's new notes have not changed since the last release ({last[:7]})",
|
|
105
|
+
code='WHATSNEW_UNCHANGED',
|
|
106
|
+
hint="Write this release's changes in store/whatsnew first, or pass "
|
|
107
|
+
'--same-notes to ship them again on purpose.')
|
|
108
|
+
|
|
109
|
+
@command('deploy', 'Bump, build and release to a Play track (internal by default)', risk='publish',
|
|
110
|
+
needs=('play',),
|
|
111
|
+
args=(Arg('level', 'version bump', kind='positional', nargs='?',
|
|
112
|
+
choices=('major', 'minor', 'patch'), default='patch'),
|
|
113
|
+
Arg('track', 'track to release to', choices=TRACK_CHOICES, default='internal'),
|
|
114
|
+
Arg('rollout', 'production only: staged rollout percentage, e.g. 20'),
|
|
115
|
+
Arg('same_notes', 'ship the release note unchanged from the last release', kind='flag')),
|
|
116
|
+
examples=('mkit deploy', 'mkit deploy minor', 'mkit deploy --dry-run',
|
|
117
|
+
'mkit deploy --yes --confirm com.example.app'))
|
|
118
|
+
def deploy(ctx, args):
|
|
119
|
+
out, project = ctx.out, ctx.project
|
|
120
|
+
kind = kinds.for_project(project)
|
|
121
|
+
repo = Repo(project.root)
|
|
122
|
+
rollout = parse_percent(args.rollout)
|
|
123
|
+
if rollout and args.track != 'prod':
|
|
124
|
+
raise UsageError('--rollout only applies to --track prod')
|
|
125
|
+
old_name, old_code = kind.current_version()
|
|
126
|
+
subtitle = f'{args.level} bump to {play.label(play.track_id(args.track))}' + (' (dry run)' if ctx.dry_run else '')
|
|
127
|
+
out.start(f'{project.name} deploy', STEPS, subtitle)
|
|
128
|
+
|
|
129
|
+
# 1 -------------------------------------------------------------- preflight
|
|
130
|
+
out.step('Preflight')
|
|
131
|
+
if not repo.is_repo():
|
|
132
|
+
raise MkitError(f'{project.root} is not a git repository', code='NO_GIT')
|
|
133
|
+
ctx.secrets.json(auth.PLAY) # required: fails now with the exact key to add
|
|
134
|
+
if not kind.signing_file():
|
|
135
|
+
raise MkitError('no signing config found (key.properties / keystore.properties)',
|
|
136
|
+
code='NO_SIGNING', hint='Put the release keystore config in place, or set '
|
|
137
|
+
'signing under [app] in mkit.toml.')
|
|
138
|
+
jdk = toolchain.find_jdk(project)
|
|
139
|
+
if project.kind == 'flutter':
|
|
140
|
+
toolchain.tool('flutter')
|
|
141
|
+
notes = play.release_notes(project)
|
|
142
|
+
play.check_note_style(notes)
|
|
143
|
+
if notes and not args.same_notes:
|
|
144
|
+
_notes_written_for_this_release(repo, project)
|
|
145
|
+
rules_ok, rules_why = feature_ready(ctx, 'firebase', auth.FIREBASE, lambda: _rules_extra(ctx))
|
|
146
|
+
doc_ok, doc_why = feature_ready(ctx, 'firebase', auth.FIREBASE,
|
|
147
|
+
lambda: (bool(project.get('firebase.update_doc')), None))
|
|
148
|
+
tg_ok, tg_why = feature_ready(ctx, 'notify.telegram', 'TELEGRAM')
|
|
149
|
+
hooks.run(ctx, 'pre_deploy', version_name=old_name, version_code=old_code)
|
|
150
|
+
out.done(f'JDK {toolchain.java_major(jdk)}; {len(notes)} what\'s new locale(s)')
|
|
151
|
+
|
|
152
|
+
new_name = kinds.bump_name(old_name, args.level)
|
|
153
|
+
safety.confirm(ctx, f'Release {project.name} {new_name} ({old_code + 1}) to '
|
|
154
|
+
f'{play.label(play.track_id(args.track))}',
|
|
155
|
+
[f'package {project.package}', f'from {old_name} ({old_code})'])
|
|
156
|
+
|
|
157
|
+
# 2-3 ----------------------------------------------------------- git + bump
|
|
158
|
+
out.step('Snapshot pending changes')
|
|
159
|
+
if ctx.dry_run:
|
|
160
|
+
out.done('dry run: nothing committed')
|
|
161
|
+
else:
|
|
162
|
+
dirty = repo.snapshot()
|
|
163
|
+
out.done('clean' if not dirty else f'{dirty} file(s) committed')
|
|
164
|
+
|
|
165
|
+
out.step('Bump version')
|
|
166
|
+
version_file = kind.version.path
|
|
167
|
+
original = version_file.read_bytes()
|
|
168
|
+
_, _, name, code = kind.bump(args.level)
|
|
169
|
+
bump_head = None
|
|
170
|
+
if not ctx.dry_run:
|
|
171
|
+
bump_head = repo.commit_files([version_file], f'{BUMP_PREFIX}{name} (versionCode {code})')
|
|
172
|
+
out.done(f'{old_name} ({old_code}) -> {name} ({code})')
|
|
173
|
+
|
|
174
|
+
version = f'{name} ({code})'
|
|
175
|
+
result = {'from': {'versionName': old_name, 'versionCode': old_code},
|
|
176
|
+
'versionName': name, 'versionCode': code, 'track': play.track_id(args.track),
|
|
177
|
+
'dry_run': ctx.dry_run}
|
|
178
|
+
stage = 'preflight'
|
|
179
|
+
try:
|
|
180
|
+
# 4 ---------------------------------------------------------- rules
|
|
181
|
+
stage = 'Firestore rules'
|
|
182
|
+
out.step('Deploy Firestore rules')
|
|
183
|
+
if ctx.dry_run and rules_ok:
|
|
184
|
+
out.detail('dry run: not deployed')
|
|
185
|
+
elif rules_ok:
|
|
186
|
+
fb_project, targets = firebase.deploy_rules(ctx)
|
|
187
|
+
result['rules'] = {'project': fb_project, 'targets': targets}
|
|
188
|
+
elif rules_why:
|
|
189
|
+
skip_or_fail(ctx, 'Firestore rules', rules_why)
|
|
190
|
+
out.done()
|
|
191
|
+
|
|
192
|
+
# 5 ---------------------------------------------------------- build
|
|
193
|
+
stage = 'the build'
|
|
194
|
+
out.step('Build release bundle')
|
|
195
|
+
env = dict(os.environ, JAVA_HOME=jdk)
|
|
196
|
+
built = kind.bundle(env)
|
|
197
|
+
hooks.run(ctx, 'post_build', version_name=name, version_code=code)
|
|
198
|
+
out.done(', '.join(f'{m.label} {m.aab.stat().st_size / 1_000_000:.1f} MB' for m in built))
|
|
199
|
+
|
|
200
|
+
# 6 ---------------------------------------------------------- upload
|
|
201
|
+
stage = 'the Play upload'
|
|
202
|
+
out.step(f'Upload to Play ({play.label(play.track_id(args.track))})')
|
|
203
|
+
uploads = play.upload_release(ctx, kind, args.track, notes=notes, dry_run=ctx.dry_run,
|
|
204
|
+
rollout=rollout, release_name=name)
|
|
205
|
+
result['uploads'] = uploads
|
|
206
|
+
out.done(('validated, discarded: ' if ctx.dry_run else '')
|
|
207
|
+
+ ', '.join(f'{u["module"]} {u["versionCode"]}' for u in uploads))
|
|
208
|
+
except BaseException as error:
|
|
209
|
+
# BaseException: Ctrl+C during the build must undo the bump too.
|
|
210
|
+
if ctx.dry_run:
|
|
211
|
+
version_file.write_bytes(original)
|
|
212
|
+
elif bump_head and repo.undo(bump_head):
|
|
213
|
+
out.warn('version bump commit undone; nothing was released')
|
|
214
|
+
if not ctx.dry_run and isinstance(error, Exception):
|
|
215
|
+
notify.send(ctx, notify.message(ctx, version, f'<b>Deploy failed</b> at {notify.esc(stage)}',
|
|
216
|
+
[('Branch', repo.branch()), ('Error', getattr(error, 'message', str(error))[:300])]))
|
|
217
|
+
if isinstance(error, Exception) and not isinstance(error, MkitError):
|
|
218
|
+
raise auth.translate(error, 'Play') from None
|
|
219
|
+
raise
|
|
220
|
+
|
|
221
|
+
if ctx.dry_run:
|
|
222
|
+
version_file.write_bytes(original)
|
|
223
|
+
for step in ('Update doc', 'Notify', 'Push'):
|
|
224
|
+
out.step(step)
|
|
225
|
+
out.detail('dry run: not done')
|
|
226
|
+
out.done()
|
|
227
|
+
out.finish(f'{version} validated on Play; nothing committed or released')
|
|
228
|
+
return result
|
|
229
|
+
|
|
230
|
+
# 7-8: the build is live. Failures from here on warn; the bump stays.
|
|
231
|
+
#
|
|
232
|
+
# Every exception, not only MkitError. The release is on Play by this line -
|
|
233
|
+
# crashing now abandons the notify, the hooks and the closing summary over
|
|
234
|
+
# something that no longer decides whether the release happened. A dropped
|
|
235
|
+
# connection to Firestore did exactly that once, and printed a traceback
|
|
236
|
+
# calling itself a bug in mkit.
|
|
237
|
+
out.step('Publish update doc')
|
|
238
|
+
try:
|
|
239
|
+
if doc_ok:
|
|
240
|
+
result['update_doc'] = firebase.publish_version(ctx, name, code)
|
|
241
|
+
out.detail(result['update_doc'])
|
|
242
|
+
elif doc_why:
|
|
243
|
+
skip_or_fail(ctx, 'Update doc', doc_why)
|
|
244
|
+
except Exception as e:
|
|
245
|
+
out.warn(f'update doc not written: {getattr(e, "message", None) or e}. '
|
|
246
|
+
f'Re-run: mkit config set --latest')
|
|
247
|
+
ctx.exit_code = PARTIAL
|
|
248
|
+
out.done()
|
|
249
|
+
|
|
250
|
+
out.step('Notify')
|
|
251
|
+
if tg_ok:
|
|
252
|
+
# Both links on an internal release: testers open the first, and the
|
|
253
|
+
# second is where it lands once promoted.
|
|
254
|
+
sent = notify.send(ctx, notify.message(
|
|
255
|
+
ctx, version, f'Released to {play.label(play.track_id(args.track))}',
|
|
256
|
+
[('Branch', repo.branch()), ('Commit', repo.head()),
|
|
257
|
+
('Internal test', project.get('play.internal_testing_url') if args.track == 'internal' else None),
|
|
258
|
+
('Play Store', _listing_url(ctx))]))
|
|
259
|
+
result['notified'] = sent
|
|
260
|
+
elif tg_why:
|
|
261
|
+
skip_or_fail(ctx, 'Notify', tg_why)
|
|
262
|
+
try:
|
|
263
|
+
hooks.run(ctx, 'post_deploy', version_name=name, version_code=code)
|
|
264
|
+
except Exception as e:
|
|
265
|
+
out.warn(f'post_deploy hook failed: {getattr(e, "message", None) or e}')
|
|
266
|
+
ctx.exit_code = PARTIAL
|
|
267
|
+
out.done()
|
|
268
|
+
|
|
269
|
+
# 9: the commits that made this release, on the remote.
|
|
270
|
+
#
|
|
271
|
+
# Last, and never a failure: the release is live by now, so a push that
|
|
272
|
+
# cannot go leaves the user with a one-line instruction rather than a
|
|
273
|
+
# traceback on top of a successful deploy. It is the release's own history
|
|
274
|
+
# though - a bump commit that exists on one machine is a version on Play
|
|
275
|
+
# that nothing else can reproduce - so not pushing is a partial result, not
|
|
276
|
+
# a silence.
|
|
277
|
+
out.step('Push')
|
|
278
|
+
if not project.get('app.push_after_deploy', True):
|
|
279
|
+
out.done('off for this app')
|
|
280
|
+
elif (pushed := repo.push())[0]:
|
|
281
|
+
result['pushed'] = pushed[1]
|
|
282
|
+
out.done(f'{repo.branch()} -> {pushed[1]}')
|
|
283
|
+
else:
|
|
284
|
+
out.warn(f'not pushed: {pushed[1]}')
|
|
285
|
+
ctx.exit_code = PARTIAL
|
|
286
|
+
out.done()
|
|
287
|
+
|
|
288
|
+
if out.skipped and not ctx.exit_code:
|
|
289
|
+
ctx.exit_code = PARTIAL
|
|
290
|
+
out.finish(f'Released {version} to {play.label(play.track_id(args.track))}')
|
|
291
|
+
if not result.get('pushed'):
|
|
292
|
+
out.line('Not pushed: git push', 'dim')
|
|
293
|
+
return result
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@command('status', 'What every Play track holds (versions, rollout, notes)', risk='read', needs=('play',))
|
|
297
|
+
def status(ctx, args):
|
|
298
|
+
rows = play.status(ctx)
|
|
299
|
+
if not ctx.out.json:
|
|
300
|
+
ctx.out.table([(r['track'], r['status'], ', '.join(r['versionCodes']) or '-', r['name'] or '',
|
|
301
|
+
f'{r["userFraction"] * 100:g}%' if r['userFraction'] else '',
|
|
302
|
+
f'{r["notes"]} notes' if r['notes'] else '') for r in rows],
|
|
303
|
+
('track', 'status', 'versionCode', 'name', 'rollout', "what's new"))
|
|
304
|
+
return {'tracks': rows}
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@command('promote', 'Move a tested release to another track, keeping its notes and name',
|
|
308
|
+
risk='publish', needs=('play',),
|
|
309
|
+
args=(Arg('to', 'destination track', kind='positional', nargs='?', choices=TRACK_CHOICES,
|
|
310
|
+
default='prod'),
|
|
311
|
+
Arg('from', 'source track', choices=TRACK_CHOICES, default='internal'),
|
|
312
|
+
Arg('rollout', 'production only: staged rollout percentage, e.g. 20')),
|
|
313
|
+
examples=('mkit promote', 'mkit promote closed', 'mkit promote prod --from closed --rollout 20'))
|
|
314
|
+
def promote(ctx, args):
|
|
315
|
+
out = ctx.out
|
|
316
|
+
source = getattr(args, 'from')
|
|
317
|
+
rollout = parse_percent(args.rollout)
|
|
318
|
+
if rollout and args.to != 'prod':
|
|
319
|
+
raise UsageError('--rollout only applies when promoting to prod')
|
|
320
|
+
kind = kinds.for_project(ctx.project)
|
|
321
|
+
src, dst = play.label(play.track_id(source)), play.label(play.track_id(args.to))
|
|
322
|
+
safety.confirm(ctx, f'Promote {ctx.project.name} from {src} to {dst}'
|
|
323
|
+
+ (f' at {rollout * 100:g}%' if rollout else ''))
|
|
324
|
+
out.start(f'{ctx.project.name} promote', 2, f'{src} to {dst}' + (' (dry run)' if ctx.dry_run else ''))
|
|
325
|
+
out.step(f'Promote from {src}')
|
|
326
|
+
moved = play.promote(ctx, kind, source, args.to, rollout, ctx.dry_run)
|
|
327
|
+
codes = ', '.join(c for m in moved for c in m['versionCodes'])
|
|
328
|
+
version = version_label(kind, moved)
|
|
329
|
+
out.done(version)
|
|
330
|
+
out.step('Notify')
|
|
331
|
+
if not ctx.dry_run:
|
|
332
|
+
notify.send(ctx, notify.message(ctx, version, f'Promoted to {dst} from {src}'
|
|
333
|
+
+ (f', {rollout * 100:g}% rollout' if rollout else ''),
|
|
334
|
+
[('Play Store', _listing_url(ctx) if args.to == 'prod' else None)]))
|
|
335
|
+
hooks.run(ctx, 'post_promote', version_code=codes)
|
|
336
|
+
out.done()
|
|
337
|
+
out.finish(f'{version} {"validated for" if ctx.dry_run else "live on"} {dst}')
|
|
338
|
+
return {'promoted': moved, 'dry_run': ctx.dry_run}
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@command('rollout', 'Change a staged production rollout: a percentage, halt, resume or complete',
|
|
342
|
+
risk='publish', needs=('play',),
|
|
343
|
+
args=(Arg('action', 'a percentage like 50, or halt | resume | complete', kind='positional'),
|
|
344
|
+
Arg('track', 'track with the staged release', default='prod')),
|
|
345
|
+
examples=('mkit rollout 50', 'mkit rollout halt', 'mkit rollout complete'))
|
|
346
|
+
def rollout(ctx, args):
|
|
347
|
+
action = args.action.lower()
|
|
348
|
+
fraction = None
|
|
349
|
+
if action not in ('halt', 'resume', 'complete'):
|
|
350
|
+
fraction = parse_percent(action)
|
|
351
|
+
action = 'set'
|
|
352
|
+
what = {'set': f'set rollout to {fraction * 100:g}%' if fraction else '', 'halt': 'halt the rollout',
|
|
353
|
+
'resume': 'resume the rollout', 'complete': 'roll out to 100%'}[action]
|
|
354
|
+
safety.confirm(ctx, f'{ctx.project.name}: {what} on {args.track}')
|
|
355
|
+
result = play.rollout(ctx, args.track, action, fraction, ctx.dry_run)
|
|
356
|
+
if not ctx.out.json:
|
|
357
|
+
pct = f'{result["userFraction"] * 100:g}%' if result.get('userFraction') else '100%'
|
|
358
|
+
ctx.out.line(f'{result["track"]}: versionCode {", ".join(result["versionCodes"])} '
|
|
359
|
+
f'{result["status"]} {pct}' + (' (dry run, nothing committed)' if ctx.dry_run else ''))
|
|
360
|
+
return dict(result, dry_run=ctx.dry_run)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@command('rules', 'Deploy Firestore rules and indexes', risk='publish', needs=('firebase',))
|
|
364
|
+
def rules(ctx, args):
|
|
365
|
+
targets = firebase.deploy_targets(ctx)
|
|
366
|
+
project = firebase.project_id(ctx)
|
|
367
|
+
safety.confirm(ctx, f'Deploy {", ".join(targets)} to Firebase project {project}')
|
|
368
|
+
if ctx.dry_run:
|
|
369
|
+
ctx.out.line(f'dry run: would deploy {", ".join(targets)} to {project}')
|
|
370
|
+
return {'project': project, 'targets': targets, 'dry_run': True}
|
|
371
|
+
ctx.out.start(f'{ctx.project.name} rules', 1, project)
|
|
372
|
+
ctx.out.step('Deploy ' + ', '.join(targets))
|
|
373
|
+
firebase.deploy_rules(ctx)
|
|
374
|
+
ctx.out.done()
|
|
375
|
+
ctx.out.finish(f'live on {project}')
|
|
376
|
+
return {'project': project, 'targets': targets}
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
group('config', 'the in-app update document (config/app)')
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
@command('config show', 'Show the in-app update document', risk='read', needs=('firebase',))
|
|
383
|
+
def config_show(ctx, args):
|
|
384
|
+
path = firebase.update_doc_path(ctx)
|
|
385
|
+
doc = firebase.get_document(ctx, path)
|
|
386
|
+
if not ctx.out.json:
|
|
387
|
+
ctx.out.heading(f'{firebase.project_id(ctx)}/{path}')
|
|
388
|
+
for k, v in sorted(doc.items()):
|
|
389
|
+
ctx.out.line(f' {k:18} {v}')
|
|
390
|
+
if not doc:
|
|
391
|
+
ctx.out.line(' (no document yet)')
|
|
392
|
+
return {'path': path, 'document': doc}
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
@command('config set', 'Write the in-app update document: latest version, block old builds, nudge delay',
|
|
396
|
+
risk='publish', needs=('firebase',),
|
|
397
|
+
args=(Arg('latest', 'publish the current version as the latest', kind='flag'),
|
|
398
|
+
Arg('latest_build', 'set latestBuild explicitly (e.g. what Play already has)', type=int),
|
|
399
|
+
Arg('latest_version_name', 'set latestVersionName explicitly, with --latest-build'),
|
|
400
|
+
Arg('min_supported_build', 'block every build below this versionCode (0 = none)', type=int),
|
|
401
|
+
Arg('nudge_after_days', 'days an update must be known before the app mentions it', type=int)),
|
|
402
|
+
examples=('mkit config set --latest', 'mkit config set --latest-build 12 --latest-version-name 1.2.0',
|
|
403
|
+
'mkit config set --min-supported-build 42',
|
|
404
|
+
'mkit config set --min-supported-build 0'))
|
|
405
|
+
def config_set(ctx, args):
|
|
406
|
+
values = {}
|
|
407
|
+
if args.latest:
|
|
408
|
+
name, code = kinds.for_project(ctx.project).current_version()
|
|
409
|
+
values.update(latestBuild=code, latestVersionName=name)
|
|
410
|
+
if (args.latest_build is None) != (args.latest_version_name is None):
|
|
411
|
+
raise UsageError('pass --latest-build and --latest-version-name together')
|
|
412
|
+
if args.latest_build is not None:
|
|
413
|
+
values.update(latestBuild=args.latest_build, latestVersionName=args.latest_version_name)
|
|
414
|
+
if args.min_supported_build is not None:
|
|
415
|
+
values['minSupportedBuild'] = args.min_supported_build
|
|
416
|
+
if args.nudge_after_days is not None:
|
|
417
|
+
values['nudgeAfterDays'] = args.nudge_after_days
|
|
418
|
+
if not values:
|
|
419
|
+
raise UsageError('nothing to set; pass --latest, --latest-build/--latest-version-name, '
|
|
420
|
+
'--min-supported-build or --nudge-after-days')
|
|
421
|
+
path = firebase.update_doc_path(ctx)
|
|
422
|
+
safety.confirm(ctx, f'Write {path} in {firebase.project_id(ctx)}',
|
|
423
|
+
[f'{k} = {v}' for k, v in values.items()])
|
|
424
|
+
if ctx.dry_run:
|
|
425
|
+
return {'path': path, 'would_set': values, 'dry_run': True}
|
|
426
|
+
from datetime import datetime, timezone
|
|
427
|
+
firebase.patch_document(ctx, path, dict(values, updatedAt=datetime.now(timezone.utc)))
|
|
428
|
+
if not ctx.out.json:
|
|
429
|
+
ctx.out.line(f'{path}: ' + ', '.join(f'{k}={v}' for k, v in values.items()), 'green')
|
|
430
|
+
return {'path': path, 'set': values}
|
mkit/commands/selfcmd.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""`mkit self`: the engine's own version and updates.
|
|
2
|
+
|
|
3
|
+
mkit is installed one of two ways, and each updates differently:
|
|
4
|
+
|
|
5
|
+
- **As a package** (`uv tool install mkit-cli`, pipx, pip). Updating is the
|
|
6
|
+
package manager's job, and it has to be: on Windows a running mkit.exe cannot
|
|
7
|
+
replace itself. So `self update` names the exact command for the manager that
|
|
8
|
+
installed it, and `self status` says whether a newer release is on PyPI.
|
|
9
|
+
- **Editable, from a git checkout** (`uv tool install -e <checkout>`), which is
|
|
10
|
+
how one studio runs it across many apps: a pull into the checkout reaches every
|
|
11
|
+
app at once. `self update` does the pull, and says when the dependencies
|
|
12
|
+
changed (pyproject.toml), which needs one reinstall a pull cannot do.
|
|
13
|
+
"""
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .. import __version__, proc
|
|
18
|
+
from ..errors import MkitError
|
|
19
|
+
from ..registry import command, group
|
|
20
|
+
from ..toolchain import tool
|
|
21
|
+
|
|
22
|
+
# The distribution name on PyPI. The command stays `mkit`; `mkit` itself on
|
|
23
|
+
# PyPI is an unrelated, abandoned package.
|
|
24
|
+
DIST = 'mkit-cli'
|
|
25
|
+
|
|
26
|
+
group('self', "the mkit engine itself: version, updates")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def engine_repo():
|
|
30
|
+
"""The git checkout this engine runs from, or None (a package install)."""
|
|
31
|
+
here = Path(__file__).resolve()
|
|
32
|
+
for folder in here.parents:
|
|
33
|
+
if (folder / '.git').exists() and (folder / 'pyproject.toml').is_file():
|
|
34
|
+
return folder
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def upgrade_command(executable=None):
|
|
39
|
+
"""The command that upgrades a package install, for the manager that did it.
|
|
40
|
+
|
|
41
|
+
Read off the interpreter's own path, which each manager puts somewhere
|
|
42
|
+
recognisable. Wrong guesses are cheap - the alternatives are always printed
|
|
43
|
+
alongside - but the first command offered should be the one that works.
|
|
44
|
+
"""
|
|
45
|
+
exe = str(executable or sys.executable).replace('\\', '/').lower()
|
|
46
|
+
if '/uv/tools/' in exe or '/uv/tool/' in exe:
|
|
47
|
+
return f'uv tool upgrade {DIST}'
|
|
48
|
+
if '/pipx/' in exe:
|
|
49
|
+
return f'pipx upgrade {DIST}'
|
|
50
|
+
return f'pip install --upgrade {DIST}'
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
ALTERNATIVES = (f'uv tool upgrade {DIST}', f'pipx upgrade {DIST}', f'pip install --upgrade {DIST}')
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def latest_on_pypi(timeout=4):
|
|
57
|
+
"""The newest release on PyPI, or None when it cannot be asked.
|
|
58
|
+
|
|
59
|
+
Offline, a proxy, PyPI being slow: none of those is worth a failed status
|
|
60
|
+
check, so every failure means "unknown", never an error.
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
import requests
|
|
64
|
+
reply = requests.get(f'https://pypi.org/pypi/{DIST}/json', timeout=timeout)
|
|
65
|
+
if reply.ok:
|
|
66
|
+
return reply.json()['info']['version']
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _newer(latest, current):
|
|
73
|
+
def parts(v):
|
|
74
|
+
return tuple(int(p) if p.isdigit() else 0 for p in v.split('.')[:3])
|
|
75
|
+
try:
|
|
76
|
+
return parts(latest) > parts(current)
|
|
77
|
+
except Exception:
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _git(repo, *args, check=True):
|
|
82
|
+
return proc.run([tool('git'), *args], cwd=repo, check=check).strip()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@command('self status', "The engine's version, how it was installed, and whether a newer one exists",
|
|
86
|
+
risk='read', needs_project=False)
|
|
87
|
+
def self_status(ctx, args):
|
|
88
|
+
repo = engine_repo()
|
|
89
|
+
info = {'version': __version__, 'python': sys.executable, 'repo': str(repo) if repo else None,
|
|
90
|
+
'install': 'editable' if repo else 'package'}
|
|
91
|
+
if repo:
|
|
92
|
+
_git(repo, 'fetch', '--quiet', '--tags', check=False)
|
|
93
|
+
info.update(branch=_git(repo, 'rev-parse', '--abbrev-ref', 'HEAD'),
|
|
94
|
+
commit=_git(repo, 'rev-parse', '--short', 'HEAD'),
|
|
95
|
+
tag=_git(repo, 'describe', '--tags', '--abbrev=0', check=False) or None,
|
|
96
|
+
dirty=bool(_git(repo, 'status', '--porcelain')),
|
|
97
|
+
behind=int(_git(repo, 'rev-list', '--count', 'HEAD..@{u}', check=False) or 0),
|
|
98
|
+
ahead=int(_git(repo, 'rev-list', '--count', '@{u}..HEAD', check=False) or 0))
|
|
99
|
+
else:
|
|
100
|
+
latest = latest_on_pypi()
|
|
101
|
+
info.update(latest=latest, outdated=bool(latest and _newer(latest, __version__)),
|
|
102
|
+
upgrade_command=upgrade_command())
|
|
103
|
+
|
|
104
|
+
if not ctx.out.json:
|
|
105
|
+
out = ctx.out
|
|
106
|
+
if repo:
|
|
107
|
+
out.line(f'mkit {__version__} {info["repo"]}', 'bold')
|
|
108
|
+
out.line(f' {info["branch"]} {info["commit"]} last tag {info["tag"] or "none"}'
|
|
109
|
+
+ (' uncommitted changes' if info['dirty'] else ''))
|
|
110
|
+
if info['behind']:
|
|
111
|
+
out.line(f' {info["behind"]} commit(s) behind; run `mkit self update`', 'yellow')
|
|
112
|
+
elif info['ahead']:
|
|
113
|
+
out.line(f' {info["ahead"]} commit(s) not pushed', 'yellow')
|
|
114
|
+
else:
|
|
115
|
+
out.line(' up to date', 'green')
|
|
116
|
+
else:
|
|
117
|
+
out.line(f'mkit {__version__} installed as the {DIST} package', 'bold')
|
|
118
|
+
if info['outdated']:
|
|
119
|
+
out.line(f' {info["latest"]} is out; upgrade with: {info["upgrade_command"]}', 'yellow')
|
|
120
|
+
elif info['latest']:
|
|
121
|
+
out.line(' up to date', 'green')
|
|
122
|
+
else:
|
|
123
|
+
out.line(' could not check PyPI for a newer release', 'dim')
|
|
124
|
+
return info
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@command('self update', 'Update mkit: pull an editable checkout, or name the upgrade command',
|
|
128
|
+
risk='write', needs_project=False)
|
|
129
|
+
def self_update(ctx, args):
|
|
130
|
+
repo = engine_repo()
|
|
131
|
+
if not repo:
|
|
132
|
+
# A package install cannot upgrade itself from inside: on Windows the
|
|
133
|
+
# running mkit.exe is locked. Say exactly what to run instead, and make
|
|
134
|
+
# it plain in the result that nothing changed, so a script does not
|
|
135
|
+
# mistake this for an update.
|
|
136
|
+
command_line = upgrade_command()
|
|
137
|
+
if not ctx.out.json:
|
|
138
|
+
ctx.out.line(f'mkit is installed as the {DIST} package, so its package manager updates it:',
|
|
139
|
+
'bold')
|
|
140
|
+
ctx.out.line(f' {command_line}')
|
|
141
|
+
others = [c for c in ALTERNATIVES if c != command_line]
|
|
142
|
+
ctx.out.line(f' (or: {" / ".join(others)})', 'dim')
|
|
143
|
+
return {'changed': False, 'install': 'package', 'upgrade_command': command_line}
|
|
144
|
+
|
|
145
|
+
if _git(repo, 'status', '--porcelain'):
|
|
146
|
+
raise MkitError(f'{repo} has uncommitted changes; commit or stash them first', code='DIRTY_ENGINE')
|
|
147
|
+
before = _git(repo, 'rev-parse', 'HEAD')
|
|
148
|
+
_git(repo, 'pull', '--ff-only', '--tags')
|
|
149
|
+
after = _git(repo, 'rev-parse', 'HEAD')
|
|
150
|
+
changed = before != after
|
|
151
|
+
# New dependencies: an editable install picks up code, not packages. The
|
|
152
|
+
# reinstall is left to the user because it replaces the very mkit.exe that
|
|
153
|
+
# is running this command, which Windows refuses.
|
|
154
|
+
reinstall = bool(changed and _git(repo, 'diff', '--name-only', before, after, '--', 'pyproject.toml'))
|
|
155
|
+
log = _git(repo, 'log', '--oneline', f'{before}..{after}') if changed else ''
|
|
156
|
+
if not ctx.out.json:
|
|
157
|
+
ctx.out.line('already up to date' if not changed else f'updated {before[:7]} -> {after[:7]}',
|
|
158
|
+
'green')
|
|
159
|
+
for line in log.splitlines():
|
|
160
|
+
ctx.out.line(f' {line}')
|
|
161
|
+
if reinstall:
|
|
162
|
+
ctx.out.line(f'dependencies changed; finish with: uv tool install -e {repo} --reinstall',
|
|
163
|
+
'yellow')
|
|
164
|
+
return {'changed': changed, 'install': 'editable', 'from': before, 'to': after,
|
|
165
|
+
'needs_reinstall': reinstall,
|
|
166
|
+
'reinstall_command': f'uv tool install -e {repo} --reinstall' if reinstall else None,
|
|
167
|
+
'commits': log.splitlines()}
|