native-packages 0.2.0 → 0.3.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: 0f937a8788833696a7e7f71ae4a2892c9fbd5dc9da1492376155530e0fa349e6
4
- data.tar.gz: 7dc4b26da615673f0639bdb54344978bc60f54386451e35286e00bb76f8228de
3
+ metadata.gz: 164594265b7f47190d318ad3507446103c389206e0ab2901a54371b21c53f0ed
4
+ data.tar.gz: 79cfdca9089b718e2e65f4203e3ca4629542517e859992340c79229df715e68c
5
5
  SHA512:
6
- metadata.gz: 6a66d883eb494ac1a0d04311fa09a844a110b88c9fd2e75df1178f4fb79e027fdd694185c15169a705a77b99a62f041e0a9f4415617ff20dc6a1b40aaf457fb5
7
- data.tar.gz: a0900c282473d8b1b1b1da1855889a2279dcaa8eaddefc7abf7aaa554c6db40435f86aae1def077e189294adc3b1c142247a62fdeefeb9a34073e93abee460fc
6
+ metadata.gz: 587d2a749a8b5d6f380a37ca932dbb0d587bd6bee763269a5783a54d8f50bc5ab0102b32958c6eabcab79de4447b4acaa7cc8e063340a848a123b9b34d851342
7
+ data.tar.gz: a0935ea30ddf612535a11903912b2bfbe34ec947ac396254b0abefb0182e9a54e0551f797520916cb402208421251dfb2cc583fdf050c2e1eef1f3808ca82194
data/README.md CHANGED
@@ -6,14 +6,11 @@ Applications keep their build scripts, installation assets and native recipes. T
6
6
 
7
7
  ## Install and build
8
8
 
9
- Version 0.2 is being prepared for RubyGems publication. Until published, build and install the gem from this repository:
10
-
11
9
  ```sh
12
- gem build native-packages.gemspec
13
- gem install ./native-packages-0.2.0.gem
10
+ gem install native-packages
14
11
  ```
15
12
 
16
- After publication, installation is `gem install native-packages`. Install nFPM 2.47.0 separately for local use; the reusable CI workflow installs it for you. Ruby 3.2 or later is required.
13
+ Install nFPM 2.47.0 separately for local use; the reusable CI workflow installs it for you. Ruby 3.2 or later is required.
17
14
 
18
15
  From an application directory:
19
16
 
@@ -59,6 +56,19 @@ Linux binary targets declare `libc: glibc`, `musl` or `static`. The tool inspect
59
56
 
60
57
  See [configuration and commands](docs/configuration.md) for tokens, release assets, hooks, version selection and publishing, and [platform coverage](docs/platforms.md) for each format's requirements.
61
58
 
59
+ Version 0.3 adds [native DMG and Inno recipes](docs/native-recipes.md). These run
60
+ the application's existing packaging commands on macOS or Windows, using the
61
+ same verified build manifests as Linux packages. Native recipes consume prepared
62
+ directories and need no nFPM installation. The gem checks Mach-O/PE architecture,
63
+ preserves safe internal bundle links and hashes the output after signing hooks.
64
+ App compilation, installer policy and signing identities stay in the application
65
+ repository.
66
+
67
+ Preview package versions are explicit: set `release.prereleases: true` to build
68
+ `1.2.3-alpha.N`, `-beta.N` or `-rc.N` for DEB/RPM/DMG/Inno. Other formats and
69
+ downstream recipe publication remain stable-only. Uploads require an existing
70
+ GitHub release already marked as a prerelease.
71
+
62
72
  The [all-formats example](examples/native-packages-all-formats.yaml) shows separate Linux, OpenWrt, Windows and source inputs, including MSIX identity/assets and an SRPM source/spec layout.
63
73
 
64
74
  ## Release inputs and publication
@@ -85,14 +95,14 @@ packaging:
85
95
  needs: release
86
96
  permissions:
87
97
  contents: write
88
- uses: crmne/native-packages/.github/workflows/package.yml@v0.2.0
98
+ uses: crmne/native-packages/.github/workflows/package.yml@v0.3.0
89
99
  with:
90
100
  version: ${{ github.ref_name }}
91
101
  publish: true
92
102
  secrets: inherit
93
103
  ```
94
104
 
95
- This reference becomes available when v0.2.0 is tagged. Pin the corresponding commit SHA for an immutable workflow reference. With no version, the workflow validates configuration only. With a version, it installs the configured gem and nFPM, builds packages and uploads an Actions artifact. `publish: true` attaches packages to the existing release.
105
+ Pin the corresponding commit SHA for an immutable workflow reference. With no version, the workflow validates configuration only. With a version, it installs the configured gem and nFPM, builds packages and uploads an Actions artifact. `publish: true` attaches packages to the existing release.
96
106
 
97
107
  To consume an Actions artifact instead, set `source-artifact` and, if necessary, `source-directory` (default `dist`). Its files must match the local input paths in the configuration. This workflow packages on Linux, including MSIX creation from Windows binaries; native application builds and platform-specific signing jobs can use the CLI separately.
98
108
 
@@ -111,7 +121,7 @@ Migration combines `packaging/project.yml`, its nFPM definition and repository r
111
121
  bundle install
112
122
  bundle exec ruby -Ilib -e 'Dir["test/*_test.rb"].sort.each { |path| require_relative path }'
113
123
  gem build native-packages.gemspec
114
- ruby test/gem_install.rb native-packages-0.2.0.gem
124
+ ruby test/gem_install.rb native-packages-0.3.0.gem
115
125
  ```
116
126
 
