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,374 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
|
|
5
|
+
module Crossbuild
|
|
6
|
+
# A single schema problem: full path into build.yaml + human explanation.
|
|
7
|
+
class Issue
|
|
8
|
+
attr_reader :path, :message
|
|
9
|
+
|
|
10
|
+
def initialize(path, message)
|
|
11
|
+
@path = path
|
|
12
|
+
@message = message
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def to_s
|
|
16
|
+
"#{@path}: #{@message}"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class InvalidManifestError < StandardError; end
|
|
21
|
+
|
|
22
|
+
class BuildManifest
|
|
23
|
+
OS_LIST = %w[linux darwin windows].freeze
|
|
24
|
+
ARCH_LIST = %w[amd64 x86_64 arm64 aarch64 any].freeze
|
|
25
|
+
ARTIFACT_MODES = %w[symlink copy].freeze
|
|
26
|
+
VERSION_SCHEMES = %w[calver git-tag].freeze
|
|
27
|
+
TOP_LEVEL_KEYS = %w[name version output matrix].freeze
|
|
28
|
+
ENTRY_KEYS = %w[id build env steps artifacts].freeze
|
|
29
|
+
ARTIFACTS_KEYS = %w[from include to mode].freeze
|
|
30
|
+
BUILD_RE = /\A(linux|darwin|windows)\/(#{ARCH_LIST.join('|')})\z/.freeze
|
|
31
|
+
NAME_RE = /\A[a-z0-9][a-z0-9+._-]*\z/i.freeze
|
|
32
|
+
|
|
33
|
+
# One matrix entry: where it builds, what it runs, where artifacts go.
|
|
34
|
+
class Entry
|
|
35
|
+
attr_reader :id, :os, :arch, :env, :steps, :artifacts, :index
|
|
36
|
+
|
|
37
|
+
def initialize(id:, os:, arch:, env:, steps:, artifacts:, index:)
|
|
38
|
+
@id = id
|
|
39
|
+
@os = os.to_sym
|
|
40
|
+
@arch = arch
|
|
41
|
+
@env = env
|
|
42
|
+
@steps = steps
|
|
43
|
+
@artifacts = artifacts
|
|
44
|
+
@index = index
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# "linux/amd64" display form (arch spelled wails-style).
|
|
48
|
+
def platform
|
|
49
|
+
"#{@os}/#{Platform.display_arch(@arch == :any ? :x86_64 : @arch)}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def buildable_on?(host_os, host_arch)
|
|
53
|
+
@os == host_os && (@arch == :any || @arch == host_arch)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Where artifacts come from and which builds/ directories they fan out to.
|
|
58
|
+
class Artifacts
|
|
59
|
+
attr_reader :from, :include, :to, :mode
|
|
60
|
+
|
|
61
|
+
def initialize(from:, include:, to:, mode:)
|
|
62
|
+
@from = from
|
|
63
|
+
@include = include
|
|
64
|
+
@to = to
|
|
65
|
+
@mode = mode
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
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
|
+
|
|
86
|
+
def initialize(path, raw)
|
|
87
|
+
@path = path
|
|
88
|
+
@raw = raw
|
|
89
|
+
@name = nil
|
|
90
|
+
@version = nil
|
|
91
|
+
@output = 'builds'
|
|
92
|
+
@entries = []
|
|
93
|
+
@errors = []
|
|
94
|
+
@warnings = []
|
|
95
|
+
validate
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def valid?
|
|
99
|
+
@errors.empty?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Raises with the full, formatted list of problems.
|
|
103
|
+
def validate!
|
|
104
|
+
return self if valid?
|
|
105
|
+
|
|
106
|
+
lines = ["#{path}: build.yaml schema is invalid (#{@errors.size} errors):"]
|
|
107
|
+
@errors.each { |e| lines << " ✗ #{e}" }
|
|
108
|
+
lines << 'Build aborted: fix the listed nodes and retry.'
|
|
109
|
+
raise InvalidManifestError, lines.join("\n")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Report used by CLI/rake abort: errors (if any) plus warnings.
|
|
113
|
+
def error_report
|
|
114
|
+
lines = []
|
|
115
|
+
if valid?
|
|
116
|
+
noun = @entries.size == 1 ? 'entry' : 'entries'
|
|
117
|
+
lines << "#{path}: schema is valid (#{@entries.size} #{noun}: #{@entries.map(&:id).join(', ')})."
|
|
118
|
+
else
|
|
119
|
+
lines << "#{path}: build.yaml schema is invalid (#{@errors.size} errors):"
|
|
120
|
+
@errors.each { |e| lines << " ✗ #{e}" }
|
|
121
|
+
end
|
|
122
|
+
@warnings.each { |w| lines << " ⚠ #{w}" }
|
|
123
|
+
lines.join("\n")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Entries that may run on the given host; empty when nothing matches.
|
|
127
|
+
def buildable_entries(host_os, host_arch)
|
|
128
|
+
@entries.select { |e| e.buildable_on?(host_os, host_arch) }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def find_entry(id)
|
|
132
|
+
@entries.find { |e| e.id == id }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
def validate
|
|
138
|
+
unless @raw.is_a?(Hash) && !@raw.empty?
|
|
139
|
+
@errors << Issue.new('(root)', 'file must be a non-empty mapping with at least a name and a matrix')
|
|
140
|
+
return
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
check_unknown_keys('(root)', @raw.keys, TOP_LEVEL_KEYS)
|
|
144
|
+
validate_name
|
|
145
|
+
validate_version
|
|
146
|
+
validate_output
|
|
147
|
+
validate_matrix
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def validate_name
|
|
151
|
+
@name = @raw['name'].to_s
|
|
152
|
+
if @raw['name'].nil? || @name.empty?
|
|
153
|
+
@errors << Issue.new('name', 'is required: the application name, e.g. myapp')
|
|
154
|
+
elsif @name !~ NAME_RE
|
|
155
|
+
@errors << Issue.new('name', "invalid name #{@name.inspect} (letters, digits, \"+\", \"_\", \"-\", \".\")")
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def validate_version
|
|
160
|
+
spec = @raw['version']
|
|
161
|
+
if spec.nil?
|
|
162
|
+
@warnings << Issue.new('version', 'not set — defaulting to calver (YYYY.MM.DD-<secs>)')
|
|
163
|
+
@version = 'calver'
|
|
164
|
+
return
|
|
165
|
+
end
|
|
166
|
+
unless spec.is_a?(String) && !spec.strip.empty?
|
|
167
|
+
@errors << Issue.new('version', "must be a string scheme: #{VERSION_SCHEMES.join(' / ')}, env:VAR or a literal")
|
|
168
|
+
return
|
|
169
|
+
end
|
|
170
|
+
@version = spec.strip
|
|
171
|
+
return unless @version.start_with?('env:') && @version.split(':', 2)[1].to_s.strip.empty?
|
|
172
|
+
|
|
173
|
+
@errors << Issue.new('version', 'env: requires a variable name, e.g. env:APP_VERSION')
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def validate_output
|
|
177
|
+
out = @raw['output']
|
|
178
|
+
return if out.nil?
|
|
179
|
+
|
|
180
|
+
if out.is_a?(String) && !out.strip.empty?
|
|
181
|
+
@output = out.strip
|
|
182
|
+
else
|
|
183
|
+
@errors << Issue.new('output', "must be a non-empty directory name (default: builds), got #{out.inspect}")
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def validate_matrix
|
|
188
|
+
matrix = @raw['matrix']
|
|
189
|
+
unless matrix.is_a?(Array) && !matrix.empty?
|
|
190
|
+
@errors << Issue.new(
|
|
191
|
+
'matrix',
|
|
192
|
+
"must be a non-empty list of entries; expected:\n" \
|
|
193
|
+
" matrix:\n" \
|
|
194
|
+
" - build: linux/amd64\n" \
|
|
195
|
+
" steps: [wails build -platform linux/amd64]\n" \
|
|
196
|
+
" artifacts: { from: build/bin, to: [debian-12, ubuntu-24.04] }"
|
|
197
|
+
)
|
|
198
|
+
return
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
matrix.each_with_index { |entry, i| validate_entry(entry, i) }
|
|
202
|
+
check_duplicate_ids
|
|
203
|
+
check_duplicate_targets
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def validate_entry(entry, index)
|
|
207
|
+
path = "matrix[#{index}]"
|
|
208
|
+
unless entry.is_a?(Hash)
|
|
209
|
+
@errors << Issue.new(path, 'must be a mapping with build/steps/artifacts keys')
|
|
210
|
+
return
|
|
211
|
+
end
|
|
212
|
+
check_unknown_keys(path, entry.keys, ENTRY_KEYS)
|
|
213
|
+
|
|
214
|
+
build = parse_build(entry['build'], path)
|
|
215
|
+
return unless build
|
|
216
|
+
|
|
217
|
+
os, arch = build
|
|
218
|
+
steps = validate_steps(entry['steps'], path)
|
|
219
|
+
env = validate_env(entry['env'], path)
|
|
220
|
+
artifacts = validate_artifacts(entry['artifacts'], path, arch)
|
|
221
|
+
id = entry['id'].nil? ? "#{os}/#{Platform.display_arch(arch == :any ? :x86_64 : arch)}" : entry['id'].to_s
|
|
222
|
+
if entry['id'] && id.empty?
|
|
223
|
+
@errors << Issue.new("#{path}.id", 'must be a non-empty string')
|
|
224
|
+
return
|
|
225
|
+
end
|
|
226
|
+
if entry['steps'].nil? && artifacts.nil?
|
|
227
|
+
@errors << Issue.new(path, 'entry has no steps and no artifacts — it would do nothing; add steps or an artifacts section')
|
|
228
|
+
return
|
|
229
|
+
end
|
|
230
|
+
@warnings << Issue.new(path, 'entry has no steps — existing artifacts only will be distributed') if entry['steps'].nil? && artifacts
|
|
231
|
+
@entries << Entry.new(id: id, os: os, arch: arch, env: env, steps: steps,
|
|
232
|
+
artifacts: artifacts, index: index)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def parse_build(build, path)
|
|
236
|
+
if build.nil?
|
|
237
|
+
@errors << Issue.new("#{path}.build", 'is required: the platform this entry builds on, e.g. linux/amd64')
|
|
238
|
+
return nil
|
|
239
|
+
end
|
|
240
|
+
unless build.is_a?(String) && (m = build.strip.match(BUILD_RE))
|
|
241
|
+
suggestion = suggest(build.to_s, OS_LIST.map { |os| "#{os}/amd64" })
|
|
242
|
+
hint = suggestion ? " (did you mean #{suggestion.inspect}?)" : ''
|
|
243
|
+
@errors << Issue.new(
|
|
244
|
+
"#{path}.build",
|
|
245
|
+
"invalid build platform #{build.inspect}; expected os/arch#{hint} " \
|
|
246
|
+
"(os: #{OS_LIST.join(', ')}; arch: #{ARCH_LIST.join(', ')})"
|
|
247
|
+
)
|
|
248
|
+
return nil
|
|
249
|
+
end
|
|
250
|
+
os = m[1]
|
|
251
|
+
arch = m[2] == 'any' ? :any : Crosspack::Target.normalize_arch(m[2])
|
|
252
|
+
[os, arch]
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def validate_steps(steps, path)
|
|
256
|
+
return [] if steps.nil?
|
|
257
|
+
|
|
258
|
+
unless steps.is_a?(Array) && !steps.empty? && steps.all? { |s| s.is_a?(String) && !s.strip.empty? }
|
|
259
|
+
@errors << Issue.new("#{path}.steps", 'must be a non-empty list of shell command strings')
|
|
260
|
+
return []
|
|
261
|
+
end
|
|
262
|
+
steps
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def validate_env(env, path)
|
|
266
|
+
return {} if env.nil?
|
|
267
|
+
|
|
268
|
+
unless env.is_a?(Hash) && env.all? { |k, v| k.is_a?(String) && !k.empty? && v.is_a?(String) }
|
|
269
|
+
@errors << Issue.new("#{path}.env", 'must be a mapping of VAR -> string (quote values that look like numbers)')
|
|
270
|
+
return {}
|
|
271
|
+
end
|
|
272
|
+
env
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def validate_artifacts(artifacts, path, arch)
|
|
276
|
+
return nil if artifacts.nil?
|
|
277
|
+
|
|
278
|
+
unless artifacts.is_a?(Hash)
|
|
279
|
+
@errors << Issue.new("#{path}.artifacts", 'must be a mapping with from/include/to/mode keys')
|
|
280
|
+
return nil
|
|
281
|
+
end
|
|
282
|
+
check_unknown_keys("#{path}.artifacts", artifacts.keys, ARTIFACTS_KEYS)
|
|
283
|
+
|
|
284
|
+
from = artifacts['from']
|
|
285
|
+
if from.nil? || !from.is_a?(String) || from.strip.empty?
|
|
286
|
+
@errors << Issue.new("#{path}.artifacts.from", 'is required: directory the build produces, e.g. build/bin')
|
|
287
|
+
return nil
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
include = artifacts['include']
|
|
291
|
+
if include.nil?
|
|
292
|
+
include = ['*']
|
|
293
|
+
elsif !include.is_a?(Array) || include.empty? || !include.all? { |p| p.is_a?(String) && !p.strip.empty? }
|
|
294
|
+
@errors << Issue.new("#{path}.artifacts.include", 'must be a non-empty list of glob patterns, e.g. [myapp, "*.onnx"]')
|
|
295
|
+
include = []
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
to = artifacts['to']
|
|
299
|
+
unless to.is_a?(Array) && !to.empty? && to.all? { |t| t.is_a?(String) && !t.strip.empty? }
|
|
300
|
+
@errors << Issue.new("#{path}.artifacts.to", 'must be a non-empty list of crosspack targets, e.g. [debian-12, ubuntu-24.04]')
|
|
301
|
+
to = []
|
|
302
|
+
end
|
|
303
|
+
to.each { |raw| validate_target(raw, "#{path}.artifacts.to") }
|
|
304
|
+
|
|
305
|
+
mode = artifacts['mode'] || 'symlink'
|
|
306
|
+
unless ARTIFACT_MODES.include?(mode)
|
|
307
|
+
@errors << Issue.new("#{path}.artifacts.mode", "unknown mode #{mode.inspect}; allowed: #{ARTIFACT_MODES.join(' / ')}")
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
Artifacts.new(from: from.strip, include: include, to: to, mode: mode.to_sym)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def validate_target(raw, path)
|
|
314
|
+
Crosspack::Target.parse(raw)
|
|
315
|
+
rescue Crosspack::Target::Error => e
|
|
316
|
+
@errors << Issue.new("#{path}: #{raw.inspect}", e.message)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def check_duplicate_ids
|
|
320
|
+
seen = Hash.new(0)
|
|
321
|
+
@entries.each { |e| seen[e.id] += 1 }
|
|
322
|
+
seen.each do |id, count|
|
|
323
|
+
@errors << Issue.new('matrix', "duplicate entry id #{id.inspect} — give one of them an explicit unique id:") if count > 1
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def check_duplicate_targets
|
|
328
|
+
owner = {}
|
|
329
|
+
@entries.each do |e|
|
|
330
|
+
next unless e.artifacts
|
|
331
|
+
|
|
332
|
+
e.artifacts.to.each do |t|
|
|
333
|
+
if owner[t]
|
|
334
|
+
@warnings << Issue.new('matrix', "target #{t.inspect} is distributed by both #{owner[t].inspect} and #{e.id.inspect} — the last entry wins")
|
|
335
|
+
else
|
|
336
|
+
owner[t] = e.id
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def check_unknown_keys(path, keys, allowed)
|
|
343
|
+
keys.map(&:to_s).each do |key|
|
|
344
|
+
next if allowed.include?(key)
|
|
345
|
+
|
|
346
|
+
suggestion = suggest(key, allowed)
|
|
347
|
+
hint = suggestion ? " (did you mean #{suggestion.inspect}?)" : ''
|
|
348
|
+
@errors << Issue.new("#{path}.#{key}", "unknown key#{hint}; allowed: #{allowed.join(', ')}")
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def suggest(word, candidates)
|
|
353
|
+
best = candidates.min_by { |c| levenshtein(word.to_s, c) }
|
|
354
|
+
levenshtein(word.to_s, best) <= 2 ? best : nil
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def levenshtein(a, b)
|
|
358
|
+
prev = (0..b.length).to_a
|
|
359
|
+
a.chars.each_with_index do |ca, i|
|
|
360
|
+
curr = [i + 1]
|
|
361
|
+
b.chars.each_with_index do |cb, j|
|
|
362
|
+
cost = ca == cb ? 0 : 1
|
|
363
|
+
curr[j + 1] = [
|
|
364
|
+
curr[j] + 1, # insertion
|
|
365
|
+
prev[j + 1] + 1, # deletion
|
|
366
|
+
prev[j] + cost # substitution
|
|
367
|
+
].min
|
|
368
|
+
end
|
|
369
|
+
prev = curr
|
|
370
|
+
end
|
|
371
|
+
prev[b.length]
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Crossbuild
|
|
4
|
+
# Runs one or all host-buildable matrix entries: computes the version, runs
|
|
5
|
+
# the steps, distributes the artifacts into the builds/ tree.
|
|
6
|
+
class Builder
|
|
7
|
+
Result = Struct.new(:version, :entries, keyword_init: true)
|
|
8
|
+
|
|
9
|
+
attr_reader :manifest
|
|
10
|
+
|
|
11
|
+
def initialize(manifest, root: Dir.pwd, output_base: nil, version: nil,
|
|
12
|
+
host_os: Platform.os, host_arch: Platform.arch)
|
|
13
|
+
@manifest = manifest
|
|
14
|
+
@root = root
|
|
15
|
+
@output_base = output_base || manifest.output
|
|
16
|
+
@version_override = version
|
|
17
|
+
@host_os = host_os
|
|
18
|
+
@host_arch = host_arch
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# entry_id: nil — every entry buildable on this host; otherwise the exact
|
|
22
|
+
# entry id (which must exist, but may target another host — the user
|
|
23
|
+
# asked for it explicitly).
|
|
24
|
+
def run(entry_id: nil)
|
|
25
|
+
manifest.validate!
|
|
26
|
+
version = @version_override || VersionScheme.new(manifest.version).compute(root: @root)
|
|
27
|
+
entries = select_entries(entry_id)
|
|
28
|
+
|
|
29
|
+
entries.each do |entry|
|
|
30
|
+
puts "\n==> #{manifest.name} [#{entry.id}] version #{version}"
|
|
31
|
+
run_entry(entry, version)
|
|
32
|
+
end
|
|
33
|
+
Result.new(version: version, entries: entries)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def select_entries(entry_id)
|
|
39
|
+
if entry_id
|
|
40
|
+
entry = manifest.find_entry(entry_id)
|
|
41
|
+
raise Error, "unknown matrix entry #{entry_id.inspect}; available: #{manifest.entries.map(&:id).join(', ')}" unless entry
|
|
42
|
+
|
|
43
|
+
[entry]
|
|
44
|
+
else
|
|
45
|
+
buildable = manifest.buildable_entries(@host_os, @host_arch)
|
|
46
|
+
raise Error, "nothing to build on #{@host_os}/#{Platform.display_arch(@host_arch)} " \
|
|
47
|
+
"(matrix entries: #{manifest.entries.map(&:id).join(', ')})" if buildable.empty?
|
|
48
|
+
|
|
49
|
+
buildable
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def run_entry(entry, version)
|
|
54
|
+
arch = entry.arch == :any ? @host_arch : entry.arch
|
|
55
|
+
vars = {
|
|
56
|
+
name: manifest.name,
|
|
57
|
+
version: version,
|
|
58
|
+
platform: "#{entry.os}/#{Platform.display_arch(arch)}",
|
|
59
|
+
os: entry.os.to_s,
|
|
60
|
+
arch: Platform.display_arch(arch),
|
|
61
|
+
id: entry.id
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
Runner.new(root: @root, vars: vars, env: entry.env).run(entry.steps) unless entry.steps.empty?
|
|
65
|
+
|
|
66
|
+
dirs = Distributor.new(root: @root, output_base: @output_base).distribute(entry, host_arch: @host_arch)
|
|
67
|
+
return if dirs.empty?
|
|
68
|
+
|
|
69
|
+
dirs.each { |d| puts "🌳 #{d.sub("#{@root}/", '')}" }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
5
|
+
module Crossbuild
|
|
6
|
+
# Fans the artifacts a matrix entry produced out into the per-target builds
|
|
7
|
+
# tree — builds/<family>[/<version>]/<arch>/ — laid out exactly like the
|
|
8
|
+
# crosspacks/ tree crosspack writes, so crosspack can pack straight from it.
|
|
9
|
+
# Items are symlinked by default (packers copy through symlinks); copy mode
|
|
10
|
+
# is available for trees that get archived or shipped as-is.
|
|
11
|
+
class Distributor
|
|
12
|
+
attr_reader :placed_dirs
|
|
13
|
+
|
|
14
|
+
def initialize(root:, output_base:)
|
|
15
|
+
@root = root
|
|
16
|
+
@output_base = output_base
|
|
17
|
+
@placed_dirs = []
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Returns the list of builds/ directories that received artifacts.
|
|
21
|
+
def distribute(entry, host_arch:)
|
|
22
|
+
spec = entry.artifacts
|
|
23
|
+
return [] unless spec
|
|
24
|
+
|
|
25
|
+
from_dir = File.expand_path(spec.from, @root)
|
|
26
|
+
unless File.directory?(from_dir)
|
|
27
|
+
raise Error, "artifacts.from directory not found: #{spec.from} (#{from_dir}) — did the steps run?"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
items = select_items(from_dir, spec.include)
|
|
31
|
+
arch = entry.arch == :any ? host_arch : entry.arch
|
|
32
|
+
|
|
33
|
+
spec.to.each do |raw|
|
|
34
|
+
target = Crosspack::Target.parse(raw, arch: arch)
|
|
35
|
+
dst_dir = target.output_dir(File.expand_path(@output_base, @root))
|
|
36
|
+
FileUtils.mkdir_p(dst_dir)
|
|
37
|
+
items.each { |item| place(item, File.join(dst_dir, File.basename(item)), spec.mode) }
|
|
38
|
+
@placed_dirs << dst_dir
|
|
39
|
+
end
|
|
40
|
+
@placed_dirs.uniq
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# Resolve include globs inside from_dir; a missing selection is an error,
|
|
46
|
+
# not a silently empty build.
|
|
47
|
+
def select_items(from_dir, patterns)
|
|
48
|
+
items = []
|
|
49
|
+
patterns.each do |pattern|
|
|
50
|
+
matches = Dir.glob(File.join(from_dir, pattern.to_s))
|
|
51
|
+
.reject { |p| p.end_with?('.') || p.end_with?('..') }
|
|
52
|
+
.select { |p| File.exist?(p) }
|
|
53
|
+
if matches.empty?
|
|
54
|
+
raise Error, "artifacts pattern #{pattern.inspect} matched nothing inside #{spec_from_display(from_dir)}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
items.concat(matches)
|
|
58
|
+
end
|
|
59
|
+
items.uniq.sort
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def spec_from_display(from_dir)
|
|
63
|
+
from_dir.sub("#{@root}/", '')
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def place(src, dst, mode)
|
|
67
|
+
if mode == :copy
|
|
68
|
+
FileUtils.rm_rf(dst)
|
|
69
|
+
FileUtils.cp_r(src, dst)
|
|
70
|
+
else
|
|
71
|
+
FileUtils.rm_f(dst)
|
|
72
|
+
FileUtils.ln_sf(src, dst) # absolute target, like hvoice's link_builds_tree
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Crossbuild
|
|
4
|
+
# Human-readable build matrix: which entries exist, which can run on this
|
|
5
|
+
# host, what they run and where their artifacts land.
|
|
6
|
+
class Matrix
|
|
7
|
+
def initialize(manifest, host_os: Platform.os, host_arch: Platform.arch)
|
|
8
|
+
@manifest = manifest
|
|
9
|
+
@host_os = host_os
|
|
10
|
+
@host_arch = host_arch
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def render
|
|
14
|
+
host = "#{@host_os}/#{Platform.display_arch(@host_arch)}"
|
|
15
|
+
lines = ["build.yaml matrix — host: #{host}"]
|
|
16
|
+
@manifest.entries.each { |e| lines << render_entry(e) }
|
|
17
|
+
buildable = @manifest.buildable_entries(@host_os, @host_arch)
|
|
18
|
+
lines << if buildable.empty?
|
|
19
|
+
"\nNothing can build on this host. crossbuild build runs only matching entries."
|
|
20
|
+
else
|
|
21
|
+
"\n#{buildable.size} of #{@manifest.entries.size} entries build here: #{buildable.map(&:id).join(', ')}"
|
|
22
|
+
end
|
|
23
|
+
lines.join("\n")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def render_entry(entry)
|
|
29
|
+
if entry.buildable_on?(@host_os, @host_arch)
|
|
30
|
+
marker = '✓'
|
|
31
|
+
note = 'buildable here'
|
|
32
|
+
else
|
|
33
|
+
marker = '·'
|
|
34
|
+
note = "needs #{entry.platform}"
|
|
35
|
+
end
|
|
36
|
+
parts = [" #{marker} #{entry.id.ljust(18)} #{note}"]
|
|
37
|
+
parts << if entry.steps.empty?
|
|
38
|
+
'no steps (distribute only)'
|
|
39
|
+
else
|
|
40
|
+
"#{entry.steps.size} step#{'s' unless entry.steps.size == 1}"
|
|
41
|
+
end
|
|
42
|
+
parts << artifact_note(entry) if entry.artifacts
|
|
43
|
+
parts.join(' ')
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def artifact_note(entry)
|
|
47
|
+
from = entry.artifacts.from
|
|
48
|
+
to = entry.artifacts.to.join(', ')
|
|
49
|
+
"#{from} -> #{to}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Crossbuild
|
|
4
|
+
# Host platform detection, normalized to the os/arch vocabulary build.yaml
|
|
5
|
+
# uses: linux/amd64, darwin/arm64, windows/amd64.
|
|
6
|
+
module Platform
|
|
7
|
+
OS_BY_RUBY = {
|
|
8
|
+
linux: /linux/,
|
|
9
|
+
darwin: /darwin/,
|
|
10
|
+
windows: /mingw|mswin|cygwin/i
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
class Error < StandardError; end
|
|
14
|
+
|
|
15
|
+
def self.os
|
|
16
|
+
OS_BY_RUBY.each { |os, re| return os if RUBY_PLATFORM =~ re }
|
|
17
|
+
raise Error, "unknown host OS from RUBY_PLATFORM=#{RUBY_PLATFORM.inspect}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# :x86_64 or :arm64 — the same symbols Crosspack::Target uses.
|
|
21
|
+
def self.arch
|
|
22
|
+
cpu = RbConfig::CONFIG['host_cpu'].to_s
|
|
23
|
+
return :arm64 if cpu.match?(/arm64|aarch64/i)
|
|
24
|
+
return :x86_64 if cpu.match?(/x86_64|amd64|x64/i)
|
|
25
|
+
|
|
26
|
+
raise Error, "unknown host CPU from host_cpu=#{cpu.inspect}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# "linux/amd64" — the display form used in matrix listings; arch is
|
|
30
|
+
# spelled deb-style (amd64/arm64) to match wails -platform strings.
|
|
31
|
+
def self.to_s
|
|
32
|
+
"#{os}/#{display_arch(arch)}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# x86_64 -> "amd64", arm64 -> "arm64" (wails/darwin spelling).
|
|
36
|
+
def self.display_arch(arch)
|
|
37
|
+
arch == :x86_64 ? 'amd64' : arch.to_s
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Crossbuild
|
|
4
|
+
# Runs the shell steps of a matrix entry from the project root. Commands may
|
|
5
|
+
# reference build facts via {{version}}, {{name}}, {{platform}}, {{os}},
|
|
6
|
+
# {{arch}} and {{id}} placeholders — expanded by the gem itself so the same
|
|
7
|
+
# build.yaml works under POSIX shells and cmd.exe. The same facts are also
|
|
8
|
+
# exported as CROSSBUILD_* environment variables.
|
|
9
|
+
class Runner
|
|
10
|
+
class BuildError < Error; end
|
|
11
|
+
|
|
12
|
+
PLACEHOLDER_RE = /\{\{\s*([a-z_]+)\s*\}\}/i.freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :vars
|
|
15
|
+
|
|
16
|
+
def initialize(root:, vars: {}, env: {})
|
|
17
|
+
@root = root
|
|
18
|
+
@vars = vars
|
|
19
|
+
@env = env
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def run(steps)
|
|
23
|
+
steps.each { |step| run_step(step) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def interpolate(command)
|
|
27
|
+
command.gsub(PLACEHOLDER_RE) do
|
|
28
|
+
key = Regexp.last_match(1).downcase.to_sym
|
|
29
|
+
value = @vars[key]
|
|
30
|
+
raise BuildError, "unknown placeholder {{#{key}}} in step: #{command}" if value.nil?
|
|
31
|
+
|
|
32
|
+
value.to_s
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def run_step(step)
|
|
39
|
+
command = interpolate(step)
|
|
40
|
+
puts "▶ #{command}"
|
|
41
|
+
env = process_env
|
|
42
|
+
success = system(env, command, chdir: @root)
|
|
43
|
+
return if success
|
|
44
|
+
|
|
45
|
+
status = $?.nil? ? 'unknown status' : "exit #{$?.exitstatus || $?.to_i}"
|
|
46
|
+
raise BuildError, "step failed (#{status}): #{command}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def process_env
|
|
50
|
+
crossbuild_env = {
|
|
51
|
+
'CROSSBUILD_NAME' => @vars[:name].to_s,
|
|
52
|
+
'CROSSBUILD_VERSION' => @vars[:version].to_s,
|
|
53
|
+
'CROSSBUILD_PLATFORM' => @vars[:platform].to_s,
|
|
54
|
+
'CROSSBUILD_OS' => @vars[:os].to_s,
|
|
55
|
+
'CROSSBUILD_ARCH' => @vars[:arch].to_s,
|
|
56
|
+
'CROSSBUILD_ID' => @vars[:id].to_s
|
|
57
|
+
}
|
|
58
|
+
# Manifest env wins over CROSSBUILD_* bookkeeping vars, both over inherit.
|
|
59
|
+
ENV.to_h.merge(crossbuild_env).merge(@env)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|