crosspack 0.2.1 → 0.5.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 +4 -4
- data/CHANGELOG.md +69 -0
- data/README.md +148 -117
- data/exe/crosspack +118 -44
- data/lib/crossbuild/build_manifest.rb +122 -27
- data/lib/crossbuild/builder.rb +32 -6
- data/lib/crossbuild/dep_installer.rb +127 -0
- data/lib/crossbuild/distributor.rb +15 -2
- data/lib/crossbuild/matrix.rb +29 -2
- data/lib/crossbuild/platform.rb +35 -2
- data/lib/crossbuild/runner.rb +1 -1
- data/lib/crossbuild/version.rb +1 -1
- data/lib/crossbuild/version_scheme.rb +3 -2
- data/lib/crossbuild.rb +15 -7
- data/lib/crosspack/builders/pkgbuild.rb +1 -1
- data/lib/crosspack/builders/rpm.rb +2 -1
- data/lib/crosspack/builds.rb +12 -0
- data/lib/crosspack/config.rb +164 -0
- data/lib/crosspack/manifest.rb +44 -30
- data/lib/crosspack/matrix.rb +2 -2
- data/lib/crosspack/package_manifest.rb +16 -26
- data/lib/crosspack/packer.rb +34 -15
- data/lib/crosspack/resolver.rb +2 -2
- data/lib/crosspack/version.rb +1 -1
- data/lib/crosspack.rb +3 -2
- metadata +10 -9
- data/exe/crossbuild +0 -76
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'yaml'
|
|
4
|
-
|
|
5
3
|
module Crossbuild
|
|
6
|
-
# A single schema problem: full path into build
|
|
4
|
+
# A single schema problem: full path into the build: section + human
|
|
5
|
+
# explanation.
|
|
7
6
|
class Issue
|
|
8
7
|
attr_reader :path, :message
|
|
9
8
|
|
|
@@ -17,16 +16,17 @@ module Crossbuild
|
|
|
17
16
|
end
|
|
18
17
|
end
|
|
19
18
|
|
|
20
|
-
class InvalidManifestError <
|
|
19
|
+
class InvalidManifestError < Crosspack::InvalidManifestError; end
|
|
21
20
|
|
|
22
21
|
class BuildManifest
|
|
23
22
|
OS_LIST = %w[linux darwin windows].freeze
|
|
24
23
|
ARCH_LIST = %w[amd64 x86_64 arm64 aarch64 any].freeze
|
|
25
24
|
ARTIFACT_MODES = %w[symlink copy].freeze
|
|
26
25
|
VERSION_SCHEMES = %w[calver git-tag].freeze
|
|
27
|
-
TOP_LEVEL_KEYS = %w[name version output matrix].freeze
|
|
26
|
+
TOP_LEVEL_KEYS = %w[name version output deps matrix].freeze
|
|
28
27
|
ENTRY_KEYS = %w[id build env steps artifacts].freeze
|
|
29
28
|
ARTIFACTS_KEYS = %w[from include to mode].freeze
|
|
29
|
+
DEP_KEYS = %w[hosts].freeze
|
|
30
30
|
BUILD_RE = /\A(linux|darwin|windows)\/(#{ARCH_LIST.join('|')})\z/.freeze
|
|
31
31
|
NAME_RE = /\A[a-z0-9][a-z0-9+._-]*\z/i.freeze
|
|
32
32
|
|
|
@@ -54,6 +54,22 @@ module Crossbuild
|
|
|
54
54
|
end
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
# A build dependency from the top-level deps: section. `hosts` maps a
|
|
58
|
+
# host selector (distro id from os-release, ID_LIKE token, os name or
|
|
59
|
+
# "*") to a rule with per-system commands:
|
|
60
|
+
# verify: presence probe (default: PATH lookup of the name)
|
|
61
|
+
# install: how to install it (a verify-only rule may omit this)
|
|
62
|
+
class Dependency
|
|
63
|
+
RULE_KEYS = %w[verify install].freeze
|
|
64
|
+
|
|
65
|
+
attr_reader :name, :hosts
|
|
66
|
+
|
|
67
|
+
def initialize(name, hosts)
|
|
68
|
+
@name = name
|
|
69
|
+
@hosts = hosts
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
57
73
|
# Where artifacts come from and which builds/ directories they fan out to.
|
|
58
74
|
class Artifacts
|
|
59
75
|
attr_reader :from, :include, :to, :mode
|
|
@@ -66,22 +82,7 @@ module Crossbuild
|
|
|
66
82
|
end
|
|
67
83
|
end
|
|
68
84
|
|
|
69
|
-
attr_reader :path, :name, :version, :output, :entries, :errors, :warnings
|
|
70
|
-
|
|
71
|
-
def self.load(path)
|
|
72
|
-
unless File.file?(path)
|
|
73
|
-
raise InvalidManifestError,
|
|
74
|
-
"Build manifest not found: #{path}\n" \
|
|
75
|
-
'Create build.yaml next to the Rakefile (see crossbuild/README.md).'
|
|
76
|
-
end
|
|
77
|
-
begin
|
|
78
|
-
raw = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
|
|
79
|
-
rescue Psych::SyntaxError => e
|
|
80
|
-
raise InvalidManifestError,
|
|
81
|
-
"#{path}: YAML syntax error on line #{e.line}: #{e.problem}"
|
|
82
|
-
end
|
|
83
|
-
new(path, raw)
|
|
84
|
-
end
|
|
85
|
+
attr_reader :path, :name, :version, :output, :deps, :entries, :errors, :warnings
|
|
85
86
|
|
|
86
87
|
def initialize(path, raw)
|
|
87
88
|
@path = path
|
|
@@ -89,6 +90,7 @@ module Crossbuild
|
|
|
89
90
|
@name = nil
|
|
90
91
|
@version = nil
|
|
91
92
|
@output = 'builds'
|
|
93
|
+
@deps = []
|
|
92
94
|
@entries = []
|
|
93
95
|
@errors = []
|
|
94
96
|
@warnings = []
|
|
@@ -103,7 +105,7 @@ module Crossbuild
|
|
|
103
105
|
def validate!
|
|
104
106
|
return self if valid?
|
|
105
107
|
|
|
106
|
-
lines = ["#{path}: build
|
|
108
|
+
lines = ["#{path}: the build: section is invalid (#{@errors.size} errors):"]
|
|
107
109
|
@errors.each { |e| lines << " ✗ #{e}" }
|
|
108
110
|
lines << 'Build aborted: fix the listed nodes and retry.'
|
|
109
111
|
raise InvalidManifestError, lines.join("\n")
|
|
@@ -114,9 +116,9 @@ module Crossbuild
|
|
|
114
116
|
lines = []
|
|
115
117
|
if valid?
|
|
116
118
|
noun = @entries.size == 1 ? 'entry' : 'entries'
|
|
117
|
-
lines << "#{path}:
|
|
119
|
+
lines << "#{path}: the build: section is valid (#{@entries.size} #{noun}: #{@entries.map(&:id).join(', ')})."
|
|
118
120
|
else
|
|
119
|
-
lines << "#{path}: build
|
|
121
|
+
lines << "#{path}: the build: section is invalid (#{@errors.size} errors):"
|
|
120
122
|
@errors.each { |e| lines << " ✗ #{e}" }
|
|
121
123
|
end
|
|
122
124
|
@warnings.each { |w| lines << " ⚠ #{w}" }
|
|
@@ -132,11 +134,33 @@ module Crossbuild
|
|
|
132
134
|
@entries.find { |e| e.id == id }
|
|
133
135
|
end
|
|
134
136
|
|
|
137
|
+
# The single matrix entry that distributes artifacts to the given target
|
|
138
|
+
# string (e.g. "ubuntu-24.04"). Raises Crossbuild::Error when no entry
|
|
139
|
+
# claims it or when several do (ambiguous).
|
|
140
|
+
def entry_for_target(raw)
|
|
141
|
+
matches = @entries.select { |e| e.artifacts&.to&.include?(raw) }
|
|
142
|
+
declared = @entries.filter_map { |e| e.artifacts&.to }.flatten.uniq
|
|
143
|
+
if matches.empty?
|
|
144
|
+
raise Error,
|
|
145
|
+
"no matrix entry distributes to target #{raw.inspect}; " \
|
|
146
|
+
"declared targets: #{declared.join(', ')}"
|
|
147
|
+
end
|
|
148
|
+
if matches.size > 1
|
|
149
|
+
raise Error,
|
|
150
|
+
"target #{raw.inspect} is distributed by several entries " \
|
|
151
|
+
"(#{matches.map(&:id).join(', ')}) — crosspack build <target> needs exactly one"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
matches.first
|
|
155
|
+
end
|
|
156
|
+
|
|
135
157
|
private
|
|
136
158
|
|
|
137
159
|
def validate
|
|
138
160
|
unless @raw.is_a?(Hash) && !@raw.empty?
|
|
139
|
-
@errors << Issue.new('(root)',
|
|
161
|
+
@errors << Issue.new('(root)',
|
|
162
|
+
'the build: section of crosspack.yml is missing or empty — ' \
|
|
163
|
+
'the deps and build stages need it (matrix, steps, artifacts)')
|
|
140
164
|
return
|
|
141
165
|
end
|
|
142
166
|
|
|
@@ -144,13 +168,14 @@ module Crossbuild
|
|
|
144
168
|
validate_name
|
|
145
169
|
validate_version
|
|
146
170
|
validate_output
|
|
171
|
+
validate_deps
|
|
147
172
|
validate_matrix
|
|
148
173
|
end
|
|
149
174
|
|
|
150
175
|
def validate_name
|
|
151
176
|
@name = @raw['name'].to_s
|
|
152
177
|
if @raw['name'].nil? || @name.empty?
|
|
153
|
-
@errors << Issue.new('name', 'is required
|
|
178
|
+
@errors << Issue.new('name', 'is required at the top level of crosspack.yml, e.g. name: myapp')
|
|
154
179
|
elsif @name !~ NAME_RE
|
|
155
180
|
@errors << Issue.new('name', "invalid name #{@name.inspect} (letters, digits, \"+\", \"_\", \"-\", \".\")")
|
|
156
181
|
end
|
|
@@ -159,7 +184,7 @@ module Crossbuild
|
|
|
159
184
|
def validate_version
|
|
160
185
|
spec = @raw['version']
|
|
161
186
|
if spec.nil?
|
|
162
|
-
@warnings << Issue.new('version', 'not set — defaulting to calver (YYYY.MM.DD-<secs>)')
|
|
187
|
+
@warnings << Issue.new('version', 'not set at the top level of crosspack.yml — defaulting to calver (YYYY.MM.DD-<secs>)')
|
|
163
188
|
@version = 'calver'
|
|
164
189
|
return
|
|
165
190
|
end
|
|
@@ -184,6 +209,76 @@ module Crossbuild
|
|
|
184
209
|
end
|
|
185
210
|
end
|
|
186
211
|
|
|
212
|
+
def validate_deps
|
|
213
|
+
deps = @raw['deps']
|
|
214
|
+
return if deps.nil?
|
|
215
|
+
|
|
216
|
+
unless deps.is_a?(Hash)
|
|
217
|
+
@errors << Issue.new(
|
|
218
|
+
'deps',
|
|
219
|
+
"must be a mapping of dependency name -> {check?, hosts}; expected:\n" \
|
|
220
|
+
" deps:\n" \
|
|
221
|
+
" imagemagick:\n" \
|
|
222
|
+
" check: magick -version\n" \
|
|
223
|
+
" hosts:\n" \
|
|
224
|
+
" ubuntu: sudo apt-get install -y imagemagick"
|
|
225
|
+
)
|
|
226
|
+
return
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
deps.each { |name, body| validate_dep(name, body) }
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def validate_dep(name, body)
|
|
233
|
+
path = "deps.#{name}"
|
|
234
|
+
if name !~ NAME_RE
|
|
235
|
+
@errors << Issue.new(path, "invalid dependency name #{name.inspect} (letters, digits, \"+\", \"_\", \"-\", \".\")")
|
|
236
|
+
return
|
|
237
|
+
end
|
|
238
|
+
unless body.is_a?(Hash)
|
|
239
|
+
@errors << Issue.new(path, 'must be a mapping with the key: hosts')
|
|
240
|
+
return
|
|
241
|
+
end
|
|
242
|
+
check_unknown_keys(path, body.keys, DEP_KEYS)
|
|
243
|
+
|
|
244
|
+
hosts = body['hosts']
|
|
245
|
+
unless hosts.is_a?(Hash) && !hosts.empty?
|
|
246
|
+
@errors << Issue.new(
|
|
247
|
+
"#{path}.hosts",
|
|
248
|
+
"must be a non-empty mapping of host selector -> {verify?, install?}; expected:\n" \
|
|
249
|
+
" deps:\n" \
|
|
250
|
+
" #{name}:\n" \
|
|
251
|
+
" hosts:\n" \
|
|
252
|
+
" ubuntu:\n" \
|
|
253
|
+
" verify: dpkg -s #{name}\n" \
|
|
254
|
+
" install: sudo apt-get install -y #{name}"
|
|
255
|
+
)
|
|
256
|
+
return
|
|
257
|
+
end
|
|
258
|
+
hosts.each { |selector, rule| validate_host_rule(name, selector, rule) }
|
|
259
|
+
return unless valid_dep?(name, body)
|
|
260
|
+
|
|
261
|
+
@deps << Dependency.new(name, hosts)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def validate_host_rule(name, selector, rule)
|
|
265
|
+
path = "deps.#{name}.hosts.#{selector}"
|
|
266
|
+
unless rule.is_a?(Hash) && !rule.empty?
|
|
267
|
+
@errors << Issue.new(path, "must be a mapping with verify and/or install commands, e.g. { install: sudo apt-get install -y #{name} }")
|
|
268
|
+
return
|
|
269
|
+
end
|
|
270
|
+
check_unknown_keys(path, rule.keys, Dependency::RULE_KEYS)
|
|
271
|
+
rule.each do |key, command|
|
|
272
|
+
unless command.is_a?(String) && !command.strip.empty?
|
|
273
|
+
@errors << Issue.new("#{path}.#{key}", 'must be a non-empty shell command')
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def valid_dep?(name, body)
|
|
279
|
+
@errors.none? { |e| e.path.start_with?("deps.#{name}") }
|
|
280
|
+
end
|
|
281
|
+
|
|
187
282
|
def validate_matrix
|
|
188
283
|
matrix = @raw['matrix']
|
|
189
284
|
unless matrix.is_a?(Array) && !matrix.empty?
|
data/lib/crossbuild/builder.rb
CHANGED
|
@@ -8,12 +8,13 @@ module Crossbuild
|
|
|
8
8
|
|
|
9
9
|
attr_reader :manifest
|
|
10
10
|
|
|
11
|
-
def initialize(manifest, root: Dir.pwd, output_base: nil, version: nil,
|
|
11
|
+
def initialize(manifest, root: Dir.pwd, output_base: nil, version: nil, deps: true,
|
|
12
12
|
host_os: Platform.os, host_arch: Platform.arch)
|
|
13
13
|
@manifest = manifest
|
|
14
14
|
@root = root
|
|
15
15
|
@output_base = output_base || manifest.output
|
|
16
16
|
@version_override = version
|
|
17
|
+
@deps = deps
|
|
17
18
|
@host_os = host_os
|
|
18
19
|
@host_arch = host_arch
|
|
19
20
|
end
|
|
@@ -21,20 +22,44 @@ module Crossbuild
|
|
|
21
22
|
# entry_id: nil — every entry buildable on this host; otherwise the exact
|
|
22
23
|
# entry id (which must exist, but may target another host — the user
|
|
23
24
|
# asked for it explicitly).
|
|
24
|
-
|
|
25
|
+
# target: a raw target string (e.g. "ubuntu-24.04") — builds the single
|
|
26
|
+
# matrix entry distributing to it and fans artifacts out to that target
|
|
27
|
+
# only; the entry must be buildable on this host (no cross-compilation).
|
|
28
|
+
def run(entry_id: nil, target: nil)
|
|
25
29
|
manifest.validate!
|
|
30
|
+
ensure_deps
|
|
26
31
|
version = @version_override || VersionScheme.new(manifest.version).compute(root: @root)
|
|
27
|
-
entries = select_entries(entry_id)
|
|
32
|
+
entries = target ? entries_for_target(target) : select_entries(entry_id)
|
|
28
33
|
|
|
29
34
|
entries.each do |entry|
|
|
30
35
|
puts "\n==> #{manifest.name} [#{entry.id}] version #{version}"
|
|
31
|
-
run_entry(entry, version)
|
|
36
|
+
run_entry(entry, version, only: target)
|
|
32
37
|
end
|
|
33
38
|
Result.new(version: version, entries: entries)
|
|
34
39
|
end
|
|
35
40
|
|
|
36
41
|
private
|
|
37
42
|
|
|
43
|
+
# deps: false (--no-deps) skips the pre-build check/install pass.
|
|
44
|
+
def ensure_deps
|
|
45
|
+
return if !@deps || manifest.deps.empty?
|
|
46
|
+
|
|
47
|
+
puts '⧗ build deps'
|
|
48
|
+
DepInstaller.new(manifest, root: @root).ensure_all
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def entries_for_target(raw)
|
|
52
|
+
entry = manifest.entry_for_target(raw)
|
|
53
|
+
host = "#{@host_os}/#{Platform.display_arch(@host_arch)}"
|
|
54
|
+
unless entry.buildable_on?(@host_os, @host_arch)
|
|
55
|
+
raise Error,
|
|
56
|
+
"the entry for #{raw.inspect} (#{entry.id}) builds on #{entry.platform}, " \
|
|
57
|
+
"this host is #{host} — cross-compilation is not supported"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
[entry]
|
|
61
|
+
end
|
|
62
|
+
|
|
38
63
|
def select_entries(entry_id)
|
|
39
64
|
if entry_id
|
|
40
65
|
entry = manifest.find_entry(entry_id)
|
|
@@ -50,7 +75,7 @@ module Crossbuild
|
|
|
50
75
|
end
|
|
51
76
|
end
|
|
52
77
|
|
|
53
|
-
def run_entry(entry, version)
|
|
78
|
+
def run_entry(entry, version, only: nil)
|
|
54
79
|
arch = entry.arch == :any ? @host_arch : entry.arch
|
|
55
80
|
vars = {
|
|
56
81
|
name: manifest.name,
|
|
@@ -63,7 +88,8 @@ module Crossbuild
|
|
|
63
88
|
|
|
64
89
|
Runner.new(root: @root, vars: vars, env: entry.env).run(entry.steps) unless entry.steps.empty?
|
|
65
90
|
|
|
66
|
-
dirs = Distributor.new(root: @root, output_base: @output_base)
|
|
91
|
+
dirs = Distributor.new(root: @root, output_base: @output_base)
|
|
92
|
+
.distribute(entry, host_arch: @host_arch, version: version, only: only)
|
|
67
93
|
return if dirs.empty?
|
|
68
94
|
|
|
69
95
|
dirs.each { |d| puts "🌳 #{d.sub("#{@root}/", '')}" }
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Crossbuild
|
|
4
|
+
# Verifies and installs the build dependencies declared in the deps:
|
|
5
|
+
# rules of the build: section of crosspack.yml. Host selectors (distro
|
|
6
|
+
# id, ID_LIKE tokens, os, "*" — see Platform.selectors) are matched
|
|
7
|
+
# against each dependency's hosts: rules; the first hit wins. Every rule
|
|
8
|
+
# carries its own commands:
|
|
9
|
+
# `verify` decides presence (exit 0 = installed; default: PATH lookup of
|
|
10
|
+
# the dependency name), `install` says how to install it on that host.
|
|
11
|
+
class DepInstaller
|
|
12
|
+
class DepError < Error; end
|
|
13
|
+
|
|
14
|
+
# Outcome of one dependency: resolved selector + install command, and
|
|
15
|
+
# whether the dependency is present after ensure_all/check_all ran.
|
|
16
|
+
Status = Struct.new(:dep, :selector, :command, :installed, keyword_init: true)
|
|
17
|
+
|
|
18
|
+
attr_reader :statuses
|
|
19
|
+
|
|
20
|
+
def initialize(manifest, root:, selectors: Platform.selectors, os: Platform.os)
|
|
21
|
+
@manifest = manifest
|
|
22
|
+
@root = root
|
|
23
|
+
@selectors = selectors
|
|
24
|
+
@os = os
|
|
25
|
+
@runner = Runner.new(root: root, vars: {
|
|
26
|
+
name: manifest.name,
|
|
27
|
+
version: manifest.version,
|
|
28
|
+
os: os.to_s,
|
|
29
|
+
arch: Platform.display_arch(Platform.arch),
|
|
30
|
+
platform: Platform.to_s,
|
|
31
|
+
id: Platform.to_s
|
|
32
|
+
})
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Verify every dependency; install the missing ones via their host rule
|
|
36
|
+
# and re-verify. Raises DepError when something stays missing.
|
|
37
|
+
def ensure_all
|
|
38
|
+
@statuses = @manifest.deps.map { |dep| ensure_dep(dep) }
|
|
39
|
+
@statuses
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Report-only pass for `crosspack deps <target> --check`: never installs.
|
|
43
|
+
def check_all
|
|
44
|
+
@statuses = @manifest.deps.map do |dep|
|
|
45
|
+
selector, rule = resolve(dep)
|
|
46
|
+
Status.new(dep: dep.name, selector: selector, command: display_command(rule),
|
|
47
|
+
installed: present?(dep, rule))
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# One line per dependency for the CLI doctor output.
|
|
52
|
+
def report
|
|
53
|
+
return ' (no deps declared)' if @manifest.deps.empty?
|
|
54
|
+
|
|
55
|
+
(@statuses || check_all).map do |s|
|
|
56
|
+
format(' %-20s %-8s %-8s %s',
|
|
57
|
+
s.dep, s.installed ? 'ok' : 'MISSING', s.selector, s.command)
|
|
58
|
+
end.join("\n")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def ensure_dep(dep)
|
|
64
|
+
selector, rule = resolve(dep)
|
|
65
|
+
if present?(dep, rule)
|
|
66
|
+
return Status.new(dep: dep.name, selector: selector,
|
|
67
|
+
command: display_command(rule), installed: true)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
install(dep, selector, rule)
|
|
71
|
+
unless present?(dep, rule)
|
|
72
|
+
detail = rule['verify'] ? "the verify `#{rule['verify']}` still fails" : "#{dep.name} is still not on PATH"
|
|
73
|
+
raise DepError,
|
|
74
|
+
"#{dep.name}: #{detail} after `#{rule['install']}`. " \
|
|
75
|
+
'Fix the install command or the verify probe.'
|
|
76
|
+
end
|
|
77
|
+
Status.new(dep: dep.name, selector: selector, command: display_command(rule), installed: true)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def resolve(dep)
|
|
81
|
+
@selectors.each do |selector|
|
|
82
|
+
return [selector, dep.hosts[selector]] if dep.hosts.key?(selector)
|
|
83
|
+
end
|
|
84
|
+
raise DepError,
|
|
85
|
+
"#{dep.name}: no rule for this host (selectors tried: #{@selectors.join(', ')}; " \
|
|
86
|
+
"described: #{dep.hosts.keys.join(', ')}) — add one of those or a \"*\" wildcard to deps.#{dep.name}.hosts"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def present?(dep, rule)
|
|
90
|
+
verify = rule['verify']
|
|
91
|
+
return on_path?(dep.name) if verify.nil?
|
|
92
|
+
|
|
93
|
+
system(@runner.interpolate(verify), chdir: @root, out: File::NULL, err: File::NULL)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def install(dep, selector, rule)
|
|
97
|
+
command = rule['install']
|
|
98
|
+
if command.nil?
|
|
99
|
+
raise DepError,
|
|
100
|
+
"#{dep.name}: verification failed on #{selector} and the rule has no install command — " \
|
|
101
|
+
"add deps.#{dep.name}.hosts.#{selector}.install"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
command = @runner.interpolate(command)
|
|
105
|
+
puts "▶ #{command} (#{dep.name} @ #{selector})"
|
|
106
|
+
return if system(command, chdir: @root)
|
|
107
|
+
|
|
108
|
+
status = $?.nil? ? 'unknown status' : "exit #{$?.exitstatus || $?.to_i}"
|
|
109
|
+
raise DepError, "#{dep.name}: install command failed (#{status}): #{command}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def display_command(rule)
|
|
113
|
+
rule['install'] || "(verify only: #{rule['verify']})"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Pure-Ruby PATH probe (portable: no `command -v` / `where` shell dance;
|
|
117
|
+
# `system` skips the shell for metacharacter-free strings, where shell
|
|
118
|
+
# builtins do not resolve). On Windows PATHEXT extensions are honored.
|
|
119
|
+
def on_path?(name)
|
|
120
|
+
exts = @os == :windows ? ENV['PATHEXT'].to_s.split(File::PATH_SEPARATOR) : ['']
|
|
121
|
+
ENV['PATH'].to_s.split(File::PATH_SEPARATOR).any? do |dir|
|
|
122
|
+
base = File.join(dir.strip.empty? ? '.' : dir, name)
|
|
123
|
+
exts.any? { |ext| File.executable?("#{base}#{ext}") }
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -8,7 +8,12 @@ module Crossbuild
|
|
|
8
8
|
# crosspacks/ tree crosspack writes, so crosspack can pack straight from it.
|
|
9
9
|
# Items are symlinked by default (packers copy through symlinks); copy mode
|
|
10
10
|
# is available for trees that get archived or shipped as-is.
|
|
11
|
+
#
|
|
12
|
+
# The build version is stamped into .crosspack-build in every target
|
|
13
|
+
# directory, so a later `crosspack pack <target>` needs no --version.
|
|
11
14
|
class Distributor
|
|
15
|
+
STAMP_NAME = Crosspack::Builds::STAMP_NAME
|
|
16
|
+
|
|
12
17
|
attr_reader :placed_dirs
|
|
13
18
|
|
|
14
19
|
def initialize(root:, output_base:)
|
|
@@ -18,7 +23,9 @@ module Crossbuild
|
|
|
18
23
|
end
|
|
19
24
|
|
|
20
25
|
# Returns the list of builds/ directories that received artifacts.
|
|
21
|
-
|
|
26
|
+
# only: distribute to just this raw target string (from artifacts.to)
|
|
27
|
+
# instead of all of them — used by `crosspack build <target>`.
|
|
28
|
+
def distribute(entry, host_arch:, version: nil, only: nil)
|
|
22
29
|
spec = entry.artifacts
|
|
23
30
|
return [] unless spec
|
|
24
31
|
|
|
@@ -30,11 +37,17 @@ module Crossbuild
|
|
|
30
37
|
items = select_items(from_dir, spec.include)
|
|
31
38
|
arch = entry.arch == :any ? host_arch : entry.arch
|
|
32
39
|
|
|
33
|
-
spec.to.
|
|
40
|
+
targets = only ? spec.to.select { |raw| raw == only } : spec.to
|
|
41
|
+
if targets.empty?
|
|
42
|
+
raise Error, "#{entry.id} does not distribute to #{only.inspect} (its targets: #{spec.to.join(', ')})"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
targets.each do |raw|
|
|
34
46
|
target = Crosspack::Target.parse(raw, arch: arch)
|
|
35
47
|
dst_dir = target.output_dir(File.expand_path(@output_base, @root))
|
|
36
48
|
FileUtils.mkdir_p(dst_dir)
|
|
37
49
|
items.each { |item| place(item, File.join(dst_dir, File.basename(item)), spec.mode) }
|
|
50
|
+
File.write(File.join(dst_dir, STAMP_NAME), "#{version}\n") if version
|
|
38
51
|
@placed_dirs << dst_dir
|
|
39
52
|
end
|
|
40
53
|
@placed_dirs.uniq
|
data/lib/crossbuild/matrix.rb
CHANGED
|
@@ -4,15 +4,17 @@ module Crossbuild
|
|
|
4
4
|
# Human-readable build matrix: which entries exist, which can run on this
|
|
5
5
|
# host, what they run and where their artifacts land.
|
|
6
6
|
class Matrix
|
|
7
|
-
def initialize(manifest, host_os: Platform.os, host_arch: Platform.arch
|
|
7
|
+
def initialize(manifest, host_os: Platform.os, host_arch: Platform.arch,
|
|
8
|
+
selectors: Platform.selectors)
|
|
8
9
|
@manifest = manifest
|
|
9
10
|
@host_os = host_os
|
|
10
11
|
@host_arch = host_arch
|
|
12
|
+
@selectors = selectors
|
|
11
13
|
end
|
|
12
14
|
|
|
13
15
|
def render
|
|
14
16
|
host = "#{@host_os}/#{Platform.display_arch(@host_arch)}"
|
|
15
|
-
lines = ["build
|
|
17
|
+
lines = ["build matrix — host: #{host}"]
|
|
16
18
|
@manifest.entries.each { |e| lines << render_entry(e) }
|
|
17
19
|
buildable = @manifest.buildable_entries(@host_os, @host_arch)
|
|
18
20
|
lines << if buildable.empty?
|
|
@@ -20,6 +22,7 @@ module Crossbuild
|
|
|
20
22
|
else
|
|
21
23
|
"\n#{buildable.size} of #{@manifest.entries.size} entries build here: #{buildable.map(&:id).join(', ')}"
|
|
22
24
|
end
|
|
25
|
+
lines << render_deps
|
|
23
26
|
lines.join("\n")
|
|
24
27
|
end
|
|
25
28
|
|
|
@@ -48,5 +51,29 @@ module Crossbuild
|
|
|
48
51
|
to = entry.artifacts.to.join(', ')
|
|
49
52
|
"#{from} -> #{to}"
|
|
50
53
|
end
|
|
54
|
+
|
|
55
|
+
# How each deps: entry resolves on this host's selector list.
|
|
56
|
+
def render_deps
|
|
57
|
+
return '' if @manifest.deps.empty?
|
|
58
|
+
|
|
59
|
+
lines = ["\ndeps on this host (#{join_selectors}):"]
|
|
60
|
+
@manifest.deps.each do |dep|
|
|
61
|
+
selector = @selectors.find { |s| dep.hosts.key?(s) }
|
|
62
|
+
lines << if selector
|
|
63
|
+
" ⚙ #{dep.name.ljust(18)} #{selector} -> #{rule_note(dep.hosts[selector])}"
|
|
64
|
+
else
|
|
65
|
+
" ✗ #{dep.name.ljust(18)} no rule for this host (described: #{dep.hosts.keys.join(', ')})"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
lines.join("\n")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def rule_note(rule)
|
|
72
|
+
rule['install'] || "(verify only: #{rule['verify']})"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def join_selectors
|
|
76
|
+
@selectors.join(' > ')
|
|
77
|
+
end
|
|
51
78
|
end
|
|
52
79
|
end
|
data/lib/crossbuild/platform.rb
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Crossbuild
|
|
4
|
-
# Host platform detection, normalized to the os/arch vocabulary
|
|
5
|
-
# uses: linux/amd64, darwin/arm64, windows/amd64.
|
|
4
|
+
# Host platform detection, normalized to the os/arch vocabulary the
|
|
5
|
+
# build: section uses: linux/amd64, darwin/arm64, windows/amd64.
|
|
6
6
|
module Platform
|
|
7
7
|
OS_BY_RUBY = {
|
|
8
8
|
linux: /linux/,
|
|
@@ -36,5 +36,38 @@ module Crossbuild
|
|
|
36
36
|
def self.display_arch(arch)
|
|
37
37
|
arch == :x86_64 ? 'amd64' : arch.to_s
|
|
38
38
|
end
|
|
39
|
+
|
|
40
|
+
# Distro id from /etc/os-release ("ubuntu", "fedora", ...), nil off Linux.
|
|
41
|
+
def self.distro
|
|
42
|
+
return nil unless os == :linux
|
|
43
|
+
|
|
44
|
+
facts = os_release
|
|
45
|
+
facts['ID']
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Ordered host selectors for the deps: rules of the build: section — distro id,
|
|
49
|
+
# then ID_LIKE tokens (Linux only), then the os, then "*" as the final
|
|
50
|
+
# wildcard. The first selector with a matching rule wins.
|
|
51
|
+
def self.selectors
|
|
52
|
+
sel = []
|
|
53
|
+
if os == :linux
|
|
54
|
+
facts = os_release
|
|
55
|
+
sel << facts['ID'] if facts['ID']
|
|
56
|
+
sel.concat(facts['ID_LIKE'].to_s.split)
|
|
57
|
+
end
|
|
58
|
+
sel << os.to_s << '*'
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Parses /etc/os-release into a Hash (ID, ID_LIKE, ...); {} when absent.
|
|
62
|
+
def self.os_release(path: '/etc/os-release')
|
|
63
|
+
File.read(path).each_line.with_object({}) do |line, facts|
|
|
64
|
+
key, value = line.split('=', 2)
|
|
65
|
+
next if value.nil?
|
|
66
|
+
|
|
67
|
+
facts[key.strip] = value.strip.delete_prefix('"').delete_suffix('"')
|
|
68
|
+
end
|
|
69
|
+
rescue Errno::ENOENT
|
|
70
|
+
{}
|
|
71
|
+
end
|
|
39
72
|
end
|
|
40
73
|
end
|
data/lib/crossbuild/runner.rb
CHANGED
|
@@ -4,7 +4,7 @@ module Crossbuild
|
|
|
4
4
|
# Runs the shell steps of a matrix entry from the project root. Commands may
|
|
5
5
|
# reference build facts via {{version}}, {{name}}, {{platform}}, {{os}},
|
|
6
6
|
# {{arch}} and {{id}} placeholders — expanded by the gem itself so the same
|
|
7
|
-
#
|
|
7
|
+
# config works under POSIX shells and cmd.exe. The same facts are also
|
|
8
8
|
# exported as CROSSBUILD_* environment variables.
|
|
9
9
|
class Runner
|
|
10
10
|
class BuildError < Error; end
|
data/lib/crossbuild/version.rb
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Crossbuild
|
|
4
|
-
# Computes the build version from the `version:`
|
|
4
|
+
# Computes the build version from the top-level `version:` of
|
|
5
|
+
# crosspack.yml.
|
|
5
6
|
# Supported specs:
|
|
6
7
|
# calver — YYYY.MM.DD-<seconds since local midnight>, e.g. 2026.08.31-33837
|
|
7
8
|
# git-tag — latest `git describe --tags --abbrev=0`, fallback 0.0.0-dev
|
|
@@ -43,7 +44,7 @@ module Crossbuild
|
|
|
43
44
|
|
|
44
45
|
def env_version(var)
|
|
45
46
|
value = ENV[var].to_s.strip
|
|
46
|
-
raise Error, "version env var #{var} is not set (version: env:#{var}
|
|
47
|
+
raise Error, "version env var #{var} is not set (version: env:#{var} at the top level of crosspack.yml)" if value.empty?
|
|
47
48
|
|
|
48
49
|
value
|
|
49
50
|
end
|
data/lib/crossbuild.rb
CHANGED
|
@@ -11,17 +11,25 @@ require_relative 'crossbuild/platform'
|
|
|
11
11
|
require_relative 'crossbuild/version_scheme'
|
|
12
12
|
require_relative 'crossbuild/build_manifest'
|
|
13
13
|
require_relative 'crossbuild/runner'
|
|
14
|
+
require_relative 'crossbuild/dep_installer'
|
|
14
15
|
require_relative 'crossbuild/distributor'
|
|
15
16
|
require_relative 'crossbuild/matrix'
|
|
16
17
|
require_relative 'crossbuild/builder'
|
|
17
18
|
|
|
18
19
|
module Crossbuild
|
|
19
|
-
# Convenience wrapper: Crossbuild.build('
|
|
20
|
-
# host-buildable matrix entry and returns Builder::Result.
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
20
|
+
# Convenience wrapper: Crossbuild.build('crosspack.yml') runs every
|
|
21
|
+
# host-buildable matrix entry and returns Builder::Result. target: builds
|
|
22
|
+
# the entry distributing to that target only; deps: false skips the
|
|
23
|
+
# pre-build dependency check/install pass. The manifest argument may be a
|
|
24
|
+
# Config, a path to crosspack.yml or a ready BuildManifest.
|
|
25
|
+
def self.build(manifest, entry_id: nil, target: nil, root: Dir.pwd, output_base: nil, version: nil,
|
|
26
|
+
deps: true, host_os: Platform.os, host_arch: Platform.arch)
|
|
27
|
+
m = case manifest
|
|
28
|
+
when BuildManifest then manifest
|
|
29
|
+
when Crosspack::Config then manifest.build_manifest
|
|
30
|
+
else Crosspack::Config.coerce(manifest, root: root).build_manifest
|
|
31
|
+
end
|
|
32
|
+
Builder.new(m, root: root, output_base: output_base, version: version, deps: deps,
|
|
33
|
+
host_os: host_os, host_arch: host_arch).run(entry_id: entry_id, target: target)
|
|
26
34
|
end
|
|
27
35
|
end
|
|
@@ -42,7 +42,7 @@ module Crosspack
|
|
|
42
42
|
|
|
43
43
|
<<~PKGBUILD
|
|
44
44
|
# Maintainer: #{maintainer}
|
|
45
|
-
# Generated by crosspack — edit deps.
|
|
45
|
+
# Generated by crosspack — edit the deps: section of crosspack.yml, not this file.
|
|
46
46
|
pkgname=#{name}
|
|
47
47
|
pkgver=#{version}
|
|
48
48
|
pkgrel=#{release}
|