##// END OF EJS Templates
wix: always normalize version string...
Gregory Szorc -
r44633:2251b6cd stable
parent child Browse files
Show More
@@ -1,550 +1,553 b''
1 1 # wix.py - WiX installer functionality
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 collections
11 11 import os
12 12 import pathlib
13 13 import re
14 14 import shutil
15 15 import subprocess
16 16 import typing
17 17 import uuid
18 18 import xml.dom.minidom
19 19
20 20 from .downloads import download_entry
21 21 from .py2exe import (
22 22 build_py2exe,
23 23 stage_install,
24 24 )
25 25 from .util import (
26 26 extract_zip_to_directory,
27 27 process_install_rules,
28 28 sign_with_signtool,
29 29 )
30 30
31 31
32 32 EXTRA_PACKAGES = {
33 33 'distutils',
34 34 'pygments',
35 35 }
36 36
37 37
38 38 EXTRA_INSTALL_RULES = [
39 39 ('contrib/packaging/wix/COPYING.rtf', 'COPYING.rtf'),
40 40 ('contrib/win32/mercurial.ini', 'defaultrc/mercurial.rc'),
41 41 ]
42 42
43 43 STAGING_REMOVE_FILES = [
44 44 # We use the RTF variant.
45 45 'copying.txt',
46 46 ]
47 47
48 48 SHORTCUTS = {
49 49 # hg.1.html'
50 50 'hg.file.5d3e441c_28d9_5542_afd0_cdd4234f12d5': {
51 51 'Name': 'Mercurial Command Reference',
52 52 },
53 53 # hgignore.5.html
54 54 'hg.file.5757d8e0_f207_5e10_a2ec_3ba0a062f431': {
55 55 'Name': 'Mercurial Ignore Files',
56 56 },
57 57 # hgrc.5.html
58 58 'hg.file.92e605fd_1d1a_5dc6_9fc0_5d2998eb8f5e': {
59 59 'Name': 'Mercurial Configuration Files',
60 60 },
61 61 }
62 62
63 63
64 64 def find_version(source_dir: pathlib.Path):
65 65 version_py = source_dir / 'mercurial' / '__version__.py'
66 66
67 67 with version_py.open('r', encoding='utf-8') as fh:
68 68 source = fh.read().strip()
69 69
70 70 m = re.search('version = b"(.*)"', source)
71 71 return m.group(1)
72 72
73 73
74 74 def normalize_version(version):
75 75 """Normalize Mercurial version string so WiX accepts it.
76 76
77 77 Version strings have to be numeric ``A.B.C[.D]`` to conform with MSI's
78 78 requirements.
79 79
80 80 We normalize RC version or the commit count to a 4th version component.
81 81 We store this in the 4th component because ``A.B.C`` releases do occur
82 82 and we want an e.g. ``5.3rc0`` version to be semantically less than a
83 83 ``5.3.1rc2`` version. This requires always reserving the 3rd version
84 84 component for the point release and the ``X.YrcN`` release is always
85 85 point release 0.
86 86
87 87 In the case of an RC and presence of ``+`` suffix data, we can't use both
88 88 because the version format is limited to 4 components. We choose to use
89 89 RC and throw away the commit count in the suffix. This means we could
90 90 produce multiple installers with the same normalized version string.
91 91
92 92 >>> normalize_version("5.3")
93 93 '5.3.0'
94 94
95 95 >>> normalize_version("5.3rc0")
96 96 '5.3.0.0'
97 97
98 98 >>> normalize_version("5.3rc1")
99 99 '5.3.0.1'
100 100
101 101 >>> normalize_version("5.3rc1+2-abcdef")
102 102 '5.3.0.1'
103 103
104 104 >>> normalize_version("5.3+2-abcdef")
105 105 '5.3.0.2'
106 106 """
107 107 if '+' in version:
108 108 version, extra = version.split('+', 1)
109 109 else:
110 110 extra = None
111 111
112 112 # 4.9rc0
113 113 if version[:-1].endswith('rc'):
114 114 rc = int(version[-1:])
115 115 version = version[:-3]
116 116 else:
117 117 rc = None
118 118
119 119 # Ensure we have at least X.Y version components.
120 120 versions = [int(v) for v in version.split('.')]
121 121 while len(versions) < 3:
122 122 versions.append(0)
123 123
124 124 if len(versions) < 4:
125 125 if rc is not None:
126 126 versions.append(rc)
127 127 elif extra:
128 128 # <commit count>-<hash>+<date>
129 129 versions.append(int(extra.split('-')[0]))
130 130
131 131 return '.'.join('%d' % x for x in versions[0:4])
132 132
133 133
134 134 def ensure_vc90_merge_modules(build_dir):
135 135 x86 = (
136 136 download_entry(
137 137 'vc9-crt-x86-msm',
138 138 build_dir,
139 139 local_name='microsoft.vcxx.crt.x86_msm.msm',
140 140 )[0],
141 141 download_entry(
142 142 'vc9-crt-x86-msm-policy',
143 143 build_dir,
144 144 local_name='policy.x.xx.microsoft.vcxx.crt.x86_msm.msm',
145 145 )[0],
146 146 )
147 147
148 148 x64 = (
149 149 download_entry(
150 150 'vc9-crt-x64-msm',
151 151 build_dir,
152 152 local_name='microsoft.vcxx.crt.x64_msm.msm',
153 153 )[0],
154 154 download_entry(
155 155 'vc9-crt-x64-msm-policy',
156 156 build_dir,
157 157 local_name='policy.x.xx.microsoft.vcxx.crt.x64_msm.msm',
158 158 )[0],
159 159 )
160 160 return {
161 161 'x86': x86,
162 162 'x64': x64,
163 163 }
164 164
165 165
166 166 def run_candle(wix, cwd, wxs, source_dir, defines=None):
167 167 args = [
168 168 str(wix / 'candle.exe'),
169 169 '-nologo',
170 170 str(wxs),
171 171 '-dSourceDir=%s' % source_dir,
172 172 ]
173 173
174 174 if defines:
175 175 args.extend('-d%s=%s' % define for define in sorted(defines.items()))
176 176
177 177 subprocess.run(args, cwd=str(cwd), check=True)
178 178
179 179
180 180 def make_post_build_signing_fn(
181 181 name,
182 182 subject_name=None,
183 183 cert_path=None,
184 184 cert_password=None,
185 185 timestamp_url=None,
186 186 ):
187 187 """Create a callable that will use signtool to sign hg.exe."""
188 188
189 189 def post_build_sign(source_dir, build_dir, dist_dir, version):
190 190 description = '%s %s' % (name, version)
191 191
192 192 sign_with_signtool(
193 193 dist_dir / 'hg.exe',
194 194 description,
195 195 subject_name=subject_name,
196 196 cert_path=cert_path,
197 197 cert_password=cert_password,
198 198 timestamp_url=timestamp_url,
199 199 )
200 200
201 201 return post_build_sign
202 202
203 203
204 204 def make_files_xml(staging_dir: pathlib.Path, is_x64) -> str:
205 205 """Create XML string listing every file to be installed."""
206 206
207 207 # We derive GUIDs from a deterministic file path identifier.
208 208 # We shoehorn the name into something that looks like a URL because
209 209 # the UUID namespaces are supposed to work that way (even though
210 210 # the input data probably is never validated).
211 211
212 212 doc = xml.dom.minidom.parseString(
213 213 '<?xml version="1.0" encoding="utf-8"?>'
214 214 '<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">'
215 215 '</Wix>'
216 216 )
217 217
218 218 # Assemble the install layout by directory. This makes it easier to
219 219 # emit XML, since each directory has separate entities.
220 220 manifest = collections.defaultdict(dict)
221 221
222 222 for root, dirs, files in os.walk(staging_dir):
223 223 dirs.sort()
224 224
225 225 root = pathlib.Path(root)
226 226 rel_dir = root.relative_to(staging_dir)
227 227
228 228 for i in range(len(rel_dir.parts)):
229 229 parent = '/'.join(rel_dir.parts[0 : i + 1])
230 230 manifest.setdefault(parent, {})
231 231
232 232 for f in sorted(files):
233 233 full = root / f
234 234 manifest[str(rel_dir).replace('\\', '/')][full.name] = full
235 235
236 236 component_groups = collections.defaultdict(list)
237 237
238 238 # Now emit a <Fragment> for each directory.
239 239 # Each directory is composed of a <DirectoryRef> pointing to its parent
240 240 # and defines child <Directory>'s and a <Component> with all the files.
241 241 for dir_name, entries in sorted(manifest.items()):
242 242 # The directory id is derived from the path. But the root directory
243 243 # is special.
244 244 if dir_name == '.':
245 245 parent_directory_id = 'INSTALLDIR'
246 246 else:
247 247 parent_directory_id = 'hg.dir.%s' % dir_name.replace('/', '.')
248 248
249 249 fragment = doc.createElement('Fragment')
250 250 directory_ref = doc.createElement('DirectoryRef')
251 251 directory_ref.setAttribute('Id', parent_directory_id)
252 252
253 253 # Add <Directory> entries for immediate children directories.
254 254 for possible_child in sorted(manifest.keys()):
255 255 if (
256 256 dir_name == '.'
257 257 and '/' not in possible_child
258 258 and possible_child != '.'
259 259 ):
260 260 child_directory_id = 'hg.dir.%s' % possible_child
261 261 name = possible_child
262 262 else:
263 263 if not possible_child.startswith('%s/' % dir_name):
264 264 continue
265 265 name = possible_child[len(dir_name) + 1 :]
266 266 if '/' in name:
267 267 continue
268 268
269 269 child_directory_id = 'hg.dir.%s' % possible_child.replace(
270 270 '/', '.'
271 271 )
272 272
273 273 directory = doc.createElement('Directory')
274 274 directory.setAttribute('Id', child_directory_id)
275 275 directory.setAttribute('Name', name)
276 276 directory_ref.appendChild(directory)
277 277
278 278 # Add <Component>s for files in this directory.
279 279 for rel, source_path in sorted(entries.items()):
280 280 if dir_name == '.':
281 281 full_rel = rel
282 282 else:
283 283 full_rel = '%s/%s' % (dir_name, rel)
284 284
285 285 component_unique_id = (
286 286 'https://www.mercurial-scm.org/wix-installer/0/component/%s'
287 287 % full_rel
288 288 )
289 289 component_guid = uuid.uuid5(uuid.NAMESPACE_URL, component_unique_id)
290 290 component_id = 'hg.component.%s' % str(component_guid).replace(
291 291 '-', '_'
292 292 )
293 293
294 294 component = doc.createElement('Component')
295 295
296 296 component.setAttribute('Id', component_id)
297 297 component.setAttribute('Guid', str(component_guid).upper())
298 298 component.setAttribute('Win64', 'yes' if is_x64 else 'no')
299 299
300 300 # Assign this component to a top-level group.
301 301 if dir_name == '.':
302 302 component_groups['ROOT'].append(component_id)
303 303 elif '/' in dir_name:
304 304 component_groups[dir_name[0 : dir_name.index('/')]].append(
305 305 component_id
306 306 )
307 307 else:
308 308 component_groups[dir_name].append(component_id)
309 309
310 310 unique_id = (
311 311 'https://www.mercurial-scm.org/wix-installer/0/%s' % full_rel
312 312 )
313 313 file_guid = uuid.uuid5(uuid.NAMESPACE_URL, unique_id)
314 314
315 315 # IDs have length limits. So use GUID to derive them.
316 316 file_guid_normalized = str(file_guid).replace('-', '_')
317 317 file_id = 'hg.file.%s' % file_guid_normalized
318 318
319 319 file_element = doc.createElement('File')
320 320 file_element.setAttribute('Id', file_id)
321 321 file_element.setAttribute('Source', str(source_path))
322 322 file_element.setAttribute('KeyPath', 'yes')
323 323 file_element.setAttribute('ReadOnly', 'yes')
324 324
325 325 component.appendChild(file_element)
326 326 directory_ref.appendChild(component)
327 327
328 328 fragment.appendChild(directory_ref)
329 329 doc.documentElement.appendChild(fragment)
330 330
331 331 for group, component_ids in sorted(component_groups.items()):
332 332 fragment = doc.createElement('Fragment')
333 333 component_group = doc.createElement('ComponentGroup')
334 334 component_group.setAttribute('Id', 'hg.group.%s' % group)
335 335
336 336 for component_id in component_ids:
337 337 component_ref = doc.createElement('ComponentRef')
338 338 component_ref.setAttribute('Id', component_id)
339 339 component_group.appendChild(component_ref)
340 340
341 341 fragment.appendChild(component_group)
342 342 doc.documentElement.appendChild(fragment)
343 343
344 344 # Add <Shortcut> to files that have it defined.
345 345 for file_id, metadata in sorted(SHORTCUTS.items()):
346 346 els = doc.getElementsByTagName('File')
347 347 els = [el for el in els if el.getAttribute('Id') == file_id]
348 348
349 349 if not els:
350 350 raise Exception('could not find File[Id=%s]' % file_id)
351 351
352 352 for el in els:
353 353 shortcut = doc.createElement('Shortcut')
354 354 shortcut.setAttribute('Id', 'hg.shortcut.%s' % file_id)
355 355 shortcut.setAttribute('Directory', 'ProgramMenuDir')
356 356 shortcut.setAttribute('Icon', 'hgIcon.ico')
357 357 shortcut.setAttribute('IconIndex', '0')
358 358 shortcut.setAttribute('Advertise', 'yes')
359 359 for k, v in sorted(metadata.items()):
360 360 shortcut.setAttribute(k, v)
361 361
362 362 el.appendChild(shortcut)
363 363
364 364 return doc.toprettyxml()
365 365
366 366
367 367 def build_installer(
368 368 source_dir: pathlib.Path,
369 369 python_exe: pathlib.Path,
370 370 msi_name='mercurial',
371 371 version=None,
372 372 post_build_fn=None,
373 373 extra_packages_script=None,
374 374 extra_wxs: typing.Optional[typing.Dict[str, str]] = None,
375 375 extra_features: typing.Optional[typing.List[str]] = None,
376 376 ):
377 377 """Build a WiX MSI installer.
378 378
379 379 ``source_dir`` is the path to the Mercurial source tree to use.
380 380 ``arch`` is the target architecture. either ``x86`` or ``x64``.
381 381 ``python_exe`` is the path to the Python executable to use/bundle.
382 382 ``version`` is the Mercurial version string. If not defined,
383 383 ``mercurial/__version__.py`` will be consulted.
384 384 ``post_build_fn`` is a callable that will be called after building
385 385 Mercurial but before invoking WiX. It can be used to e.g. facilitate
386 386 signing. It is passed the paths to the Mercurial source, build, and
387 387 dist directories and the resolved Mercurial version.
388 388 ``extra_packages_script`` is a command to be run to inject extra packages
389 389 into the py2exe binary. It should stage packages into the virtualenv and
390 390 print a null byte followed by a newline-separated list of packages that
391 391 should be included in the exe.
392 392 ``extra_wxs`` is a dict of {wxs_name: working_dir_for_wxs_build}.
393 393 ``extra_features`` is a list of additional named Features to include in
394 394 the build. These must match Feature names in one of the wxs scripts.
395 395 """
396 396 arch = 'x64' if r'\x64' in os.environ.get('LIB', '') else 'x86'
397 397
398 398 hg_build_dir = source_dir / 'build'
399 399 dist_dir = source_dir / 'dist'
400 400 wix_dir = source_dir / 'contrib' / 'packaging' / 'wix'
401 401
402 402 requirements_txt = wix_dir / 'requirements.txt'
403 403
404 404 build_py2exe(
405 405 source_dir,
406 406 hg_build_dir,
407 407 python_exe,
408 408 'wix',
409 409 requirements_txt,
410 410 extra_packages=EXTRA_PACKAGES,
411 411 extra_packages_script=extra_packages_script,
412 412 )
413 413
414 version = version or normalize_version(find_version(source_dir))
414 orig_version = version or find_version(source_dir)
415 version = normalize_version(orig_version)
415 416 print('using version string: %s' % version)
417 if version != orig_version:
418 print('(normalized from: %s)' % orig_version)
416 419
417 420 if post_build_fn:
418 421 post_build_fn(source_dir, hg_build_dir, dist_dir, version)
419 422
420 423 build_dir = hg_build_dir / ('wix-%s' % arch)
421 424 staging_dir = build_dir / 'stage'
422 425
423 426 build_dir.mkdir(exist_ok=True)
424 427
425 428 # Purge the staging directory for every build so packaging is pristine.
426 429 if staging_dir.exists():
427 430 print('purging %s' % staging_dir)
428 431 shutil.rmtree(staging_dir)
429 432
430 433 stage_install(source_dir, staging_dir, lower_case=True)
431 434
432 435 # We also install some extra files.
433 436 process_install_rules(EXTRA_INSTALL_RULES, source_dir, staging_dir)
434 437
435 438 # And remove some files we don't want.
436 439 for f in STAGING_REMOVE_FILES:
437 440 p = staging_dir / f
438 441 if p.exists():
439 442 print('removing %s' % p)
440 443 p.unlink()
441 444
442 445 wix_pkg, wix_entry = download_entry('wix', hg_build_dir)
443 446 wix_path = hg_build_dir / ('wix-%s' % wix_entry['version'])
444 447
445 448 if not wix_path.exists():
446 449 extract_zip_to_directory(wix_pkg, wix_path)
447 450
448 451 ensure_vc90_merge_modules(hg_build_dir)
449 452
450 453 source_build_rel = pathlib.Path(os.path.relpath(source_dir, build_dir))
451 454
452 455 defines = {'Platform': arch}
453 456
454 457 # Derive a .wxs file with the staged files.
455 458 manifest_wxs = build_dir / 'stage.wxs'
456 459 with manifest_wxs.open('w', encoding='utf-8') as fh:
457 460 fh.write(make_files_xml(staging_dir, is_x64=arch == 'x64'))
458 461
459 462 run_candle(wix_path, build_dir, manifest_wxs, staging_dir, defines=defines)
460 463
461 464 for source, rel_path in sorted((extra_wxs or {}).items()):
462 465 run_candle(wix_path, build_dir, source, rel_path, defines=defines)
463 466
464 467 source = wix_dir / 'mercurial.wxs'
465 468 defines['Version'] = version
466 469 defines['Comments'] = 'Installs Mercurial version %s' % version
467 470 defines['VCRedistSrcDir'] = str(hg_build_dir)
468 471 if extra_features:
469 472 assert all(';' not in f for f in extra_features)
470 473 defines['MercurialExtraFeatures'] = ';'.join(extra_features)
471 474
472 475 run_candle(wix_path, build_dir, source, source_build_rel, defines=defines)
473 476
474 477 msi_path = (
475 478 source_dir / 'dist' / ('%s-%s-%s.msi' % (msi_name, version, arch))
476 479 )
477 480
478 481 args = [
479 482 str(wix_path / 'light.exe'),
480 483 '-nologo',
481 484 '-ext',
482 485 'WixUIExtension',
483 486 '-sw1076',
484 487 '-spdb',
485 488 '-o',
486 489 str(msi_path),
487 490 ]
488 491
489 492 for source, rel_path in sorted((extra_wxs or {}).items()):
490 493 assert source.endswith('.wxs')
491 494 source = os.path.basename(source)
492 495 args.append(str(build_dir / ('%s.wixobj' % source[:-4])))
493 496
494 497 args.extend(
495 498 [str(build_dir / 'stage.wixobj'), str(build_dir / 'mercurial.wixobj'),]
496 499 )
497 500
498 501 subprocess.run(args, cwd=str(source_dir), check=True)
499 502
500 503 print('%s created' % msi_path)
501 504
502 505 return {
503 506 'msi_path': msi_path,
504 507 }
505 508
506 509
507 510 def build_signed_installer(
508 511 source_dir: pathlib.Path,
509 512 python_exe: pathlib.Path,
510 513 name: str,
511 514 version=None,
512 515 subject_name=None,
513 516 cert_path=None,
514 517 cert_password=None,
515 518 timestamp_url=None,
516 519 extra_packages_script=None,
517 520 extra_wxs=None,
518 521 extra_features=None,
519 522 ):
520 523 """Build an installer with signed executables."""
521 524
522 525 post_build_fn = make_post_build_signing_fn(
523 526 name,
524 527 subject_name=subject_name,
525 528 cert_path=cert_path,
526 529 cert_password=cert_password,
527 530 timestamp_url=timestamp_url,
528 531 )
529 532
530 533 info = build_installer(
531 534 source_dir,
532 535 python_exe=python_exe,
533 536 msi_name=name.lower(),
534 537 version=version,
535 538 post_build_fn=post_build_fn,
536 539 extra_packages_script=extra_packages_script,
537 540 extra_wxs=extra_wxs,
538 541 extra_features=extra_features,
539 542 )
540 543
541 544 description = '%s %s' % (name, version)
542 545
543 546 sign_with_signtool(
544 547 info['msi_path'],
545 548 description,
546 549 subject_name=subject_name,
547 550 cert_path=cert_path,
548 551 cert_password=cert_password,
549 552 timestamp_url=timestamp_url,
550 553 )
General Comments 0
You need to be logged in to leave comments. Login now