117
127
  Tests build real packages, inspect their payloads and exercise repository publication against local Git fixtures. CI additionally runs disposable Linux install/upgrade/remove checks, SRPM rebuilds and Windows MSIX acceptance. Runtime build manifests report installation as `not-tested`: CI fixture coverage is not a substitute for testing each application's packages.
data/docs/cli-design.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Proposal: an installable CLI with one project configuration
2
2
 
3
+ The owner approved compatible shared support for RekordFlash's native alpha
4
+ packaging on 2026-09-13. Version 0.3's [native recipe and prerelease policy](native-recipes.md)
5
+ extends the original stable-only policy explicitly; published 0.2 consumers stay
6
+ pinned. DMG/Inno adapters coordinate existing app scripts and retain their
7
+ signing/version decisions, without adding a compiler or plugin framework.
8
+
3
9
  Status: accepted, 2026-09-13. Publishing was subsequently aligned with RubyLLM: GitHub release publication triggers the workflow, using `RUBYGEMS_AUTH_TOKEN`. The original Trusted Publishing proposal below is superseded by that decision. The v0.2 implementation follows this design. See the current [configuration reference](configuration.md) and [release setup](releasing.md) for the implemented interface and publication requirements. This document records the original design; it does not describe v0.1.0.
4
10
 
5
11
  The proposed interface is `gem install native-packages`, `native-packages init`, and `native-packages build`. A project normally needs one `native-packages.yaml` file. The tool should expose every nFPM packager and use the application's declared inputs to decide what to build.
@@ -19,6 +19,12 @@
19
19
 
20
20
  ## Targets
21
21
 
22
+ Version 0.3 additionally accepts macOS `dmg` and Windows `inno` targets with an
23
+ application-owned `native.command` and `native.output`. These consume local
24
+ prepared directories on their native host, with no nFPM executable required.
25
+ See [native recipes](native-recipes.md) for the complete contract and examples.
26
+ The nFPM targets below retain their existing behavior.
27
+
22
28
  A target declares `platform` (`linux` or `windows`), `arch` (nFPM/Go architecture name), `formats`, and `input`. `kind` defaults to `binary`; `data` permits packages without executables, and `source` is required for a separate SRPM target. Linux binaries declare `libc` as `glibc`, `musl` or `static`. IPK also requires an `abi` label identifying the device/distribution baseline.
23
29
 
24
30
  A target's optional `nfpm` mapping overrides the shared definition. Maps merge recursively; lists replace in full. Package name, version, platform and architecture must not conflict with their canonical declarations. Format-specific architecture overrides belong in the corresponding nFPM section. Distinct target inputs must be supplied for incompatible ABIs or platforms.
