native-packages 0.5.1 → 0.7.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,7 +1,7 @@
1
1
  # Supply the listed build outputs before running this example.
2
2
  schema: 1
3
3
  tool:
4
- version: '0.5.1'
4
+ version: '0.7.0'
5
5
  nfpm: '2.47.0'
6
6
  nfpm:
7
7
  name: example-app
@@ -1,6 +1,6 @@
1
1
  schema: 1
2
2
  tool:
3
- version: '0.5.1'
3
+ version: '0.7.0'
4
4
  nfpm: '2.47.0'
5
5
  nfpm:
6
6
  name: example-app
@@ -85,7 +85,7 @@ module NativePackages
85
85
  configuration.validate
86
86
  selected = configuration.select(ids: ids, formats: formats)
87
87
  number = version(value, release)
88
- configuration.check_prerelease(number, selected)
88
+ configuration.check_prerelease(number, selected, defer_recipes: defer_recipes)
89
89
  if release && selected.values.any? { |target| target["native"] }
90
90
  raise Error, "native recipes consume local prepared directories; use --version with native build artifacts"
91
91
  end
@@ -330,16 +330,16 @@ module NativePackages
330
330
  raise Error, "missing recipe outputs: #{missing.join(', ')}" unless missing.empty?
331
331
  end
332
332
 
333
- def verify(output, complete: true)
333
+ def verify(output, complete: true, ids: [])
334
334
  output = Pathname.new(output).expand_path(root)
335
335
  manifest = JSON.parse((output / "build.json").read)
336
336
  raise Error, "unsupported build manifest" unless manifest.fetch("schema") == 1
337
337
  raise Error, "build belongs to another project/configuration" unless manifest.fetch("name") == configuration.name && manifest.fetch("configuration") == configuration.digest
338
338
  number = configuration.package_version(manifest.fetch("version"))
339
339
  selected = configuration.select(ids: manifest.fetch("targets").keys)
340
- configuration.check_prerelease(number, selected)
341
- expected = configuration.targets.transform_values { |target| target.fetch("formats") }
342
- raise Error, "incomplete target set; aggregate all configured targets before publishing" if complete && manifest.fetch("targets") != expected
340
+ configuration.check_prerelease(number, selected, defer_recipes: manifest.dig("recipes", "state") == "deferred")
341
+ expected = configuration.select(ids: ids).transform_values { |target| target.fetch("formats") }
342
+ raise Error, "incomplete target set; supply every format for all requested targets before publishing" if complete && manifest.fetch("targets") != expected
343
343
  expected_pairs = manifest.fetch("targets").flat_map { |id, formats| formats.map { |format| [id, format] } }.sort
344
344
  actual_pairs = manifest.fetch("packages").map { |entry| entry.values_at("target", "format") }.sort
345
345
  raise Error, "manifest packages do not match its target set" unless actual_pairs == expected_pairs
@@ -357,10 +357,10 @@ module NativePackages
357
357
  manifest
358
358
  end
359
359
 
360
- def publish(output, destinations, body_file: nil)
360
+ def publish(output, destinations, body_file: nil, ids: [])
361
361
  configuration.validate
362
362
  output = Pathname.new(output).expand_path(root)
363
- manifest = verify(output)
363
+ manifest = verify(output, ids: ids)
364
364
  raise Error, "supply at least one --to destination" if destinations.empty?
365
365
  destinations.each { |name| project.repositories.select(name) unless name == "github" }
366
366
  if manifest.fetch("version").include?("-")
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "build"
4
4
  require_relative "scaffold"
5
+ require_relative "release_signing"
5
6
 
6
7
  module NativePackages
7
8
  module CLI
@@ -15,9 +16,14 @@ module NativePackages
15
16
  [--output DIRECTORY] [--dry-run] Choose output or inspect the plan
16
17
  [--defer-recipes] Build targets without recipe assets/tools
17
18
  notarize-macos DIRECTORY --output DIRECTORY Copy, sign and notarize portable Mac code
19
+ release-checksums DIRECTORY --output FILE Hash a flat directory of release artifacts
20
+ sign-checksums FILE --public-key HEX_FILE Sign verified checksums with Ed25519
21
+ [--key-env VARIABLE] Default: NATIVE_PACKAGES_SIGNING_KEY
22
+ verify-checksums FILE --public-key HEX_FILE Verify signature and artifact hashes
18
23
  aggregate DIR... --output DIRECTORY Combine target builds before publishing
19
24
  [--finalize-recipes] Generate recipes from deferred builds
