crosspack 0.2.0 → 0.4.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.
@@ -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,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crossbuild
4
+ # Verifies and installs the build dependencies declared in the deps:
5
+ # section of build.yaml. Host selectors (distro id, ID_LIKE tokens, os,
6
+ # "*" — see Platform.selectors) are matched against each dependency's
7
+ # hosts: rules; the first hit wins. Every rule carries its own commands:
8
+ # `verify` decides presence (exit 0 = installed; default: PATH lookup of
9
+ # the dependency name), `install` says how to install it on that host.
10
+ class DepInstaller
11
+ class DepError < Error; end
12
+
13
+ # Outcome of one dependency: resolved selector + install command, and
14
+ # whether the dependency is present after ensure_all/check_all ran.
15
+ Status = Struct.new(:dep, :selector, :command, :installed, keyword_init: true)
16
+
17
+ attr_reader :statuses
18
+
19
+ def initialize(manifest, root:, selectors: Platform.selectors, os: Platform.os)
20
+ @manifest = manifest
21
+ @root = root
22
+ @selectors = selectors
23
+ @os = os
24
+ @runner = Runner.new(root: root, vars: {
25
+ name: manifest.name,
26
+ version: manifest.version,
27
+ os: os.to_s,
28
+ arch: Platform.display_arch(Platform.arch),
29
+ platform: Platform.to_s,
30
+ id: Platform.to_s
31
+ })
32
+ end
33
+
34
+ # Verify every dependency; install the missing ones via their host rule
35
+ # and re-verify. Raises DepError when something stays missing.
36
+ def ensure_all
37
+ @statuses = @manifest.deps.map { |dep| ensure_dep(dep) }
38
+ @statuses
39
+ end
40
+
41
+ # Report-only pass for `crosspack deps <target> --check`: never installs.
42
+ def check_all
43
+ @statuses = @manifest.deps.map do |dep|
44
+ selector, rule = resolve(dep)
45
+ Status.new(dep: dep.name, selector: selector, command: display_command(rule),
46
+ installed: present?(dep, rule))
47
+ end
48
+ end
49
+
50
+ # One line per dependency for the CLI doctor output.
51
+ def report
52
+ return ' (no deps declared)' if @manifest.deps.empty?
53
+
54
+ (@statuses || check_all).map do |s|
55
+ format(' %-20s %-8s %-8s %s',
56
+ s.dep, s.installed ? 'ok' : 'MISSING', s.selector, s.command)
57
+ end.join("\n")
58
+ end
59
+
60
+ private
61
+
62
+ def ensure_dep(dep)
63
+ selector, rule = resolve(dep)
64
+ if present?(dep, rule)
65
+ return Status.new(dep: dep.name, selector: selector,
66
+ command: display_command(rule), installed: true)
67
+ end
68
+
69
+ install(dep, selector, rule)
70
+ unless present?(dep, rule)
71
+ detail = rule['verify'] ? "the verify `#{rule['verify']}` still fails" : "#{dep.name} is still not on PATH"
72
+ raise DepError,
73
+ "#{dep.name}: #{detail} after `#{rule['install']}`. " \
74
+ 'Fix the install command or the verify probe.'
75
+ end
76
+ Status.new(dep: dep.name, selector: selector, command: display_command(rule), installed: true)
77
+ end
78
+
79
+ def resolve(dep)
80
+ @selectors.each do |selector|
81
+ return [selector, dep.hosts[selector]] if dep.hosts.key?(selector)
82
+ end
83
+ raise DepError,
84
+ "#{dep.name}: no rule for this host (selectors tried: #{@selectors.join(', ')}; " \
85
+ "described: #{dep.hosts.keys.join(', ')}) — add one of those or a \"*\" wildcard to deps.#{dep.name}.hosts"
86
+ end
87
+
88
+ def present?(dep, rule)
89
+ verify = rule['verify']
90
+ return on_path?(dep.name) if verify.nil?
91
+
92
+ system(@runner.interpolate(verify), chdir: @root, out: File::NULL, err: File::NULL)
93
+ end
94
+
95
+ def install(dep, selector, rule)
96
+ command = rule['install']
97
+ if command.nil?
98
+ raise DepError,
99
+ "#{dep.name}: verification failed on #{selector} and the rule has no install command — " \
100
+ "add deps.#{dep.name}.hosts.#{selector}.install"
101
+ end
102
+
103
+ command = @runner.interpolate(command)
104
+ puts "▶ #{command} (#{dep.name} @ #{selector})"
105
+ return if system(command, chdir: @root)
106
+
107
+ status = $?.nil? ? 'unknown status' : "exit #{$?.exitstatus || $?.to_i}"
108
+ raise DepError, "#{dep.name}: install command failed (#{status}): #{command}"
109
+ end
110
+
111
+ def display_command(rule)
112
+ rule['install'] || "(verify only: #{rule['verify']})"
113
+ end
114
+
115
+ # Pure-Ruby PATH probe (portable: no `command -v` / `where` shell dance;
116
+ # `system` skips the shell for metacharacter-free strings, where shell
117
+ # builtins do not resolve). On Windows PATHEXT extensions are honored.
118
+ def on_path?(name)
119
+ exts = @os == :windows ? ENV['PATHEXT'].to_s.split(File::PATH_SEPARATOR) : ['']
120
+ ENV['PATH'].to_s.split(File::PATH_SEPARATOR).any? do |dir|
121
+ base = File.join(dir.strip.empty? ? '.' : dir, name)
122
+ exts.any? { |ext| File.executable?("#{base}#{ext}") }
123
+ end
124
+ end
125
+ end
126
+ 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,10 +4,12 @@ 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
@@ -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
@@ -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: matrix of build.yaml — 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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Crossbuild
4
- VERSION = '0.1.0'
4
+ VERSION = '0.4.0'
5
5
  end