@@ -41,7 +47,7 @@ Strings can contain `@NAME@`, `@VERSION@`, `@TAG@`, `@DATE@`, `@SOURCE_DATE_EPOC
41
47
 
42
48
  Additional `assets` retain the existing `file`, optional `url`, and `checksummed` fields. Local builds require `local` paths for these assets. Release builds download and hash them, checking the published checksum unless `checksummed: false` is explicitly set for a source asset. Each contributes `@KEY_FILE@`, `@KEY_URL@` and `@KEY_SHA256@` to recipe rendering. Unresolved tokens fail validation/build.
43
49
 
44
- Omitting `--version` requires an exact stable release tag at HEAD. `--release` supplies the version and conflicts with a different `--version`. Stable versions currently use `vMAJOR.MINOR.PATCH`; prerelease tags are rejected. Build timestamps use `SOURCE_DATE_EPOCH`, otherwise the relevant Git commit timestamp, otherwise the current time for projects without Git metadata. Supply `SOURCE_DATE_EPOCH` for repeatable builds outside Git.
50
+ Omitting `--version` requires an exact supported release tag at HEAD. `--release` supplies the version and conflicts with a different `--version`. Versions default to stable `vMAJOR.MINOR.PATCH`. Setting `release.prereleases: true` additionally permits `-alpha.N`, `-beta.N` and `-rc.N` for DEB/RPM/DMG/Inno builds without downstream recipes; preview upload requires an existing GitHub prerelease. Native directory recipes use local `--version` mode. Build timestamps use `SOURCE_DATE_EPOCH`, otherwise the relevant Git commit timestamp, otherwise the current time for projects without Git metadata. Supply `SOURCE_DATE_EPOCH` for repeatable builds outside Git.
45
51
 
46
52
  ## Output and publication
47
53
 
@@ -0,0 +1,127 @@
1
+ # Native DMG and Inno recipes (0.3)
2
+
3
+ The shared build command can coordinate existing macOS and Windows packaging
4
+ scripts. These are native command adapters, separate from nFPM. They do not
5
+ generate an installer recipe, compile an app, supply signing identities or
6
+ automatically notarize anything. The application retains those decisions.
7
+
8
+ ```yaml
9
+ schema: 1
10
+ tool:
11
+ version: '0.3.0'
12
+ nfpm: '2.47.0'
13
+ nfpm:
14
+ name: example-app
15
+ maintainer: Example <example@example.org>
16
+ description: Example application
17
+ license: MIT
18
+ release:
19
+ repository: example/example-app
20
+ prereleases: true
21
+ targets:
22
+ macos-arm64:
23
+ platform: macos
24
+ arch: arm64
25
+ formats: [dmg]
26
+ input:
27
+ kind: directory
28
+ local: Example.app
29
+ native:
30
+ command: [ruby, packaging/dmg.rb, '@PAYLOAD@', '@PACKAGE@', '@VERSION@']
31
+ output: 'example-app-@TAG@-macos-arm64.dmg'
32
+ windows-amd64:
33
+ platform: windows
34
+ arch: amd64
35
+ formats: [inno]
36
+ input:
37
+ kind: directory
38
+ local: dist/windows
39
+ native:
40
+ command: [ruby, packaging/setup.rb, '@PAYLOAD@', '@PACKAGE@', '@VERSION@']
41
+ output: 'example-app-@TAG@-windows-setup.exe'
42
+ ```
43
+
44
+ Run each target on its own host using `build --version VERSION --target ID`.
45
+ Use the normal `aggregate` command to combine complete target results later.
46
+ Use the same configuration and `SOURCE_DATE_EPOCH` across jobs. No nFPM binary
47
+ is required when only native targets are selected; its version remains part of
48
+ the configuration for compatibility with mixed Linux/native builds.
49
+
50
+ `native.command` is a nonempty argument array, including `@PACKAGE@`, which
51
+ resolves to the exact output filename in a fresh directory. `@PAYLOAD@` is an
52
+ owned copy of the input directory's contents. It does not retain the original
53
+ directory's basename: a DMG script should copy it into its own temporary
54
+ `Example.app` folder before calling hdiutil. Other existing target tokens apply.
55
+ Commands run in the application configuration directory and receive the same
56
+ target/version environment as other build hooks.
57
+
58
+ DMG requires a macOS binary target; Inno requires a Windows binary target. Both
59
+ use one format per target and `input.kind: directory` with `input.local`.
60
+ Release-download mode cannot supply a directory: prepare or download/unpack
61
+ the app in its build job, then use local mode. `validate` and dry runs do not
62
+ execute commands. `doctor` checks the host and recipe executable; the recipe
63
+ must check its own additional tools. Mac architecture inspection also needs
64
+ Apple's `lipo`. Supported Mac targets are `amd64`, `arm64` and `universal` (both).
65
+
66
+ Input copying preserves file modes and internal relative symlinks, including
67
+ framework links. Escaping, dangling and special-file entries fail. The staged
68
+ tree must match the original digest and remain unchanged after packaging and
69
+ signing hooks. Package only this staged input; do not read another application
70
+ build from a global location. Recipes should preserve existing destinations,
71
+ clean only their own temporary files and use argument arrays for subprocesses.
72
+
73
+ The command must create exactly its declared `.dmg` or `.exe` file. The adapter
74
+ checks a UDIF trailer or PE container respectively. This is a container check,
75
+ not proof of a valid installer, signature or application. The existing
76
+ `after_package` hook can sign/notarize the output in place before the final hash
77
+ is recorded. Do not modify a package after building its manifest: signature
78
+ stapling changes bytes and must run inside that hook if the manifest is to verify.
79
+
80
+ Applications should invoke hdiutil's verification, platform signature checks and
81
+ their normal installer compiler checks in their recipes. Preserve native numeric
82
+ version ordering across alpha and stable releases; a SemVer suffix cannot be
83
+ passed directly to a numeric Windows/macOS version field. The gem does not infer
84
+ that policy from an application's release numbering.
85
+
86
+ ## Prerelease policy
87
+
88
+ `release.prereleases: true` explicitly enables `MAJOR.MINOR.PATCH-alpha.N`,
89
+ `-beta.N` and `-rc.N`, with a positive sequence number and no build suffix.
90
+ Default/legacy commands continue to require stable versions. Preview builds
91
+ support DEB, RPM, DMG and Inno only and cannot contain downstream recipes.
92
+ nFPM's normal SemVer conversion produces, for example, `1.2.3~alpha.1` in DEB/RPM;
93
+ overriding that prerelease or disabling conversion is rejected. Other package
94
+ formats need their own reviewed version policy before being enabled.
95
+
96
+ Preview publication only attaches to an existing GitHub release marked as a
97
+ prerelease. It never creates a release or changes one from stable to prerelease.
98
+ All configured targets must be aggregated and their hashes must verify first.
99
+ This opt-in cannot affect applications still pinned to the published 0.2.0 gem.
100
+
101
+ ## Acceptance scope
102
+
103
+ `ruby -Ilib test/native_acceptance.rb NEW_DIRECTORY` builds a small real native
104
+ executable and packages alpha 1, alpha 2 and stable versions. It installs into an
105
+ owned disposable directory, checks the executable/version and model fixture,
106
+ rolls back to alpha 1 and removes the installation. Mac images are attached
107
+ read-only and detached; Windows uses a unique per-user fixture identity and
108
+ uninstaller. On Windows, run with a C compiler environment and set
109
+ `NATIVE_PACKAGES_ISCC` to the actual Inno compiler path.
110
+
111
+ These are gem fixtures, not application installation evidence. They do not
112
+ exercise a DJ library, USB export, licensing, notarization or OS-store acceptance.
113
+ The generated application build manifest continues to say `installation:
114
+ not-tested`; do not turn that into a success claim merely because gem CI passed.
115
+
116
+ The [2026-09-13 native record](acceptance/0.3.0-native.json) records successful
117
+ DMG lifecycle checks on an ARM64 Mac and Inno lifecycle checks on Windows x64.
118
+ Both covered alpha 1 → alpha 2 → stable → alpha 1 and final removal, preserving
119
+ the fixture model and exact executable bytes. The native Mac run exposed the
120
+ SSH-locale command-output bug, fixed before the successful run. Inno Setup
121
+ 6.7.3's official installer was verified against its SHA-256 and Pyrsys B.V.
122
+ signature; fixtures compiled with MSVC 14.51. Linux passed 49 tests and 287
123
+ assertions, including all existing package formats; the isolated gem install
124
+ and build check passed. These local results precede hosted CI for this version.
125
+
126
+ References: [nFPM version conversion](https://nfpm.goreleaser.com/docs/configuration/),
127
+ [Inno command-line compiler](https://jrsoftware.org/ishelp/topic_compilercmdline.htm).
data/docs/platforms.md CHANGED
@@ -15,7 +15,9 @@ All seven nFPM 2.47.0 packagers are exposed by the v0.2 CLI. Each target explici
15
15
  | Homebrew | Hash/render supplied formulae or casks and publish a tap | macOS bundles, signing/notarization, native recipe logic |
16
16
  | Nixpkgs/Gentoo/Void and similar repositories | Template rendering and configured Git/PR/MR publication | Native recipes, dependency hashes, distro checks and review |
17
17
  | Flatpak | No build/publication adapter yet | Use flatpak-builder and native manifests/runtime/sandbox settings |
18
- | Other Windows installers | No installer adapter beyond MSIX | Existing Inno Setup/NSIS/WiX and WinGet/Scoop integrations |
18
+ | Inno Setup | Native command adapter, PE inspection, copied input and final output hashes | Inno recipe/compiler, application version mapping, signing and installation tests |
19
+ | macOS DMG | Native command adapter, Mach-O inspection, copied bundle and final output hashes | Bundle, hdiutil recipe, signing/notarization, update/rollback testing |
20
+ | Other Windows installers | No adapter for NSIS/WiX yet | Existing native tools and WinGet/Scoop integrations |
19
21
 
20
22
  The IPK acceptance fixture targets OpenWrt 24.10.8. OpenWrt 25.12 switched to APK, so IPK is not the package format for every OpenWrt release. [OpenWrt 25.12 release notes](https://openwrt.org/releases/25.12/notes-25.12.0)
21
23
 
data/docs/releasing.md CHANGED
@@ -23,7 +23,7 @@ Update the gemspec, `NativePackages::VERSION`, example/version references and ch
23
23
  Create a GitHub release for that commit:
24
24
 
25
25
  ```sh