20
25
  publish --from DIRECTORY --to github,aur Publish a complete, verified build
26
+ [--target ID] Require exactly these complete targets
21
27
  migrate [--dry-run] Convert packaging/project.yml
22
28
  repositories List downstream destinations
23
29
  stage TARGET DIRECTORY Stage prepared recipes
@@ -49,8 +55,13 @@ module NativePackages
49
55
  flags.on("--help", "-h") { puts HELP; return }
50
56
  flags.on("--output DIRECTORY") { |value| options[:output] = Pathname.new(value).expand_path(root) } if %w[build aggregate artifacts prepare notarize-macos].include?(command)
51
57
  flags.on("--body-file FILE") { |value| options[:body_file] = Pathname.new(value).expand_path(root) } if command == "publish"
58
+ flags.on("--output FILE") { |value| options[:output] = Pathname.new(value).expand_path(root) } if command == "release-checksums"
59
+ if %w[sign-checksums verify-checksums].include?(command)
60
+ flags.on("--public-key FILE") { |value| options[:public_key] = Pathname.new(value).expand_path(root) }
61
+ end
62
+ flags.on("--key-env VARIABLE") { |value| options[:key_env] = value } if command == "sign-checksums"
63
+ flags.on("--target ID") { |value| options[:ids] << value } if %w[build doctor publish].include?(command)
52
64
  if %w[build doctor].include?(command)
53
- flags.on("--target ID") { |value| options[:ids] << value }
54
65
  flags.on("--format FORMAT") { |value| options[:formats] << value }
55
66
  flags.on("--release TAG") { |value| options[:release] = value }
56
67
  flags.on("--defer-recipes") { options[:defer_recipes] = true }
@@ -77,11 +88,23 @@ module NativePackages
77
88
  end
78
89
  end
79
90
  parser.parse!(arguments)
91
+ raise Error, "publish --target requires --from" if command == "publish" && !options[:from] && !options[:ids].empty?
80
92
  arity = { "init" => 0..0, "migrate" => 0..0, "build" => 0..0, "doctor" => 0..0, "aggregate" => 1..,
93
+ "release-checksums" => 1..1, "sign-checksums" => 1..1, "verify-checksums" => 1..1,
81
94
  "prepare" => 1..1, "check" => 1..1, "artifacts" => 1..1, "publish-release" => 2..2,
82
95
  "notarize-macos" => 1..1, "repositories" => 0..0, "stage" => 2..2, "diff" => 1..1, "publish" => options[:from] ? 0..0 : 1..1,
83
96
  "publish-aur" => 1..1, "status" => 0..1, "check-version" => 1..1, "validate" => 0..0 }[command]
84
97
  raise Error, HELP unless arity&.cover?(arguments.length)
98
+ if %w[release-checksums sign-checksums verify-checksums].include?(command)
99
+ path = Pathname.new(arguments.first).expand_path(root)
100
+ if command == "release-checksums"
101
+ raise Error, "release-checksums requires --output" unless options[:output]
102
+ return ReleaseSigning.checksums(path, output: options.fetch(:output))
103
+ end
104
+ raise Error, "#{command} requires --public-key" unless options[:public_key]
105
+ return ReleaseSigning.sign(path, **options.slice(:public_key, :key_env)) if command == "sign-checksums"
106
+ return ReleaseSigning.verify(path, public_key: options.fetch(:public_key))
107
+ end
85
108
  if command == "init"
86
109
  raise Error, "init writes native-packages.yaml in the current directory" if config_path
87
110
  return Scaffold.new(root).init(**options.slice(:interactive, :name, :input).merge(formats: options[:init_formats]))
@@ -104,7 +127,7 @@ module NativePackages
104
127
  when "aggregate"
105
128
  raise Error, "aggregate requires --output" unless options[:output]
106
129
  return builder.aggregate(arguments, output: options.fetch(:output), finalize_recipes: options.fetch(:finalize_recipes, false))
107
- when "publish" then return builder.publish(options.fetch(:from), options.fetch(:destinations), body_file: options[:body_file])
130
+ when "publish" then return builder.publish(options.fetch(:from), options.fetch(:destinations), body_file: options[:body_file], ids: options[:ids])
108
131
  end
109
132
  end
110
133
  if configuration
@@ -56,10 +56,10 @@ module NativePackages
56
56
  def package(target) = merge(data.fetch("nfpm"), target.fetch("nfpm", {}))
