crosspack 0.2.1 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b365ad4ba6ab874d40dec1ef153ab18104f329ee6ebb7a638a27267089eb7c43
4
- data.tar.gz: c8b0eef099fecc0f97f7964560e093812fe50e7627bb5a092bfadcbf8322f668
3
+ metadata.gz: 527794ea0ef513791363042fcace5c4a973a859e74c4300dedeec931268b00fc
4
+ data.tar.gz: 5bbad1503be58d2190c7f042fdd29ca8271d1d107826d8653589ef17c8587a9d
5
5
  SHA512:
6
- metadata.gz: 7c3d441c8d44206115a24ca0668843781b97631fdf3064a933d51709735911153861057bd0efa81b99406d4276b7c92b70993f12905b6e4ac6e9012b39db1f1d
7
- data.tar.gz: d5bc914e071fd1be81b6cd1703b3120ce3d260a2a94188bba34b3b13f8e672f271d24c985642f43005fde6dbefc4dca542f67b74b213631eba071958e7e02a1f
6
+ metadata.gz: 465b113f78d7e0dce817e0a65c122437998938dbb66cd438c63762289dcc1a9c4a3ccf4df9a7639f73ec983aa20f31a94c34d357118efbdfdfb1415b9828fad1
7
+ data.tar.gz: 021f427e66d2a9c58eb02dde0907cfb7bf5f8c89a081018b2839df819a7c478dc4952f7fc9ba7246c4b2030488065b644401edfd32282ca987809019675c0670
data/CHANGELOG.md CHANGED
@@ -5,6 +5,51 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.0] - 2026-09-13
9
+
10
+ ### Breaking
11
+ - The separate `crossbuild` executable is gone: one command, one staged
12
+ pipeline — `crosspack deps <target>` → `crosspack build <target>` →
13
+ `crosspack pack <target>`. The former crossbuild commands moved under
14
+ `crosspack` (validate/matrix/version gained build.yaml coverage; the gem
15
+ now installs only the `crosspack` binary).
16
+ - build.yaml `deps:` schema: host rules are now mappings with per-system
17
+ `verify` and `install` commands instead of a global `check:` plus a
18
+ bare command string. The 0.3.0 schema was never released outside the
19
+ local gem build, so no migration path is provided.
20
+ - `crosspack pack <target>` takes the target as a positional argument
21
+ (the old `--target` flag is gone) and `--version` is now optional: it
22
+ defaults to the version stamped by the build stage.
23
+
24
+ ### Added
25
+ - Stage pipeline with gating: `crosspack build <target>` resolves the
26
+ matrix entry distributing to that target, runs the deps stage first,
27
+ and fans artifacts out into that target's builds/ directory only;
28
+ `crosspack pack <target>` refuses to run before the build stage
29
+ ("Build stage not done for target ... — Run: crosspack build <target>").
30
+ - The build stage stamps `.crosspack-build` (version) into every
31
+ `builds/<target>/` directory; `crosspack pack` reads it as the default
32
+ package version.
33
+ - deps.yaml schema versioning: an optional top-level `version:` (default
34
+ 1). A deps.yaml declaring a newer schema than this crosspack is
35
+ rejected at load time with an upgrade hint; the key is reserved and no
36
+ longer parsed as a canonical dependency name.
37
+ - deps: rules may be verify-only (no install command) — presence is
38
+ checked, and a missing dependency reports which `install:` to add.
39
+
40
+ ## [0.3.0] - 2026-09-13
41
+
42
+ ### Added
43
+ - build.yaml `deps:` section: build dependencies with per-host install
44
+ commands (e.g. `ubuntu: sudo apt-get install -y imagemagick`, `windows:
45
+ winget install ...`, or any project script). Host selectors resolve in
46
+ order: distro id from `/etc/os-release`, `ID_LIKE` tokens, os, `"*"`.
47
+ - `crossbuild deps` command: doctor + install of the deps: dependencies;
48
+ `--check` reports presence only and exits 1 when something is missing.
49
+ - `crossbuild build` now verifies/installs deps: dependencies before the
50
+ matrix entries; `--no-deps` skips the pass.
51
+ - `crossbuild matrix` lists how each deps: entry resolves on this host.
52
+
8
53
  ## [0.2.1] - 2026-09-11
9
54
 
10
55
  ### Fixed
data/README.md CHANGED
@@ -1,32 +1,49 @@
1
1
  # crosspack
2
2
 
3
3
  Cross-platform build **and** pack toolchain for Wails-style desktop apps,
4
- shipped as one gem with two cooperating commands:
4
+ shipped as one gem with one command and a staged pipeline:
5
5
 