data/lib/crossbuild.rb CHANGED
@@ -11,17 +11,20 @@ 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
20
  # 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)
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.
24
+ def self.build(manifest, entry_id: nil, target: nil, root: Dir.pwd, output_base: nil, version: nil,
25
+ deps: true, host_os: Platform.os, host_arch: Platform.arch)
23
26
  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)
27
+ Builder.new(m, root: root, output_base: output_base, version: version, deps: deps,
28
+ host_os: host_os, host_arch: host_arch).run(entry_id: entry_id, target: target)
26
29
  end
27
30
  end
@@ -27,8 +27,17 @@ module Crosspack
27
27
  "#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}"
28
28
  end
29
29
 
30
+ # Escapes XML entities so manifest values (e.g. a maintainer with
31
+ # "Name <email>") never break the generated .wxs.
32
+ def self.escape(text)
33
+ text.to_s.gsub('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;').gsub('"', '&quot;')
34
+ end
35
+
30
36
  def self.generate_wxs(name:, version:, manufacturer:, summary:, files:,
31
37
  arch: 'x64')
38
+ name = escape(name)
39
+ manufacturer = escape(manufacturer)
40
+ summary = escape(summary)
32
41
  directory = arch == 'arm64' ? 'ProgramFiles6432Folder' : 'ProgramFiles64Folder'
33
42
  components = files.values.each_with_index.map do |dst, i|
34
43
  src = files.key(dst).gsub('/', '\\')
@@ -97,7 +106,7 @@ module Crosspack
97
106
  return wxs_path
98
107
  end
99
108
 