26
- gh release create v0.2.0 --target main --title 'native-packages 0.2.0' --notes-file release-notes.md
26
+ gh release create v0.3.0 --target main --title 'native-packages 0.3.0' --notes-file release-notes.md
27
27
  ```
28
28
 
29
29
  Adding `--draft` permits review before publication and does not trigger the publisher. The tag must match the exact gem version and point to a commit on `main`. Set `--prerelease` only for a prerelease gem version.
@@ -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.2.0'
4
+ version: '0.3.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.2.0'
3
+ version: '0.3.0'
4
4
  nfpm: '2.47.0'
5
5
  nfpm:
6
6
  name: example-app
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "configuration"
4
4
  require_relative "inspection"
5
+ require_relative "native_recipe"
5
6
 
6
7
  module NativePackages
7
8
  class Build
@@ -29,7 +30,12 @@ module NativePackages
29
30
  def doctor(ids: [], formats: [], release: false)
30
31
  configuration.validate
31
32
  selected = configuration.select(ids: ids, formats: formats)
32
- nfpm_version unless selected.empty?
33
+ nfpm_version if selected.values.any? { |target| (target.fetch("formats") & Configuration::NATIVE_FORMATS).empty? }
34
+ selected.each do |id, target|
35
+ next unless target["native"]
36
+ metadata = configuration.target_tokens(configuration.tokens("9.8.7"), id, target, "/payload").merge("PACKAGE" => "/output/package", "FORMAT" => target.fetch("formats").first)
37
+ NativeRecipe.new(root).doctor(target, render_tree(target.fetch("native").fetch("command"), metadata))
38
+ end
33
39
  required = []
34
40
  required << "readelf" if selected.values.any? { |target| target["platform"] == "linux" && target.fetch("kind", "binary") == "binary" }
35
41
  required << "bsdtar" if selected.values.any? { |target| target.fetch("input").fetch("kind", "archive") == "archive" }
@@ -44,12 +50,12 @@ module NativePackages
44
50
  end
45
51
 
46
52
  def version(value, release)
47
- if release && value && version_arg(value) != version_arg(release)
53
+ if release && value && configuration.package_version(value) != configuration.package_version(release)
48
54
  raise Error, "--release and --version disagree"
49
55
  end
50
56
  value ||= release
51
57
  value ||= capture("git", "describe", "--exact-match", "--tags", "HEAD")
52
- version_arg(value)
58
+ configuration.package_version(value)
53
59
  rescue Error => error
54
60
  raise error if value
55
61
  raise Error, "no exact release tag at HEAD; supply --version"
@@ -71,10 +77,14 @@ module NativePackages
71
77
  configuration.validate
72
78
  selected = configuration.select(ids: ids, formats: formats)
73
79
  number = version(value, release)
80
+ configuration.check_prerelease(number, selected)
81
+ if release && selected.values.any? { |target| target["native"] }
82
+ raise Error, "native recipes consume local prepared directories; use --version with native build artifacts"
83
+ end
74
84
  output = Pathname.new(output || root / "dist/packages" / number).expand_path(root)
75
85
  if dry_run
76
86
  puts JSON.pretty_generate("version" => number, "mode" => release ? "release" : "local", "output" => output.to_s,
77
- "targets" => selected.transform_values { |target| target.slice("formats", "platform", "arch", "input", "before_build", "after_package") })
87
+ "targets" => selected.transform_values { |target| target.slice("formats", "platform", "arch", "input", "before_build", "after_package", "native") })
78
88
  return
79
89
  end
80
90
  raise Error, "output exists: #{output}; choose a fresh --output" if output.exist?
@@ -98,28 +108,54 @@ module NativePackages
98
108
  if source.directory? && (output.to_s == source.expand_path.to_s || output.to_s.start_with?(source.expand_path.to_s + File::SEPARATOR))
99
109
  raise Error, "package output must be outside the input directory"
100
110
  end
101
- sources << { "target" => id, "path" => source.to_s, "sha256" => input_digest(source) }
111
+ native = target["native"] && NativeRecipe.new(root)
112
+ digest = native ? native.digest(source) : input_digest(source)
113
+ sources << { "target" => id, "path" => source.to_s, "sha256" => digest }
102
114
  Dir.mktmpdir("native-packages-input-") do |directory|
103
115
  payload = Pathname.new(directory) / "payload"
104
- copy_input(source, payload, input.fetch("kind", "archive"))
116
+ if native
117
+ FileUtils.cp_r(source, payload, preserve: true)
118
+ raise Error, "native input changed while staging" unless native.digest(payload) == digest
119
+ else
120
+ copy_input(source, payload, input.fetch("kind", "archive"))
121
+ end
105
122
  target.fetch("formats").each do |format|
106
- package = render_tree(configuration.package(target), configuration.target_tokens(info, id, target, payload))
107
- package.merge!("name" => configuration.name, "version" => number, "arch" => target.fetch("arch"),
108
- "platform" => target.fetch("platform"), "mtime" => info.fetch("DATE"))
109
- inspection = Inspection.new(root, configuration.data.fetch("libraries")).check(package, target, format)
110
- config_path = Pathname.new(directory) / "nfpm.yaml"
111
- write(config_path, YAML.dump(package))
112
123
  destination = staging / "packages" / id / format
113
124
  destination.mkpath
114
- run "nfpm", "package", "--config", config_path, "--packager", format, "--target", destination,
115
- env: { "SOURCE_DATE_EPOCH" => info.fetch("SOURCE_DATE_EPOCH").to_s }
116
- packages = files(destination)
117
- raise Error, "nFPM did not create exactly one #{format} package" unless packages.length == 1
125
+ if native
126
+ inspection = native.inspect(payload, target)
127
+ recipe = target.fetch("native")
128
+ tokens = configuration.target_tokens(info, id, target, payload)
129
+ package_path = destination / render(recipe.fetch("output"), tokens)
130
+ run(*render_tree(recipe.fetch("command"), tokens.merge("PACKAGE" => package_path.to_s, "FORMAT" => format)),
131
+ env: { "SOURCE_DATE_EPOCH" => info.fetch("SOURCE_DATE_EPOCH").to_s, "NATIVE_PACKAGES_TARGET" => id, "NATIVE_PACKAGES_VERSION" => number })
132
+ packages = files(destination)
133
+ raise Error, "native recipe must create only its declared output" unless packages == [package_path]
134
+ native.check_output(package_path, format)
135
+ else
136
+ package = render_tree(configuration.package(target), configuration.target_tokens(info, id, target, payload))
137
+ if number.include?("-") && (package.fetch("version_schema", "semver") != "semver" || %w[prerelease version_metadata].any? { |key| package.key?(key) && !package[key].to_s.empty? })
138
+ raise Error, "prerelease package versions must use unmodified nFPM semver conversion"
139
+ end
140
+ package.merge!("name" => configuration.name, "version" => number, "arch" => target.fetch("arch"),
141
+ "platform" => target.fetch("platform"), "mtime" => info.fetch("DATE"))
142
+ inspection = Inspection.new(root, configuration.data.fetch("libraries")).check(package, target, format)
143
+ config_path = Pathname.new(directory) / "nfpm.yaml"
144
+ write(config_path, YAML.dump(package))
145
+ run "nfpm", "package", "--config", config_path, "--packager", format, "--target", destination,
146
+ env: { "SOURCE_DATE_EPOCH" => info.fetch("SOURCE_DATE_EPOCH").to_s }
147
+ packages = files(destination)
148
+ raise Error, "nFPM did not create exactly one #{format} package" unless packages.length == 1
149
+ end
118
150
  if target["after_package"]
119
151
  signing = configuration.target_tokens(info, id, target, payload).merge("PACKAGE" => packages.first.to_s, "FORMAT" => format)
120
152
  run(*render_tree(target.fetch("after_package"), signing), env: { "NATIVE_PACKAGES_TARGET" => id, "NATIVE_PACKAGES_VERSION" => number })
121
153
  raise Error, "after_package must preserve the package path and output set" unless files(destination) == packages
122
154
  end
155
+ if native
156
+ native.check_output(packages.first, format)
157
+ raise Error, "native recipes/signing must not change their input payload" unless native.digest(payload) == digest
158
+ end
123
159
  records << { "target" => id, "format" => format, "path" => packages.first.relative_path_from(staging).to_s,
124
160
  "sha256" => sha256(packages.first), "validation" => inspection }
125
161
  end
@@ -127,7 +163,7 @@ module NativePackages
127
163
  end
128
164
  recipes = staging / "recipes"
129
165
  project.generate(recipes, info)
130
- project.check(recipes)
166
+ project.check(recipes, prerelease: number.include?("-"))
131
167
  if !configuration.data.fetch("templates").empty?
132
168
  project.recipe_archive(recipes, staging, name: configuration.name, version: number, epoch: info.fetch("SOURCE_DATE_EPOCH"))
133
169
  (staging / "packaging-checksums.txt").delete
@@ -217,7 +253,9 @@ module NativePackages
217
253
  manifest = JSON.parse((output / "build.json").read)
218
254
  raise Error, "unsupported build manifest" unless manifest.fetch("schema") == 1
219
255
  raise Error, "build belongs to another project/configuration" unless manifest.fetch("name") == configuration.name && manifest.fetch("configuration") == configuration.digest
220
- version_arg(manifest.fetch("version"))
256
+ number = configuration.package_version(manifest.fetch("version"))
257
+ selected = configuration.select(ids: manifest.fetch("targets").keys)
258
+ configuration.check_prerelease(number, selected)
221
259
  expected = configuration.targets.transform_values { |target| target.fetch("formats") }
222
260
  raise Error, "incomplete target set; aggregate all configured targets before publishing" if complete && manifest.fetch("targets") != expected
223
261
  expected_pairs = manifest.fetch("targets").flat_map { |id, formats| formats.map { |format| [id, format] } }.sort
@@ -242,6 +280,11 @@ module NativePackages
242
280
  manifest = verify(output)
243
281
  raise Error, "supply at least one --to destination" if destinations.empty?
244
282
  destinations.each { |name| project.repositories.select(name) unless name == "github" }
283
+ if manifest.fetch("version").include?("-")
284
+ raise Error, "prereleases can only attach to an existing GitHub prerelease" unless destinations == ["github"]
285
+ marked = capture("gh", "release", "view", "v#{manifest.fetch('version')}", "--repo", project.repository, "--json", "isPrerelease", "--jq", ".isPrerelease")
286
+ raise Error, "destination release must already be marked prerelease" unless marked == "true"
287
+ end
245
288
  destinations.each do |destination|
246
289
  if destination == "github"
247
290
  paths = manifest.fetch("packages").map { |entry| output / entry.fetch("path") }
@@ -5,7 +5,8 @@ require_relative "project"
5
5
  module NativePackages
6
6
  class Configuration
7
7
  include Support
8
- FORMATS = %w[deb rpm archlinux apk ipk msix srpm].freeze
8
+ NATIVE_FORMATS = %w[dmg inno].freeze
9
+ FORMATS = (%w[deb rpm archlinux apk ipk msix srpm] + NATIVE_FORMATS).freeze
9
10
  NFPM_VERSION = "2.47.0"
10
11
  NAMES = %w[native-packages.yaml native-packages.yml].freeze
11
12
  KEYS = %w[schema tool nfpm targets release assets templates repositories revisions libraries version_file version_section].freeze
@@ -53,6 +54,15 @@ module NativePackages
53
54
  def targets = data.fetch("targets")
54
55
  def digest = OpenSSL::Digest::SHA256.hexdigest(JSON.generate(data))
55
56
  def package(target) = merge(data.fetch("nfpm"), target.fetch("nfpm", {}))
57
+ def package_version(value) = version_arg(value, prerelease: data.fetch("release").fetch("prereleases", false))
58
+
59
+ def check_prerelease(version, selected)
60
+ return unless version.include?("-")
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?
63
+ raise Error, "prereleases support deb, rpm, dmg and inno only, without downstream recipes"
64
+ end
65
+ end
56
66
 
57
67
  def project
58
68
  config = { "version" => 1, "name" => name, "repository" => data.fetch("release").fetch("repository", ""),
@@ -69,21 +79,44 @@ module NativePackages
69
79
  raise Error, "configuration needs native-packages #{wanted}; run gem install native-packages -v #{wanted}, then native-packages _#{wanted}_ COMMAND"
70
80
  end
71
81
  raise Error, "unsupported nFPM version; use #{NFPM_VERSION}" unless data.fetch("tool").fetch("nfpm") == NFPM_VERSION
82
+ if data.fetch("release").key?("prereleases") && ![true, false].include?(data.fetch("release")["prereleases"])
83
+ raise Error, "release.prereleases must be true or false"
84
+ end
72
85
  metadata = tokens("9.8.7", epoch: 1_767_225_600)
73
86
  targets.each do |id, target|
74
87
  raise Error, "invalid target name: #{id}" unless /\A[a-z0-9][a-z0-9-]*\z/.match?(id)
75
- unknown = target.keys - %w[platform arch libc abi kind formats input nfpm before_build after_package compiler_target]
88
+ unknown = target.keys - %w[platform arch libc abi kind formats input nfpm before_build after_package compiler_target native]
76
89
  raise Error, "targets.#{id}: unknown fields #{unknown.join(', ')}" unless unknown.empty?
77
90
  formats = target.fetch("formats")
78
91
  unless formats.is_a?(Array) && !formats.empty? && formats.uniq == formats && (formats - FORMATS).empty?
79
92
  raise Error, "targets.#{id}.formats: choose from #{FORMATS.join(', ')}"
80
93
  end
81
- raise Error, "targets.#{id}.platform: use linux or windows" unless %w[linux windows].include?(target.fetch("platform"))
94
+ raise Error, "targets.#{id}.platform: use linux, windows or macos" unless %w[linux windows macos].include?(target.fetch("platform"))
82
95
  raise Error, "targets.#{id}.arch: expected an architecture" unless /\A[a-zA-Z0-9_+-]+\z/.match?(target.fetch("arch"))
83
96
  kind = target.fetch("kind", "binary")
84
97
  raise Error, "targets.#{id}.kind: use binary, data or source" unless %w[binary data source].include?(kind)
85
98
  raise Error, "#{id}: MSIX requires a Windows target with only msix format" if formats.include?("msix") && (target["platform"] != "windows" || formats != ["msix"])
86
- raise Error, "#{id}: Windows targets require msix" if target["platform"] == "windows" && formats != ["msix"]
99
+ raise Error, "#{id}: Windows targets require msix or inno" if target["platform"] == "windows" && ![["msix"], ["inno"]].include?(formats)
100
+ raise Error, "#{id}: macOS targets require dmg" if target["platform"] == "macos" && formats != ["dmg"]
101
+ native = !(formats & NATIVE_FORMATS).empty?
102
+ if native
103
+ expected = { "dmg" => "macos", "inno" => "windows" }[formats.first]
104
+ raise Error, "#{id}: native formats require a separate binary target on their native platform" unless formats.length == 1 && expected == target["platform"] && kind == "binary"
105
+ recipe = mapping(target.fetch("native"), "#{id}.native")
106
+ raise Error, "#{id}.native: expected command and output" unless recipe.keys.sort == %w[command output]
107
+ command = recipe["command"]
108
+ unless command.is_a?(Array) && !command.empty? && command.all? { |part| part.is_a?(String) } && command.any? { |part| part.include?("@PACKAGE@") }
109
+ raise Error, "#{id}.native.command: use command arguments including @PACKAGE@"
110
+ end
111
+ filename = render(recipe.fetch("output"), target_tokens(metadata, id, target, "/payload"))
112
+ extension = formats == ["dmg"] ? ".dmg" : ".exe"
113
+ unless /\A[a-zA-Z0-9][a-zA-Z0-9._+-]*\z/.match?(filename) && filename.end_with?(extension)
114
+ raise Error, "#{id}.native.output: expected a safe #{extension} filename"
115
+ end
116
+ render_tree(command, target_tokens(metadata, id, target, "/payload").merge("PACKAGE" => "/output/#{filename}", "FORMAT" => formats.first))
117
+ elsif target.key?("native")
118
+ raise Error, "#{id}: native commands require a dmg or inno target"
119
+ end
87
120
  raise Error, "#{id}: SRPM requires a separate source target" if (formats.include?("srpm") && (kind != "source" || formats != ["srpm"])) || (kind == "source" && formats != ["srpm"])
88
121
  if kind == "binary" && target["platform"] == "linux"
89
122
  raise Error, "#{id}.libc: declare glibc, musl or static" unless %w[glibc musl static].include?(target["libc"])
@@ -92,12 +125,14 @@ module NativePackages
92
125
  input = mapping(target.fetch("input"), "#{id}.input")
93
126
  raise Error, "#{id}.input: declare local or release_asset" unless input["local"] || input["release_asset"]
94
127
  raise Error, "#{id}.input.kind: use file, directory or archive" unless %w[file directory archive].include?(input.fetch("kind", "archive"))
128
+ raise Error, "#{id}: native packaging requires an input directory" if native && input.fetch("kind", "archive") != "directory"
129
+ raise Error, "#{id}: native packaging requires input.local" if native && !input["local"]
95
130
  %w[before_build after_package].each do |key|
96
131
  hook = target[key]
97
132
  raise Error, "#{id}.#{key}: use a nonempty array of command arguments" if hook && (!hook.is_a?(Array) || hook.empty? || !hook.all? { |part| part.is_a?(String) })
98
133
  end
99
134
  rendered = render_tree(package(target), target_tokens(metadata, id, target, "/payload"))
100
- raise Error, "#{id}: nfpm.contents must be an array" unless rendered["contents"].is_a?(Array)
135
+ raise Error, "#{id}: nfpm.contents must be an array" unless native || rendered["contents"].is_a?(Array)
101
136
  %w[maintainer description license].each do |key|
102
137
  raise Error, "#{id}: fill in nfpm.#{key}" unless rendered[key].is_a?(String) && !rendered[key].strip.empty?
103
138
  end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "find"
4
+
5
+ module NativePackages
6
+ # Coordinate application-owned native recipes. The gem owns the input copy,
7
+ # output boundary and manifest; applications retain installer/signing policy.
8
+ class NativeRecipe
9
+ include Support
10
+ MACHO = ["feedface", "feedfacf", "cefaedfe", "cffaedfe", "cafebabe", "bebafeca", "cafebabf", "bfbafeca"].freeze
11
+ attr_reader :root
12
+
13
+ def initialize(root)
14
+ @root = root
15
+ end
16
+
17
+ def self.host
18
+ return "windows" if Gem.win_platform?
19
+ RUBY_PLATFORM.include?("darwin") ? "macos" : "linux"
20
+ end
21
+
22
+ def doctor(target, command)
23
+ raise Error, "#{target.fetch('platform')} packaging must run on that native host" unless self.class.host == target.fetch("platform")
24
+ required = [command.first]
25
+ required << "lipo" if target["platform"] == "macos"
26
+ missing = required.reject { |tool| available?(tool) || (Pathname.new(tool).absolute? && File.file?(tool) && File.executable?(tool)) }
27
+ raise Error, "install required native tools: #{missing.join(', ')}" unless missing.empty?
28
+ end
29
+
30
+ def tree(path)
31
+ path = Pathname.new(path)
32
+ raise Error, "native input must be a real directory" unless path.directory? && !path.symlink?
33
+ boundary = path.realpath.to_s + File::SEPARATOR
34
+ Find.find(path.to_s).sort.map do |name|
35
+ item = Pathname.new(name)
36
+ stat = item.lstat
37
+ relative = item.relative_path_from(path).to_s
38
+ if stat.symlink?
39
+ link = item.readlink
40
+ unless !link.absolute? && item.realpath.to_s.start_with?(boundary)
41
+ raise Error, "native input symlink leaves the payload: #{relative}"
42
+ end
43
+ [relative, "symlink", link.to_s]
44
+ elsif stat.directory?
45
+ [relative, "directory", stat.mode & 0o777]
46
+ elsif stat.file?
47
+ [relative, "file", stat.mode & 0o777, sha256(item)]
48
+ else
49
+ raise Error, "unsupported native input entry: #{relative}"
50
+ end
51
+ end
52
+ rescue Errno::ENOENT, Errno::ELOOP => error
53
+ raise Error, "invalid native input link or missing entry: #{error.message}"
54
+ end
55
+
56
+ def digest(path) = OpenSSL::Digest::SHA256.hexdigest(JSON.generate(tree(path)))
57
+
58
+ def inspect(payload, target)
59
+ paths = tree(payload).filter_map { |relative, kind, *| payload / relative if kind == "file" }
60
+ return Inspection.new(root).pe(paths, target) if target["platform"] == "windows"
61
+ expected = { "amd64" => "x86_64", "arm64" => "arm64", "universal" => "arm64 x86_64" }[target.fetch("arch")]
62
+ raise Error, "Mach-O inspection supports amd64, arm64 and universal" unless expected
63
+ binaries = paths.select { |path| MACHO.include?(path.binread(4).unpack1("H*")) }
64
+ raise Error, "macOS target contains no Mach-O binaries" if binaries.empty?
65
+ binaries.each do |path|
66
+ arches = capture("lipo", "-archs", path).split.sort
67
+ raise Error, "wrong Mach-O architecture: #{path}" unless (expected.split - arches).empty?
68
+ end
69
+ { "kind" => "macho", "binaries" => binaries.length, "architecture" => target.fetch("arch"), "dependencies" => "explicit", "installation" => "not-tested" }
70
+ end
71
+
72
+ def check_output(path, format)
73
+ raise Error, "native recipe must create a regular package file" unless path.file? && !path.symlink?
74
+ valid = File.open(path, "rb") do |file|
75
+ if format == "dmg"
76
+ next false if file.size < 512
77
+ file.seek(-512, IO::SEEK_END)
78
+ file.read(4) == "koly"
79
+ else
80
+ next false unless file.size >= 64 && file.read(2) == "MZ"
81
+ file.seek(0x3c)
82
+ offset = file.read(4).unpack1("L<")
83
+ next false unless offset >= 64 && offset + 24 <= file.size
84
+ file.seek(offset)
85
+ file.read(4) == "PE\0\0"
86
+ end
87
+ end
88
+ raise Error, "native recipe output is not a #{format} container" unless valid
89
+ end
90
+ end
91
+ end
@@ -103,11 +103,11 @@ module NativePackages
103
103
  write(output / "release.json", JSON.pretty_generate(metadata.sort.to_h) + "\n")
104
104
  end
105
105
 
106
- def check(output)
106
+ def check(output, prerelease: false)
107
107
  output = Pathname.new(output).expand_path
108
108
  metadata = JSON.parse((output / "release.json").read)
109
109
  raise Error, "prepared recipes belong to another project" unless metadata.fetch("NAME") == package_name
110
- version_arg(metadata.fetch("VERSION"))
110
+ version_arg(metadata.fetch("VERSION"), prerelease: prerelease)
111
111
  Dir.mktmpdir("native-packages-check-") do |temporary|
112
112
  expected = Pathname.new(temporary)
113
113
  generate(expected, metadata)
@@ -12,7 +12,7 @@ require "tmpdir"
12
12
  require "yaml"
13
13
 
14
14
  module NativePackages
15
- VERSION = "0.2.0"
15
+ VERSION = "0.3.0"
16
16
  class Error < StandardError; end
17
17
 
18
18
  module Support
@@ -24,7 +24,9 @@ module NativePackages
24
24
  end
25
25
 
26
26
  def capture(*arguments, env: {}, chdir: root)
27
- output, error, status = Open3.capture3(env, *arguments.map(&:to_s), chdir: chdir.to_s)
27
+ # Native tools can emit UTF-8 progress even when an SSH session declares
28
+ # US-ASCII. Preserve those bytes; locale-dependent strip would raise.
29
+ output, error, status = Open3.capture3(env, *arguments.map(&:to_s), chdir: chdir.to_s, binmode: true)
28
30
  raise Error, "#{arguments.first} failed: #{error.strip}" unless status.success?
29
31
  output.strip
30
32
  end
@@ -36,9 +38,13 @@ module NativePackages
36
38
  end
37
39
  end
38
40
 
39
- def version_arg(value)
41
+ def version_arg(value, prerelease: false)
40
42
  version = value.delete_prefix("v")
41
- raise Error, "expected a stable version such as 1.2.3" unless /\A(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)\z/.match?(version)
43
+ stable = /\A(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)\z/.match?(version)
44
+ preview = prerelease && /\A(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)-(alpha|beta|rc)\.[1-9]\d*\z/.match?(version)
45
+ unless stable || preview
46
+ raise Error, prerelease ? "expected 1.2.3 or 1.2.3-alpha.N, -beta.N or -rc.N" : "expected a stable version such as 1.2.3"
47
+ end
42
48
  version
43
49
  end
44
50
 
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.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Carmine Paolino
@@ -21,6 +21,7 @@ files:
21
21
  - docs/cli-design.md
22
22
  - docs/configuration.md
23
23
  - docs/legacy.md
24
+ - docs/native-recipes.md
24
25
  - docs/platforms.md
25
26
  - docs/releasing.md
26
27
  - examples/native-packages-all-formats.yaml
@@ -35,6 +36,7 @@ files:
35
36
  - lib/native_packages/cli.rb
36
37
  - lib/native_packages/configuration.rb
37
38
  - lib/native_packages/inspection.rb
39
+ - lib/native_packages/native_recipe.rb
38
40
  - lib/native_packages/project.rb
39
41
  - lib/native_packages/repositories.rb
40
42
  - lib/native_packages/scaffold.rb