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.
@@ -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.yaml + human explanation.
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 < StandardError; end
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.yaml schema is invalid (#{@errors.size} errors):"]
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}: schema is valid (#{@entries.size} #{noun}: #{@entries.map(&:id).join(', ')})."
119
+ lines << "#{path}: the build: section is valid (#{@entries.size} #{noun}: #{@entries.map(&:id).join(', ')})."
118
120
  else
119
- lines << "#{path}: build.yaml schema is invalid (#{@errors.size} errors):"
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)', 'file must be a non-empty mapping with at least a name and a matrix')
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: the application name, e.g. myapp')
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?
@@ -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
- def run(entry_id: nil)
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).distribute(entry, host_arch: @host_arch)
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
- def distribute(entry, host_arch:)
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.each do |raw|
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
@@ -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.yaml matrix — host: #{host}"]
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
@@ -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 build.yaml
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
@@ -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
- # build.yaml works under POSIX shells and cmd.exe. The same facts are also
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Crossbuild
4
- VERSION = '0.1.0'
4
+ VERSION = '0.5.0'
5
5
  end
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Crossbuild
4
- # Computes the build version from the `version:` field of build.yaml.
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} in build.yaml)" if value.empty?
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('build.yaml') runs every
20
- # host-buildable matrix entry and returns Builder::Result.
21
- def self.build(manifest, entry_id: nil, root: Dir.pwd, output_base: nil, version: nil,
22
- host_os: Platform.os, host_arch: Platform.arch)
23
- m = manifest.is_a?(BuildManifest) ? manifest : BuildManifest.load(manifest)
24
- Builder.new(m, root: root, output_base: output_base, version: version,
25
- host_os: host_os, host_arch: host_arch).run(entry_id: entry_id)
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.yaml, not this file.
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}