6
- - **`crossbuild`** — runs the build steps of a project from a small
7
- `build.yaml` matrix and fans the produced artifacts out into the
8
- per-target tree `builds/<family>[/<version>]/<arch>/`.
9
- - **`crosspack`** turns that compiled tree into native packages
10
- (deb / rpm / PKGBUILD / WiX / app bundle) for any target
11
- distro × version × arch, driven by two small manifests.
6
+ ```
7
+ crosspack deps <target> # verify/install the build host's dependencies
8
+ crosspack build <target> # compile the target's matrix entry into builds/
9
+ crosspack pack <target> # package builds/<target> as a native package
10
+ ```
12
11
 
13
- The two commands share the same target vocabulary (`Crosspack::Target`),
14
- so the build tree and the pack tree never drift apart. Build and pack
15
- stay cleanly separated: `crossbuild` never packages, `crosspack` never
16
- compiles feed the output of one straight into the other:
12
+ Every stage is keyed by the same target vocabulary `debian-12`,
13
+ `ubuntu-24.04`, `fedora-41`, `arch`, `macos`, `windows-11.0` and gated by
14
+ the previous one: `build` refuses to start before the deps stage passes, and
15
+ `pack` refuses to run before `build` filled `builds/<target>/`. The build
16
+ stage also stamps the version into the tree, so `pack` normally needs no
17
+ `--version`:
17
18
 
18
19
  ```
19
- crossbuild build # builds/ tree
20
- crosspack pack --target debian-12 --version 2026.08.31-33837
20
+ crosspack deps ubuntu-24.04
21
+ crosspack build ubuntu-24.04
22
+ crosspack pack ubuntu-24.04
21
23
  ```
22
24
 
23
- ## crossbuild: build.yaml
25
+ ## build.yaml — what and where to build
24
26
 
25
27
  ```yaml
26
28
  name: myapp
27
29
  version: calver # calver | git-tag | env:VAR | any literal string
28
30
  output: builds # default builds; mirrors package.yaml `sources`
29
31
 
32
+ deps: # build dependencies, per-host verify/install
33
+ imagemagick:
34
+ hosts: # first selector matching this host wins
35
+ ubuntu:
36
+ verify: dpkg -s imagemagick
37
+ install: sudo apt-get install -y imagemagick
38
+ darwin:
39
+ verify: magick -version
40
+ install: brew install imagemagick
41
+ windows:
42
+ verify: where magick
43
+ install: winget install -e --id ImageMagick.ImageMagick
44
+ "*": # any command — package manager or a project script
45
+ install: ./scripts/install-imagemagick.sh
46
+
30
47
  matrix:
31
48
  - build: linux/amd64 # os/arch this entry builds on (arch may be "any")
32
49
  env: # extra env for the steps of this entry
@@ -63,6 +80,19 @@ builds/ubuntu/24.04/amd64/myapp -> build/bin/myapp (symlink)
63
80
  builds/windows/11.0/x86_64/myapp.exe
64
81
  ```
65
82
 
83
+ ### Build dependencies (deps:)
84
+
85
+ `deps:` is the host axis to `deps.yaml`'s target axis: deps.yaml declares
86
+ what a *package* needs at runtime; `deps:` declares what the *build host*
87
+ must have. Checks differ per system, so every host rule carries its own
88
+ commands: `verify` (presence probe; exit 0 = present; without it the name is
89
+ looked up in PATH) and `install` (how to install it; may be omitted for
90
+ verify-only rules). Selectors are tried in order: distro id from
91
+ `/etc/os-release` (`ubuntu`), then `ID_LIKE` tokens (`debian`), then the os
92
+ (`linux`, `darwin`, `windows`), then `"*"`. The deps stage runs verify →
93
+ install → re-verify for every dependency; a dependency that is still missing
94
+ fails the stage. Commands support the `{{name}}`/`{{os}}`/... placeholders.
95
+
66
96
  ### Version schemes
67
97
 
68
98
  | scheme | value |
@@ -72,22 +102,9 @@ builds/windows/11.0/x86_64/myapp.exe
72
102
  | `env:VAR` | taken from `VAR`; build fails loudly when unset |
73
103
  | other string | used verbatim, e.g. `version: 1.2.3` |
74
104
 
75
- ### crossbuild CLI
76
-
77
- ```
78
- crossbuild validate # build.yaml against the schema
79
- crossbuild matrix # entries × host buildability table
80
- crossbuild version # computed version
81
- crossbuild build [--target linux/amd64] # all host-buildable entries, or one
82
- crossbuild targets # what is in builds/ ready to pack
83
- ```
84
-
85
- `build` refuses invalid manifests with path-pointing errors, runs only the
86
- entries whose `build:` platform matches the host, then distributes.
105
+ ## package.yaml + deps.yaml — what and how to pack
87
106
 
