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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +36 -0
- data/LICENSE +18 -0
- data/README.md +192 -0
- data/exe/crossbuild +76 -0
- data/exe/crosspack +119 -0
- data/lib/crossbuild/build_manifest.rb +374 -0
- data/lib/crossbuild/builder.rb +72 -0
- data/lib/crossbuild/distributor.rb +76 -0
- data/lib/crossbuild/matrix.rb +52 -0
- data/lib/crossbuild/platform.rb +40 -0
- data/lib/crossbuild/runner.rb +62 -0
- data/lib/crossbuild/version.rb +5 -0
- data/lib/crossbuild/version_scheme.rb +51 -0
- data/lib/crossbuild.rb +27 -0
- data/lib/crosspack/builders/app_dir.rb +88 -0
- data/lib/crosspack/builders/deb.rb +121 -0
- data/lib/crosspack/builders/pkgbuild.rb +64 -0
- data/lib/crosspack/builders/rpm.rb +149 -0
- data/lib/crosspack/builders/wix.rb +119 -0
- data/lib/crosspack/builds.rb +61 -0
- data/lib/crosspack/manifest.rb +237 -0
- data/lib/crosspack/matrix.rb +67 -0
- data/lib/crosspack/package_manifest.rb +219 -0
- data/lib/crosspack/packer.rb +223 -0
- data/lib/crosspack/resolver.rb +91 -0
- data/lib/crosspack/target.rb +93 -0
- data/lib/crosspack/version.rb +5 -0
- data/lib/crosspack.rb +38 -0
- metadata +78 -0
|
@@ -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
|