57
57
  def package_version(value) = version_arg(value, prerelease: data.fetch("release").fetch("prereleases", false))
58
58
 
59
- def check_prerelease(version, selected)
59
+ def check_prerelease(version, selected, defer_recipes: false)
60
60
  return unless version.include?("-")
61
61
  raise Error, "prerelease builds require release.prereleases: true" unless data.fetch("release")["prereleases"] == true
62
- unless selected.values.all? { |target| (target.fetch("formats") - %w[deb rpm dmg inno]).empty? } && data.fetch("templates").empty?
62
+ unless selected.values.all? { |target| (target.fetch("formats") - %w[deb rpm dmg inno]).empty? } && (defer_recipes || data.fetch("templates").empty?)
63
63
  raise Error, "prereleases support deb, rpm, dmg and inno only, without downstream recipes"
64
64
  end
65
65
  end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module NativePackages
6
+ # Portable release manifests, independent of package format or CI provider.
7
+ # Ed25519 signs the exact file bytes (no prehash, encoding or canonicalization).
8
+ module ReleaseSigning
9
+ LIMIT = 1024 * 1024
10
+ PUBLIC_PREFIX = ["302a300506032b6570032100"].pack("H*").freeze
11
+ FILENAME = /\A[A-Za-z0-9][A-Za-z0-9._+\-]*\z/
12
+ module_function
13
+
14
+ def checksums(directory, output:)
15
+ directory = Pathname.new(directory).realpath
16
+ output = Pathname.new(output).expand_path
17
+ raise Error, "invalid checksum filename" unless FILENAME.match?(output.basename.to_s)
18
+ raise Error, "checksum output must be inside the artifact directory" unless output.dirname.realpath == directory
19
+ raise Error, "refusing to overwrite checksum output" if output.exist? || output.symlink?
20
+ entries = directory.children.sort
21
+ raise Error, "no release artifacts" if entries.empty?
22
+ names = entries.map { |path| path.basename.to_s }
23
+ raise Error, "ambiguous artifact filenames" unless names.map(&:downcase).uniq.length == names.length
24
+ manifest = entries.map do |path|
25
+ name = path.basename.to_s
26
+ raise Error, "invalid artifact filename" unless FILENAME.match?(name)
27
+ raise Error, "signature already exists" if name == "#{output.basename}.sig"
28
+ regular_file!(path)
29
+ "#{Digest::SHA256.file(path).hexdigest} #{name}\n"
30
+ end.join
31
+ raise Error, "release manifest exceeds 1 MiB" if manifest.bytesize > LIMIT
32
+ write_new(output, manifest)
33
+ end
34
+
35
+ def sign(manifest, public_key:, key_env: "NATIVE_PACKAGES_SIGNING_KEY")
36
+ raise Error, "invalid signing key variable name" unless /\A[A-Z][A-Z0-9_]*\z/.match?(key_env)
37
+ pem = ENV.delete(key_env)
38
+ raise Error, "missing signing key environment variable" if pem.nil? || pem.empty?
39
+ # No subprocess receives the private key, and no private file is created.
40
+ key = OpenSSL::PKey.read(pem, "")
41
+ trusted = read_public_key(public_key)
42
+ raise Error, "signing key does not match trusted Ed25519 public key" unless key.oid == "ED25519" && key.public_to_der == trusted.public_to_der
43
+ manifest = Pathname.new(manifest)
44
+ bytes = bounded_read(manifest, LIMIT)
45
+ verify_files(manifest, bytes)
46
+ signature = key.sign(nil, bytes)
47
+ raise Error, "could not verify generated signature" unless signature.bytesize == 64 && trusted.verify(nil, signature, bytes)
48
+ write_new(Pathname.new("#{manifest}.sig"), signature)
49
+ rescue OpenSSL::OpenSSLError
50
+ # Do not expose parser diagnostics from secret input.
51
+ raise Error, "could not use Ed25519 signing key"
52
+ end
53
+
54
+ def verify(manifest, public_key:)
55
+ manifest = Pathname.new(manifest)
56
+ bytes = bounded_read(manifest, LIMIT)
57
+ signature = bounded_read(Pathname.new("#{manifest}.sig"), 64)
58
+ trusted = read_public_key(public_key)
59
+ raise Error, "invalid release signature" unless signature.bytesize == 64 && trusted.verify(nil, signature, bytes)
60
+ verify_files(manifest, bytes)
61
+ true
62
+ rescue OpenSSL::OpenSSLError
63
+ raise Error, "invalid Ed25519 public key or release signature"
64
+ end
65
+
66
+ def read_public_key(path)
67
+ hex = bounded_read(Pathname.new(path), 128).strip
68
+ raise Error, "public key must be 32 bytes encoded as hex" unless /\A[0-9a-fA-F]{64}\z/.match?(hex)
69
+ OpenSSL::PKey.read(PUBLIC_PREFIX + [hex].pack("H*"))
70
+ end
71
+
72
+ def verify_files(manifest, bytes)
73
+ raise Error, "empty release manifest" if bytes.empty?
74
+ seen = {}
75
+ bytes.each_line do |line|
76
+ match = /\A([0-9a-fA-F]{64}) ([A-Za-z0-9][A-Za-z0-9._+\-]*)\n\z/.match(line)
77
+ raise Error, "invalid release checksum entry" unless match
78
+ digest, name = match.captures
79
+ raise Error, "duplicate or self-referencing checksum entry" if seen[name.downcase] || [manifest.basename.to_s.downcase, "#{manifest.basename}.sig".downcase].include?(name.downcase)
80
+ seen[name.downcase] = true
81
+ path = manifest.dirname / name
82
+ regular_file!(path)
83
+ raise Error, "release artifact checksum mismatch: #{name}" unless Digest::SHA256.file(path).hexdigest == digest.downcase
84
+ end
85
+ end
86
+
87
+ def regular_file!(path)
88
+ raise Error, "release input must be a regular file: #{path.basename}" unless path.lstat.file?
89
+ end
90
+
91
+ def bounded_read(path, limit)
92
+ regular_file!(path)
93
+ bytes = File.binread(path, limit + 1) || "".b
94
+ raise Error, "release metadata exceeds size limit" if bytes.bytesize > limit
95
+ bytes
96
+ end
97
+
98
+ def write_new(path, bytes)
99
+ File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o644) do |file|
100
+ file.binmode
101
+ file.write(bytes)
102
+ end
103
+ path
104
+ end
105
+ end
106
+ end
@@ -12,7 +12,7 @@ require "tmpdir"
12
12
  require "yaml"