88
- ## crosspack: two manifests
89
-
90
- **`package.yaml`** — what and how to pack:
107
+ **`package.yaml`**:
91
108
 
92
109
  ```yaml
93
110
  name: myapp
@@ -99,9 +116,9 @@ description: |-
99
116
  license: Proprietary # optional, default Proprietary
100
117
  section: utils # optional, deb only
101
118
 
102
- sources: builds # compiled artifacts tree written by crossbuild:
103
- # builds/<family>/<version>/<arch>/ — pack without
104
- # a matching build directory fails honestly
119
+ sources: builds # compiled artifacts tree written by the build
120
+ # stage: builds/<family>/<version>/<arch>/ —
121
+ # pack without a matching build dir fails honestly
105
122
  prefix: usr/local # install prefix inside the package
106
123
  lib_dir: lib/myapp # optional, default lib/<name>
107
124
 
@@ -124,9 +141,13 @@ icon: build/appicon.png # optional, -> <prefix>/share/pixmaps/<name>.png
124
141
  ```
125
142
 
126
143
  **`deps.yaml`** — canonical dependency names resolved to concrete packages
127
- per distro family/version (see the file itself for the full schema):
144
+ per distro family/version. An optional top-level `version:` declares the
145
+ schema version; a deps.yaml written for a newer crosspack is rejected with
146
+ an upgrade hint:
128
147
 
129
148
  ```yaml
149
+ version: 1 # optional, currently 1
150
+
130
151
  webkit2gtk:
131
152
  targets:
132
153
  debian:
@@ -136,17 +157,24 @@ webkit2gtk:
136
157
  windows: system # preinstalled (WebView2)
137
158
  ```
138
159
 
139
- Both files are validated against embedded schemas with actionable,
140
- path-pointing error messages; every builder refuses to run on an invalid
160
+ All three files are validated against embedded schemas with actionable,
161
+ path-pointing error messages; every stage refuses to run on an invalid
141
162
  manifest.
142
163
 
143
164
  ## CLI
144
165
 
145
166
  ```
146
- crosspack validate # both package.yaml and deps.yaml
147
- crosspack resolve --target debian-12 # Depends line for the target
148
- crosspack matrix # dependency x target table
149
- crosspack pack --target debian-12 --version 2026.08.31-1234 [--root .]
167
+ # staged pipeline
168
+ crosspack deps <target> [--check] # verify/install build deps (--check: report only)
169
+ crosspack build <target>|--all [--no-deps] [--version X]
170
+ crosspack pack <target> [--version X] # version defaults to the build stamp
171
+
172
+ # inspection
173
+ crosspack validate # package.yaml, deps.yaml and build.yaml
174
+ crosspack resolve <target> # Depends line for the target
175
+ crosspack matrix # deps x target + build matrix tables
176
+ crosspack version # computed build version
177
+ crosspack targets # what is in builds/ ready to pack
150
178
  ```
151
179
 
152
180
  `pack` picks the builder from the target and writes to
@@ -164,20 +192,21 @@ crosspacks/arch/x86_64/PKGBUILD
164
192
  require 'crosspack' # pulls in Crossbuild too
165
193
 
166
194
  Crossbuild.build('build.yaml', root: Dir.pwd) # all host entries
167
- Crossbuild.build('build.yaml', entry_id: 'windows') # one entry explicitly
195
+ Crossbuild.build('build.yaml', target: 'ubuntu-24.04') # the stages' build, library-level
168
196
 
169
197
  Crosspack.pack(
170
198
  manifest: 'package.yaml', deps: 'deps.yaml',
171
199
  target: Crosspack::Target.parse('debian-12', arch: 'amd64'),
172
- version: '2026.08.31-1234',
200
+ version: nil, # nil -> the version stamped by the build stage
173
201
  root: Dir.pwd, output_base: 'crosspacks'
174
202
  )
175
203
  ```
176
204
 
177
205
  Lower-level pieces are public too: on the build side `Crossbuild::
