##// END OF EJS Templates
automation: delete code related to Python 2.7 support...
Gregory Szorc -
r49702:4561ec90 default
parent child Browse files
Show More
@@ -1,574 +1,551 b''
1 # cli.py - Command line interface for automation
1 # cli.py - Command line interface for automation
2 #
2 #
3 # Copyright 2019 Gregory Szorc <gregory.szorc@gmail.com>
3 # Copyright 2019 Gregory Szorc <gregory.szorc@gmail.com>
4 #
4 #
5 # This software may be used and distributed according to the terms of the
5 # This software may be used and distributed according to the terms of the
6 # GNU General Public License version 2 or any later version.
6 # GNU General Public License version 2 or any later version.
7
7
8 # no-check-code because Python 3 native.
8 # no-check-code because Python 3 native.
9
9
10 import argparse
10 import argparse
11 import concurrent.futures as futures
11 import concurrent.futures as futures
12 import os
12 import os
13 import pathlib
13 import pathlib
14 import time
14 import time
15
15
16 from . import (
16 from . import (
17 aws,
17 aws,
18 HGAutomation,
18 HGAutomation,
19 linux,
19 linux,
20 try_server,
20 try_server,
21 windows,
21 windows,
22 )
22 )
23
23
24
24
25 SOURCE_ROOT = pathlib.Path(
25 SOURCE_ROOT = pathlib.Path(
26 os.path.abspath(__file__)
26 os.path.abspath(__file__)
27 ).parent.parent.parent.parent
27 ).parent.parent.parent.parent
28 DIST_PATH = SOURCE_ROOT / 'dist'
28 DIST_PATH = SOURCE_ROOT / 'dist'
29
29
30
30
31 def bootstrap_linux_dev(
31 def bootstrap_linux_dev(
32 hga: HGAutomation, aws_region, distros=None, parallel=False
32 hga: HGAutomation, aws_region, distros=None, parallel=False
33 ):
33 ):
34 c = hga.aws_connection(aws_region)
34 c = hga.aws_connection(aws_region)
35
35
36 if distros:
36 if distros:
37 distros = distros.split(',')
37 distros = distros.split(',')
38 else:
38 else:
39 distros = sorted(linux.DISTROS)
39 distros = sorted(linux.DISTROS)
40
40
41 # TODO There is a wonky interaction involving KeyboardInterrupt whereby
41 # TODO There is a wonky interaction involving KeyboardInterrupt whereby
42 # the context manager that is supposed to terminate the temporary EC2
42 # the context manager that is supposed to terminate the temporary EC2
43 # instance doesn't run. Until we fix this, make parallel building opt-in
43 # instance doesn't run. Until we fix this, make parallel building opt-in
44 # so we don't orphan instances.
44 # so we don't orphan instances.
45 if parallel:
45 if parallel:
46 fs = []
46 fs = []
47
47
48 with futures.ThreadPoolExecutor(len(distros)) as e:
48 with futures.ThreadPoolExecutor(len(distros)) as e:
49 for distro in distros:
49 for distro in distros:
50 fs.append(e.submit(aws.ensure_linux_dev_ami, c, distro=distro))
50 fs.append(e.submit(aws.ensure_linux_dev_ami, c, distro=distro))
51
51
52 for f in fs:
52 for f in fs:
53 f.result()
53 f.result()
54 else:
54 else:
55 for distro in distros:
55 for distro in distros:
56 aws.ensure_linux_dev_ami(c, distro=distro)
56 aws.ensure_linux_dev_ami(c, distro=distro)
57
57
58
58
59 def bootstrap_windows_dev(hga: HGAutomation, aws_region, base_image_name):
59 def bootstrap_windows_dev(hga: HGAutomation, aws_region, base_image_name):
60 c = hga.aws_connection(aws_region)
60 c = hga.aws_connection(aws_region)
61 image = aws.ensure_windows_dev_ami(c, base_image_name=base_image_name)
61 image = aws.ensure_windows_dev_ami(c, base_image_name=base_image_name)
62 print('Windows development AMI available as %s' % image.id)
62 print('Windows development AMI available as %s' % image.id)
63
63
64
64
65 def build_inno(
65 def build_inno(
66 hga: HGAutomation,
66 hga: HGAutomation,
67 aws_region,
67 aws_region,
68 python_version,
69 arch,
68 arch,
70 revision,
69 revision,
71 version,
70 version,
72 base_image_name,
71 base_image_name,
73 ):
72 ):
74 c = hga.aws_connection(aws_region)
73 c = hga.aws_connection(aws_region)
75 image = aws.ensure_windows_dev_ami(c, base_image_name=base_image_name)
74 image = aws.ensure_windows_dev_ami(c, base_image_name=base_image_name)
76 DIST_PATH.mkdir(exist_ok=True)
75 DIST_PATH.mkdir(exist_ok=True)
77
76
78 with aws.temporary_windows_dev_instances(c, image, 't3.medium') as insts:
77 with aws.temporary_windows_dev_instances(c, image, 't3.medium') as insts:
79 instance = insts[0]
78 instance = insts[0]
80
79
81 windows.synchronize_hg(SOURCE_ROOT, revision, instance)
80 windows.synchronize_hg(SOURCE_ROOT, revision, instance)
82
81
83 for py_version in python_version:
82 for a in arch:
84 for a in arch:
83 windows.build_inno_installer(
85 windows.build_inno_installer(
84 instance.winrm_client,
86 instance.winrm_client,
85 a,
87 py_version,
86 DIST_PATH,
88 a,
87 version=version,
89 DIST_PATH,
88 )
90 version=version,
91 )
92
89
93
90
94 def build_wix(
91 def build_wix(
95 hga: HGAutomation,
92 hga: HGAutomation,
96 aws_region,
93 aws_region,
97 python_version,
98 arch,
94 arch,
99 revision,
95 revision,
100 version,
96 version,
101 base_image_name,
97 base_image_name,
102 ):
98 ):
103 c = hga.aws_connection(aws_region)
99 c = hga.aws_connection(aws_region)
104 image = aws.ensure_windows_dev_ami(c, base_image_name=base_image_name)
100 image = aws.ensure_windows_dev_ami(c, base_image_name=base_image_name)
105 DIST_PATH.mkdir(exist_ok=True)
101 DIST_PATH.mkdir(exist_ok=True)
106
102
107 with aws.temporary_windows_dev_instances(c, image, 't3.medium') as insts:
103 with aws.temporary_windows_dev_instances(c, image, 't3.medium') as insts:
108 instance = insts[0]
104 instance = insts[0]
109
105
110 windows.synchronize_hg(SOURCE_ROOT, revision, instance)
106 windows.synchronize_hg(SOURCE_ROOT, revision, instance)
111
107
112 for py_version in python_version:
108 for a in arch:
113 for a in arch:
109 windows.build_wix_installer(
114 windows.build_wix_installer(
110 instance.winrm_client,
115 instance.winrm_client,
111 a,
116 py_version,
112 DIST_PATH,
117 a,
113 version=version,
118 DIST_PATH,
114 )
119 version=version,
120 )
121
115
122
116
123 def build_windows_wheel(
117 def build_windows_wheel(
124 hga: HGAutomation,
118 hga: HGAutomation,
125 aws_region,
119 aws_region,
126 python_version,
120 python_version,
127 arch,
121 arch,
128 revision,
122 revision,
129 base_image_name,
123 base_image_name,
130 ):
124 ):
131 c = hga.aws_connection(aws_region)
125 c = hga.aws_connection(aws_region)
132 image = aws.ensure_windows_dev_ami(c, base_image_name=base_image_name)
126 image = aws.ensure_windows_dev_ami(c, base_image_name=base_image_name)
133 DIST_PATH.mkdir(exist_ok=True)
127 DIST_PATH.mkdir(exist_ok=True)
134
128
135 with aws.temporary_windows_dev_instances(c, image, 't3.medium') as insts:
129 with aws.temporary_windows_dev_instances(c, image, 't3.medium') as insts:
136 instance = insts[0]
130 instance = insts[0]
137
131
138 windows.synchronize_hg(SOURCE_ROOT, revision, instance)
132 windows.synchronize_hg(SOURCE_ROOT, revision, instance)
139
133
140 for py_version in python_version:
134 for py_version in python_version:
141 for a in arch:
135 for a in arch:
142 windows.build_wheel(
136 windows.build_wheel(
143 instance.winrm_client, py_version, a, DIST_PATH
137 instance.winrm_client, py_version, a, DIST_PATH
144 )
138 )
145
139
146
140
147 def build_all_windows_packages(
141 def build_all_windows_packages(
148 hga: HGAutomation, aws_region, revision, version, base_image_name
142 hga: HGAutomation, aws_region, revision, version, base_image_name
149 ):
143 ):
150 c = hga.aws_connection(aws_region)
144 c = hga.aws_connection(aws_region)
151 image = aws.ensure_windows_dev_ami(c, base_image_name=base_image_name)
145 image = aws.ensure_windows_dev_ami(c, base_image_name=base_image_name)
152 DIST_PATH.mkdir(exist_ok=True)
146 DIST_PATH.mkdir(exist_ok=True)
153
147
154 with aws.temporary_windows_dev_instances(c, image, 'm6i.large') as insts:
148 with aws.temporary_windows_dev_instances(c, image, 'm6i.large') as insts:
155 instance = insts[0]
149 instance = insts[0]
156
150
157 winrm_client = instance.winrm_client
151 winrm_client = instance.winrm_client
158
152
159 windows.synchronize_hg(SOURCE_ROOT, revision, instance)
153 windows.synchronize_hg(SOURCE_ROOT, revision, instance)
160
154
161 for py_version in ("3.7", "3.8", "3.9", "3.10"):
155 for py_version in ("3.7", "3.8", "3.9", "3.10"):
162 for arch in ("x86", "x64"):
156 for arch in ("x86", "x64"):
163 windows.purge_hg(winrm_client)
157 windows.purge_hg(winrm_client)
164 windows.build_wheel(
158 windows.build_wheel(
165 winrm_client,
159 winrm_client,
166 python_version=py_version,
160 python_version=py_version,
167 arch=arch,
161 arch=arch,
168 dest_path=DIST_PATH,
162 dest_path=DIST_PATH,
169 )
163 )
170
164
171 for py_version in (2, 3):
165 for arch in ('x86', 'x64'):
172 for arch in ('x86', 'x64'):
166 windows.purge_hg(winrm_client)
173 windows.purge_hg(winrm_client)
167 windows.build_inno_installer(
174 windows.build_inno_installer(
168 winrm_client, arch, DIST_PATH, version=version
175 winrm_client, py_version, arch, DIST_PATH, version=version
169 )
176 )
170 windows.build_wix_installer(
177 windows.build_wix_installer(
171 winrm_client, arch, DIST_PATH, version=version
178 winrm_client, py_version, arch, DIST_PATH, version=version
172 )
179 )
180
173
181
174
182 def terminate_ec2_instances(hga: HGAutomation, aws_region):
175 def terminate_ec2_instances(hga: HGAutomation, aws_region):
183 c = hga.aws_connection(aws_region, ensure_ec2_state=False)
176 c = hga.aws_connection(aws_region, ensure_ec2_state=False)
184 aws.terminate_ec2_instances(c.ec2resource)
177 aws.terminate_ec2_instances(c.ec2resource)
185
178
186
179
187 def purge_ec2_resources(hga: HGAutomation, aws_region):
180 def purge_ec2_resources(hga: HGAutomation, aws_region):
188 c = hga.aws_connection(aws_region, ensure_ec2_state=False)
181 c = hga.aws_connection(aws_region, ensure_ec2_state=False)
189 aws.remove_resources(c)
182 aws.remove_resources(c)
190
183
191
184
192 def run_tests_linux(
185 def run_tests_linux(
193 hga: HGAutomation,
186 hga: HGAutomation,
194 aws_region,
187 aws_region,
195 instance_type,
188 instance_type,
196 python_version,
189 python_version,
197 test_flags,
190 test_flags,
198 distro,
191 distro,
199 filesystem,
192 filesystem,
200 ):
193 ):
201 c = hga.aws_connection(aws_region)
194 c = hga.aws_connection(aws_region)
202 image = aws.ensure_linux_dev_ami(c, distro=distro)
195 image = aws.ensure_linux_dev_ami(c, distro=distro)
203
196
204 t_start = time.time()
197 t_start = time.time()
205
198
206 ensure_extra_volume = filesystem not in ('default', 'tmpfs')
199 ensure_extra_volume = filesystem not in ('default', 'tmpfs')
207
200
208 with aws.temporary_linux_dev_instances(
201 with aws.temporary_linux_dev_instances(
209 c, image, instance_type, ensure_extra_volume=ensure_extra_volume
202 c, image, instance_type, ensure_extra_volume=ensure_extra_volume
210 ) as insts:
203 ) as insts:
211
204
212 instance = insts[0]
205 instance = insts[0]
213
206
214 linux.prepare_exec_environment(
207 linux.prepare_exec_environment(
215 instance.ssh_client, filesystem=filesystem
208 instance.ssh_client, filesystem=filesystem
216 )
209 )
217 linux.synchronize_hg(SOURCE_ROOT, instance, '.')
210 linux.synchronize_hg(SOURCE_ROOT, instance, '.')
218 t_prepared = time.time()
211 t_prepared = time.time()
219 linux.run_tests(instance.ssh_client, python_version, test_flags)
212 linux.run_tests(instance.ssh_client, python_version, test_flags)
220 t_done = time.time()
213 t_done = time.time()
221
214
222 t_setup = t_prepared - t_start
215 t_setup = t_prepared - t_start
223 t_all = t_done - t_start
216 t_all = t_done - t_start
224
217
225 print(
218 print(
226 'total time: %.1fs; setup: %.1fs; tests: %.1fs; setup overhead: %.1f%%'
219 'total time: %.1fs; setup: %.1fs; tests: %.1fs; setup overhead: %.1f%%'
227 % (t_all, t_setup, t_done - t_prepared, t_setup / t_all * 100.0)
220 % (t_all, t_setup, t_done - t_prepared, t_setup / t_all * 100.0)
228 )
221 )
229
222
230
223
231 def run_tests_windows(
224 def run_tests_windows(
232 hga: HGAutomation,
225 hga: HGAutomation,
233 aws_region,
226 aws_region,
234 instance_type,
227 instance_type,
235 python_version,
228 python_version,
236 arch,
229 arch,
237 test_flags,
230 test_flags,
238 base_image_name,
231 base_image_name,
239 ):
232 ):
240 c = hga.aws_connection(aws_region)
233 c = hga.aws_connection(aws_region)
241 image = aws.ensure_windows_dev_ami(c, base_image_name=base_image_name)
234 image = aws.ensure_windows_dev_ami(c, base_image_name=base_image_name)
242
235
243 with aws.temporary_windows_dev_instances(
236 with aws.temporary_windows_dev_instances(
244 c, image, instance_type, disable_antivirus=True
237 c, image, instance_type, disable_antivirus=True
245 ) as insts:
238 ) as insts:
246 instance = insts[0]
239 instance = insts[0]
247
240
248 windows.synchronize_hg(SOURCE_ROOT, '.', instance)
241 windows.synchronize_hg(SOURCE_ROOT, '.', instance)
249 windows.run_tests(
242 windows.run_tests(
250 instance.winrm_client, python_version, arch, test_flags
243 instance.winrm_client, python_version, arch, test_flags
251 )
244 )
252
245
253
246
254 def publish_windows_artifacts(
247 def publish_windows_artifacts(
255 hg: HGAutomation,
248 hg: HGAutomation,
256 aws_region,
249 aws_region,
257 version: str,
250 version: str,
258 pypi: bool,
251 pypi: bool,
259 mercurial_scm_org: bool,
252 mercurial_scm_org: bool,
260 ssh_username: str,
253 ssh_username: str,
261 ):
254 ):
262 windows.publish_artifacts(
255 windows.publish_artifacts(
263 DIST_PATH,
256 DIST_PATH,
264 version,
257 version,
265 pypi=pypi,
258 pypi=pypi,
266 mercurial_scm_org=mercurial_scm_org,
259 mercurial_scm_org=mercurial_scm_org,
267 ssh_username=ssh_username,
260 ssh_username=ssh_username,
268 )
261 )
269
262
270
263
271 def run_try(hga: HGAutomation, aws_region: str, rev: str):
264 def run_try(hga: HGAutomation, aws_region: str, rev: str):
272 c = hga.aws_connection(aws_region, ensure_ec2_state=False)
265 c = hga.aws_connection(aws_region, ensure_ec2_state=False)
273 try_server.trigger_try(c, rev=rev)
266 try_server.trigger_try(c, rev=rev)
274
267
275
268
276 def get_parser():
269 def get_parser():
277 parser = argparse.ArgumentParser()
270 parser = argparse.ArgumentParser()
278
271
279 parser.add_argument(
272 parser.add_argument(
280 '--state-path',
273 '--state-path',
281 default='~/.hgautomation',
274 default='~/.hgautomation',
282 help='Path for local state files',
275 help='Path for local state files',
283 )
276 )
284 parser.add_argument(
277 parser.add_argument(
285 '--aws-region',
278 '--aws-region',
286 help='AWS region to use',
279 help='AWS region to use',
287 default='us-west-2',
280 default='us-west-2',
288 )
281 )
289
282
290 subparsers = parser.add_subparsers()
283 subparsers = parser.add_subparsers()
291
284
292 sp = subparsers.add_parser(
285 sp = subparsers.add_parser(
293 'bootstrap-linux-dev',
286 'bootstrap-linux-dev',
294 help='Bootstrap Linux development environments',
287 help='Bootstrap Linux development environments',
295 )
288 )
296 sp.add_argument(
289 sp.add_argument(
297 '--distros',
290 '--distros',
298 help='Comma delimited list of distros to bootstrap',
291 help='Comma delimited list of distros to bootstrap',
299 )
292 )
300 sp.add_argument(
293 sp.add_argument(
301 '--parallel',
294 '--parallel',
302 action='store_true',
295 action='store_true',
303 help='Generate AMIs in parallel (not CTRL-c safe)',
296 help='Generate AMIs in parallel (not CTRL-c safe)',
304 )
297 )
305 sp.set_defaults(func=bootstrap_linux_dev)
298 sp.set_defaults(func=bootstrap_linux_dev)
306
299
307 sp = subparsers.add_parser(
300 sp = subparsers.add_parser(
308 'bootstrap-windows-dev',
301 'bootstrap-windows-dev',
309 help='Bootstrap the Windows development environment',
302 help='Bootstrap the Windows development environment',
310 )
303 )
311 sp.add_argument(
304 sp.add_argument(
312 '--base-image-name',
305 '--base-image-name',
313 help='AMI name of base image',
306 help='AMI name of base image',
314 default=aws.WINDOWS_BASE_IMAGE_NAME,
307 default=aws.WINDOWS_BASE_IMAGE_NAME,
315 )
308 )
316 sp.set_defaults(func=bootstrap_windows_dev)
309 sp.set_defaults(func=bootstrap_windows_dev)
317
310
318 sp = subparsers.add_parser(
311 sp = subparsers.add_parser(
319 'build-all-windows-packages',
312 'build-all-windows-packages',
320 help='Build all Windows packages',
313 help='Build all Windows packages',
321 )
314 )
322 sp.add_argument(
315 sp.add_argument(
323 '--revision',
316 '--revision',
324 help='Mercurial revision to build',
317 help='Mercurial revision to build',
325 default='.',
318 default='.',
326 )
319 )
327 sp.add_argument(
320 sp.add_argument(
328 '--version',
321 '--version',
329 help='Mercurial version string to use',
322 help='Mercurial version string to use',
330 )
323 )
331 sp.add_argument(
324 sp.add_argument(
332 '--base-image-name',
325 '--base-image-name',
333 help='AMI name of base image',
326 help='AMI name of base image',
334 default=aws.WINDOWS_BASE_IMAGE_NAME,
327 default=aws.WINDOWS_BASE_IMAGE_NAME,
335 )
328 )
336 sp.set_defaults(func=build_all_windows_packages)
329 sp.set_defaults(func=build_all_windows_packages)
337
330
338 sp = subparsers.add_parser(
331 sp = subparsers.add_parser(
339 'build-inno',
332 'build-inno',
340 help='Build Inno Setup installer(s)',
333 help='Build Inno Setup installer(s)',
341 )
334 )
342 sp.add_argument(
335 sp.add_argument(
343 '--python-version',
344 help='Which version of Python to target',
345 choices={3},
346 type=int,
347 nargs='*',
348 default=[3],
349 )
350 sp.add_argument(
351 '--arch',
336 '--arch',
352 help='Architecture to build for',
337 help='Architecture to build for',
353 choices={'x86', 'x64'},
338 choices={'x86', 'x64'},
354 nargs='*',
339 nargs='*',
355 default=['x64'],
340 default=['x64'],
356 )
341 )
357 sp.add_argument(
342 sp.add_argument(
358 '--revision',
343 '--revision',
359 help='Mercurial revision to build',
344 help='Mercurial revision to build',
360 default='.',
345 default='.',
361 )
346 )
362 sp.add_argument(
347 sp.add_argument(
363 '--version',
348 '--version',
364 help='Mercurial version string to use in installer',
349 help='Mercurial version string to use in installer',
365 )
350 )
366 sp.add_argument(
351 sp.add_argument(
367 '--base-image-name',
352 '--base-image-name',
368 help='AMI name of base image',
353 help='AMI name of base image',
369 default=aws.WINDOWS_BASE_IMAGE_NAME,
354 default=aws.WINDOWS_BASE_IMAGE_NAME,
370 )
355 )
371 sp.set_defaults(func=build_inno)
356 sp.set_defaults(func=build_inno)
372
357
373 sp = subparsers.add_parser(
358 sp = subparsers.add_parser(
374 'build-windows-wheel',
359 'build-windows-wheel',
375 help='Build Windows wheel(s)',
360 help='Build Windows wheel(s)',
376 )
361 )
377 sp.add_argument(
362 sp.add_argument(
378 '--python-version',
363 '--python-version',
379 help='Python version to build for',
364 help='Python version to build for',
380 choices={'3.7', '3.8', '3.9', '3.10'},
365 choices={'3.7', '3.8', '3.9', '3.10'},
381 nargs='*',
366 nargs='*',
382 default=['3.8'],
367 default=['3.8'],
383 )
368 )
384 sp.add_argument(
369 sp.add_argument(
385 '--arch',
370 '--arch',
386 help='Architecture to build for',
371 help='Architecture to build for',
387 choices={'x86', 'x64'},
372 choices={'x86', 'x64'},
388 nargs='*',
373 nargs='*',
389 default=['x64'],
374 default=['x64'],
390 )
375 )
391 sp.add_argument(
376 sp.add_argument(
392 '--revision',
377 '--revision',
393 help='Mercurial revision to build',
378 help='Mercurial revision to build',
394 default='.',
379 default='.',
395 )
380 )
396 sp.add_argument(
381 sp.add_argument(
397 '--base-image-name',
382 '--base-image-name',
398 help='AMI name of base image',
383 help='AMI name of base image',
399 default=aws.WINDOWS_BASE_IMAGE_NAME,
384 default=aws.WINDOWS_BASE_IMAGE_NAME,
400 )
385 )
401 sp.set_defaults(func=build_windows_wheel)
386 sp.set_defaults(func=build_windows_wheel)
402
387
403 sp = subparsers.add_parser('build-wix', help='Build WiX installer(s)')
388 sp = subparsers.add_parser('build-wix', help='Build WiX installer(s)')
404 sp.add_argument(
389 sp.add_argument(
405 '--python-version',
406 help='Which version of Python to target',
407 choices={3},
408 type=int,
409 nargs='*',
410 default=[3],
411 )
412 sp.add_argument(
413 '--arch',
390 '--arch',
414 help='Architecture to build for',
391 help='Architecture to build for',
415 choices={'x86', 'x64'},
392 choices={'x86', 'x64'},
416 nargs='*',
393 nargs='*',
417 default=['x64'],
394 default=['x64'],
418 )
395 )
419 sp.add_argument(
396 sp.add_argument(
420 '--revision',
397 '--revision',
421 help='Mercurial revision to build',
398 help='Mercurial revision to build',
422 default='.',
399 default='.',
423 )
400 )
424 sp.add_argument(
401 sp.add_argument(
425 '--version',
402 '--version',
426 help='Mercurial version string to use in installer',
403 help='Mercurial version string to use in installer',
427 )
404 )
428 sp.add_argument(
405 sp.add_argument(
429 '--base-image-name',
406 '--base-image-name',
430 help='AMI name of base image',
407 help='AMI name of base image',
431 default=aws.WINDOWS_BASE_IMAGE_NAME,
408 default=aws.WINDOWS_BASE_IMAGE_NAME,
432 )
409 )
433 sp.set_defaults(func=build_wix)
410 sp.set_defaults(func=build_wix)
434
411
435 sp = subparsers.add_parser(
412 sp = subparsers.add_parser(
436 'terminate-ec2-instances',
413 'terminate-ec2-instances',
437 help='Terminate all active EC2 instances managed by us',
414 help='Terminate all active EC2 instances managed by us',
438 )
415 )
439 sp.set_defaults(func=terminate_ec2_instances)
416 sp.set_defaults(func=terminate_ec2_instances)
440
417
441 sp = subparsers.add_parser(
418 sp = subparsers.add_parser(
442 'purge-ec2-resources',
419 'purge-ec2-resources',
443 help='Purge all EC2 resources managed by us',
420 help='Purge all EC2 resources managed by us',
444 )
421 )
445 sp.set_defaults(func=purge_ec2_resources)
422 sp.set_defaults(func=purge_ec2_resources)
446
423
447 sp = subparsers.add_parser(
424 sp = subparsers.add_parser(
448 'run-tests-linux',
425 'run-tests-linux',
449 help='Run tests on Linux',
426 help='Run tests on Linux',
450 )
427 )
451 sp.add_argument(
428 sp.add_argument(
452 '--distro',
429 '--distro',
453 help='Linux distribution to run tests on',
430 help='Linux distribution to run tests on',
454 choices=linux.DISTROS,
431 choices=linux.DISTROS,
455 default='debian10',
432 default='debian10',
456 )
433 )
457 sp.add_argument(
434 sp.add_argument(
458 '--filesystem',
435 '--filesystem',
459 help='Filesystem type to use',
436 help='Filesystem type to use',
460 choices={'btrfs', 'default', 'ext3', 'ext4', 'jfs', 'tmpfs', 'xfs'},
437 choices={'btrfs', 'default', 'ext3', 'ext4', 'jfs', 'tmpfs', 'xfs'},
461 default='default',
438 default='default',
462 )
439 )
463 sp.add_argument(
440 sp.add_argument(
464 '--instance-type',
441 '--instance-type',
465 help='EC2 instance type to use',
442 help='EC2 instance type to use',
466 default='c5.9xlarge',
443 default='c5.9xlarge',
467 )
444 )
468 sp.add_argument(
445 sp.add_argument(
469 '--python-version',
446 '--python-version',
470 help='Python version to use',
447 help='Python version to use',
471 choices={
448 choices={
472 'system3',
449 'system3',
473 '3.5',
450 '3.5',
474 '3.6',
451 '3.6',
475 '3.7',
452 '3.7',
476 '3.8',
453 '3.8',
477 'pypy',
454 'pypy',
478 'pypy3.5',
455 'pypy3.5',
479 'pypy3.6',
456 'pypy3.6',
480 },
457 },
481 default='system3',
458 default='system3',
482 )
459 )
483 sp.add_argument(
460 sp.add_argument(
484 'test_flags',
461 'test_flags',
485 help='Extra command line flags to pass to run-tests.py',
462 help='Extra command line flags to pass to run-tests.py',
486 nargs='*',
463 nargs='*',
487 )
464 )
488 sp.set_defaults(func=run_tests_linux)
465 sp.set_defaults(func=run_tests_linux)
489
466
490 sp = subparsers.add_parser(
467 sp = subparsers.add_parser(
491 'run-tests-windows',
468 'run-tests-windows',
492 help='Run tests on Windows',
469 help='Run tests on Windows',
493 )
470 )
494 sp.add_argument(
471 sp.add_argument(
495 '--instance-type',
472 '--instance-type',
496 help='EC2 instance type to use',
473 help='EC2 instance type to use',
497 default='m6i.large',
474 default='m6i.large',
498 )
475 )
499 sp.add_argument(
476 sp.add_argument(
500 '--python-version',
477 '--python-version',
501 help='Python version to use',
478 help='Python version to use',
502 choices={'3.5', '3.6', '3.7', '3.8', '3.9', '3.10'},
479 choices={'3.5', '3.6', '3.7', '3.8', '3.9', '3.10'},
503 default='3.9',
480 default='3.9',
504 )
481 )
505 sp.add_argument(
482 sp.add_argument(
506 '--arch',
483 '--arch',
507 help='Architecture to test',
484 help='Architecture to test',
508 choices={'x86', 'x64'},
485 choices={'x86', 'x64'},
509 default='x64',
486 default='x64',
510 )
487 )
511 sp.add_argument(
488 sp.add_argument(
512 '--test-flags',
489 '--test-flags',
513 help='Extra command line flags to pass to run-tests.py',
490 help='Extra command line flags to pass to run-tests.py',
514 )
491 )
515 sp.add_argument(
492 sp.add_argument(
516 '--base-image-name',
493 '--base-image-name',
517 help='AMI name of base image',
494 help='AMI name of base image',
518 default=aws.WINDOWS_BASE_IMAGE_NAME,
495 default=aws.WINDOWS_BASE_IMAGE_NAME,
519 )
496 )
520 sp.set_defaults(func=run_tests_windows)
497 sp.set_defaults(func=run_tests_windows)
521
498
522 sp = subparsers.add_parser(
499 sp = subparsers.add_parser(
523 'publish-windows-artifacts',
500 'publish-windows-artifacts',
524 help='Publish built Windows artifacts (wheels, installers, etc)',
501 help='Publish built Windows artifacts (wheels, installers, etc)',
525 )
502 )
526 sp.add_argument(
503 sp.add_argument(
527 '--no-pypi',
504 '--no-pypi',
528 dest='pypi',
505 dest='pypi',
529 action='store_false',
506 action='store_false',
530 default=True,
507 default=True,
531 help='Skip uploading to PyPI',
508 help='Skip uploading to PyPI',
532 )
509 )
533 sp.add_argument(
510 sp.add_argument(
534 '--no-mercurial-scm-org',
511 '--no-mercurial-scm-org',
535 dest='mercurial_scm_org',
512 dest='mercurial_scm_org',
536 action='store_false',
513 action='store_false',
537 default=True,
514 default=True,
538 help='Skip uploading to www.mercurial-scm.org',
515 help='Skip uploading to www.mercurial-scm.org',
539 )
516 )
540 sp.add_argument(
517 sp.add_argument(
541 '--ssh-username',
518 '--ssh-username',
542 help='SSH username for mercurial-scm.org',
519 help='SSH username for mercurial-scm.org',
543 )
520 )
544 sp.add_argument(
521 sp.add_argument(
545 'version',
522 'version',
546 help='Mercurial version string to locate local packages',
523 help='Mercurial version string to locate local packages',
547 )
524 )
548 sp.set_defaults(func=publish_windows_artifacts)
525 sp.set_defaults(func=publish_windows_artifacts)
549
526
550 sp = subparsers.add_parser(
527 sp = subparsers.add_parser(
551 'try', help='Run CI automation against a custom changeset'
528 'try', help='Run CI automation against a custom changeset'
552 )
529 )
553 sp.add_argument('-r', '--rev', default='.', help='Revision to run CI on')
530 sp.add_argument('-r', '--rev', default='.', help='Revision to run CI on')
554 sp.set_defaults(func=run_try)
531 sp.set_defaults(func=run_try)
555
532
556 return parser
533 return parser
557
534
558
535
559 def main():
536 def main():
560 parser = get_parser()
537 parser = get_parser()
561 args = parser.parse_args()
538 args = parser.parse_args()
562
539
563 local_state_path = pathlib.Path(os.path.expanduser(args.state_path))
540 local_state_path = pathlib.Path(os.path.expanduser(args.state_path))
564 automation = HGAutomation(local_state_path)
541 automation = HGAutomation(local_state_path)
565
542
566 if not hasattr(args, 'func'):
543 if not hasattr(args, 'func'):
567 parser.print_help()
544 parser.print_help()
568 return
545 return
569
546
570 kwargs = dict(vars(args))
547 kwargs = dict(vars(args))
571 del kwargs['func']
548 del kwargs['func']
572 del kwargs['state_path']
549 del kwargs['state_path']
573
550
574 args.func(automation, **kwargs)
551 args.func(automation, **kwargs)
@@ -1,544 +1,539 b''
1 # windows.py - Automation specific to Windows
1 # windows.py - Automation specific to Windows
2 #
2 #
3 # Copyright 2019 Gregory Szorc <gregory.szorc@gmail.com>
3 # Copyright 2019 Gregory Szorc <gregory.szorc@gmail.com>
4 #
4 #
5 # This software may be used and distributed according to the terms of the
5 # This software may be used and distributed according to the terms of the
6 # GNU General Public License version 2 or any later version.
6 # GNU General Public License version 2 or any later version.
7
7
8 # no-check-code because Python 3 native.
8 # no-check-code because Python 3 native.
9
9
10 import datetime
10 import datetime
11 import os
11 import os
12 import paramiko
12 import paramiko
13 import pathlib
13 import pathlib
14 import re
14 import re
15 import subprocess
15 import subprocess
16 import tempfile
16 import tempfile
17
17
18 from .pypi import upload as pypi_upload
18 from .pypi import upload as pypi_upload
19 from .winrm import run_powershell
19 from .winrm import run_powershell
20
20
21
21
22 HG_PURGE = r'''
22 HG_PURGE = r'''
23 $Env:PATH = "C:\hgdev\venv-bootstrap\Scripts;$Env:PATH"
23 $Env:PATH = "C:\hgdev\venv-bootstrap\Scripts;$Env:PATH"
24 Set-Location C:\hgdev\src
24 Set-Location C:\hgdev\src
25 hg.exe --config extensions.purge= purge --all
25 hg.exe --config extensions.purge= purge --all
26 if ($LASTEXITCODE -ne 0) {
26 if ($LASTEXITCODE -ne 0) {
27 throw "process exited non-0: $LASTEXITCODE"
27 throw "process exited non-0: $LASTEXITCODE"
28 }
28 }
29 Write-Output "purged Mercurial repo"
29 Write-Output "purged Mercurial repo"
30 '''
30 '''
31
31
32 HG_UPDATE_CLEAN = r'''
32 HG_UPDATE_CLEAN = r'''
33 $Env:PATH = "C:\hgdev\venv-bootstrap\Scripts;$Env:PATH"
33 $Env:PATH = "C:\hgdev\venv-bootstrap\Scripts;$Env:PATH"
34 Set-Location C:\hgdev\src
34 Set-Location C:\hgdev\src
35 hg.exe --config extensions.purge= purge --all
35 hg.exe --config extensions.purge= purge --all
36 if ($LASTEXITCODE -ne 0) {{
36 if ($LASTEXITCODE -ne 0) {{
37 throw "process exited non-0: $LASTEXITCODE"
37 throw "process exited non-0: $LASTEXITCODE"
38 }}
38 }}
39 hg.exe update -C {revision}
39 hg.exe update -C {revision}
40 if ($LASTEXITCODE -ne 0) {{
40 if ($LASTEXITCODE -ne 0) {{
41 throw "process exited non-0: $LASTEXITCODE"
41 throw "process exited non-0: $LASTEXITCODE"
42 }}
42 }}
43 hg.exe log -r .
43 hg.exe log -r .
44 Write-Output "updated Mercurial working directory to {revision}"
44 Write-Output "updated Mercurial working directory to {revision}"
45 '''.lstrip()
45 '''.lstrip()
46
46
47 BUILD_INNO_PYTHON3 = r'''
47 BUILD_INNO_PYTHON3 = r'''
48 $Env:RUSTUP_HOME = "C:\hgdev\rustup"
48 $Env:RUSTUP_HOME = "C:\hgdev\rustup"
49 $Env:CARGO_HOME = "C:\hgdev\cargo"
49 $Env:CARGO_HOME = "C:\hgdev\cargo"
50 Set-Location C:\hgdev\src
50 Set-Location C:\hgdev\src
51 C:\hgdev\python37-x64\python.exe contrib\packaging\packaging.py inno --pyoxidizer-target {pyoxidizer_target} --version {version}
51 C:\hgdev\python37-x64\python.exe contrib\packaging\packaging.py inno --pyoxidizer-target {pyoxidizer_target} --version {version}
52 if ($LASTEXITCODE -ne 0) {{
52 if ($LASTEXITCODE -ne 0) {{
53 throw "process exited non-0: $LASTEXITCODE"
53 throw "process exited non-0: $LASTEXITCODE"
54 }}
54 }}
55 '''
55 '''
56
56
57
57
58 BUILD_WHEEL = r'''
58 BUILD_WHEEL = r'''
59 Set-Location C:\hgdev\src
59 Set-Location C:\hgdev\src
60 C:\hgdev\python{python_version}-{arch}\python.exe -m pip wheel --wheel-dir dist .
60 C:\hgdev\python{python_version}-{arch}\python.exe -m pip wheel --wheel-dir dist .
61 if ($LASTEXITCODE -ne 0) {{
61 if ($LASTEXITCODE -ne 0) {{
62 throw "process exited non-0: $LASTEXITCODE"
62 throw "process exited non-0: $LASTEXITCODE"
63 }}
63 }}
64 '''
64 '''
65
65
66 BUILD_WIX_PYTHON3 = r'''
66 BUILD_WIX_PYTHON3 = r'''
67 $Env:RUSTUP_HOME = "C:\hgdev\rustup"
67 $Env:RUSTUP_HOME = "C:\hgdev\rustup"
68 $Env:CARGO_HOME = "C:\hgdev\cargo"
68 $Env:CARGO_HOME = "C:\hgdev\cargo"
69 Set-Location C:\hgdev\src
69 Set-Location C:\hgdev\src
70 C:\hgdev\python37-x64\python.exe contrib\packaging\packaging.py wix --pyoxidizer-target {pyoxidizer_target} --version {version}
70 C:\hgdev\python37-x64\python.exe contrib\packaging\packaging.py wix --pyoxidizer-target {pyoxidizer_target} --version {version}
71 if ($LASTEXITCODE -ne 0) {{
71 if ($LASTEXITCODE -ne 0) {{
72 throw "process exited non-0: $LASTEXITCODE"
72 throw "process exited non-0: $LASTEXITCODE"
73 }}
73 }}
74 '''
74 '''
75
75
76
76
77 RUN_TESTS = r'''
77 RUN_TESTS = r'''
78 C:\hgdev\MinGW\msys\1.0\bin\sh.exe --login -c "cd /c/hgdev/src/tests && /c/hgdev/{python_path}/python.exe run-tests.py {test_flags}"
78 C:\hgdev\MinGW\msys\1.0\bin\sh.exe --login -c "cd /c/hgdev/src/tests && /c/hgdev/{python_path}/python.exe run-tests.py {test_flags}"
79 if ($LASTEXITCODE -ne 0) {{
79 if ($LASTEXITCODE -ne 0) {{
80 throw "process exited non-0: $LASTEXITCODE"
80 throw "process exited non-0: $LASTEXITCODE"
81 }}
81 }}
82 '''
82 '''
83
83
84
84
85 WHEEL_FILENAME_PYTHON37_X86 = 'mercurial-{version}-cp37-cp37m-win32.whl'
85 WHEEL_FILENAME_PYTHON37_X86 = 'mercurial-{version}-cp37-cp37m-win32.whl'
86 WHEEL_FILENAME_PYTHON37_X64 = 'mercurial-{version}-cp37-cp37m-win_amd64.whl'
86 WHEEL_FILENAME_PYTHON37_X64 = 'mercurial-{version}-cp37-cp37m-win_amd64.whl'
87 WHEEL_FILENAME_PYTHON38_X86 = 'mercurial-{version}-cp38-cp38-win32.whl'
87 WHEEL_FILENAME_PYTHON38_X86 = 'mercurial-{version}-cp38-cp38-win32.whl'
88 WHEEL_FILENAME_PYTHON38_X64 = 'mercurial-{version}-cp38-cp38-win_amd64.whl'
88 WHEEL_FILENAME_PYTHON38_X64 = 'mercurial-{version}-cp38-cp38-win_amd64.whl'
89 WHEEL_FILENAME_PYTHON39_X86 = 'mercurial-{version}-cp39-cp39-win32.whl'
89 WHEEL_FILENAME_PYTHON39_X86 = 'mercurial-{version}-cp39-cp39-win32.whl'
90 WHEEL_FILENAME_PYTHON39_X64 = 'mercurial-{version}-cp39-cp39-win_amd64.whl'
90 WHEEL_FILENAME_PYTHON39_X64 = 'mercurial-{version}-cp39-cp39-win_amd64.whl'
91 WHEEL_FILENAME_PYTHON310_X86 = 'mercurial-{version}-cp310-cp310-win32.whl'
91 WHEEL_FILENAME_PYTHON310_X86 = 'mercurial-{version}-cp310-cp310-win32.whl'
92 WHEEL_FILENAME_PYTHON310_X64 = 'mercurial-{version}-cp310-cp310-win_amd64.whl'
92 WHEEL_FILENAME_PYTHON310_X64 = 'mercurial-{version}-cp310-cp310-win_amd64.whl'
93
93
94 EXE_FILENAME_PYTHON3_X86 = 'Mercurial-{version}-x86.exe'
94 EXE_FILENAME_PYTHON3_X86 = 'Mercurial-{version}-x86.exe'
95 EXE_FILENAME_PYTHON3_X64 = 'Mercurial-{version}-x64.exe'
95 EXE_FILENAME_PYTHON3_X64 = 'Mercurial-{version}-x64.exe'
96
96
97 MSI_FILENAME_PYTHON3_X86 = 'mercurial-{version}-x86.msi'
97 MSI_FILENAME_PYTHON3_X86 = 'mercurial-{version}-x86.msi'
98 MSI_FILENAME_PYTHON3_X64 = 'mercurial-{version}-x64.msi'
98 MSI_FILENAME_PYTHON3_X64 = 'mercurial-{version}-x64.msi'
99
99
100 MERCURIAL_SCM_BASE_URL = 'https://mercurial-scm.org/release/windows'
100 MERCURIAL_SCM_BASE_URL = 'https://mercurial-scm.org/release/windows'
101
101
102 X86_USER_AGENT_PATTERN = '.*Windows.*'
102 X86_USER_AGENT_PATTERN = '.*Windows.*'
103 X64_USER_AGENT_PATTERN = '.*Windows.*(WOW|x)64.*'
103 X64_USER_AGENT_PATTERN = '.*Windows.*(WOW|x)64.*'
104
104
105 # TODO remove Python version once Python 2 is dropped.
105 # TODO remove Python version once Python 2 is dropped.
106 EXE_PYTHON3_X86_DESCRIPTION = (
106 EXE_PYTHON3_X86_DESCRIPTION = (
107 'Mercurial {version} Inno Setup installer - x86 Windows (Python 3) '
107 'Mercurial {version} Inno Setup installer - x86 Windows (Python 3) '
108 '- does not require admin rights'
108 '- does not require admin rights'
109 )
109 )
110 EXE_PYTHON3_X64_DESCRIPTION = (
110 EXE_PYTHON3_X64_DESCRIPTION = (
111 'Mercurial {version} Inno Setup installer - x64 Windows (Python 3) '
111 'Mercurial {version} Inno Setup installer - x64 Windows (Python 3) '
112 '- does not require admin rights'
112 '- does not require admin rights'
113 )
113 )
114 MSI_PYTHON3_X86_DESCRIPTION = (
114 MSI_PYTHON3_X86_DESCRIPTION = (
115 'Mercurial {version} MSI installer - x86 Windows (Python 3) '
115 'Mercurial {version} MSI installer - x86 Windows (Python 3) '
116 '- requires admin rights'
116 '- requires admin rights'
117 )
117 )
118 MSI_PYTHON3_X64_DESCRIPTION = (
118 MSI_PYTHON3_X64_DESCRIPTION = (
119 'Mercurial {version} MSI installer - x64 Windows (Python 3) '
119 'Mercurial {version} MSI installer - x64 Windows (Python 3) '
120 '- requires admin rights'
120 '- requires admin rights'
121 )
121 )
122
122
123
123
124 def fix_authorized_keys_permissions(winrm_client, path):
124 def fix_authorized_keys_permissions(winrm_client, path):
125 commands = [
125 commands = [
126 '$ErrorActionPreference = "Stop"',
126 '$ErrorActionPreference = "Stop"',
127 'Repair-AuthorizedKeyPermission -FilePath %s -Confirm:$false' % path,
127 'Repair-AuthorizedKeyPermission -FilePath %s -Confirm:$false' % path,
128 r'icacls %s /remove:g "NT Service\sshd"' % path,
128 r'icacls %s /remove:g "NT Service\sshd"' % path,
129 ]
129 ]
130
130
131 run_powershell(winrm_client, '\n'.join(commands))
131 run_powershell(winrm_client, '\n'.join(commands))
132
132
133
133
134 def synchronize_hg(hg_repo: pathlib.Path, revision: str, ec2_instance):
134 def synchronize_hg(hg_repo: pathlib.Path, revision: str, ec2_instance):
135 """Synchronize local Mercurial repo to remote EC2 instance."""
135 """Synchronize local Mercurial repo to remote EC2 instance."""
136
136
137 winrm_client = ec2_instance.winrm_client
137 winrm_client = ec2_instance.winrm_client
138
138
139 with tempfile.TemporaryDirectory() as temp_dir:
139 with tempfile.TemporaryDirectory() as temp_dir:
140 temp_dir = pathlib.Path(temp_dir)
140 temp_dir = pathlib.Path(temp_dir)
141
141
142 ssh_dir = temp_dir / '.ssh'
142 ssh_dir = temp_dir / '.ssh'
143 ssh_dir.mkdir()
143 ssh_dir.mkdir()
144 ssh_dir.chmod(0o0700)
144 ssh_dir.chmod(0o0700)
145
145
146 # Generate SSH key to use for communication.
146 # Generate SSH key to use for communication.
147 subprocess.run(
147 subprocess.run(
148 [
148 [
149 'ssh-keygen',
149 'ssh-keygen',
150 '-t',
150 '-t',
151 'rsa',
151 'rsa',
152 '-b',
152 '-b',
153 '4096',
153 '4096',
154 '-N',
154 '-N',
155 '',
155 '',
156 '-f',
156 '-f',
157 str(ssh_dir / 'id_rsa'),
157 str(ssh_dir / 'id_rsa'),
158 ],
158 ],
159 check=True,
159 check=True,
160 capture_output=True,
160 capture_output=True,
161 )
161 )
162
162
163 # Add it to ~/.ssh/authorized_keys on remote.
163 # Add it to ~/.ssh/authorized_keys on remote.
164 # This assumes the file doesn't already exist.
164 # This assumes the file doesn't already exist.
165 authorized_keys = r'c:\Users\Administrator\.ssh\authorized_keys'
165 authorized_keys = r'c:\Users\Administrator\.ssh\authorized_keys'
166 winrm_client.execute_cmd(r'mkdir c:\Users\Administrator\.ssh')
166 winrm_client.execute_cmd(r'mkdir c:\Users\Administrator\.ssh')
167 winrm_client.copy(str(ssh_dir / 'id_rsa.pub'), authorized_keys)
167 winrm_client.copy(str(ssh_dir / 'id_rsa.pub'), authorized_keys)
168 fix_authorized_keys_permissions(winrm_client, authorized_keys)
168 fix_authorized_keys_permissions(winrm_client, authorized_keys)
169
169
170 public_ip = ec2_instance.public_ip_address
170 public_ip = ec2_instance.public_ip_address
171
171
172 ssh_config = temp_dir / '.ssh' / 'config'
172 ssh_config = temp_dir / '.ssh' / 'config'
173
173
174 with open(ssh_config, 'w', encoding='utf-8') as fh:
174 with open(ssh_config, 'w', encoding='utf-8') as fh:
175 fh.write('Host %s\n' % public_ip)
175 fh.write('Host %s\n' % public_ip)
176 fh.write(' User Administrator\n')
176 fh.write(' User Administrator\n')
177 fh.write(' StrictHostKeyChecking no\n')
177 fh.write(' StrictHostKeyChecking no\n')
178 fh.write(' UserKnownHostsFile %s\n' % (ssh_dir / 'known_hosts'))
178 fh.write(' UserKnownHostsFile %s\n' % (ssh_dir / 'known_hosts'))
179 fh.write(' IdentityFile %s\n' % (ssh_dir / 'id_rsa'))
179 fh.write(' IdentityFile %s\n' % (ssh_dir / 'id_rsa'))
180
180
181 if not (hg_repo / '.hg').is_dir():
181 if not (hg_repo / '.hg').is_dir():
182 raise Exception(
182 raise Exception(
183 '%s is not a Mercurial repository; '
183 '%s is not a Mercurial repository; '
184 'synchronization not yet supported' % hg_repo
184 'synchronization not yet supported' % hg_repo
185 )
185 )
186
186
187 env = dict(os.environ)
187 env = dict(os.environ)
188 env['HGPLAIN'] = '1'
188 env['HGPLAIN'] = '1'
189 env['HGENCODING'] = 'utf-8'
189 env['HGENCODING'] = 'utf-8'
190
190
191 hg_bin = hg_repo / 'hg'
191 hg_bin = hg_repo / 'hg'
192
192
193 res = subprocess.run(
193 res = subprocess.run(
194 ['python3', str(hg_bin), 'log', '-r', revision, '-T', '{node}'],
194 ['python3', str(hg_bin), 'log', '-r', revision, '-T', '{node}'],
195 cwd=str(hg_repo),
195 cwd=str(hg_repo),
196 env=env,
196 env=env,
197 check=True,
197 check=True,
198 capture_output=True,
198 capture_output=True,
199 )
199 )
200
200
201 full_revision = res.stdout.decode('ascii')
201 full_revision = res.stdout.decode('ascii')
202
202
203 args = [
203 args = [
204 'python3',
204 'python3',
205 hg_bin,
205 hg_bin,
206 '--config',
206 '--config',
207 'ui.ssh=ssh -F %s' % ssh_config,
207 'ui.ssh=ssh -F %s' % ssh_config,
208 '--config',
208 '--config',
209 'ui.remotecmd=c:/hgdev/venv-bootstrap/Scripts/hg.exe',
209 'ui.remotecmd=c:/hgdev/venv-bootstrap/Scripts/hg.exe',
210 # Also ensure .hgtags changes are present so auto version
210 # Also ensure .hgtags changes are present so auto version
211 # calculation works.
211 # calculation works.
212 'push',
212 'push',
213 '-f',
213 '-f',
214 '-r',
214 '-r',
215 full_revision,
215 full_revision,
216 '-r',
216 '-r',
217 'file(.hgtags)',
217 'file(.hgtags)',
218 'ssh://%s/c:/hgdev/src' % public_ip,
218 'ssh://%s/c:/hgdev/src' % public_ip,
219 ]
219 ]
220
220
221 res = subprocess.run(args, cwd=str(hg_repo), env=env)
221 res = subprocess.run(args, cwd=str(hg_repo), env=env)
222
222
223 # Allow 1 (no-op) to not trigger error.
223 # Allow 1 (no-op) to not trigger error.
224 if res.returncode not in (0, 1):
224 if res.returncode not in (0, 1):
225 res.check_returncode()
225 res.check_returncode()
226
226
227 run_powershell(
227 run_powershell(
228 winrm_client, HG_UPDATE_CLEAN.format(revision=full_revision)
228 winrm_client, HG_UPDATE_CLEAN.format(revision=full_revision)
229 )
229 )
230
230
231 # TODO detect dirty local working directory and synchronize accordingly.
231 # TODO detect dirty local working directory and synchronize accordingly.
232
232
233
233
234 def purge_hg(winrm_client):
234 def purge_hg(winrm_client):
235 """Purge the Mercurial source repository on an EC2 instance."""
235 """Purge the Mercurial source repository on an EC2 instance."""
236 run_powershell(winrm_client, HG_PURGE)
236 run_powershell(winrm_client, HG_PURGE)
237
237
238
238
239 def find_latest_dist(winrm_client, pattern):
239 def find_latest_dist(winrm_client, pattern):
240 """Find path to newest file in dist/ directory matching a pattern."""
240 """Find path to newest file in dist/ directory matching a pattern."""
241
241
242 res = winrm_client.execute_ps(
242 res = winrm_client.execute_ps(
243 r'$v = Get-ChildItem -Path C:\hgdev\src\dist -Filter "%s" '
243 r'$v = Get-ChildItem -Path C:\hgdev\src\dist -Filter "%s" '
244 '| Sort-Object LastWriteTime -Descending '
244 '| Sort-Object LastWriteTime -Descending '
245 '| Select-Object -First 1\n'
245 '| Select-Object -First 1\n'
246 '$v.name' % pattern
246 '$v.name' % pattern
247 )
247 )
248 return res[0]
248 return res[0]
249
249
250
250
251 def copy_latest_dist(winrm_client, pattern, dest_path):
251 def copy_latest_dist(winrm_client, pattern, dest_path):
252 """Copy latest file matching pattern in dist/ directory.
252 """Copy latest file matching pattern in dist/ directory.
253
253
254 Given a WinRM client and a file pattern, find the latest file on the remote
254 Given a WinRM client and a file pattern, find the latest file on the remote
255 matching that pattern and copy it to the ``dest_path`` directory on the
255 matching that pattern and copy it to the ``dest_path`` directory on the
256 local machine.
256 local machine.
257 """
257 """
258 latest = find_latest_dist(winrm_client, pattern)
258 latest = find_latest_dist(winrm_client, pattern)
259 source = r'C:\hgdev\src\dist\%s' % latest
259 source = r'C:\hgdev\src\dist\%s' % latest
260 dest = dest_path / latest
260 dest = dest_path / latest
261 print('copying %s to %s' % (source, dest))
261 print('copying %s to %s' % (source, dest))
262 winrm_client.fetch(source, str(dest))
262 winrm_client.fetch(source, str(dest))
263
263
264
264
265 def build_inno_installer(
265 def build_inno_installer(
266 winrm_client,
266 winrm_client,
267 python_version: int,
268 arch: str,
267 arch: str,
269 dest_path: pathlib.Path,
268 dest_path: pathlib.Path,
270 version=None,
269 version=None,
271 ):
270 ):
272 """Build the Inno Setup installer on a remote machine.
271 """Build the Inno Setup installer on a remote machine.
273
272
274 Using a WinRM client, remote commands are executed to build
273 Using a WinRM client, remote commands are executed to build
275 a Mercurial Inno Setup installer.
274 a Mercurial Inno Setup installer.
276 """
275 """
277 print(
276 print('building Inno Setup installer for %s' % arch)
278 'building Inno Setup installer for Python %d %s'
279 % (python_version, arch)
280 )
281
277
282 # TODO fix this limitation in packaging code
278 # TODO fix this limitation in packaging code
283 if not version:
279 if not version:
284 raise Exception("version string is required when building for Python 3")
280 raise Exception("version string is required when building for Python 3")
285
281
286 if arch == "x86":
282 if arch == "x86":
287 target_triple = "i686-pc-windows-msvc"
283 target_triple = "i686-pc-windows-msvc"
288 elif arch == "x64":
284 elif arch == "x64":
289 target_triple = "x86_64-pc-windows-msvc"
285 target_triple = "x86_64-pc-windows-msvc"
290 else:
286 else:
291 raise Exception("unhandled arch: %s" % arch)
287 raise Exception("unhandled arch: %s" % arch)
292
288
293 ps = BUILD_INNO_PYTHON3.format(
289 ps = BUILD_INNO_PYTHON3.format(
294 pyoxidizer_target=target_triple,
290 pyoxidizer_target=target_triple,
295 version=version,
291 version=version,
296 )
292 )
297
293
298 run_powershell(winrm_client, ps)
294 run_powershell(winrm_client, ps)
299 copy_latest_dist(winrm_client, '*.exe', dest_path)
295 copy_latest_dist(winrm_client, '*.exe', dest_path)
300
296
301
297
302 def build_wheel(
298 def build_wheel(
303 winrm_client, python_version: str, arch: str, dest_path: pathlib.Path
299 winrm_client, python_version: str, arch: str, dest_path: pathlib.Path
304 ):
300 ):
305 """Build Python wheels on a remote machine.
301 """Build Python wheels on a remote machine.
306
302
307 Using a WinRM client, remote commands are executed to build a Python wheel
303 Using a WinRM client, remote commands are executed to build a Python wheel
308 for Mercurial.
304 for Mercurial.
309 """
305 """
310 print('Building Windows wheel for Python %s %s' % (python_version, arch))
306 print('Building Windows wheel for Python %s %s' % (python_version, arch))
311
307
312 ps = BUILD_WHEEL.format(
308 ps = BUILD_WHEEL.format(
313 python_version=python_version.replace(".", ""), arch=arch
309 python_version=python_version.replace(".", ""), arch=arch
314 )
310 )
315
311
316 run_powershell(winrm_client, ps)
312 run_powershell(winrm_client, ps)
317 copy_latest_dist(winrm_client, '*.whl', dest_path)
313 copy_latest_dist(winrm_client, '*.whl', dest_path)
318
314
319
315
320 def build_wix_installer(
316 def build_wix_installer(
321 winrm_client,
317 winrm_client,
322 python_version: int,
323 arch: str,
318 arch: str,
324 dest_path: pathlib.Path,
319 dest_path: pathlib.Path,
325 version=None,
320 version=None,
326 ):
321 ):
327 """Build the WiX installer on a remote machine.
322 """Build the WiX installer on a remote machine.
328
323
329 Using a WinRM client, remote commands are executed to build a WiX installer.
324 Using a WinRM client, remote commands are executed to build a WiX installer.
330 """
325 """
331 print('Building WiX installer for Python %d %s' % (python_version, arch))
326 print('Building WiX installer for %s' % arch)
332
327
333 # TODO fix this limitation in packaging code
328 # TODO fix this limitation in packaging code
334 if not version:
329 if not version:
335 raise Exception("version string is required when building for Python 3")
330 raise Exception("version string is required when building for Python 3")
336
331
337 if arch == "x86":
332 if arch == "x86":
338 target_triple = "i686-pc-windows-msvc"
333 target_triple = "i686-pc-windows-msvc"
339 elif arch == "x64":
334 elif arch == "x64":
340 target_triple = "x86_64-pc-windows-msvc"
335 target_triple = "x86_64-pc-windows-msvc"
341 else:
336 else:
342 raise Exception("unhandled arch: %s" % arch)
337 raise Exception("unhandled arch: %s" % arch)
343
338
344 ps = BUILD_WIX_PYTHON3.format(
339 ps = BUILD_WIX_PYTHON3.format(
345 pyoxidizer_target=target_triple,
340 pyoxidizer_target=target_triple,
346 version=version,
341 version=version,
347 )
342 )
348
343
349 run_powershell(winrm_client, ps)
344 run_powershell(winrm_client, ps)
350 copy_latest_dist(winrm_client, '*.msi', dest_path)
345 copy_latest_dist(winrm_client, '*.msi', dest_path)
351
346
352
347
353 def run_tests(winrm_client, python_version, arch, test_flags=''):
348 def run_tests(winrm_client, python_version, arch, test_flags=''):
354 """Run tests on a remote Windows machine.
349 """Run tests on a remote Windows machine.
355
350
356 ``python_version`` is a ``X.Y`` string like ``2.7`` or ``3.7``.
351 ``python_version`` is a ``X.Y`` string like ``2.7`` or ``3.7``.
357 ``arch`` is ``x86`` or ``x64``.
352 ``arch`` is ``x86`` or ``x64``.
358 ``test_flags`` is a str representing extra arguments to pass to
353 ``test_flags`` is a str representing extra arguments to pass to
359 ``run-tests.py``.
354 ``run-tests.py``.
360 """
355 """
361 if not re.match(r'\d\.\d', python_version):
356 if not re.match(r'\d\.\d', python_version):
362 raise ValueError(
357 raise ValueError(
363 r'python_version must be \d.\d; got %s' % python_version
358 r'python_version must be \d.\d; got %s' % python_version
364 )
359 )
365
360
366 if arch not in ('x86', 'x64'):
361 if arch not in ('x86', 'x64'):
367 raise ValueError('arch must be x86 or x64; got %s' % arch)
362 raise ValueError('arch must be x86 or x64; got %s' % arch)
368
363
369 python_path = 'python%s-%s' % (python_version.replace('.', ''), arch)
364 python_path = 'python%s-%s' % (python_version.replace('.', ''), arch)
370
365
371 ps = RUN_TESTS.format(
366 ps = RUN_TESTS.format(
372 python_path=python_path,
367 python_path=python_path,
373 test_flags=test_flags or '',
368 test_flags=test_flags or '',
374 )
369 )
375
370
376 run_powershell(winrm_client, ps)
371 run_powershell(winrm_client, ps)
377
372
378
373
379 def resolve_wheel_artifacts(dist_path: pathlib.Path, version: str):
374 def resolve_wheel_artifacts(dist_path: pathlib.Path, version: str):
380 return (
375 return (
381 dist_path / WHEEL_FILENAME_PYTHON37_X86.format(version=version),
376 dist_path / WHEEL_FILENAME_PYTHON37_X86.format(version=version),
382 dist_path / WHEEL_FILENAME_PYTHON37_X64.format(version=version),
377 dist_path / WHEEL_FILENAME_PYTHON37_X64.format(version=version),
383 dist_path / WHEEL_FILENAME_PYTHON38_X86.format(version=version),
378 dist_path / WHEEL_FILENAME_PYTHON38_X86.format(version=version),
384 dist_path / WHEEL_FILENAME_PYTHON38_X64.format(version=version),
379 dist_path / WHEEL_FILENAME_PYTHON38_X64.format(version=version),
385 dist_path / WHEEL_FILENAME_PYTHON39_X86.format(version=version),
380 dist_path / WHEEL_FILENAME_PYTHON39_X86.format(version=version),
386 dist_path / WHEEL_FILENAME_PYTHON39_X64.format(version=version),
381 dist_path / WHEEL_FILENAME_PYTHON39_X64.format(version=version),
387 dist_path / WHEEL_FILENAME_PYTHON310_X86.format(version=version),
382 dist_path / WHEEL_FILENAME_PYTHON310_X86.format(version=version),
388 dist_path / WHEEL_FILENAME_PYTHON310_X64.format(version=version),
383 dist_path / WHEEL_FILENAME_PYTHON310_X64.format(version=version),
389 )
384 )
390
385
391
386
392 def resolve_all_artifacts(dist_path: pathlib.Path, version: str):
387 def resolve_all_artifacts(dist_path: pathlib.Path, version: str):
393 return (
388 return (
394 dist_path / WHEEL_FILENAME_PYTHON37_X86.format(version=version),
389 dist_path / WHEEL_FILENAME_PYTHON37_X86.format(version=version),
395 dist_path / WHEEL_FILENAME_PYTHON37_X64.format(version=version),
390 dist_path / WHEEL_FILENAME_PYTHON37_X64.format(version=version),
396 dist_path / WHEEL_FILENAME_PYTHON38_X86.format(version=version),
391 dist_path / WHEEL_FILENAME_PYTHON38_X86.format(version=version),
397 dist_path / WHEEL_FILENAME_PYTHON38_X64.format(version=version),
392 dist_path / WHEEL_FILENAME_PYTHON38_X64.format(version=version),
398 dist_path / WHEEL_FILENAME_PYTHON39_X86.format(version=version),
393 dist_path / WHEEL_FILENAME_PYTHON39_X86.format(version=version),
399 dist_path / WHEEL_FILENAME_PYTHON39_X64.format(version=version),
394 dist_path / WHEEL_FILENAME_PYTHON39_X64.format(version=version),
400 dist_path / WHEEL_FILENAME_PYTHON310_X86.format(version=version),
395 dist_path / WHEEL_FILENAME_PYTHON310_X86.format(version=version),
401 dist_path / WHEEL_FILENAME_PYTHON310_X64.format(version=version),
396 dist_path / WHEEL_FILENAME_PYTHON310_X64.format(version=version),
402 dist_path / EXE_FILENAME_PYTHON3_X86.format(version=version),
397 dist_path / EXE_FILENAME_PYTHON3_X86.format(version=version),
403 dist_path / EXE_FILENAME_PYTHON3_X64.format(version=version),
398 dist_path / EXE_FILENAME_PYTHON3_X64.format(version=version),
404 dist_path / MSI_FILENAME_PYTHON3_X86.format(version=version),
399 dist_path / MSI_FILENAME_PYTHON3_X86.format(version=version),
405 dist_path / MSI_FILENAME_PYTHON3_X64.format(version=version),
400 dist_path / MSI_FILENAME_PYTHON3_X64.format(version=version),
406 )
401 )
407
402
408
403
409 def generate_latest_dat(version: str):
404 def generate_latest_dat(version: str):
410 python3_x86_exe_filename = EXE_FILENAME_PYTHON3_X86.format(version=version)
405 python3_x86_exe_filename = EXE_FILENAME_PYTHON3_X86.format(version=version)
411 python3_x64_exe_filename = EXE_FILENAME_PYTHON3_X64.format(version=version)
406 python3_x64_exe_filename = EXE_FILENAME_PYTHON3_X64.format(version=version)
412 python3_x86_msi_filename = MSI_FILENAME_PYTHON3_X86.format(version=version)
407 python3_x86_msi_filename = MSI_FILENAME_PYTHON3_X86.format(version=version)
413 python3_x64_msi_filename = MSI_FILENAME_PYTHON3_X64.format(version=version)
408 python3_x64_msi_filename = MSI_FILENAME_PYTHON3_X64.format(version=version)
414
409
415 entries = (
410 entries = (
416 (
411 (
417 '10',
412 '10',
418 version,
413 version,
419 X86_USER_AGENT_PATTERN,
414 X86_USER_AGENT_PATTERN,
420 '%s/%s' % (MERCURIAL_SCM_BASE_URL, python3_x86_exe_filename),
415 '%s/%s' % (MERCURIAL_SCM_BASE_URL, python3_x86_exe_filename),
421 EXE_PYTHON3_X86_DESCRIPTION.format(version=version),
416 EXE_PYTHON3_X86_DESCRIPTION.format(version=version),
422 ),
417 ),
423 (
418 (
424 '10',
419 '10',
425 version,
420 version,
426 X64_USER_AGENT_PATTERN,
421 X64_USER_AGENT_PATTERN,
427 '%s/%s' % (MERCURIAL_SCM_BASE_URL, python3_x64_exe_filename),
422 '%s/%s' % (MERCURIAL_SCM_BASE_URL, python3_x64_exe_filename),
428 EXE_PYTHON3_X64_DESCRIPTION.format(version=version),
423 EXE_PYTHON3_X64_DESCRIPTION.format(version=version),
429 ),
424 ),
430 (
425 (
431 '10',
426 '10',
432 version,
427 version,
433 X86_USER_AGENT_PATTERN,
428 X86_USER_AGENT_PATTERN,
434 '%s/%s' % (MERCURIAL_SCM_BASE_URL, python3_x86_msi_filename),
429 '%s/%s' % (MERCURIAL_SCM_BASE_URL, python3_x86_msi_filename),
435 MSI_PYTHON3_X86_DESCRIPTION.format(version=version),
430 MSI_PYTHON3_X86_DESCRIPTION.format(version=version),
436 ),
431 ),
437 (
432 (
438 '10',
433 '10',
439 version,
434 version,
440 X64_USER_AGENT_PATTERN,
435 X64_USER_AGENT_PATTERN,
441 '%s/%s' % (MERCURIAL_SCM_BASE_URL, python3_x64_msi_filename),
436 '%s/%s' % (MERCURIAL_SCM_BASE_URL, python3_x64_msi_filename),
442 MSI_PYTHON3_X64_DESCRIPTION.format(version=version),
437 MSI_PYTHON3_X64_DESCRIPTION.format(version=version),
443 ),
438 ),
444 )
439 )
445
440
446 lines = ['\t'.join(e) for e in entries]
441 lines = ['\t'.join(e) for e in entries]
447
442
448 return '\n'.join(lines) + '\n'
443 return '\n'.join(lines) + '\n'
449
444
450
445
451 def publish_artifacts_pypi(dist_path: pathlib.Path, version: str):
446 def publish_artifacts_pypi(dist_path: pathlib.Path, version: str):
452 """Publish Windows release artifacts to PyPI."""
447 """Publish Windows release artifacts to PyPI."""
453
448
454 wheel_paths = resolve_wheel_artifacts(dist_path, version)
449 wheel_paths = resolve_wheel_artifacts(dist_path, version)
455
450
456 for p in wheel_paths:
451 for p in wheel_paths:
457 if not p.exists():
452 if not p.exists():
458 raise Exception('%s not found' % p)
453 raise Exception('%s not found' % p)
459
454
460 print('uploading wheels to PyPI (you may be prompted for credentials)')
455 print('uploading wheels to PyPI (you may be prompted for credentials)')
461 pypi_upload(wheel_paths)
456 pypi_upload(wheel_paths)
462
457
463
458
464 def publish_artifacts_mercurial_scm_org(
459 def publish_artifacts_mercurial_scm_org(
465 dist_path: pathlib.Path, version: str, ssh_username=None
460 dist_path: pathlib.Path, version: str, ssh_username=None
466 ):
461 ):
467 """Publish Windows release artifacts to mercurial-scm.org."""
462 """Publish Windows release artifacts to mercurial-scm.org."""
468 all_paths = resolve_all_artifacts(dist_path, version)
463 all_paths = resolve_all_artifacts(dist_path, version)
469
464
470 for p in all_paths:
465 for p in all_paths:
471 if not p.exists():
466 if not p.exists():
472 raise Exception('%s not found' % p)
467 raise Exception('%s not found' % p)
473
468
474 client = paramiko.SSHClient()
469 client = paramiko.SSHClient()
475 client.load_system_host_keys()
470 client.load_system_host_keys()
476 # We assume the system SSH configuration knows how to connect.
471 # We assume the system SSH configuration knows how to connect.
477 print('connecting to mercurial-scm.org via ssh...')
472 print('connecting to mercurial-scm.org via ssh...')
478 try:
473 try:
479 client.connect('mercurial-scm.org', username=ssh_username)
474 client.connect('mercurial-scm.org', username=ssh_username)
480 except paramiko.AuthenticationException:
475 except paramiko.AuthenticationException:
481 print('error authenticating; is an SSH key available in an SSH agent?')
476 print('error authenticating; is an SSH key available in an SSH agent?')
482 raise
477 raise
483
478
484 print('SSH connection established')
479 print('SSH connection established')
485
480
486 print('opening SFTP client...')
481 print('opening SFTP client...')
487 sftp = client.open_sftp()
482 sftp = client.open_sftp()
488 print('SFTP client obtained')
483 print('SFTP client obtained')
489
484
490 for p in all_paths:
485 for p in all_paths:
491 dest_path = '/var/www/release/windows/%s' % p.name
486 dest_path = '/var/www/release/windows/%s' % p.name
492 print('uploading %s to %s' % (p, dest_path))
487 print('uploading %s to %s' % (p, dest_path))
493
488
494 with p.open('rb') as fh:
489 with p.open('rb') as fh:
495 data = fh.read()
490 data = fh.read()
496
491
497 with sftp.open(dest_path, 'wb') as fh:
492 with sftp.open(dest_path, 'wb') as fh:
498 fh.write(data)
493 fh.write(data)
499 fh.chmod(0o0664)
494 fh.chmod(0o0664)
500
495
501 latest_dat_path = '/var/www/release/windows/latest.dat'
496 latest_dat_path = '/var/www/release/windows/latest.dat'
502
497
503 now = datetime.datetime.utcnow()
498 now = datetime.datetime.utcnow()
504 backup_path = dist_path / (
499 backup_path = dist_path / (
505 'latest-windows-%s.dat' % now.strftime('%Y%m%dT%H%M%S')
500 'latest-windows-%s.dat' % now.strftime('%Y%m%dT%H%M%S')
506 )
501 )
507 print('backing up %s to %s' % (latest_dat_path, backup_path))
502 print('backing up %s to %s' % (latest_dat_path, backup_path))
508
503
509 with sftp.open(latest_dat_path, 'rb') as fh:
504 with sftp.open(latest_dat_path, 'rb') as fh:
510 latest_dat_old = fh.read()
505 latest_dat_old = fh.read()
511
506
512 with backup_path.open('wb') as fh:
507 with backup_path.open('wb') as fh:
513 fh.write(latest_dat_old)
508 fh.write(latest_dat_old)
514
509
515 print('writing %s with content:' % latest_dat_path)
510 print('writing %s with content:' % latest_dat_path)
516 latest_dat_content = generate_latest_dat(version)
511 latest_dat_content = generate_latest_dat(version)
517 print(latest_dat_content)
512 print(latest_dat_content)
518
513
519 with sftp.open(latest_dat_path, 'wb') as fh:
514 with sftp.open(latest_dat_path, 'wb') as fh:
520 fh.write(latest_dat_content.encode('ascii'))
515 fh.write(latest_dat_content.encode('ascii'))
521
516
522
517
523 def publish_artifacts(
518 def publish_artifacts(
524 dist_path: pathlib.Path,
519 dist_path: pathlib.Path,
525 version: str,
520 version: str,
526 pypi=True,
521 pypi=True,
527 mercurial_scm_org=True,
522 mercurial_scm_org=True,
528 ssh_username=None,
523 ssh_username=None,
529 ):
524 ):
530 """Publish Windows release artifacts.
525 """Publish Windows release artifacts.
531
526
532 Files are found in `dist_path`. We will look for files with version string
527 Files are found in `dist_path`. We will look for files with version string
533 `version`.
528 `version`.
534
529
535 `pypi` controls whether we upload to PyPI.
530 `pypi` controls whether we upload to PyPI.
536 `mercurial_scm_org` controls whether we upload to mercurial-scm.org.
531 `mercurial_scm_org` controls whether we upload to mercurial-scm.org.
537 """
532 """
538 if pypi:
533 if pypi:
539 publish_artifacts_pypi(dist_path, version)
534 publish_artifacts_pypi(dist_path, version)
540
535
541 if mercurial_scm_org:
536 if mercurial_scm_org:
542 publish_artifacts_mercurial_scm_org(
537 publish_artifacts_mercurial_scm_org(
543 dist_path, version, ssh_username=ssh_username
538 dist_path, version, ssh_username=ssh_username
544 )
539 )
General Comments 0
You need to be logged in to leave comments. Login now