13
13
 
14
14
  module NativePackages
15
- VERSION = "0.5.1"
15
+ VERSION = "0.7.0"
16
16
  class Error < StandardError; end
17
17
 
18
18
  module Support
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: native-packages
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Carmine Paolino
@@ -16,12 +16,30 @@ executables:
16
16
  extensions: []
17
17
  extra_rdoc_files: []
18
18
  files:
19
+ - CONTRIBUTING.md
19
20
  - LICENSE
20
21
  - README.md
22
+ - docs/404.md
23
+ - docs/_guides/apple-notarization.md
24
+ - docs/_guides/building-packages.md
25
+ - docs/_guides/distribution-recipes.md
26
+ - docs/_guides/getting-started.md
27
+ - docs/_guides/github-actions.md
28
+ - docs/_guides/multi-platform.md
29
+ - docs/_guides/native-recipes.md
30
+ - docs/_guides/prereleases.md
31
+ - docs/_guides/publishing.md
32
+ - docs/_guides/release-signing.md
33
+ - docs/_reference/commands.md
34
+ - docs/_reference/configuration.md
35
+ - docs/_reference/platforms.md
36
+ - docs/_reference/troubleshooting.md
21
37
  - docs/acceptance/0.5.0-notarization.md
22
38
  - docs/apple-notarization.md
39
+ - docs/assets/images/logo.svg
23
40
  - docs/cli-design.md
24
41
  - docs/configuration.md
42
+ - docs/index.md
25
43
  - docs/legacy.md
26
44
  - docs/native-recipes.md
27
45
  - docs/platforms.md
@@ -41,6 +59,7 @@ files:
41
59
  - lib/native_packages/macos_signing.rb
42
60
  - lib/native_packages/native_recipe.rb
43
61
  - lib/native_packages/project.rb
62
+ - lib/native_packages/release_signing.rb
44
63
  - lib/native_packages/repositories.rb
45
64
  - lib/native_packages/scaffold.rb
46
65
  - lib/native_packages/support.rb
@@ -64,7 +83,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
64
83
  - !ruby/object:Gem::Version
65
84
  version: '0'
66
85
  requirements: []
67
- rubygems_version: 4.0.16
86
+ rubygems_version: 4.0.20
68
87
  specification_version: 4
69
- summary: Shared release packaging and downstream recipe updates with nFPM
88
+ summary: Build native application packages and publish release updates
70
89
  test_files: []