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,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'tmpdir'
5
+
6
+ module Crosspack
7
+ # Single entry point: takes a compiled artifacts directory (per
8
+ # package.yaml), resolves dependencies for the target (per deps.yaml) and
9
+ # produces a native package under output_base/<family>/<version>/<arch>/.
10
+ # The builder is chosen by the target format — deb, rpm or PKGBUILD.
11
+ class Packer
12
+ def self.pack(manifest:, deps:, target:, version:, root: Dir.pwd,
13
+ output_base: 'crosspacks')
14
+ new(manifest: manifest, deps: deps, target: target, version: version,
15
+ root: root, output_base: output_base).pack
16
+ end
17
+
18
+ def initialize(manifest:, deps:, target:, version:, root:, output_base:)
19
+ @pkg_path = manifest
20
+ @deps_path = deps
21
+ @target = target
22
+ @version = version.to_s
23
+ @root = root
24
+ @output_base = output_base
25
+ end
26
+
27
+ def pack
28
+ @pkg = PackageManifest.load(@pkg_path)
29
+ @deps = Manifest.load(@deps_path)
30
+ validate_both!
31
+ @pkg_data = build_layout
32
+
33
+ case @target.format
34
+ when :deb
35
+ pack_deb
36
+ when :rpm
37
+ pack_rpm
38
+ when :pkgbuild
39
+ pack_pkgbuild
40
+ when :winget
41
+ pack_windows
42
+ when :cask
43
+ pack_macos
44
+ else
45
+ raise BuildError,
46
+ "packing is not defined for target #{@target} (format #{@target.format})"
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def validate_both!
53
+ errors = []
54
+ errors << @pkg.error_report unless @pkg.valid?
55
+ errors << @deps.error_report unless @deps.valid?
56
+ return if errors.empty?
57
+
58
+ raise InvalidManifestError, errors.join("\n")
59
+ end
60
+
61
+ # Layout shared by every format. The artifacts tree mirrors the output
62
+ # tree exactly — builds/<family>[/<version>]/<arch>/ — so the source
63
+ # directory for a target is computed the same way as its output dir.
64
+ # A Linux binary can never sneak into a Windows package: if the target's
65
+ # build directory is absent, packing fails.
66
+ def build_layout
67
+ src_dir = File.expand_path(@target.output_dir(@pkg.sources, @target.format), @root)
68
+ unless File.directory?(src_dir)
69
+ available = Builds.available(File.expand_path(@pkg.sources, @root))
70
+ hint = available.empty? ? 'no built targets at all.' :
71
+ "available builds: #{available.map { |t| "#{t} (#{t.output_dir(@pkg.sources, t.format)})" }.join(', ')}."
72
+ raise BuildError,
73
+ "No compiled artifacts for target #{@target}: expected them in #{src_dir}.\n" \
74
+ "The builds tree (#{@pkg.sources}) mirrors the output tree: builds/<family>/<version>/<arch>/. " \
75
+ "Currently #{hint}\n" \
76
+ 'Build the application for this target first, then pack.'
77
+ end
78
+
79
+ lib_dst = File.join(@pkg.prefix, @pkg.lib_dir)
80
+ files = @pkg.payload.to_h { |n| [File.join(src_dir, n), File.join(lib_dst, n)] }
81
+
82
+ if @pkg.icon
83
+ icon_src = File.expand_path(@pkg.icon, @root)
84
+ raise BuildError, "icon: file not found #{icon_src}" unless File.file?(icon_src)
85
+
86
+ files[icon_src] = File.join(@pkg.prefix, 'share', 'pixmaps', "#{@pkg.name}.png")
87
+ end
88
+
89
+ if @pkg.desktop
90
+ files[desktop_entry_path] = File.join(@pkg.prefix, 'share', 'applications', "#{@pkg.name}.desktop")
91
+ end
92
+
93
+ symlinks = @pkg.links.to_h { |link, target| [File.join(@pkg.prefix, link), target] }
94
+ executables = @pkg.executables.map { |exe| File.join(lib_dst, exe) }
95
+
96
+ { files: files, symlinks: symlinks, executables: executables,
97
+ src_dir: src_dir, lib_dst: lib_dst }
98
+ end
99
+
100
+ # Writes the .desktop file next to other staging artifacts and returns
101
+ # its path (rooted under build/ so repeated runs reuse one location).
102
+ def desktop_entry_path
103
+ dir = File.join(@root, 'build', 'pkg')
104
+ FileUtils.mkdir_p(dir)
105
+ path = File.join(dir, "#{@pkg.name}.desktop")
106
+ d = @pkg.desktop
107
+ File.write(path, <<~DESKTOP)
108
+ [Desktop Entry]
109
+ Type=Application
110
+ Name=#{d['name'] || @pkg.name}
111
+ Comment=#{d['comment'] || @pkg.summary}
112
+ Exec=#{d['exec'] || @pkg.name}
113
+ Icon=#{@pkg.name}
114
+ Terminal=false
115
+ Categories=#{d['categories'] || 'Utility;'}
116
+ DESKTOP
117
+ path
118
+ end
119
+
120
+ def resolved_depends(format)
121
+ Resolver.new(@deps).depends(@target, format: format)
122
+ end
123
+
124
+ def pack_deb
125
+ output = File.join(@target.output_dir(@output_base, :deb),
126
+ "#{@pkg.name}_#{@version}_#{@target.package_arch(:deb)}.deb")
127
+ Builders::Deb.build(
128
+ name: @pkg.name,
129
+ version: @version,
130
+ architecture: @target.package_arch(:deb),
131
+ maintainer: @pkg.maintainer,
132
+ description: @pkg.description,
133
+ depends: resolved_depends(:deb),
134
+ files: @pkg_data[:files],
135
+ symlinks: @pkg_data[:symlinks],
136
+ executables: @pkg_data[:executables],
137
+ section: @pkg.section,
138
+ output: output
139
+ )
140
+ output
141
+ end
142
+
143
+ def pack_rpm
144
+ version, release = split_version
145
+ output = File.join(@target.output_dir(@output_base, :rpm),
146
+ "#{@pkg.name}-#{version}-#{release}.#{@target.package_arch(:rpm)}.rpm")
147
+ Builders::Rpm.build(
148
+ name: @pkg.name,
149
+ version: version,
150
+ release: release,
151
+ summary: @pkg.summary,
152
+ license: @pkg.license,
153
+ requires: resolved_depends(:rpm),
154
+ files: @pkg_data[:files],
155
+ symlinks: @pkg_data[:symlinks],
156
+ executables: @pkg_data[:executables],
157
+ description: @pkg.summary,
158
+ arch: @target.package_arch(:rpm),
159
+ output: output
160
+ )
161
+ output
162
+ end
163
+
164
+ def pack_pkgbuild
165
+ version, release = split_version
166
+ output = File.join(@target.output_dir(@output_base, :pkgbuild), 'PKGBUILD')
167
+ Builders::Pkgbuild.generate(
168
+ name: @pkg.name,
169
+ version: version,
170
+ release: release,
171
+ pkgdesc: @pkg.summary,
172
+ maintainer: @pkg.maintainer,
173
+ depends: resolved_depends(:pkgbuild),
174
+ arch: [@target.package_arch(:pkgbuild)],
175
+ # PKGBUILD sources are archive-relative names, not local paths.
176
+ files: @pkg.payload.to_h { |n| [n, File.join(@pkg_data[:lib_dst], n)] },
177
+ symlinks: @pkg_data[:symlinks],
178
+ executables: @pkg_data[:executables],
179
+ source: ["#{@pkg.name}-#{version}.tar.gz"],
180
+ license: @pkg.license,
181
+ output: output
182
+ )
183
+ output
184
+ end
185
+
186
+ def split_version
187
+ parts = @version.split('-', 2)
188
+ parts << '1' if parts.size == 1
189
+ parts
190
+ end
191
+
192
+ # Windows: flat payload under Program Files/<name>/ via WiX. Unix symlinks
193
+ # do not apply; everything from `sources` goes in as regular files.
194
+ def pack_windows
195
+ output = File.join(@target.output_dir(@output_base, :winget),
196
+ "#{@pkg.name}-#{Builders::Wix.msi_version(@version)}.msi")
197
+ files = @pkg.payload.to_h { |n| [File.join(@pkg_data[:src_dir], n), n] }
198
+ Builders::Wix.build(
199
+ name: @pkg.name,
200
+ version: @version,
201
+ manufacturer: @pkg.maintainer,
202
+ summary: @pkg.summary,
203
+ files: files,
204
+ arch: @target.package_arch(:winget),
205
+ output: output
206
+ )
207
+ end
208
+
209
+ # macOS: .app bundle staging; DMG itself requires a Mac (hdiutil).
210
+ def pack_macos
211
+ output_dir = @target.output_dir(@output_base, :cask)
212
+ files = @pkg.payload.to_h { |n| [File.join(@pkg_data[:src_dir], n), n] }
213
+ Builders::AppDir.build(
214
+ name: @pkg.name,
215
+ version: @version,
216
+ summary: @pkg.summary,
217
+ files: files,
218
+ executables: @pkg.executables,
219
+ output: output_dir
220
+ )
221
+ end
222
+ end
223
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crosspack
4
+ # Result of resolving one canonical dependency for one target.
5
+ # verdict: :packages — concrete names in `names` (ordered alternatives);
6
+ # :system / :none / :bundled — nothing to install.
7
+ class Resolution
8
+ attr_reader :canonical, :verdict, :names
9
+
10
+ def initialize(canonical, verdict, names = [])
11
+ @canonical = canonical
12
+ @verdict = verdict
13
+ @names = names
14
+ end
15
+
16
+ def packages?
17
+ @verdict == :packages
18
+ end
19
+
20
+ def to_s
21
+ packages? ? @names.join(' | ') : @verdict.to_s
22
+ end
23
+ end
24
+
25
+ class ResolveError < StandardError; end
26
+
27
+ # Turns (manifest, target) into concrete package dependencies. No network,
28
+ # no search: everything comes from deps.yaml.
29
+ class Resolver
30
+ def initialize(manifest)
31
+ @manifest = manifest
32
+ @manifest.validate!
33
+ end
34
+
35
+ # Returns [Resolution] for every canonical name in the manifest.
36
+ def resolve(target)
37
+ family = target.family.to_s
38
+ @manifest.deps.map do |name, body|
39
+ rules = body['targets']
40
+ rule = rules[family]
41
+ if rule.nil?
42
+ available = rules.keys.map(&:to_s).join(', ')
43
+ raise ResolveError,
44
+ "#{name}: deps.yaml has no rules for family #{family} (target #{target}). " \
45
+ "Add the section #{name}: targets: #{family}: ... — currently described: #{available}"
46
+ end
47
+
48
+ value = resolve_rule(name, rule, target, family)
49
+ if value.is_a?(Array)
50
+ Resolution.new(name, :packages, value)
51
+ else
52
+ Resolution.new(name, value.to_sym)
53
+ end
54
+ end
55
+ end
56
+
57
+ # Dependencies rendered for a package format:
58
+ # :deb -> "pkg-a | pkg-b, pkg-c" (Depends: line)
59
+ # :rpm -> ["pkg-a", "pkg-c"] (first alternative wins)
60
+ # :pkgbuild -> same as rpm
61
+ def depends(target, format: target.format)
62
+ packages = resolve(target).select(&:packages?)
63
+ case format
64
+ when :deb
65
+ packages.map { |r| r.names.join(' | ') }.join(', ')
66
+ when :rpm, :pkgbuild, :cask, :winget
67
+ packages.map { |r| r.names.first }
68
+ else
69
+ raise ArgumentError, "unknown format #{format.inspect} (allowed: :deb, :rpm, :pkgbuild)"
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ def resolve_rule(name, rule, target, family)
76
+ return rule if rule.is_a?(String) # whole-family verdict
77
+
78
+ key = target.version || '*'
79
+ return rule[key] if rule.key?(key)
80
+
81
+ fallback = rule['*']
82
+ return fallback unless fallback.nil?
83
+
84
+ versions = rule.keys.map { |v| v == '*' ? '"*" (any)' : v.inspect }.join(', ')
85
+ raise ResolveError,
86
+ "#{name}: no rules for #{family} #{target.version.inspect} and no \"*\" key. " \
87
+ "Versions described in the file: #{versions}. Add targets.#{family}.\"#{target.version}\" " \
88
+ 'or a "*" wildcard rule'
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crosspack
4
+ # A build target: distro family + optional version + CPU architecture.
5
+ # Parsed from strings like "debian-12", "ubuntu-24.04", "fedora-41", "arch".
6
+ class Target
7
+ FAMILIES = %i[debian ubuntu fedora almalinux rocky opensuse arch macos windows].freeze
8
+ VERSIONLESS_FAMILIES = %i[arch].freeze
9
+ # Version is allowed but not required (macos-15, windows-11.0, or bare).
10
+ OPTIONAL_VERSION_FAMILIES = %i[macos windows].freeze
11
+ FORMAT_BY_FAMILY = {
12
+ debian: :deb, ubuntu: :deb,
13
+ fedora: :rpm, almalinux: :rpm, rocky: :rpm, opensuse: :rpm,
14
+ arch: :pkgbuild, macos: :cask, windows: :winget
15
+ }.freeze
16
+
17
+ class Error < StandardError; end
18
+
19
+ attr_reader :family, :version, :arch
20
+
21
+ # Normalizes common architecture spellings: amd64/x64 -> x86_64,
22
+ # aarch64 -> arm64.
23
+ ARCH_ALIASES = {
24
+ amd64: :x86_64, 'x64': :x86_64, x86_64: :x86_64, x86: nil,
25
+ arm64: :arm64, aarch64: :arm64, armhf: nil
26
+ }.freeze
27
+
28
+ def self.normalize_arch(value)
29
+ norm = value.to_s.strip.downcase.sub(/\Ax86-64\z/, 'x86_64').to_sym
30
+ ARCH_ALIASES.fetch(norm, norm)
31
+ end
32
+
33
+ def self.parse(str, arch: :x86_64)
34
+ s = str.to_s.strip
35
+ raise Error, 'target string is empty' if s.empty?
36
+
37
+ family, version = s.split('-', 2)
38
+ family_sym = family.to_sym
39
+ unless FAMILIES.include?(family_sym)
40
+ raise Error, "unknown distro family #{family.inspect}; allowed: #{FAMILIES.join(', ')}"
41
+ end
42
+
43
+ norm_arch = normalize_arch(arch)
44
+ raise Error, "unsupported architecture #{arch.inspect} (supported: x86_64/amd64, arm64/aarch64)" if norm_arch.nil?
45
+
46
+ if VERSIONLESS_FAMILIES.include?(family_sym) && version
47
+ raise Error, "family #{family} does not use a version (remove \"-#{version}\")"
48
+ end
49
+ version_required = !VERSIONLESS_FAMILIES.include?(family_sym) &&
50
+ !OPTIONAL_VERSION_FAMILIES.include?(family_sym)
51
+ if version_required && version.to_s.strip.empty?
52
+ raise Error, "specify a version for family #{family}, e.g. #{family}-12"
53
+ end
54
+
55
+ new(family_sym, version, norm_arch)
56
+ end
57
+
58
+ def initialize(family, version = nil, arch = :x86_64)
59
+ @family = family.to_sym
60
+ @version = version && version.to_s
61
+ @arch = arch
62
+ end
63
+
64
+ def format
65
+ FORMAT_BY_FAMILY.fetch(@family)
66
+ end
67
+
68
+ # Per-format architecture spelling: deb uses amd64/arm64, rpm and
69
+ # PKGBUILD use x86_64/aarch64.
70
+ def package_arch(format = self.format)
71
+ return 'amd64' if format == :deb && @arch == :x86_64
72
+ return 'arm64' if format == :deb && @arch == :arm64
73
+ return 'aarch64' if @arch == :arm64
74
+
75
+ 'x86_64'
76
+ end
77
+
78
+ def to_s
79
+ @version ? "#{@family}-#{@version}" : @family.to_s
80
+ end
81
+ alias inspect to_s
82
+
83
+ # Output layout for build artifacts: base/<family>[/<version>]/<arch>,
84
+ # e.g. crosspacks/ubuntu/24.04/amd64, crosspacks/windows/11.0/x86_64.
85
+ # Families without a version (arch, or bare macos/windows) skip the segment.
86
+ def output_dir(base, format = self.format)
87
+ parts = [base.to_s, @family.to_s]
88
+ parts << @version if @version
89
+ parts << package_arch(format)
90
+ File.join(*parts)
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crosspack
4
+ VERSION = '0.2.0'
5
+ end
data/lib/crosspack.rb ADDED
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'crosspack/version'
4
+ require_relative 'crosspack/target'
5
+ require_relative 'crosspack/manifest'
6
+ require_relative 'crosspack/package_manifest'
7
+ require_relative 'crosspack/resolver'
8
+ require_relative 'crosspack/matrix'
9
+ require_relative 'crosspack/builds'
10
+ require_relative 'crosspack/packer'
11
+ require_relative 'crosspack/builders/deb'
12
+ require_relative 'crosspack/builders/rpm'
13
+ require_relative 'crosspack/builders/pkgbuild'
14
+ require_relative 'crosspack/builders/wix'
15
+ require_relative 'crosspack/builders/app_dir'
16
+
17
+ module Crosspack
18
+ BUILDERS = {
19
+ deb: Builders::Deb,
20
+ rpm: Builders::Rpm,
21
+ pkgbuild: Builders::Pkgbuild,
22
+ winget: Builders::Wix,
23
+ cask: Builders::AppDir
24
+ }.freeze
25
+
26
+ # Shared options for all builders, expressed once so every format stages
27
+ # the same file tree:
28
+ # files: { source_path => destination_inside_package (no leading /) }
29
+ # symlinks: { link_path_inside_package => link_target }
30
+ # executables: [destination paths to chmod 0755]
31
+ class BuildError < StandardError; end
32
+
33
+ # Convenience wrapper: Crosspack.pack(manifest: 'package.yaml',
34
+ # deps: 'deps.yaml', target: Target.parse('debian-12'), version: '1.0-1')
35
+ def self.pack(**kwargs)
36
+ Packer.pack(**kwargs)
37
+ end
38
+ end
metadata ADDED
@@ -0,0 +1,78 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: crosspack
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Oleg Orlov
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-09-08 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: 'Two cooperating commands: crossbuild runs the build steps of a project
14
+ from a build.yaml matrix and fans the produced artifacts out into the per-target
15
+ builds/ tree; crosspack turns that tree into native packages, driven by a cross-distro
16
+ deps.yaml. Both share the same target vocabulary so the trees never drift apart.'
17
+ email:
18
+ - orelcokolov@gmail.com
19
+ executables:
20
+ - crosspack
21
+ - crossbuild
22
+ extensions: []
23
+ extra_rdoc_files: []
24
+ files:
25
+ - CHANGELOG.md
26
+ - LICENSE
27
+ - README.md
28
+ - exe/crossbuild
29
+ - exe/crosspack
30
+ - lib/crossbuild.rb
31
+ - lib/crossbuild/build_manifest.rb
32
+ - lib/crossbuild/builder.rb
33
+ - lib/crossbuild/distributor.rb
34
+ - lib/crossbuild/matrix.rb
35
+ - lib/crossbuild/platform.rb
36
+ - lib/crossbuild/runner.rb
37
+ - lib/crossbuild/version.rb
38
+ - lib/crossbuild/version_scheme.rb
39
+ - lib/crosspack.rb
40
+ - lib/crosspack/builders/app_dir.rb
41
+ - lib/crosspack/builders/deb.rb
42
+ - lib/crosspack/builders/pkgbuild.rb
43
+ - lib/crosspack/builders/rpm.rb
44
+ - lib/crosspack/builders/wix.rb
45
+ - lib/crosspack/builds.rb
46
+ - lib/crosspack/manifest.rb
47
+ - lib/crosspack/matrix.rb
48
+ - lib/crosspack/package_manifest.rb
49
+ - lib/crosspack/packer.rb
50
+ - lib/crosspack/resolver.rb
51
+ - lib/crosspack/target.rb
52
+ - lib/crosspack/version.rb
53
+ homepage: https://github.com/OrelSokolov/crosspack
54
+ licenses:
55
+ - Nonstandard
56
+ metadata:
57
+ source_code_uri: https://github.com/OrelSokolov/crosspack
58
+ changelog_uri: https://github.com/OrelSokolov/crosspack/blob/main/CHANGELOG.md
59
+ post_install_message:
60
+ rdoc_options: []
61
+ require_paths:
62
+ - lib
63
+ required_ruby_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: 3.2.0
68
+ required_rubygems_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '0'
73
+ requirements: []
74
+ rubygems_version: 3.5.22
75
+ signing_key:
76
+ specification_version: 4
77
+ summary: Cross-platform build orchestrator and native package builder (deb/rpm/PKGBUILD/winget/cask)
78
+ test_files: []