100
- out, status = Open3.capture2e(wix, 'build', "-arch #{arch}",
109
+ out, status = Open3.capture2e(wix, 'build', '-arch', arch.to_s,
101
110
  '-o', output, wxs_path)
102
111
  unless status.success?
103
112
  raise BuildError, "wix build failed to build #{output}:\n#{out}"
@@ -105,11 +114,12 @@ module Crosspack
105
114
  output
106
115
  end
107
116
 
117
+ # WiX v4+ is a .NET tool invoked as `wix` (dotnet tool install -g wix);
118
+ # plain `dotnet` cannot build an MSI, so it is not a fallback.
108
119
  def self.wix_command
109
- %w[wix dotnet].each do |cmd|
110
- _, _, status = Open3.capture3(cmd, '--version')
111
- return cmd if status.success?
112
- end
120
+ _, _, status = Open3.capture3('wix', '--version')
121
+ return 'wix' if status.success?
122
+
113
123
  nil
114
124
  rescue Errno::ENOENT
115
125
  nil
@@ -8,6 +8,10 @@ module Crosspack
8
8
  module Builds
9
9
  ARCH_DIR_RE = /\A(amd64|x86_64|x64|arm64|aarch64)\z/i.freeze
10
10
 
11
+ # Written by `crosspack build <target>` into every builds/<target>/
12
+ # directory; `crosspack pack <target>` reads it as the default version.
13
+ STAMP_NAME = '.crosspack-build'
14
+
11
15
  # Returns [Target] for every target directory found under root.
12
16
  def self.available(root)
13
17
  return [] unless File.directory?(root)
@@ -30,6 +34,14 @@ module Crosspack
30
34
  lines.join("\n")
31
35
  end
32
36
 
37
+ # The version stamped by `crosspack build <target>` into the target's
38
+ # builds/ directory, or nil when the directory has no stamp (packed by
39
+ # an older build or never built through the stage pipeline).
40
+ def self.version_for(target, root)
41
+ stamp = File.join(target.output_dir(root.to_s, target.format), STAMP_NAME)
42
+ File.exist?(stamp) ? File.read(stamp).strip : nil
43
+ end
44
+
33
45
  private
34
46
 
35
47
  def self.scan_family(dir, family)
@@ -33,9 +33,15 @@ module Crosspack
33
33
 
34
34
  # Parses and validates deps.yaml. Root keys are canonical dependency
35
35
  # names; each maps to { targets: { family: { "version": [pkgs] } } } or a
36
- # scalar verdict for a whole family.
36
+ # scalar verdict for a whole family. A reserved top-level `version:` key
37
+ # declares the schema version (see SCHEMA_VERSION).
37
38
  class Manifest
38
- attr_reader :path, :deps, :errors, :warnings
39
+ # The deps.yaml schema version this crosspack understands. Files
40
+ # declaring a newer version are rejected at load time with an upgrade
41
+ # hint; older (or absent) versions are accepted.
42
+ SCHEMA_VERSION = 1
43
+
44
+ attr_reader :path, :deps, :schema_version, :errors, :warnings
39
45
 
40
46
  def self.load(path)
41
47
  unless File.file?(path)
@@ -54,10 +60,12 @@ module Crosspack
54
60
 
55
61
  def initialize(path, raw)
56
62
  @path = path
57
- @deps = raw.is_a?(Hash) ? raw : {}
58
- @raw = raw
63
+ @schema_version = raw.is_a?(Hash) ? raw['version'] : nil
59
64
  @errors = []
60
65
  @warnings = []
66
+ check_schema_version!
67
+ @deps = raw.is_a?(Hash) ? raw.reject { |key, _| key == 'version' } : {}
68
+ @raw = raw
61
69
  validate
62
70
  end
63
71
 
@@ -90,6 +98,26 @@ module Crosspack
90
98
 
91
99
  private
92
100
 
101
+ # A schema version newer than this crosspack supports is a hard, loud
102
+ # error at load time — every command (resolve, matrix, pack) reports it,
103
+ # not just validate. Malformed versions (not an integer >= 1) are
104
+ # ordinary schema errors.
105
+ def check_schema_version!
106
+ v = @schema_version
107
+ return if v.nil?
108
+
109
+ unless v.is_a?(Integer) && v >= 1
110
+ @errors << Issue.new('version', "schema version must be an integer >= 1, got #{v.inspect}")
111
+ return
112
+ end
113
+ return if v <= SCHEMA_VERSION
114
+
115
+ raise InvalidManifestError,
116
+ "#{@path}: deps.yaml declares schema version #{v}, " \
117
+ "but this crosspack (#{Crosspack::VERSION}) supports up to #{SCHEMA_VERSION}. " \
118
+ 'Update the gem (gem update crosspack) or lower the declared version.'
119
+ end
120
+
93
121
  def validate
94
122
  unless @raw.is_a?(Hash) && !@raw.empty?
95
123
  @errors << Issue.new(
@@ -98,7 +126,7 @@ module Crosspack
98
126
  )
99
127
  return
100
128
  end
101
- @raw.each { |name, body| validate_dep(name.to_s, body) }
129
+ @raw.each { |name, body| validate_dep(name.to_s, body) unless name == 'version' }
102
130
  end
103
131
 
104
132
  def validate_dep(name, body)
@@ -8,8 +8,10 @@ module Crosspack
8
8
  # package.yaml), resolves dependencies for the target (per deps.yaml) and
9
9
  # produces a native package under output_base/<family>/<version>/<arch>/.
10
10
  # The builder is chosen by the target format — deb, rpm or PKGBUILD.
11
+ # version: when omitted, the version stamped by `crosspack build <target>`
12
+ # (.crosspack-build in the target's builds/ directory) is used.
11
13
  class Packer
12
- def self.pack(manifest:, deps:, target:, version:, root: Dir.pwd,
14
+ def self.pack(manifest:, deps:, target:, version: nil, root: Dir.pwd,
13
15
  output_base: 'crosspacks')
14
16
  new(manifest: manifest, deps: deps, target: target, version: version,
15
17
  root: root, output_base: output_base).pack
@@ -19,15 +21,16 @@ module Crosspack
19
21
  @pkg_path = manifest
20
22
  @deps_path = deps
21
23
  @target = target
22
- @version = version.to_s
24
+ @version = version && version.to_s
23
25
  @root = root
24
- @output_base = output_base
26
+ @output_base = output_base || 'crosspacks'
25
27
  end
26
28
 
27
29
  def pack
28
30
  @pkg = PackageManifest.load(@pkg_path)
29
31
  @deps = Manifest.load(@deps_path)
30
32
  validate_both!
33
+ resolve_version!
31
34
  @pkg_data = build_layout
32
35
 
33
36
  case @target.format
@@ -49,6 +52,21 @@ module Crosspack
49
52
 
50
53
  private
51
54
 
55
+ # --version wins; otherwise the version recorded by the build stage.
56
+ def resolve_version!
57
+ return unless @version.nil? || @version.strip.empty?
58
+
59
+ stamped = Builds.version_for(@target, File.expand_path(@pkg.sources, @root))
60
+ if stamped.nil? || stamped.strip.empty?
61
+ raise BuildError,
62
+ "No version for target #{@target}: no --version given and no build stamp " \
63
+ "(#{Builds::STAMP_NAME}) in #{@pkg.sources}. " \
64
+ "Run: crosspack build #{@target} — or pass --version explicitly."
65
+ end
66
+
67
+ @version = stamped
68
+ end
69
+
52
70
  def validate_both!
53
71
  errors = []
54
72
  errors << @pkg.error_report unless @pkg.valid?
@@ -70,10 +88,10 @@ module Crosspack
70
88
  hint = available.empty? ? 'no built targets at all.' :
71
89
  "available builds: #{available.map { |t| "#{t} (#{t.output_dir(@pkg.sources, t.format)})" }.join(', ')}."
72
90
  raise BuildError,
73
- "No compiled artifacts for target #{@target}: expected them in #{src_dir}.\n" \
91
+ "Build stage not done for target #{@target}: expected compiled artifacts in #{src_dir}.\n" \
74
92
  "The builds tree (#{@pkg.sources}) mirrors the output tree: builds/<family>/<version>/<arch>/. " \
75
93
  "Currently #{hint}\n" \
76
- 'Build the application for this target first, then pack.'
94
+ "Run: crosspack build #{@target}"
77
95
  end
78
96
 
79
97
  lib_dst = File.join(@pkg.prefix, @pkg.lib_dir)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Crosspack
4
- VERSION = '0.2.0'
4
+ VERSION = '0.4.0'
5
5
  end
metadata CHANGED
@@ -1,35 +1,35 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: crosspack
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Oleg Orlov
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-08 00:00:00.000000000 Z
11
+ date: 2026-09-13 00:00:00.000000000 Z
12
12
  dependencies: []
13
- description: 'Two cooperating commands: crossbuild runs the build steps of a project
14
- from a build.yaml matrix and fans the produced artifacts out into the per-target
15
- builds/ tree; crosspack turns that tree into native packages, driven by a cross-distro
16
- deps.yaml. Both share the same target vocabulary so the trees never drift apart.'
13
+ description: 'One command, one target vocabulary: crosspack deps <target> verifies
14
+ and installs the build host dependencies, crosspack build <target> runs the build.yaml
15
+ matrix entry and fans artifacts into builds/, crosspack pack <target> turns that
16
+ tree into a native package. Stages are gated — no packing before building — and
17
+ keyed by the same target strings throughout.'
17
18
  email:
18
19
  - orelcokolov@gmail.com
19
20
  executables:
20
21
  - crosspack
21
- - crossbuild
22
22
  extensions: []
23
23
  extra_rdoc_files: []
24
24
  files:
25
25
  - CHANGELOG.md
26
26
  - LICENSE
27
27
  - README.md
28
- - exe/crossbuild
29
28
  - exe/crosspack
30
29
  - lib/crossbuild.rb
31
30
  - lib/crossbuild/build_manifest.rb
32
31
  - lib/crossbuild/builder.rb
32
+ - lib/crossbuild/dep_installer.rb
33
33
  - lib/crossbuild/distributor.rb
34
34
  - lib/crossbuild/matrix.rb
35
35
  - lib/crossbuild/platform.rb
@@ -74,5 +74,5 @@ requirements: []
74
74
  rubygems_version: 3.5.22
75
75
  signing_key:
76
76
  specification_version: 4
77
- summary: Cross-platform build orchestrator and native package builder (deb/rpm/PKGBUILD/winget/cask)
77
+ summary: 'Staged cross-platform pipeline: deps → build → pack native packages (deb/rpm/PKGBUILD/winget/cask)'
78
78
  test_files: []