##// END OF EJS Templates
wix: use original version string for MSI filename...
Gregory Szorc -
r44634:62111bc5 stable
parent child Browse files
Show More
@@ -1,553 +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 414 orig_version = version or find_version(source_dir)
415 415 version = normalize_version(orig_version)
416 416 print('using version string: %s' % version)
417 417 if version != orig_version:
418 418 print('(normalized from: %s)' % orig_version)
419 419
420 420 if post_build_fn:
421 421 post_build_fn(source_dir, hg_build_dir, dist_dir, version)
422 422
423 423 build_dir = hg_build_dir / ('wix-%s' % arch)
424 424 staging_dir = build_dir / 'stage'
425 425
426 426 build_dir.mkdir(exist_ok=True)
427 427
428 428 # Purge the staging directory for every build so packaging is pristine.
429 429 if staging_dir.exists():
430 430 print('purging %s' % staging_dir)
431 431 shutil.rmtree(staging_dir)
432 432
433 433 stage_install(source_dir, staging_dir, lower_case=True)
434 434
435 435 # We also install some extra files.
436 436 process_install_rules(EXTRA_INSTALL_RULES, source_dir, staging_dir)
437 437
438 438 # And remove some files we don't want.
439 439 for f in STAGING_REMOVE_FILES:
440 440 p = staging_dir / f
441 441 if p.exists():
442 442 print('removing %s' % p)
443 443 p.unlink()
444 444
445 445 wix_pkg, wix_entry = download_entry('wix', hg_build_dir)
446 446 wix_path = hg_build_dir / ('wix-%s' % wix_entry['version'])
447 447
448 448 if not wix_path.exists():
449 449 extract_zip_to_directory(wix_pkg, wix_path)
450 450
451 451 ensure_vc90_merge_modules(hg_build_dir)
452 452
453 453 source_build_rel = pathlib.Path(os.path.relpath(source_dir, build_dir))
454 454
455 455 defines = {'Platform': arch}
456 456
457 457 # Derive a .wxs file with the staged files.
458 458 manifest_wxs = build_dir / 'stage.wxs'
459 459 with manifest_wxs.open('w', encoding='utf-8') as fh:
460 460 fh.write(make_files_xml(staging_dir, is_x64=arch == 'x64'))
461 461
462 462 run_candle(wix_path, build_dir, manifest_wxs, staging_dir, defines=defines)
463 463
464 464 for source, rel_path in sorted((extra_wxs or {}).items()):
465 465 run_candle(wix_path, build_dir, source, rel_path, defines=defines)
466 466
467 467 source = wix_dir / 'mercurial.wxs'
468 468 defines['Version'] = version
469 469 defines['Comments'] = 'Installs Mercurial version %s' % version
470 470 defines['VCRedistSrcDir'] = str(hg_build_dir)
471 471 if extra_features:
472 472 assert all(';' not in f for f in extra_features)
473 473 defines['MercurialExtraFeatures'] = ';'.join(extra_features)
474 474
475 475 run_candle(wix_path, build_dir, source, source_build_rel, defines=defines)
476 476
477 477 msi_path = (
478 source_dir / 'dist' / ('%s-%s-%s.msi' % (msi_name, version, arch))
478 source_dir / 'dist' / ('%s-%s-%s.msi' % (msi_name, orig_version, arch))
479 479 )
480 480
481 481 args = [
482 482 str(wix_path / 'light.exe'),
483 483 '-nologo',
484 484 '-ext',
485 485 'WixUIExtension',
486 486 '-sw1076',
487 487 '-spdb',
488 488 '-o',
489 489 str(msi_path),
490 490 ]
491 491
492 492 for source, rel_path in sorted((extra_wxs or {}).items()):
493 493 assert source.endswith('.wxs')
494 494 source = os.path.basename(source)
495 495 args.append(str(build_dir / ('%s.wixobj' % source[:-4])))
496 496
497 497 args.extend(
498 498 [str(build_dir / 'stage.wixobj'), str(build_dir / 'mercurial.wixobj'),]
499 499 )
500 500
501 501 subprocess.run(args, cwd=str(source_dir), check=True)
502 502
503 503 print('%s created' % msi_path)
504 504
505 505 return {
506 506 'msi_path': msi_path,
507 507 }
508 508
509 509
510 510 def build_signed_installer(
511 511 source_dir: pathlib.Path,
512 512 python_exe: pathlib.Path,
513 513 name: str,
514 514 version=None,
515 515 subject_name=None,
516 516 cert_path=None,
517 517 cert_password=None,
518 518 timestamp_url=None,
519 519 extra_packages_script=None,
520 520 extra_wxs=None,
521 521 extra_features=None,
522 522 ):
523 523 """Build an installer with signed executables."""
524 524
525 525 post_build_fn = make_post_build_signing_fn(
526 526 name,
527 527 subject_name=subject_name,
528 528 cert_path=cert_path,
529 529 cert_password=cert_password,
530 530 timestamp_url=timestamp_url,
531 531 )
532 532
533 533 info = build_installer(
534 534 source_dir,
535 535 python_exe=python_exe,
536 536 msi_name=name.lower(),
537 537 version=version,
538 538 post_build_fn=post_build_fn,
539 539 extra_packages_script=extra_packages_script,
540 540 extra_wxs=extra_wxs,
541 541 extra_features=extra_features,
542 542 )
543 543
544 544 description = '%s %s' % (name, version)
545 545
546 546 sign_with_signtool(
547 547 info['msi_path'],
548 548 description,
549 549 subject_name=subject_name,
550 550 cert_path=cert_path,
551 551 cert_password=cert_password,
552 552 timestamp_url=timestamp_url,
553 553 )
General Comments 0
You need to be logged in to leave comments. Login now