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,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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crossbuild
4
+ VERSION = '0.1.0'
5
+ end