crosspack 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crossbuild
4
+ # Computes the build version from the `version:` field of build.yaml.
5
+ # Supported specs:
6
+ # calver — YYYY.MM.DD-<seconds since local midnight>, e.g. 2026.08.31-33837
7
+ # git-tag — latest `git describe --tags --abbrev=0`, fallback 0.0.0-dev
8
+ # env:VAR — take VAR from the environment (error when unset)
9
+ # <other> — any other non-empty string is used verbatim as a literal
10
+ # nil/absent defaults to calver.
11
+ class VersionScheme
12
+ class Error < StandardError; end
13
+
14
+ CALVER = 'calver'.freeze
15
+ GIT_TAG = 'git-tag'.freeze
16
+ ENV_PREFIX = 'env:'.freeze
17
+
18
+ def initialize(spec)
19
+ @spec = spec.nil? || spec.to_s.strip.empty? ? CALVER : spec.to_s.strip
20
+ end
21
+
22
+ def compute(root: Dir.pwd, now: Time.now)
23
+ case @spec
24
+ when CALVER
25
+ format('%s-%d', now.strftime('%Y.%m.%d'), now.hour * 3600 + now.min * 60 + now.sec)
26
+ when GIT_TAG
27
+ git_tag(root)
28
+ when /\A#{ENV_PREFIX}(\S+)\z/
29
+ env_version(Regexp.last_match(1))
30
+ else
31
+ @spec
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def git_tag(root)
38
+ tag = Dir.chdir(root) { `git describe --tags --abbrev=0 2>/dev/null`.strip }
39
+ tag.empty? ? '0.0.0-dev' : tag
40
+ rescue Errno::ENOENT
41
+ '0.0.0-dev'
42
+ end
43
+
44
+ def env_version(var)
45
+ value = ENV[var].to_s.strip
46
+ raise Error, "version env var #{var} is not set (version: env:#{var} in build.yaml)" if value.empty?
47
+
48
+ value
49
+ end
50
+ end
51
+ end
data/lib/crossbuild.rb ADDED
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crossbuild
4
+ class Error < StandardError; end
5
+ end
6
+
7
+ require_relative 'crosspack'
8
+
9
+ require_relative 'crossbuild/version'
10
+ require_relative 'crossbuild/platform'
11
+ require_relative 'crossbuild/version_scheme'
12
+ require_relative 'crossbuild/build_manifest'
13
+ require_relative 'crossbuild/runner'
14
+ require_relative 'crossbuild/distributor'
15
+ require_relative 'crossbuild/matrix'
16
+ require_relative 'crossbuild/builder'
17
+
18
+ module Crossbuild
19
+ # Convenience wrapper: Crossbuild.build('build.yaml') runs every
20
+ # host-buildable matrix entry and returns Builder::Result.
21
+ def self.build(manifest, entry_id: nil, root: Dir.pwd, output_base: nil, version: nil,
22
+ host_os: Platform.os, host_arch: Platform.arch)
23
+ m = manifest.is_a?(BuildManifest) ? manifest : BuildManifest.load(manifest)
24
+ Builder.new(m, root: root, output_base: output_base, version: version,
25
+ host_os: host_os, host_arch: host_arch).run(entry_id: entry_id)
26
+ end
27
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module Crosspack
6
+ module Builders
7
+ # Stages a macOS .app bundle directory (MyApp.app/Contents/...). A real
8
+ # DMG requires hdiutil (macOS only); from Linux this directory layout is
9
+ # the honest maximum — it can be zipped or wrapped into a DMG on a Mac
10
+ # as-is.
11
+ class AppDir
12
+ # files: { source_path => basename inside Contents/MacOS }
13
+ # executables: basenames to chmod 0755
14
+ def self.build(name:, version:, summary:, files:, executables: [],
15
+ identifier: nil, output:)
16
+ app_dir = File.join(output, "#{name}.app")
17
+ FileUtils.rm_rf(app_dir)
18
+ macos_dir = File.join(app_dir, 'Contents', 'MacOS')
19
+ resources_dir = File.join(app_dir, 'Contents', 'Resources')
20
+ FileUtils.mkdir_p(macos_dir)
21
+ FileUtils.mkdir_p(resources_dir)
22
+
23
+ missing = files.reject { |src, _dst| File.file?(src) }
24
+ unless missing.empty?
25
+ details = missing.map { |src, dst| " ✗ #{src} (for #{dst})" }.join("\n")
26
+ raise BuildError,
27
+ "Source files for packing not found:\n#{details}\n" \
28
+ 'Build the application for the target platform first.'
29
+ end
30
+
31
+ files.each do |src, dst|
32
+ target = File.join(macos_dir, dst)
33
+ FileUtils.cp(src, target)
34
+ FileUtils.chmod(executables.include?(dst) ? 0o755 : 0o644, target)
35
+ end
36
+
37
+ write_info_plist(File.join(app_dir, 'Contents', 'Info.plist'),
38
+ name: name, version: version, summary: summary,
39
+ identifier: identifier)
40
+
41
+ File.write(File.join(output, 'BUILD-DMG.txt'), <<~TXT)
42
+ DMG not built: hdiutil only exists on macOS.
43
+ On a Mac: hdiutil create -volname #{name} -srcfolder #{name}.app -ov -format UDZO #{name}-#{version}.dmg
44
+ Or archive the bundle: zip -r #{name}-#{version}.zip #{name}.app
45
+ TXT
46
+ app_dir
47
+ end
48
+
49
+ def self.write_info_plist(path, name:, version:, summary:, identifier: nil)
50
+ bundle_id = identifier || "org.crosspack.#{name}"
51
+ File.write(path, <<~PLIST)
52
+ <?xml version="1.0" encoding="UTF-8"?>
53
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
54
+ <plist version="1.0">
55
+ <dict>
56
+ <key>CFBundleName</key>
57
+ <string>#{name}</string>
58
+ <key>CFBundleDisplayName</key>
59
+ <string>#{name}</string>
60
+ <key>CFBundleExecutable</key>
61
+ <string>#{name}</string>
62
+ <key>CFBundleIdentifier</key>
63
+ <string>#{bundle_id}</string>
64
+ <key>CFBundlePackageType</key>
65
+ <string>APPL</string>
66
+ <key>CFBundleShortVersionString</key>
67
+ <string>#{version}</string>
68
+ <key>CFBundleVersion</key>
69
+ <string>#{version}</string>
70
+ <key>CFBundleInfoDictionaryVersion</key>
71
+ <string>6.0</string>
72
+ <key>LSMinimumSystemVersion</key>
73
+ <string>11.0</string>
74
+ <key>NSPrincipalClass</key>
75
+ <string>NSApplication</string>
76
+ <key>NSSupportsAutomaticTermination</key>
77
+ <true/>
78
+ <key>NSSupportsSuddenTermination</key>
79
+ <false/>
80
+ <key>NSMicrophoneUsageDescription</key>
81
+ <string>#{summary}</string>
82
+ </dict>
83
+ </plist>
84
+ PLIST
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'open3'
5
+
6
+ module Crosspack
7
+ module Builders
8
+ # Stages a package tree and packs it with dpkg-deb. No external Ruby
9
+ # dependencies; mirrors the layout conventions used by the hvoice
10
+ # Rakefile (files under a prefix, launcher symlink in bin).
11
+ class Deb
12
+ def self.build(name:, version:, architecture:, maintainer:, description:,
13
+ depends:, files:, symlinks: {}, executables: [],
14
+ section: 'utils', priority: 'optional', output:)
15
+ new(name: name, version: version, architecture: architecture,
16
+ maintainer: maintainer, description: description, depends: depends,
17
+ files: files, symlinks: symlinks, executables: executables,
18
+ section: section, priority: priority, output: output).build
19
+ end
20
+
21
+ def initialize(name:, version:, architecture:, maintainer:, description:,
22
+ depends:, files:, symlinks:, executables:, section:,
23
+ priority:, output:)
24
+ @name = name
25
+ @version = version
26
+ @architecture = architecture
27
+ @maintainer = maintainer
28
+ @description = description.to_s
29
+ @depends = depends
30
+ @files = files || {}
31
+ @symlinks = symlinks || {}
32
+ @executables = executables || []
33
+ @section = section
34
+ @priority = priority
35
+ @output = output
36
+ end
37
+
38
+ def build
39
+ check_tool!
40
+ stage_dir = File.join(File.dirname(@output), ".stage-#{@name}")
41
+ pkg_root = File.join(stage_dir, @name)
42
+ FileUtils.rm_rf(stage_dir)
43
+ FileUtils.mkdir_p(pkg_root)
44
+
45
+ stage_files(pkg_root)
46
+ stage_symlinks(pkg_root)
47
+ write_control(File.join(pkg_root, 'DEBIAN'))
48
+
49
+ FileUtils.mkdir_p(File.dirname(@output))
50
+ FileUtils.rm_f(@output)
51
+ out, status = Open3.capture2e('dpkg-deb', '--build', '--root-owner-group', pkg_root, @output)
52
+ unless status.success?
53
+ raise BuildError,
54
+ "dpkg-deb failed to build the package #{@output}:\n#{out}\n" \
55
+ 'Check the package structure in the staging directory above.'
56
+ end
57
+ FileUtils.rm_rf(stage_dir)
58
+ @output
59
+ end
60
+
61
+ private
62
+
63
+ def check_tool!
64
+ _, _, status = Open3.capture3('dpkg-deb', '--version')
65
+ return if status.success?
66
+
67
+ raise BuildError, 'dpkg-deb not found in PATH. Install it with: sudo apt install dpkg'
68
+ rescue Errno::ENOENT
69
+ raise BuildError, 'dpkg-deb not found in PATH. Install it with: sudo apt install dpkg'
70
+ end
71
+
72
+ def stage_files(pkg_root)
73
+ missing = @files.reject { |src, _dst| File.file?(src) }
74
+ unless missing.empty?
75
+ details = missing.map { |src, dst| " ✗ #{src} (for #{dst})" }.join("\n")
76
+ raise BuildError,
77
+ "Source files for packing not found:\n#{details}\n" \
78
+ 'Build the application first (e.g. rake build:ubuntu).'
79
+ end
80
+
81
+ @files.each do |src, dst|
82
+ target = File.join(pkg_root, dst)
83
+ FileUtils.mkdir_p(File.dirname(target))
84
+ FileUtils.cp(src, target)
85
+ FileUtils.chmod(executable?(dst) ? 0o755 : 0o644, target)
86
+ end
87
+ end
88
+
89
+ def stage_symlinks(pkg_root)
90
+ @symlinks.each do |link, target|
91
+ link_path = File.join(pkg_root, link)
92
+ FileUtils.mkdir_p(File.dirname(link_path))
93
+ FileUtils.ln_sf(target, link_path)
94
+ end
95
+ end
96
+
97
+ def executable?(dst)
98
+ @executables.include?(dst)
99
+ end
100
+
101
+ def write_control(debian_dir)
102
+ FileUtils.mkdir_p(debian_dir)
103
+ description_lines = @description.lines.map(&:chomp).reject(&:empty?)
104
+ summary = description_lines.shift || @name
105
+ body = description_lines.map { |l| " #{l}" }
106
+
107
+ File.write(File.join(debian_dir, 'control'), <<~CONTROL)
108
+ Package: #{@name}
109
+ Version: #{@version}
110
+ Section: #{@section}
111
+ Priority: #{@priority}
112
+ Architecture: #{@architecture}
113
+ Maintainer: #{@maintainer}
114
+ Depends: #{@depends}
115
+ Description: #{summary}
116
+ #{body.join("\n")}
117
+ CONTROL
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module Crosspack
6
+ module Builders
7
+ # Generates a PKGBUILD for AUR from the same file layout as the deb/rpm
8
+ # builders. `files` keys are paths *inside the extracted source archive*
9
+ # (relative to "$srcdir/$pkgname-$pkgver"), values are destinations in
10
+ # the package root.
11
+ class Pkgbuild
12
+ def self.generate(name:, version:, release:, pkgdesc:, depends:, arch:,
13
+ files:, symlinks: {}, executables: [], source:,
14
+ maintainer:, url: '', license: 'Proprietary', output:)
15
+ content = render(
16
+ name: name, version: version, release: release, pkgdesc: pkgdesc,
17
+ depends: Array(depends), arch: Array(arch), files: files,
18
+ symlinks: symlinks, executables: executables, source: Array(source),
19
+ maintainer: maintainer, url: url, license: license
20
+ )
21
+ FileUtils.mkdir_p(File.dirname(output))
22
+ File.write(output, content)
23
+ output
24
+ end
25
+
26
+ def self.render(name:, version:, release:, pkgdesc:, depends:, arch:,
27
+ files:, symlinks:, executables:, source:, maintainer:,
28
+ url:, license:)
29
+ depends_line = depends.map { |d| "'#{d}'" }.join(' ')
30
+ source_line = source.map { |s| "'#{s}'" }.join(' ')
31
+ srcbase = "$srcdir/$pkgname-$pkgver"
32
+ escaped_desc = pkgdesc.gsub("'", %q{'\''})
33
+
34
+ install_lines = files.map do |src, dst|
35
+ mode = executables.include?(dst) ? '755' : '644'
36
+ " install -Dm#{mode} \"#{File.join(srcbase, src)}\" \"$pkgdir/#{dst}\""
37
+ end
38
+ link_lines = symlinks.map do |link, target|
39
+ " install -d \"$(dirname \"$pkgdir/#{File.dirname(link)}\")\"\n" \
40
+ " ln -s #{target} \"$pkgdir/#{link}\""
41
+ end
42
+
43
+ <<~PKGBUILD
44
+ # Maintainer: #{maintainer}
45
+ # Generated by crosspack — edit deps.yaml, not this file.
46
+ pkgname=#{name}
47
+ pkgver=#{version}
48
+ pkgrel=#{release}
49
+ pkgdesc='#{escaped_desc}'
50
+ arch=(#{arch.join(' ')})
51
+ url='#{url}'
52
+ license=(#{license})
53
+ depends=(#{depends_line})
54
+ source=(#{source_line})
55
+ sha256sums=('SKIP')
56
+
57
+ package() {
58
+ #{(install_lines + link_lines).join("\n")}
59
+ }
60
+ PKGBUILD
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'open3'
5
+ require 'tmpdir'
6
+
7
+ module Crosspack
8
+ module Builders
9
+ # Stages a buildroot and packs it with rpmbuild. The spec is generated
10
+ # with AutoReqProv disabled so dependency names come exclusively from
11
+ # deps.yaml (resolved per target), never from ELF soname scanning.
12
+ class Rpm
13
+ def self.build(name:, version:, release:, summary:, license:, requires:,
14
+ files:, symlinks: {}, executables: [], description: nil,
15
+ arch: 'x86_64', output:)
16
+ spec = generate_spec(name: name, version: version, release: release,
17
+ summary: summary, license: license, requires: requires,
18
+ description: description, files: files,
19
+ symlinks: symlinks)
20
+ new(spec: spec, files: files, symlinks: symlinks,
21
+ executables: executables, arch: arch, output: output).build
22
+ end
23
+
24
+ # The spec text, separated from #build so tests can check it without
25
+ # having rpmbuild installed.
26
+ def self.generate_spec(name:, version:, release:, summary:, license:,
27
+ requires:, description: nil, files:, symlinks: {})
28
+ description ||= summary
29
+ requires_line = Array(requires).join(', ')
30
+ entries = spec_entries(files: files, symlinks: symlinks)
31
+
32
+ <<~SPEC
33
+ Name: #{name}
34
+ Version: #{version}
35
+ Release: #{release}
36
+ Summary: #{summary}
37
+ License: #{license}
38
+ AutoReqProv: no
39
+ #{requires_line.empty? ? '' : "Requires: #{requires_line}"}
40
+ %description
41
+ #{description}
42
+
43
+ %prep
44
+
45
+ %build
46
+
47
+ %install
48
+ # Files are staged into the buildroot by crosspack.
49
+
50
+ %files
51
+ %defattr(-,root,root,-)
52
+ #{entries.join("\n")}
53
+ SPEC
54
+ end
55
+
56
+ # %files entries: %dir for every directory so we never claim ownership
57
+ # of shared paths like /usr or /usr/local.
58
+ def self.spec_entries(files:, symlinks:)
59
+ dirs = Set.new
60
+ (files.values + symlinks.keys).each do |dst|
61
+ parts = dst.split(File::SEPARATOR)
62
+ parts.pop
63
+ until parts.empty?
64
+ dirs << parts.join(File::SEPARATOR)
65
+ parts.pop
66
+ end
67
+ end
68
+ # Never claim ownership of directories every system already has.
69
+ skip = ->(d) { d.split(File::SEPARATOR).size == 1 || d == 'usr/local' }
70
+ sorted_dirs = dirs.to_a.sort.reject { |d| skip.call(d) }.map { |d| "%dir /#{d}" }
71
+ file_lines = files.values.map { |dst| "/#{dst}" }
72
+ link_lines = symlinks.keys.map { |dst| "/#{dst}" }
73
+ sorted_dirs + file_lines + link_lines
74
+ end
75
+
76
+ def initialize(spec:, files:, symlinks:, executables:, arch:, output:)
77
+ @spec = spec
78
+ @files = files
79
+ @symlinks = symlinks
80
+ @executables = executables
81
+ @arch = arch
82
+ @output = output
83
+ end
84
+
85
+ def build
86
+ check_tool!
87
+ missing = @files.reject { |src, _dst| File.file?(src) }
88
+ unless missing.empty?
89
+ details = missing.map { |src, dst| " ✗ #{src} (for #{dst})" }.join("\n")
90
+ raise BuildError,
91
+ "Source files for packing not found:\n#{details}\n" \
92
+ 'Build the application first (e.g. rake build:ubuntu).'
93
+ end
94
+
95
+ Dir.mktmpdir('crosspack-rpm') do |topdir|
96
+ buildroot = File.join(topdir, 'buildroot')
97
+ stage(buildroot)
98
+
99
+ spec_path = File.join(topdir, "#{File.basename(@output, '.rpm')}.spec")
100
+ File.write(spec_path, @spec)
101
+
102
+ %w[BUILD RPMS SOURCES SPECS SRPMS].each { |d| FileUtils.mkdir_p(File.join(topdir, d)) }
103
+
104
+ out, status = Open3.capture2e(
105
+ 'rpmbuild', '-bb', "--target #{@arch}", "--define _topdir #{topdir}",
106
+ "--buildroot #{buildroot}", spec_path
107
+ )
108
+ unless status.success?
109
+ raise BuildError,
110
+ "rpmbuild failed to build #{@output}:\n#{out}\n" \
111
+ 'Check the spec and the staging directory above.'
112
+ end
113
+
114
+ produced = Dir.glob(File.join(topdir, 'RPMS', '**', '*.rpm')).first
115
+ raise BuildError, "rpmbuild completed but no .rpm found in #{topdir}/RPMS:\n#{out}" unless produced
116
+
117
+ FileUtils.mkdir_p(File.dirname(@output))
118
+ FileUtils.cp(produced, @output)
119
+ end
120
+ @output
121
+ end
122
+
123
+ private
124
+
125
+ def check_tool!
126
+ _, _, status = Open3.capture3('rpmbuild', '--version')
127
+ return if status.success?
128
+
129
+ raise BuildError, 'rpmbuild not found in PATH. Install it with: sudo apt install rpm'
130
+ rescue Errno::ENOENT
131
+ raise BuildError, 'rpmbuild not found in PATH. Install it with: sudo apt install rpm'
132
+ end
133
+
134
+ def stage(buildroot)
135
+ @files.each do |src, dst|
136
+ target = File.join(buildroot, dst)
137
+ FileUtils.mkdir_p(File.dirname(target))
138
+ FileUtils.cp(src, target)
139
+ FileUtils.chmod(@executables.include?(dst) ? 0o755 : 0o644, target)
140
+ end
141
+ @symlinks.each do |link, target|
142
+ link_path = File.join(buildroot, link)
143
+ FileUtils.mkdir_p(File.dirname(link_path))
144
+ FileUtils.ln_sf(target, link_path)
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'open3'
5
+ require 'digest'
6
+
7
+ module Crosspack
8
+ module Builders
9
+ # Windows MSI packaging via the WiX toolset (wixtoolset.org). WiX v4+
10
+ # runs on Linux as a .NET tool, so real MSI builds are possible once
11
+ # `dotnet tool install -g wix` is available. Without it we still emit a
12
+ # complete .wxs source plus build instructions — packaging is not blocked
13
+ # by the toolchain.
14
+ class Wix
15
+ # MSI ProductVersion only honours three numeric fields (x.y.z).
16
+ def self.msi_version(version)
17
+ fields = version.to_s.split(/[.-]/).first(3)
18
+ fields << '0' while fields.size < 3
19
+ fields.map { |f| f.gsub(/\D/, '').empty? ? '0' : f.gsub(/\D/, '') }.join('.')
20
+ end
21
+
22
+ # Deterministic UpgradeCode derived from the package name, so upgrades
23
+ # chain across releases without a hand-maintained GUID.
24
+ def self.upgrade_code(name)
25
+ digest = Digest::SHA1.hexdigest("crosspack:#{name}")
26
+ hex = digest[0, 32].upcase
27
+ "#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}"
28
+ end
29
+
30
+ def self.generate_wxs(name:, version:, manufacturer:, summary:, files:,
31
+ arch: 'x64')
32
+ directory = arch == 'arm64' ? 'ProgramFiles6432Folder' : 'ProgramFiles64Folder'
33
+ components = files.values.each_with_index.map do |dst, i|
34
+ src = files.key(dst).gsub('/', '\\')
35
+ " <Component Id=\"c#{i}\" Guid=\"*\">\n" \
36
+ " <File Id=\"f#{i}\" Source=\"#{src}\" />\n" \
37
+ ' </Component>'
38
+ end.join("\n")
39
+
40
+ <<~WXS
41
+ <?xml version="1.0" encoding="UTF-8"?>
42
+ <!-- Generated by crosspack. Build with: wix build -arch #{arch} -o #{name}.msi #{name}.wxs -->
43
+ <Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
44
+ <Package Name="#{name}" Manufacturer="#{manufacturer}" Version="#{msi_version(version)}"
45
+ UpgradeCode="{#{upgrade_code(name)}}" Scope="perMachine">
46
+ <SummaryInformation Description="#{summary}" />
47
+ <MajorUpgrade DowngradeErrorMessage="A newer version of #{name} is already installed." />
48
+
49
+ <StandardDirectory Id="#{directory}">
50
+ <Directory Id="INSTALLDIR" Name="#{name}" />
51
+ </StandardDirectory>
52
+
53
+ <Feature Id="Main" Title="#{name}" Level="1">
54
+ <ComponentGroupRef Id="Payload" />
55
+ </Feature>
56
+ </Package>
57
+
58
+ <Fragment>
59
+ <ComponentGroup Id="Payload">
60
+ #{components}
61
+ </ComponentGroup>
62
+ </Fragment>
63
+ </Wix>
64
+ WXS
65
+ end
66
+
67
+ # Writes the .wxs next to the output and, when WiX is installed,
68
+ # builds a real MSI. Returns the path of whatever was produced.
69
+ def self.build(name:, version:, manufacturer:, summary:, files:, output:,
70
+ arch: 'x64')
71
+ arch = { 'x86_64' => 'x64', 'aarch64' => 'arm64' }.fetch(arch, arch)
72
+ missing = files.reject { |src, _dst| File.file?(src) }
73
+ unless missing.empty?
74
+ details = missing.map { |src, dst| " ✗ #{src} (for #{dst})" }.join("\n")
75
+ raise BuildError,
76
+ "Source files for packing not found:\n#{details}\n" \
77
+ 'Build the application for the target platform first.'
78
+ end
79
+
80
+ out_dir = File.dirname(output)
81
+ FileUtils.mkdir_p(out_dir)
82
+ wxs_path = File.join(out_dir, "#{name}.wxs")
83
+ File.write(wxs_path, generate_wxs(name: name, version: version,
84
+ manufacturer: manufacturer,
85
+ summary: summary, files: files,
86
+ arch: arch))
87
+
88
+ wix = wix_command
89
+ unless wix
90
+ File.write(File.join(out_dir, 'BUILD-MSI.txt'), <<~TXT)
91
+ MSI not built: WiX toolset not found in PATH.
92
+ Install the .NET SDK and WiX:
93
+ dotnet tool install -g wix
94
+ Then build with:
95
+ wix build -arch #{arch} -o #{File.basename(output)} #{name}.wxs
96
+ TXT
97
+ return wxs_path
98
+ end
99
+
100
+ out, status = Open3.capture2e(wix, 'build', "-arch #{arch}",
101
+ '-o', output, wxs_path)
102
+ unless status.success?
103
+ raise BuildError, "wix build failed to build #{output}:\n#{out}"
104
+ end
105
+ output
106
+ end
107
+
108
+ def self.wix_command
109
+ %w[wix dotnet].each do |cmd|
110
+ _, _, status = Open3.capture3(cmd, '--version')
111
+ return cmd if status.success?
112
+ end
113
+ nil
114
+ rescue Errno::ENOENT
115
+ nil
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crosspack
4
+ # Scans a builds tree laid out exactly like the output tree —
5
+ # builds/<family>[/<version>]/<arch>/ — and reports which targets have
6
+ # compiled artifacts ready for packing. A missing directory simply means
7
+ # "nothing to pack for that target".
8
+ module Builds
9
+ ARCH_DIR_RE = /\A(amd64|x86_64|x64|arm64|aarch64)\z/i.freeze
10
+
11
+ # Returns [Target] for every target directory found under root.
12
+ def self.available(root)
13
+ return [] unless File.directory?(root)
14
+
15
+ Dir.glob(File.join(root, '*')).sort.flat_map do |family_dir|
16
+ family = File.basename(family_dir).to_sym
17
+ next [] unless Target::FAMILIES.include?(family)
18
+
19
+ scan_family(family_dir, family)
20
+ end
21
+ end
22
+
23
+ # Human-readable listing: "ubuntu/25.10/amd64 — builds/ubuntu/25.10/amd64".
24
+ def self.report(root)
25
+ targets = available(root)
26
+ return "No compiled artifacts: the #{root} tree is empty or missing." if targets.empty?
27
+
28
+ lines = ["Available builds in #{root}:"]
29
+ targets.each { |t| lines << " #{t} (#{t.output_dir(root, t.format)})" }
30
+ lines.join("\n")
31
+ end
32
+
33
+ private
34
+
35
+ def self.scan_family(dir, family)
36
+ results = []
37
+ Dir.glob(File.join(dir, '*')).select { |e| File.directory?(e) }.each do |entry|
38
+ base = File.basename(entry)
39
+ if versionless_segment?(family, base)
40
+ # The entry is an arch directory directly under the family
41
+ # (e.g. builds/arch/x86_64, builds/macos/arm64).
42
+ add_target(results, family, nil, base)
43
+ else
44
+ Dir.glob(File.join(entry, '*')).select { |d| File.directory?(d) }.each do |arch_dir|
45
+ add_target(results, family, base, File.basename(arch_dir))
46
+ end
47
+ end
48
+ end
49
+ results
50
+ end
51
+
52
+ def self.versionless_segment?(family, base)
53
+ Target::VERSIONLESS_FAMILIES.include?(family) || base.match?(ARCH_DIR_RE)
54
+ end
55
+
56
+ def self.add_target(results, family, version, arch_name)
57
+ arch = Target.normalize_arch(arch_name)
58
+ results << Target.new(family, version, arch) unless arch.nil?
59
+ end
60
+ end
61
+ end