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