178
206
  BuildManifest`, `VersionScheme`, `Runner`, `Distributor`, `Matrix`,
179
- `Builder`, `Platform`; on the pack side `Crosspack::PackageManifest`,
180
- `Manifest`, `Resolver`, `Matrix`, `Target`, `Builders::*`.
207
+ `Builder`, `DepInstaller`, `Platform`; on the pack side `Crosspack::
208
+ PackageManifest`, `Manifest`, `Resolver`, `Matrix`, `Target`, `Builds`,
209
+ `Builders::*`.
181
210
 
182
211
  Deb needs `dpkg-deb` (present on any Debian/Ubuntu), rpm needs
183
212
  `rpmbuild` (`sudo apt install rpm`), PKGBUILD generation needs nothing.
data/exe/crosspack CHANGED
@@ -5,8 +5,10 @@ require 'optparse'
5
5
 
6
6
  begin
7
7
  require 'crosspack'
8
+ require 'crossbuild'
8
9
  rescue LoadError
9
10
  require_relative '../lib/crosspack'
11
+ require_relative '../lib/crossbuild'
10
12
  end
11
13
 
12
14
  def abort_with(message)
@@ -14,21 +16,28 @@ def abort_with(message)
14
16
  exit 1
15
17
  end
16
18
 
17
- options = { file: 'deps.yaml', package: 'package.yaml', arch: 'amd64', format: nil,
18
- version: nil, root: Dir.pwd, output_base: 'crosspacks' }
19
+ options = { file: 'deps.yaml', package: 'package.yaml', build: 'build.yaml', arch: 'amd64',
20
+ format: nil, version: nil, root: Dir.pwd, output_base: nil,
21
+ check_only: false, no_deps: false, all: false }
19
22
  parser = OptionParser.new do |opts|
20
- opts.banner = "crosspack #{Crosspack::VERSION} — native package packer (deb/rpm/PKGBUILD/MSI/app)\n" \
23
+ opts.banner = "crosspack #{Crosspack::VERSION} — staged pipeline: deps → build → pack\n" \
21
24
  "Usage: crosspack <command> [options]\n" \
22
- 'Commands: validate, resolve, matrix, pack, targets'
25
+ "Stages: deps <target> [--check] verify/install build deps for the target's host\n" \
26
+ " build <target>|--all compile the target's matrix entry into builds/\n" \
27
+ " pack <target> [--version X] package builds/<target> (version from the build stamp)\n" \
28
+ 'Commands: validate, resolve, matrix, version, targets'
23
29
 
24
30
  opts.on('-f', '--file PATH', 'path to deps.yaml (default: ./deps.yaml)') { |v| options[:file] = v }
25
31
  opts.on('--package PATH', 'path to package.yaml (default: ./package.yaml)') { |v| options[:package] = v }
26
- opts.on('--target TARGET', 'target: debian-12, ubuntu-24.04, fedora-41, arch, macos, windows') { |v| options[:target] = v }
32
+ opts.on('--build PATH', 'path to build.yaml (default: ./build.yaml)') { |v| options[:build] = v }
27
33
  opts.on('--arch ARCH', 'architecture: amd64/x86_64, arm64/aarch64 (default: amd64)') { |v| options[:arch] = v }
28
34
  opts.on('--format FORMAT', 'output format for resolve: deb, rpm, pkgbuild (default: derived from the target)') { |v| options[:format] = v.to_sym }
29
- opts.on('--version VERSION', 'package version for pack (required for pack)') { |v| options[:version] = v }
35
+ opts.on('--version VERSION', 'override the version for build/pack') { |v| options[:version] = v }
30
36
  opts.on('--root PATH', 'project root for relative paths (default: .)') { |v| options[:root] = v }
31
- opts.on('--output-base DIR', 'output directory (default: ./crosspacks)') { |v| options[:output_base] = v }
37
+ opts.on('--output-base DIR', 'output directory (build stage: the manifest output, default builds; pack: default ./crosspacks)') { |v| options[:output_base] = v }
38
+ opts.on('--check', 'with `deps`: only report presence, never install (exit 1 if missing)') { options[:check_only] = true }
39
+ opts.on('--no-deps', 'with `build`: skip the deps stage') { options[:no_deps] = true }
40
+ opts.on('--all', 'with `build`: every host-buildable matrix entry (default target selection off)') { options[:all] = true }
32
41
  opts.on('-h', '--help', 'show help') do
33
42
  puts opts
34
43
  exit
@@ -43,63 +52,142 @@ end
43
52
  command = ARGV.shift
44
53
  abort_with 'No command given. See: crosspack --help' if command.nil?
45
54
 
46
- # Positional file path: crosspack validate path/to/deps.yaml
47
- options[:file] = ARGV.shift if ARGV.first && !ARGV.first.start_with?('-')
55
+ # Positional argument: a target for the stage commands (deps/build/pack,
56
+ # resolve), a deps.yaml path for validate.
57
+ positional = ARGV.shift if ARGV.first && !ARGV.first.start_with?('-')
48
58
 
49
- begin
50
- manifest = Crosspack::Manifest.load(options[:file])
51
- rescue Crosspack::InvalidManifestError => e
52
- abort_with e.message
59
+ build_path = File.expand_path(options[:build], options[:root])
60
+ build_manifest = if File.file?(build_path)
61
+ begin
62
+ Crossbuild::BuildManifest.load(build_path)
63
+ rescue Crossbuild::InvalidManifestError => e
64
+ abort_with e.message
65
+ end
66
+ end
67
+
68
+ # The matrix entry that builds `target` — the deps/build stages operate on
69
+ # its host platform.
70
+ def entry_for_target!(manifest, target, host_os, host_arch)
71
+ entry = manifest.entry_for_target(target)
72
+ host = "#{host_os}/#{Crossbuild::Platform.display_arch(host_arch)}"
73
+ unless entry.buildable_on?(host_os, host_arch)
74
+ abort_with "The entry for #{target.inspect} (#{entry.id}) builds on #{entry.platform}, " \
75
+ "this host is #{host} — cross-compilation is not supported"
76
+ end
77
+
78
+ entry
53
79
  end
54
80
 
55
81
  case command
56
82
  when 'validate'
83
+ if positional
84
+ options[:file] = positional
85
+ elsif build_manifest.nil?
86
+ warn "note: #{options[:build]} not found — the deps/build stages need it (validate covered deps/package only)"
87
+ end
57
88
  begin
58
- pkg = Crosspack::PackageManifest.load(options[:package])
89
+ pkg = Crosspack::PackageManifest.load(File.expand_path(options[:package], options[:root]))
90
+ rescue Crosspack::InvalidManifestError => e
91
+ abort_with e.message
92
+ end
93
+ begin
94
+ manifest = Crosspack::Manifest.load(File.expand_path(options[:file], options[:root]))
59
95
  rescue Crosspack::InvalidManifestError => e
60
96
  abort_with e.message
61
97
  end
62
98
  puts manifest.error_report
63
99
  puts pkg.error_report
64
- exit(manifest.valid? && pkg.valid? ? 0 : 1)
100
+ puts build_manifest.error_report if build_manifest
101
+ ok = manifest.valid? && pkg.valid? && (build_manifest.nil? || build_manifest.valid?)
102
+ exit(ok ? 0 : 1)
65
103
  when 'matrix'
66
- abort_with 'Schema is invalid — the matrix cannot be built. Run: crosspack validate' unless manifest.valid?
67
- puts Crosspack::Matrix.new(manifest).render
68
- when 'resolve'
69
- abort_with 'Specify a target: --target debian-12 (see crosspack --help)' unless options[:target]
70
104
  begin
71
- target = Crosspack::Target.parse(options[:target], arch: options[:arch])
72
- rescue Crosspack::Target::Error => e
73
- abort_with "Invalid target #{options[:target].inspect}: #{e.message}"
105
+ manifest = Crosspack::Manifest.load(File.expand_path(options[:file], options[:root]))
106
+ rescue Crosspack::InvalidManifestError => e
107
+ abort_with e.message
108
+ end
109
+ abort_with 'deps.yaml schema is invalid — run: crosspack validate' unless manifest.valid?
110
+ puts Crosspack::Matrix.new(manifest).render
111
+ if build_manifest
112
+ puts "\n#{Crossbuild::Matrix.new(build_manifest).render}"
113
+ else
114
+ warn "\nnote: #{options[:build]} not found — build matrix not shown"
74
115
  end
116
+ when 'resolve'
117
+ target_str = positional
118
+ abort_with 'Specify a target: crosspack resolve debian-12 (see crosspack --help)' unless target_str
75
119
  begin
120
+ manifest = Crosspack::Manifest.load(File.expand_path(options[:file], options[:root]))
121
+ target = Crosspack::Target.parse(target_str, arch: options[:arch])
76
122
  resolver = Crosspack::Resolver.new(manifest)
77
123
  format = options[:format] || target.format
78
124
  result = resolver.depends(target, format: format)
125
+ puts "# #{target} (#{format})"
79
126
  if result.is_a?(Array)
80
- puts "# #{target} (#{format})"
81
127
  result.each { |d| puts d }
82
128
  else
83
- puts "# #{target} (#{format})"
84
129
  puts "Depends: #{result}"
85
130
  end
86
- rescue Crosspack::ResolveError => e
87
- abort_with "Failed to resolve dependencies for #{target}:\n #{e.message}"
131
+ rescue Crosspack::Target::Error => e
132
+ abort_with "Invalid target #{target_str.inspect}: #{e.message}"
133
+ rescue Crosspack::InvalidManifestError, Crosspack::ResolveError => e
134
+ abort_with e.message
135
+ end
136
+ when 'version'
137
+ abort_with "#{options[:build]} not found — crosspack version needs a build manifest" unless build_manifest
138
+ abort_with 'Schema is invalid. Run: crosspack validate' unless build_manifest.valid?
139
+ begin
140
+ puts Crossbuild::VersionScheme.new(build_manifest.version).compute(root: options[:root])
141
+ rescue Crossbuild::VersionScheme::Error => e
142
+ abort_with e.message
143
+ end
144
+ when 'deps'
145
+ abort_with 'Specify a target: crosspack deps debian-12 (see crosspack --help)' unless positional
146
+ abort_with "#{options[:build]} not found — crosspack deps needs a build manifest" unless build_manifest
147
+ entry_for_target!(build_manifest, positional, Crossbuild::Platform.os, Crossbuild::Platform.arch)
148
+ installer = Crossbuild::DepInstaller.new(build_manifest, root: options[:root])
149
+ begin
150
+ if options[:check_only]
151
+ installer.check_all
152
+ puts "deps on this host:\n#{installer.report}"
153
+ exit(installer.statuses.any? { |s| !s.installed } ? 1 : 0)
154
+ end
155
+ installer.ensure_all
156
+ rescue Crossbuild::Error => e
157
+ abort_with e.message
158
+ end
159
+ puts "deps ready:\n#{installer.report}"
160
+ when 'build'
161
+ abort_with "#{options[:build]} not found — crosspack build needs a build manifest" unless build_manifest
162
+ if options[:all]
163
+ target = nil
164
+ else
165
+ abort_with 'Specify a target: crosspack build ubuntu-24.04 (or --all for every host-buildable entry)' unless positional
166
+ target = positional
167
+ end
168
+ begin
169
+ result = Crossbuild::Builder.new(build_manifest, root: options[:root],
170
+ output_base: options[:output_base], version: options[:version],
171
+ deps: !options[:no_deps]).run(target: target)
172
+ rescue Crossbuild::Error, Crosspack::InvalidManifestError => e
173
+ abort_with e.message
88
174
  end
175
+ puts "\n📦 #{result.entries.size} entr#{result.entries.size == 1 ? 'y' : 'ies'} built, version #{result.version}"
176
+ puts 'Pack with: crosspack pack <target>'
89
177
  when 'pack'
90
- abort_with 'Specify a target: --target debian-12 and a version: --version 1.0-1' unless options[:target] && options[:version]
178
+ abort_with 'Specify a target: crosspack pack debian-12' unless positional
91
179
  begin
92
- target = Crosspack::Target.parse(options[:target], arch: options[:arch])
180
+ target = Crosspack::Target.parse(positional, arch: options[:arch])
93
181
  path = Crosspack.pack(
94
182
  manifest: options[:package],
95
183
  deps: options[:file],
96
184
  target: target,
97
185
  version: options[:version],
98
186
  root: options[:root],
99
- output_base: options[:output_base]
187
+ output_base: options[:output_base] || 'crosspacks'
100
188
  )
101
189
  rescue Crosspack::Target::Error => e
102
- abort_with "Invalid target #{options[:target].inspect}: #{e.message}"
190
+ abort_with "Invalid target #{positional.inspect}: #{e.message}"
103
191
  rescue Crosspack::InvalidManifestError, Crosspack::ResolveError, Crosspack::BuildError => e
104
192
  abort_with e.message
105
193
  end
@@ -115,5 +203,5 @@ when 'targets'
115
203
  root = sources ? File.expand_path(sources, options[:root]) : File.join(options[:root], 'builds')
116
204
  puts Crosspack::Builds.report(root)
117
205
  else
118
- abort_with "Unknown command #{command.inspect}. Available: validate, resolve, matrix, pack, targets"
206
+ abort_with "Unknown command #{command.inspect}. Available: deps, build, pack, validate, resolve, matrix, version, targets"
119
207
  end
@@ -24,9 +24,10 @@ module Crossbuild
24
24
  ARCH_LIST = %w[amd64 x86_64 arm64 aarch64 any].freeze
25
25
  ARTIFACT_MODES = %w[symlink copy].freeze
26
26
  VERSION_SCHEMES = %w[calver git-tag].freeze
27
- TOP_LEVEL_KEYS = %w[name version output matrix].freeze
27
+ TOP_LEVEL_KEYS = %w[name version output deps matrix].freeze
28
28
  ENTRY_KEYS = %w[id build env steps artifacts].freeze
29
29
  ARTIFACTS_KEYS = %w[from include to mode].freeze
30
+ DEP_KEYS = %w[hosts].freeze
30
31
  BUILD_RE = /\A(linux|darwin|windows)\/(#{ARCH_LIST.join('|')})\z/.freeze
31
32
  NAME_RE = /\A[a-z0-9][a-z0-9+._-]*\z/i.freeze
32
33
 
@@ -54,6 +55,22 @@ module Crossbuild
54
55
  end
55
56
  end
56
57
 
58
+ # A build dependency from the top-level deps: section. `hosts` maps a
59
+ # host selector (distro id from os-release, ID_LIKE token, os name or
60
+ # "*") to a rule with per-system commands:
61
+ # verify: presence probe (default: PATH lookup of the name)
62
+ # install: how to install it (a verify-only rule may omit this)
63
+ class Dependency
64
+ RULE_KEYS = %w[verify install].freeze
65
+
66
+ attr_reader :name, :hosts
67
+
68
+ def initialize(name, hosts)
69
+ @name = name
70
+ @hosts = hosts
71
+ end
72
+ end
73
+
57
74
  # Where artifacts come from and which builds/ directories they fan out to.
58
75
  class Artifacts
59
76
  attr_reader :from, :include, :to, :mode
@@ -66,7 +83,7 @@ module Crossbuild
66
83
  end
67
84
  end
68
85
 
69
- attr_reader :path, :name, :version, :output, :entries, :errors, :warnings
86
+ attr_reader :path, :name, :version, :output, :deps, :entries, :errors, :warnings
70
87
 
71
88
  def self.load(path)
72
89
  unless File.file?(path)
@@ -89,6 +106,7 @@ module Crossbuild
89
106
  @name = nil
90
107
  @version = nil
91
108
  @output = 'builds'
109
+ @deps = []
92
110
  @entries = []
93
111
  @errors = []
94
112
  @warnings = []
@@ -132,6 +150,26 @@ module Crossbuild
132
150
  @entries.find { |e| e.id == id }
133
151
  end
134
152
 
153
+ # The single matrix entry that distributes artifacts to the given target
154
+ # string (e.g. "ubuntu-24.04"). Raises Crossbuild::Error when no entry
155
+ # claims it or when several do (ambiguous).
156
+ def entry_for_target(raw)
157
+ matches = @entries.select { |e| e.artifacts&.to&.include?(raw) }
158
+ declared = @entries.filter_map { |e| e.artifacts&.to }.flatten.uniq
159
+ if matches.empty?
160
+ raise Error,
161
+ "no matrix entry distributes to target #{raw.inspect}; " \
162
+ "declared targets: #{declared.join(', ')}"
163
+ end
164
+ if matches.size > 1
165
+ raise Error,
166
+ "target #{raw.inspect} is distributed by several entries " \
167
+ "(#{matches.map(&:id).join(', ')}) — crosspack build <target> needs exactly one"
168
+ end
169
+
170
+ matches.first
171
+ end
172
+
135
173
  private
136
174
 
137
175
  def validate
@@ -144,6 +182,7 @@ module Crossbuild
144
182
  validate_name
145
183
  validate_version
146
184
  validate_output
185
+ validate_deps
147
186
  validate_matrix
148
187
  end
149
188
 
@@ -184,6 +223,76 @@ module Crossbuild
184
223
  end
185
224
  end
186
225
 
226
+ def validate_deps
227
+ deps = @raw['deps']
228
+ return if deps.nil?
229
+
230
+ unless deps.is_a?(Hash)
231
+ @errors << Issue.new(
232
+ 'deps',
233
+ "must be a mapping of dependency name -> {check?, hosts}; expected:\n" \
234
+ " deps:\n" \
235
+ " imagemagick:\n" \
236
+ " check: magick -version\n" \
237
+ " hosts:\n" \
238
+ " ubuntu: sudo apt-get install -y imagemagick"
239
+ )
240
+ return
241
+ end
242
+
243
+ deps.each { |name, body| validate_dep(name, body) }
244
+ end
245
+
246
+ def validate_dep(name, body)
247
+ path = "deps.#{name}"
248
+ if name !~ NAME_RE
249
+ @errors << Issue.new(path, "invalid dependency name #{name.inspect} (letters, digits, \"+\", \"_\", \"-\", \".\")")
250
+ return
251
+ end
252
+ unless body.is_a?(Hash)
253
+ @errors << Issue.new(path, 'must be a mapping with the key: hosts')
254
+ return
255
+ end
256
+ check_unknown_keys(path, body.keys, DEP_KEYS)
257
+
258
+ hosts = body['hosts']
259
+ unless hosts.is_a?(Hash) && !hosts.empty?
260
+ @errors << Issue.new(
261
+ "#{path}.hosts",
262
+ "must be a non-empty mapping of host selector -> {verify?, install?}; expected:\n" \
263
+ " deps:\n" \
264
+ " #{name}:\n" \
265
+ " hosts:\n" \
266
+ " ubuntu:\n" \
267
+ " verify: dpkg -s #{name}\n" \
268
+ " install: sudo apt-get install -y #{name}"
269
+ )
270
+ return
271
+ end
272
+ hosts.each { |selector, rule| validate_host_rule(name, selector, rule) }
273
+ return unless valid_dep?(name, body)
274
+
275
+ @deps << Dependency.new(name, hosts)
276
+ end
277
+
278
+ def validate_host_rule(name, selector, rule)
279
+ path = "deps.#{name}.hosts.#{selector}"
280
+ unless rule.is_a?(Hash) && !rule.empty?
281
+ @errors << Issue.new(path, "must be a mapping with verify and/or install commands, e.g. { install: sudo apt-get install -y #{name} }")
282
+ return
283
+ end
284
+ check_unknown_keys(path, rule.keys, Dependency::RULE_KEYS)
285
+ rule.each do |key, command|
286
+ unless command.is_a?(String) && !command.strip.empty?
287
+ @errors << Issue.new("#{path}.#{key}", 'must be a non-empty shell command')
288
+ end
289
+ end
290
+ end
291
+
292
+ def valid_dep?(name, body)
293
+ @errors.none? { |e| e.path.start_with?("deps.#{name}") }
294
+ end
295
+
187
296
  def validate_matrix
188
297
  matrix = @raw['matrix']
189
298
  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,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
@@ -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.1'
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.1
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-11 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: []
data/exe/crossbuild DELETED
@@ -1,76 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- require 'optparse'
5
-
6
- begin
7
- require 'crossbuild'
8
- rescue LoadError
9
- require_relative '../lib/crossbuild'
10
- end
11
-
12
- def abort_with(message)
13
- warn message
14
- exit 1
15
- end
16
-
17
- options = { file: 'build.yaml', root: Dir.pwd, target: nil, version: nil, output_base: nil }
18
- parser = OptionParser.new do |opts|
19
- opts.banner = "crossbuild #{Crossbuild::VERSION} — cross-platform build orchestrator\n" \
20
- "Usage: crossbuild <command> [options]\n" \
21
- 'Commands: validate, matrix, version, build, targets'
22
-
23
- opts.on('-f', '--file PATH', 'path to build.yaml (default: ./build.yaml)') { |v| options[:file] = v }
24
- opts.on('--target ID', 'matrix entry to build, e.g. linux/amd64 (default: every host-buildable entry)') { |v| options[:target] = v }
25
- opts.on('--version VERSION', 'override the version scheme for build') { |v| options[:version] = v }
26
- opts.on('--root PATH', 'project root for relative paths (default: .)') { |v| options[:root] = v }
27
- opts.on('--output-base DIR', 'output directory (default: the manifest output, builds)') { |v| options[:output_base] = v }
28
- opts.on('-h', '--help', 'show help') do
29
- puts opts
30
- exit
31
- end
32
- end
33
- parser.parse!
34
-
35
- command = ARGV.shift
36
- abort_with 'No command given. See: crossbuild --help' if command.nil?
37
-
38
- # Positional file path: crossbuild validate path/to/build.yaml
39
- options[:file] = ARGV.shift if ARGV.first && !ARGV.first.start_with?('-')
40
-
41
- file = File.expand_path(options[:file], options[:root])
42
- begin
43
- manifest = Crossbuild::BuildManifest.load(file)
44
- rescue Crossbuild::InvalidManifestError => e
45
- abort_with e.message
46
- end
47
-
48
- case command
49
- when 'validate'
50
- puts manifest.error_report
51
- exit(manifest.valid? ? 0 : 1)
52
- when 'matrix'
53
- abort_with 'Schema is invalid — the matrix cannot be rendered. Run: crossbuild validate' unless manifest.valid?
54
- puts Crossbuild::Matrix.new(manifest).render
55
- when 'version'
56
- abort_with 'Schema is invalid. Run: crossbuild validate' unless manifest.valid?
57
- begin
58
- puts Crossbuild::VersionScheme.new(manifest.version).compute(root: options[:root])
59
- rescue Crossbuild::VersionScheme::Error => e
60
- abort_with e.message
61
- end
62
- when 'build'
63
- begin
64
- result = Crossbuild::Builder.new(manifest, root: options[:root], output_base: options[:output_base],
65
- version: options[:version]).run(entry_id: options[:target])
66
- rescue Crossbuild::Error, Crosspack::InvalidManifestError => e
67
- abort_with e.message
68
- end
69
- puts "\n📦 #{result.entries.size} entr#{result.entries.size == 1 ? 'y' : 'ies'} built, version #{result.version}"
70
- puts 'Pack with: crosspack pack --target <target> --version ' + result.version
71
- when 'targets'
72
- base = options[:output_base] || manifest.output
73
- puts Crosspack::Builds.report(File.expand_path(base, options[:root]))
74
- else
75
- abort_with "Unknown command #{command.inspect}. Available: validate, matrix, version, build, targets"
76
- end