native-packages 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: af0462ecd5b50ab3aeb8415ff1c95c7d910b858ef1f3804b18526cfd41ada930
4
- data.tar.gz: 48c6d9f15ad4b8ed60617416780fa5a03f294bc4f629949a5e31af7e041c84fd
3
+ metadata.gz: ccc727393fcd07bd511465e0b2c46b19679a387b6ffee081693231061424fbd9
4
+ data.tar.gz: 9a402ad593dd1035f1ab350e9cdc1a81292f0976303d6454c2ad9a3ecc53ae91
5
5
  SHA512:
6
- metadata.gz: ed735938724f514a10b46a50b21ac3131abc1d07981544b0f6f89d5010137b9e7674e041aeed97828de39d1fc2ff243241047f051a7ae78ac75beda8a446e30b
7
- data.tar.gz: 5bb0021eeab1b651c10170de7a57c1e38036714477e15a9e8d698ad68fe836d136de70107b8f180734225b502cd161ada0b01df234a382c9b582e9789a79bc54
6
+ metadata.gz: ee7950fbfd3a6d8e8d934e2c51a2620e717644c30803d660617d3e44d9c2274819fdc37dd815208493244b553976df685d2d3ec2d129eede0301bb61f7dd80d5
7
+ data.tar.gz: a57e20ba12b60b53bcbe0b9e5d896fb874d2535ae73f6f24a03b067ef9310eb35133bd51654d29a58d6e2990577a7dd6d4d056bf796ed44905c00866a087d983
data/README.md CHANGED
@@ -61,6 +61,10 @@ the application's existing packaging commands on macOS or Windows, using the
61
61
  same verified build manifests as Linux packages. Native recipes consume prepared
62
62
  directories and need no nFPM installation. The gem checks Mach-O/PE architecture,
63
63
  preserves safe internal bundle links and hashes the output after signing hooks.
64
+ With complete Apple credentials, macOS DMG builds automatically sign their owned
65
+ app copy, notarize the image, staple and validate its ticket before recording
66
+ checksums. `notarize-macos INPUT --output OUTPUT` prepares signed, notarized
67
+ portable code for an application-owned archive. See [Apple notarization](docs/apple-notarization.md).
64
68
  App compilation, installer policy and signing identities stay in the application
65
69
  repository.
66
70
 
@@ -84,6 +88,14 @@ Release mode verifies the declared binary assets against the release checksum fi
84
88
 
85
89
  Outputs include packages, prepared recipes, checksums and `build.json`. Publication verifies their hashes, configuration identity and complete target set. Partial builds can be combined with `aggregate DIR... --output DIRECTORY`. Native filenames come from nFPM.
86
90
 
91
+ Since 0.4.0, `build --defer-recipes` lets each platform package its
92
+ own inputs without acquiring global recipe assets or requiring AUR tools.
93
+ Then `aggregate --finalize-recipes` generates the downstream recipes once,
94
+ using hashes of the completed packages. This allows a Homebrew cask to reference
95
+ the DMG being built in the same release. Deferred builds cannot be published.
96
+ See [deferred recipe generation](docs/configuration.md#deferred-recipe-generation)
97
+ for the finalizer's inputs and checks.
98
+
87
99
  Optional `repositories` and `templates` sections replace the old separate registries. Existing `stage`, `diff`, `publish TARGET` and `status` commands support reviewed downstream updates. AUR uses PKGBUILD/`.SRCINFO`; Homebrew uses formulae/casks. Each destination has an independent ignored Git clone, with no application submodules or extra remotes. Native source repositories still apply their own validation and review.
88
100
 
89
101
  ## GitHub Actions
@@ -95,7 +107,7 @@ packaging:
95
107
  needs: release
96
108
  permissions:
97
109
  contents: write
98
- uses: crmne/native-packages/.github/workflows/package.yml@v0.3.1
110
+ uses: crmne/native-packages/.github/workflows/package.yml@v0.5.0
99
111
  with:
100
112
  version: ${{ github.ref_name }}
101
113
  publish: true
@@ -121,7 +133,7 @@ Migration combines `packaging/project.yml`, its nFPM definition and repository r
121
133
  bundle install
122
134
  bundle exec ruby -Ilib -e 'Dir["test/*_test.rb"].sort.each { |path| require_relative path }'
123
135
  gem build native-packages.gemspec
124
- ruby test/gem_install.rb native-packages-0.3.1.gem
136
+ ruby test/gem_install.rb native-packages-0.5.0.gem
125
137
  ```
126
138
 
127
139
  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.
@@ -0,0 +1,30 @@
1
+ # Native Apple notarization acceptance, 2026-09-13
2
+
3
+ A private Mac test used the candidate 0.5.0 implementation with the owner's
4
+ Developer ID Application certificate and app-specific notarization credentials.
5
+ No application release was created or replaced.
6
+
7
+ - Verified the published Fastpotify v0.7.1 universal DMG against its release
8
+ checksum, then copied its app and notices into an isolated test input.
9
+ - The shared native build signed its owned copy, verified both arm64 and x86_64
10
+ Mach-O slices, packaged a new DMG, obtained Apple acceptance, and stapled and
11
+ validated the notarization ticket before recording the package hash.
12
+ - Gatekeeper accepted both the resulting DMG and its contained app with
13
+ `source=Notarized Developer ID`. The app binary ran `--version` successfully.
14
+ - The portable command signed a newly compiled ARM64 executable, companion dylib
15
+ and app bundle, submitted a temporary ZIP, and obtained Apple acceptance
16
+ (submission `6a096e26-0f47-409f-b15d-654ff2a32292`). The app's stapled ticket
17
+ validated and Gatekeeper accepted it. Both standalone and bundled executables
18
+ successfully called the signed dylib after packaging. Non-code fixture data
19
+ remained unchanged, and the returned tree was archived as a tarball.
20
+
21
+ All images were detached after inspection. The gem's temporary signing keychains
22
+ were deleted. Original source payloads were not signed in place. Unit coverage
23
+ also exercises absent/partial credentials, failed acceptance/stapling, private
24
+ error redaction, inside-out signing and existing after-package hooks on both Mac
25
+ and Windows, including Apple variables present on a Windows target.
26
+
27
+ This is actual Apple service and native Gatekeeper evidence on the available
28
+ Apple Silicon Mac. It is not an Intel execution test, a fresh-machine GUI test,
29
+ or validation of Fastpotify playback, RekordFlash inference/models, FastsApp
30
+ messaging or TonePush hardware. Those remain application acceptance work.
@@ -0,0 +1,86 @@
1
+ # Apple signing and notarization
2
+
3
+ Since native-packages 0.5.0, a native macOS DMG build automatically signs and
4
+ notarizes when all six environment variables below are present. No variables
5
+ preserves existing local/unsigned behavior. Any incomplete set fails with the
6
+ missing variable names before packaging. Linux and Windows targets ignore these
7
+ Apple variables. `validate` and `build --dry-run` remain offline.
8
+
9
+ | Environment variable | Value |
10
+ | --- | --- |
11
+ | `APPLE_CERTIFICATE_P12` | Base64-encoded Developer ID Application certificate and private key, exported as PKCS#12 |
12
+ | `APPLE_CERTIFICATE_PASSWORD` | Password protecting that export |
13
+ | `APPLE_SIGNING_IDENTITY` | Exact `Developer ID Application: Name (TEAMID)` identity |
14
+ | `APPLE_ID` | Apple Account email |
15
+ | `APPLE_TEAM_ID` | Developer Program Team ID |
16
+ | `APPLE_APP_PASSWORD` | Apple Account app-specific password |
17
+
18
+ Store these in CI secrets and expose them as environment variables only in the
19
+ trusted macOS release job. The gem does not fetch secrets from a password manager
20
+ or GitHub. A free Apple account cannot replace Developer Program membership and
21
+ a valid Developer ID Application certificate.
22
+
23
+ ## Native DMG builds
24
+
25
+ Build the application's complete `.app` and prepare its configured native input
26
+ first, then run the normal command on macOS:
27
+
28
+ ```sh
29
+ native-packages --config native-packages.macos.yaml build \
30
+ --version 1.2.3 --target macos-universal --output dist/macos-packages
31
+ ```
32
+
33
+ The gem imports the certificate and notary credentials into a fresh, temporary
34
+ keychain. It never replaces the default keychain or its search list, and deletes
35
+ its keychain after success or failure. Credential values are excluded from
36
+ manifests and redacted from tool errors.
37
+
38
+ The input is copied and checked before signing. All Mach-O code and nested
39
+ frameworks/app bundles in the owned copy are signed from the inside out with a
40
+ secure timestamp and hardened runtime. Existing entitlements and requirements
41
+ are preserved; the gem does not invent exceptions such as disabling library
42
+ validation. The application remains responsible for any required entitlements.
43
+
44
+ Signing finishes **before `native.command`**. Its recipe must copy `@PAYLOAD@`
45
+ unchanged, create the configured DMG, and avoid replacing signatures with ad-hoc
46
+ ones. The original application input remains unchanged. `after_package` still
47
+ runs, then the gem signs the DMG, submits it with `notarytool`, waits for Apple's
48
+ `Accepted` result, staples and validates the ticket, and verifies the image.
49
+ Only then are package hashes and the build manifest written. The manifest's
50
+ package validation records Apple acceptance, submission ID and stapling.
51
+
52
+ Remove redundant Apple signing and notarization from application hooks when
53
+ adopting this flow. Existing Inno/MSIX signing hooks remain independent.
54
+ Submission waits up to 30 minutes; failure or timeout prevents publication of a
55
+ completed build. Apple may continue processing a timed-out submission.
56
+
57
+ ## Portable macOS archives
58
+
59
+ A standalone executable cannot carry a stapled ticket, and Apple does not accept
60
+ `.tar.gz` as a submission. Prepare the entire portable tree, including binaries,
61
+ dylibs, models and notices, then use:
62
+
63
+ ```sh
64
+ native-packages notarize-macos portable-input \
65
+ --output signed/Example-1.2.3-macos-arm64
66
+ tar czf Example-1.2.3-macos-arm64.tar.gz \
67
+ -C signed Example-1.2.3-macos-arm64
68
+ ```
69
+
70
+ The destination must be new and outside the input directory. This command needs
71
+ no application configuration. With credentials, it signs only its copy, submits
72
+ that exact code through a temporary ZIP, waits for acceptance, and staples and
73
+ validates any `.app` bundles. It then publishes the signed directory atomically.
74
+ Archive only that returned tree; do not modify its signed contents. Without
75
+ credentials it copies the input unchanged and explicitly reports that signing
76
+ and notarization were skipped.
77
+
78
+ Standalone binaries rely on Gatekeeper's online ticket lookup. An accepted
79
+ portable submission does not claim offline stapling support for raw executables.
80
+ Notarization also does not replace app startup, model/GPU, entitlement, OS-version
81
+ or installation testing. Test a fresh downloaded package with Gatekeeper before
82
+ announcing the first notarized release.
83
+
84
+ Apple references: [Developer ID certificates](https://developer.apple.com/help/account/certificates/create-developer-id-certificates/),
85
+ [signing distribution code](https://developer.apple.com/documentation/xcode/creating-distribution-signed-code-for-the-mac/),
86
+ [notarization](https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution).
@@ -39,7 +39,7 @@ before_build: [./scripts/build-linux.sh, '@ARCH@']
39
39
 
40
40
  It runs once per selected target in the configuration directory, before copying local inputs. `NATIVE_PACKAGES_TARGET` and `NATIVE_PACKAGES_VERSION` are also set. It is skipped in release mode and never executed by `validate`, `doctor` or a dry run. A shell script remains responsible for complex build operations.
41
41
 
42
- `after_package` is an optional argument array invoked once per generated package, before output hashes are recorded. It additionally receives `@PACKAGE@` and `@FORMAT@`. Use it for existing native signing tools, for example a Windows SDK SignTool script. It runs in local and release mode and must preserve the output filename and file set. The MSIX acceptance test uses SignTool because Windows rejected the signature produced by nFPM 2.47.0's built-in signer in that test.
42
+ `after_package` is an optional argument array invoked once per generated package, before output hashes are recorded. For native DMG targets, automatic [Apple notarization](apple-notarization.md) runs after this hook; input signing completes before the native recipe. It additionally receives `@PACKAGE@` and `@FORMAT@`. Use it for existing native signing tools, for example a Windows SDK SignTool script. It runs in local and release mode and must preserve the output filename and file set. The MSIX acceptance test uses SignTool because Windows rejected the signature produced by nFPM 2.47.0's built-in signer in that test.
43
43
 
44
44
  ## Tokens and additional assets
45
45
 
@@ -65,3 +65,62 @@ native-packages publish --from dist/complete --to github
65
65
  ```
66
66
 
67
67
  Each build must use the same configuration, release version and timestamp. Aggregate rejects duplicate target/format outputs, conflicting recipe files and incomplete sets. Publishing recipes can still use the existing reviewed sequence: `stage TARGET DIRECTORY/recipes`, `diff TARGET`, then `publish TARGET --body-file FILE` where required by a submission destination.
68
+
69
+
70
+ ## Deferred recipe generation
71
+
72
+ Since 0.4.0, use this opt-in sequence when downstream recipes depend
73
+ on packages built in separate jobs. The existing build and aggregate commands
74
+ keep generating and checking recipes as before when the new flags are absent.
75
+ There are no new configuration keys.
76
+
77
+ ```sh
78
+ # Run each target on its required host, with the same checkout and epoch.
79
+ native-packages doctor --target linux-amd64 --defer-recipes
80
+ native-packages build --version 1.2.3 --target linux-amd64 --defer-recipes --output dist/linux
81
+ native-packages build --version 1.2.3 --target macos-universal --defer-recipes --output dist/macos
82
+ native-packages build --version 1.2.3 --target windows-amd64 --defer-recipes --output dist/windows
83
+
84
+ # Download those build directories and remaining recipe assets onto Linux.
85
+ native-packages aggregate dist/linux dist/macos dist/windows --finalize-recipes --output dist/complete
86
+ # Run the application's package installation/upgrade/removal checks here.
87
+ native-packages publish --from dist/complete --to github,aur,homebrew
88
+ ```
89
+
90
+ A deferred build still validates configuration, selected inputs, architecture,
91
+ container contents and signing hooks. It does not acquire the global `assets`
92
+ or generate `templates`. `doctor --defer-recipes` likewise omits recipe tooling;
93
+ the selected package formats still need their own tools. Asset filenames and
94
+ URLs are available as tokens, but unknown `@KEY_SHA256@` values cannot be used
95
+ by a target's input, package definition or hooks. Such a target must use the
96
+ normal build path with its assets already available. Release-mode target inputs
97
+ still require their published checksums, even with `--defer-recipes`.
98
+
99
+ The finalizer first verifies every input manifest and the complete target set.
100
+ All inputs must defer recipes and agree on configuration, version, timestamp,
101
+ tool and recipe metadata, including Git version. Use the same Git history depth
102
+ and `SOURCE_DATE_EPOCH` on every host. Mixing ordinary and deferred builds fails.
103
+ Neither a partial target set nor a deferred build can be published. An ordinary
104
+ aggregate rejects deferred inputs unless `--finalize-recipes` is supplied.
105
+
106
+ For each global asset, finalization first looks for a verified package whose
107
+ release filename exactly matches the asset's rendered `file`. For example,
108
+ `assets.MACOS.file: app-v@VERSION@-macos-universal.dmg` resolves to a native target
109
+ with that output filename. Its hash includes the completed signing/notarization
110
+ hook. A stale `local` file cannot override that package. Duplicate package
111
+ filenames are rejected. The manifest records this asset by package filename
112
+ and digest, without a temporary staging path.
113
+
114
+ Other assets, such as portable or source archives, must be staged at their
115
+ configured `local` paths on the finalization host. Finalization reads local
116
+ inputs and does not download recipe assets from a release. This also applies
117
+ when the deferred target builds used `--release`. Recipe tools run only here:
118
+ AUR templates require makepkg or working Docker, and recipe archives require
119
+ tar and xz. The finalizer renders and validates recipes, writes the recipe
120
+ archive and complete manifest, and atomically creates a fresh output directory.
121
+ Failure leaves the original target directories intact and creates no completed
122
+ output. Existing destinations are never overwritten.
123
+
124
+ Deferral does not enable stable downstream recipes in a prerelease configuration.
125
+ The existing preview-format and stable-recipe restrictions still apply. Use a
126
+ preview configuration without downstream templates when building previews.
data/docs/legacy.md CHANGED
@@ -8,7 +8,7 @@ Applications keep their own configuration and templates. This repository holds t
8
8
 
9
9
  | Layer | Responsibility |
10
10
  | --- | --- |
11
- | Application build jobs | Compile for each OS and architecture; create compatible binaries, macOS bundles and Windows installers; sign and notarize where required. |
11
+ | Application build jobs | Compile for each OS and architecture; create compatible binaries, macOS bundles and Windows installers; provide Apple credentials to shared native builds or retain their existing signing tools. |
12
12
  | nFPM | Turn a set of built files and package metadata into native package files. |
13
13
  | native-packages | Verify release checksums, render recipes, check Linux ELF dependencies, invoke nFPM, attach packaging assets, and stage or publish downstream updates. |
14
14
  | Distribution infrastructure | Build and review submitted recipes, host package indexes, and make packages available to users. |
@@ -1,14 +1,16 @@
1
- # Native DMG and Inno recipes (0.3)
1
+ # Native DMG and Inno recipes
2
2
 
3
3
  The shared build command can coordinate existing macOS and Windows packaging
4
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.
5
+ generate an installer recipe or compile an app. Since 0.5.0, complete Apple
6
+ credentials automatically enable Developer ID signing and notarization for DMG
7
+ targets. The application supplies its identity, bundle contents and entitlements.
8
+ See [Apple notarization](apple-notarization.md).
7
9
 
8
10
  ```yaml
9
11
  schema: 1
10
12
  tool:
11
- version: '0.3.0'
13
+ version: '0.5.0'
12
14
  nfpm: '2.47.0'
13
15
  nfpm:
14
16
  name: example-app
@@ -43,15 +45,21 @@ targets:
43
45
 
44
46
  Run each target on its own host using `build --version VERSION --target ID`.
45
47
  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
48
+ Use the same configuration and `SOURCE_DATE_EPOCH` across jobs.
49
+ Since 0.4.0, configurations with AUR/Homebrew recipes can use
50
+ `build --defer-recipes` on each host, then `aggregate --finalize-recipes` on
51
+ Linux. This avoids requiring the finished DMG before its own build or running
52
+ AUR tools on macOS/Windows. The finalizer resolves recipe assets from the
53
+ completed package filenames and locally staged external assets. See
54
+ [deferred recipe generation](configuration.md#deferred-recipe-generation). No nFPM binary
47
55
  is required when only native targets are selected; its version remains part of
48
56
  the configuration for compatibility with mixed Linux/native builds.
49
57
 
50
58
  `native.command` is a nonempty argument array, including `@PACKAGE@`, which
51
59
  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.
60
+ owned copy of the input directory's contents. A top-level `.app` retains its basename; other input directories use `payload`.
61
+ A DMG script should copy the owned input into its own temporary
62
+ `Example.app` folder or image staging directory before calling hdiutil. Other existing target tokens apply.
55
63
  Commands run in the application configuration directory and receive the same
56
64
  target/version environment as other build hooks.
57
65
 
@@ -64,17 +72,18 @@ must check its own additional tools. Mac architecture inspection also needs
64
72
  Apple's `lipo`. Supported Mac targets are `amd64`, `arm64` and `universal` (both).
65
73
 
66
74
  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
75
+ framework links. Escaping, dangling and special-file entries fail. The initial staged tree must match the original digest. Automatic Apple
76
+ signing then signs only that owned copy. The signed staged tree must remain
77
+ unchanged after application packaging and `after_package` hooks. Package only this staged input; do not read another application
70
78
  build from a global location. Recipes should preserve existing destinations,
71
79
  clean only their own temporary files and use argument arrays for subprocesses.
72
80
 
73
81
  The command must create exactly its declared `.dmg` or `.exe` file. The adapter
74
82
  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
83
+ not proof of a valid installer, signature or application. The existing `after_package` hook runs before automatic Apple DMG signing,
84
+ notarization and stapling, all before the final hash is recorded. Remove redundant
85
+ Apple signing/notarization hooks when enabling automatic credentials. Other
86
+ application-specific output hooks remain supported. Do not modify a package after building its manifest: signature
78
87
  stapling changes bytes and must run inside that hook if the manifest is to verify.
79
88
 
80
89
  Applications should invoke hdiutil's verification, platform signature checks and
data/docs/platforms.md CHANGED
@@ -12,11 +12,11 @@ All seven nFPM 2.47.0 packagers are exposed by the v0.2 CLI. Each target explici
12
12
  | MSIX | nFPM `msix`, PE architecture checks, `after_package` hook for native signing | Windows executables/DLLs, application identity, assets, capabilities and signing certificate |
13
13
  | SRPM | nFPM `srpm`, source/spec input validation | A correct spec and sources, native rebuild testing |
14
14
  | AUR | Template rendering, `.SRCINFO`, staged Git publication | Native PKGBUILD source/bin/git variants and build checks |
15
- | Homebrew | Hash/render supplied formulae or casks and publish a tap | macOS bundles, signing/notarization, native recipe logic |
15
+ | Homebrew | Hash/render supplied formulae or casks and publish a tap | Completed macOS assets, 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
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 |
19
+ | macOS DMG | Native command adapter, Mach-O inspection, automatic Developer ID signing/notarization with Apple credentials, final stapled hashes | Bundle, hdiutil recipe, Apple credentials/entitlements, update/rollback testing |
20
20
  | Other Windows installers | No adapter for NSIS/WiX yet | Existing native tools and WinGet/Scoop integrations |
21
21
 
22
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)
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.3.1 --target main --title 'native-packages 0.3.1' --notes-file release-notes.md
26
+ gh release create v0.4.0 --target main --title 'native-packages 0.4.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.3.1'
4
+ version: '0.5.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.3.1'
3
+ version: '0.5.0'
4
4
  nfpm: '2.47.0'
5
5
  nfpm:
6
6
  name: example-app
@@ -3,6 +3,7 @@
3
3
  require_relative "configuration"
4
4
  require_relative "inspection"
5
5
  require_relative "native_recipe"
6
+ require_relative "macos_signing"
6
7
 
7
8
  module NativePackages
8
9
  class Build
@@ -27,7 +28,7 @@ module NativePackages
27
28
  version
28
29
  end
29
30
 
30
- def doctor(ids: [], formats: [], release: false)
31
+ def doctor(ids: [], formats: [], release: false, defer_recipes: false)
31
32
  configuration.validate
32
33
  selected = configuration.select(ids: ids, formats: formats)
33
34
  nfpm_version if selected.values.any? { |target| (target.fetch("formats") & Configuration::NATIVE_FORMATS).empty? }
@@ -35,18 +36,25 @@ module NativePackages
35
36
  next unless target["native"]
36
37
  metadata = configuration.target_tokens(configuration.tokens("9.8.7"), id, target, "/payload").merge("PACKAGE" => "/output/package", "FORMAT" => target.fetch("formats").first)
37
38
  NativeRecipe.new(root).doctor(target, render_tree(target.fetch("native").fetch("command"), metadata))
39
+ MacosSigning.new(root).doctor if target["platform"] == "macos"
38
40
  end
39
41
  required = []
40
42
  required << "readelf" if selected.values.any? { |target| target["platform"] == "linux" && target.fetch("kind", "binary") == "binary" }
41
43
  required << "bsdtar" if selected.values.any? { |target| target.fetch("input").fetch("kind", "archive") == "archive" }
42
44
  required << "curl" if release
43
- required += %w[tar xz] unless configuration.data.fetch("templates").empty?
44
45
  missing = required.uniq.reject { |tool| available?(tool) }
45
46
  raise Error, "install required tools: #{missing.join(', ')}" unless missing.empty?
47
+ recipe_tools unless defer_recipes
48
+ selected
49
+ end
50
+
51
+ def recipe_tools
52
+ return if configuration.data.fetch("templates").empty?
53
+ missing = %w[tar xz].reject { |tool| available?(tool) }
54
+ raise Error, "install required recipe tools: #{missing.join(', ')}" unless missing.empty?
46
55
  if configuration.data.fetch("templates").keys.any? { |path| path.match?(%r{\Aarch/[^/]+/PKGBUILD\z}) }
47
56
  raise Error, "AUR metadata needs makepkg or accessible Docker" unless available?("makepkg") || available?("docker")
48
57
  end
49
- selected
50
58
  end
51
59
 
52
60
  def version(value, release)
@@ -73,7 +81,7 @@ module NativePackages
73
81
  configuration.tokens(version, epoch: epoch).merge("GIT_VERSION" => git_version)
74
82
  end
75
83
 
76
- def run_build(value: nil, release: nil, ids: [], formats: [], output: nil, dry_run: false)
84
+ def run_build(value: nil, release: nil, ids: [], formats: [], output: nil, dry_run: false, defer_recipes: false)
77
85
  configuration.validate
78
86
  selected = configuration.select(ids: ids, formats: formats)
79
87
  number = version(value, release)
@@ -84,15 +92,26 @@ module NativePackages
84
92
  output = Pathname.new(output || root / "dist/packages" / number).expand_path(root)
85
93
  if dry_run
86
94
  puts JSON.pretty_generate("version" => number, "mode" => release ? "release" : "local", "output" => output.to_s,
87
- "targets" => selected.transform_values { |target| target.slice("formats", "platform", "arch", "input", "before_build", "after_package", "native") })
95
+ "recipes" => defer_recipes ? "deferred" : "included", "targets" => selected.transform_values { |target| target.slice("formats", "platform", "arch", "input", "before_build", "after_package", "native") })
88
96
  return
89
97
  end
90
98
  raise Error, "output exists: #{output}; choose a fresh --output" if output.exist?
91
- doctor(ids: ids, formats: formats, release: !!release)
99
+ doctor(ids: ids, formats: formats, release: !!release, defer_recipes: defer_recipes)
92
100
  info = metadata(number, release: !!release)
93
101
  sources = []
94
102
  expected = release ? release_checksums(info) : nil
95
- acquire_assets(info, expected, sources)
103
+ if defer_recipes
104
+ # Asset hashes are unknown until finalization. Do not silently pass the
105
+ # validation placeholders into a target's input, hooks or package data.
106
+ configuration.data.fetch("assets").each_key { |key| info.delete("#{key}_SHA256") }
107
+ selected.each do |id, target|
108
+ tokens = configuration.target_tokens(info, id, target, "/payload").merge("PACKAGE" => "/output/package", "FORMAT" => target.fetch("formats").first)
109
+ render_tree(target, tokens)
110
+ render_tree(configuration.package(target), tokens)
111
+ end
112
+ else
113
+ acquire_assets(info, expected, sources)
114
+ end
96
115
  output.dirname.mkpath
97
116
  Dir.mktmpdir(".native-packages-", output.dirname) do |temporary|
98
117
  staging = Pathname.new(temporary) / "result"
@@ -112,66 +131,69 @@ module NativePackages
112
131
  digest = native ? native.digest(source) : input_digest(source)
113
132
  sources << { "target" => id, "path" => source.to_s, "sha256" => digest }
114
133
  Dir.mktmpdir("native-packages-input-") do |directory|
115
- payload = Pathname.new(directory) / "payload"
134
+ payload = Pathname.new(directory) / (native && source.extname == ".app" ? source.basename : "payload")
116
135
  if native
117
136
  FileUtils.cp_r(source, payload, preserve: true)
118
137
  raise Error, "native input changed while staging" unless native.digest(payload) == digest
119
138
  else
120
139
  copy_input(source, payload, input.fetch("kind", "archive"))
121
140
  end
122
- target.fetch("formats").each do |format|
123
- destination = staging / "packages" / id / format
124
- destination.mkpath
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"
141
+ signer = target["platform"] == "macos" ? MacosSigning.new(root) : nil
142
+ with_signing(signer) do |signing|
143
+ signing.sign_payload(payload) if signing
144
+ payload_digest = native && native.digest(payload)
145
+ target.fetch("formats").each do |format|
146
+ destination = staging / "packages" / id / format
147
+ destination.mkpath
148
+ if native
149
+ inspection = native.inspect(payload, target)
150
+ recipe = target.fetch("native")
151
+ tokens = configuration.target_tokens(info, id, target, payload)
152
+ package_path = destination / render(recipe.fetch("output"), tokens)
153
+ run(*render_tree(recipe.fetch("command"), tokens.merge("PACKAGE" => package_path.to_s, "FORMAT" => format)),
154
+ env: { "SOURCE_DATE_EPOCH" => info.fetch("SOURCE_DATE_EPOCH").to_s, "NATIVE_PACKAGES_TARGET" => id, "NATIVE_PACKAGES_VERSION" => number })
155
+ packages = files(destination)
156
+ raise Error, "native recipe must create only its declared output" unless packages == [package_path]
157
+ native.check_output(package_path, format)
158
+ else
159
+ package = render_tree(configuration.package(target), configuration.target_tokens(info, id, target, payload))
160
+ if number.include?("-") && (package.fetch("version_schema", "semver") != "semver" || %w[prerelease version_metadata].any? { |key| package.key?(key) && !package[key].to_s.empty? })
161
+ raise Error, "prerelease package versions must use unmodified nFPM semver conversion"
162
+ end
163
+ package.merge!("name" => configuration.name, "version" => number, "arch" => target.fetch("arch"),
164
+ "platform" => target.fetch("platform"), "mtime" => info.fetch("DATE"))
165
+ inspection = Inspection.new(root, configuration.data.fetch("libraries")).check(package, target, format)
166
+ config_path = Pathname.new(directory) / "nfpm.yaml"
167
+ write(config_path, YAML.dump(package))
168
+ run "nfpm", "package", "--config", config_path, "--packager", format, "--target", destination,
169
+ env: { "SOURCE_DATE_EPOCH" => info.fetch("SOURCE_DATE_EPOCH").to_s }
170
+ packages = files(destination)
171
+ raise Error, "nFPM did not create exactly one #{format} package" unless packages.length == 1
139
172
  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
150
- if target["after_package"]
151
- signing = configuration.target_tokens(info, id, target, payload).merge("PACKAGE" => packages.first.to_s, "FORMAT" => format)
152
- run(*render_tree(target.fetch("after_package"), signing), env: { "NATIVE_PACKAGES_TARGET" => id, "NATIVE_PACKAGES_VERSION" => number })
153
- raise Error, "after_package must preserve the package path and output set" unless files(destination) == packages
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
173
+ if target["after_package"]
174
+ hook_tokens = configuration.target_tokens(info, id, target, payload).merge("PACKAGE" => packages.first.to_s, "FORMAT" => format)
175
+ run(*render_tree(target.fetch("after_package"), hook_tokens), env: { "NATIVE_PACKAGES_TARGET" => id, "NATIVE_PACKAGES_VERSION" => number })
176
+ raise Error, "after_package must preserve the package path and output set" unless files(destination) == packages
177
+ end
178
+ if native
179
+ native.check_output(packages.first, format)
180
+ raise Error, "native recipes/signing must not change their input payload" unless native.digest(payload) == payload_digest
181
+ end
182
+ inspection["apple"] = signing.notarize(packages.first) if signing
183
+ records << { "target" => id, "format" => format, "path" => packages.first.relative_path_from(staging).to_s,
184
+ "sha256" => sha256(packages.first), "validation" => inspection }
158
185
  end
159
- records << { "target" => id, "format" => format, "path" => packages.first.relative_path_from(staging).to_s,
160
- "sha256" => sha256(packages.first), "validation" => inspection }
161
186
  end
162
187
  end
163
188
  end
164
- recipes = staging / "recipes"
165
- project.generate(recipes, info)
166
- project.check(recipes, prerelease: number.include?("-"))
167
- if !configuration.data.fetch("templates").empty?
168
- project.recipe_archive(recipes, staging, name: configuration.name, version: number, epoch: info.fetch("SOURCE_DATE_EPOCH"))
169
- (staging / "packaging-checksums.txt").delete
170
- end
189
+ write_recipes(staging, info) unless defer_recipes
171
190
  manifest = { "schema" => 1, "name" => configuration.name, "version" => number, "configuration" => configuration.digest,
172
191
  "tool" => configuration.data.fetch("tool"), "epoch" => info.fetch("SOURCE_DATE_EPOCH"), "inputs" => sources,
173
192
  "targets" => selected.transform_values { |target| target.fetch("formats") }, "packages" => records,
174
193
  "files" => files(staging).to_h { |file| [file.relative_path_from(staging).to_s, sha256(file)] } }
194
+ if defer_recipes
195
+ manifest["recipes"] = { "state" => "deferred", "metadata" => info.reject { |key, _| key == "ROOT" } }
196
+ end
175
197
  write(staging / "build.json", JSON.pretty_generate(manifest) + "\n")
176
198
  write(staging / "packaging-checksums.txt", files(staging).map { |file| "#{sha256(file)} #{file.relative_path_from(staging)}\n" }.join)
177
199
  staging.rename(output)
@@ -180,6 +202,14 @@ module NativePackages
180
202
  output
181
203
  end
182
204
 
205
+ def with_signing(signer)
206
+ if signer&.enabled?
207
+ signer.with_identity { |active| yield active }
208
+ else
209
+ yield nil
210
+ end
211
+ end
212
+
183
213
  def release_cache(info)
184
214
  repository = configuration.data.fetch("release").fetch("repository")
185
215
  raise Error, "release.repository must be owner/repository" unless /\A[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\z/.match?(repository)
@@ -212,10 +242,12 @@ module NativePackages
212
242
  path
213
243
  end
214
244
 
215
- def acquire_assets(info, expected, sources)
245
+ def acquire_assets(info, expected, sources, packages: {})
216
246
  configuration.data.fetch("assets").each do |key, definition|
217
247
  asset = render_tree(definition, info)
218
- path = if expected
248
+ path = if packages.key?(asset.fetch("file"))
249
+ packages.fetch(asset.fetch("file"))
250
+ elsif expected
219
251
  release_input(asset.merge("release_asset" => asset.fetch("file")), info, expected, checksummed: asset.fetch("checksummed", true))
220
252
  else
221
253
  root / asset.fetch("local") { raise Error, "assets.#{key}: declare local input or use --release" }
@@ -224,7 +256,15 @@ module NativePackages
224
256
  info["#{key}_SHA256"] = digest
225
257
  info["#{key}_FILE"] = asset.fetch("file")
226
258
  info["#{key}_URL"] = asset.fetch("url", "#{info.fetch('UPSTREAM')}/releases/download/#{info.fetch('TAG')}/#{asset.fetch('file')}")
227
- sources << { "asset" => key, "path" => path.to_s, "sha256" => digest }
259
+ source = { "asset" => key, "sha256" => digest }
260
+ # An aggregated package survives under its release filename; the
261
+ # finalizer's temporary staging path does not survive the rename.
262
+ if packages.key?(asset.fetch("file"))
263
+ source["package"] = asset.fetch("file")
264
+ else
265
+ source["path"] = path.to_s
266
+ end
267
+ sources << source
228
268
  end
229
269
  end
230
270
 
@@ -248,6 +288,48 @@ module NativePackages
248
288
  end
249
289
  end
250
290
 
291
+ def write_recipes(output, info)
292
+ recipes = output / "recipes"
293
+ project.generate(recipes, info)
294
+ project.check(recipes, prerelease: info.fetch("VERSION").include?("-"))
295
+ unless configuration.data.fetch("templates").empty?
296
+ project.recipe_archive(recipes, output, name: configuration.name, version: info.fetch("VERSION"), epoch: info.fetch("SOURCE_DATE_EPOCH"))
297
+ (output / "packaging-checksums.txt").delete
298
+ end
299
+ end
300
+
301
+ def verify_recipes(output, manifest, complete:)
302
+ if manifest.key?("recipes")
303
+ phase = manifest.fetch("recipes")
304
+ unless phase.is_a?(Hash) && phase["state"] == "deferred" && phase["metadata"].is_a?(Hash)
305
+ raise Error, "invalid recipe phase"
306
+ end
307
+ info = phase.fetch("metadata")
308
+ unless info.values_at("NAME", "VERSION", "SOURCE_DATE_EPOCH") == manifest.values_at("name", "version", "epoch")
309
+ raise Error, "deferred recipe metadata disagrees with build"
310
+ end
311
+ package_paths = manifest.fetch("packages").map { |entry| entry.fetch("path") }
312
+ raise Error, "deferred build contains recipe files" unless (manifest.fetch("files").keys - package_paths).empty?
313
+ raise Error, "recipes are deferred; aggregate with --finalize-recipes before publishing" if complete
314
+ return
315
+ end
316
+ # Require the recipe files as well as hashes, so dropping the deferred
317
+ # marker cannot make an unfinished build appear publishable.
318
+ required = ["recipes/release.json"]
319
+ raise Error, "missing recipe metadata" unless manifest.fetch("files").key?(required.first)
320
+ info = JSON.parse((output / required.first).read)
321
+ configuration.data.fetch("templates").each_key do |path|
322
+ relative = render(path, info.merge("ROOT" => root.to_s))
323
+ required << "recipes/#{relative_path(relative)}"
324
+ required << "recipes/#{File.dirname(relative)}/.SRCINFO" if relative.match?(%r{\Aarch/[^/]+/PKGBUILD\z})
325
+ end
326
+ unless configuration.data.fetch("templates").empty?
327
+ required << "#{configuration.name}-#{manifest.fetch('version')}-packaging.tar.xz"
328
+ end
329
+ missing = required - manifest.fetch("files").keys
330
+ raise Error, "missing recipe outputs: #{missing.join(', ')}" unless missing.empty?
331
+ end
332
+
251
333
  def verify(output, complete: true)
252
334
  output = Pathname.new(output).expand_path(root)
253
335
  manifest = JSON.parse((output / "build.json").read)
@@ -271,6 +353,7 @@ module NativePackages
271
353
  manifest.fetch("packages").each do |entry|
272
354
  raise Error, "package hash does not match file manifest" unless entry.fetch("sha256") == manifest.fetch("files").fetch(entry.fetch("path"))
273
355
  end
356
+ verify_recipes(output, manifest, complete: complete)
274
357
  manifest
275
358
  end
276
359
 
@@ -304,7 +387,7 @@ module NativePackages
304
387
  end
305
388
  end
306
389
 
307
- def aggregate(inputs, output:)
390
+ def aggregate(inputs, output:, finalize_recipes: false)
308
391
  configuration.validate
309
392
  output = Pathname.new(output).expand_path(root)
310
393
  raise Error, "output exists: #{output}" if output.exist?
@@ -314,6 +397,14 @@ module NativePackages
314
397
  %w[version epoch tool].each do |key|
315
398
  raise Error, "builds disagree on #{key}" unless manifests.map { |entry| entry.fetch(key) }.uniq.length == 1
316
399
  end
400
+ phases = manifests.map { |entry| entry["recipes"] }
401
+ if phases.any?
402
+ raise Error, "do not mix deferred and completed recipe builds" if phases.any?(&:nil?)
403
+ raise Error, "deferred recipe metadata differs between builds" unless phases.uniq.length == 1
404
+ raise Error, "deferred builds require --finalize-recipes" unless finalize_recipes
405
+ elsif finalize_recipes
406
+ raise Error, "--finalize-recipes requires builds made with --defer-recipes"
407
+ end
317
408
  result = Marshal.load(Marshal.dump(manifests.first))
318
409
  result.merge!("targets" => {}, "packages" => [], "files" => {}, "inputs" => manifests.flat_map { |entry| entry.fetch("inputs") }.uniq)
319
410
  output.dirname.mkpath
@@ -338,6 +429,21 @@ module NativePackages
338
429
  configuration.targets.each do |id, target|
339
430
  result["targets"][id] = target.fetch("formats").select { |format| result["targets"].fetch(id, []).include?(format) }
340
431
  end
432
+ if finalize_recipes
433
+ expected = configuration.targets.transform_values { |target| target.fetch("formats") }
434
+ raise Error, "incomplete target set; aggregate all configured targets before finalizing recipes" unless result.fetch("targets") == expected
435
+ recipe_tools
436
+ info = result.fetch("recipes").fetch("metadata").merge("ROOT" => root.to_s)
437
+ packages = result.fetch("packages").each_with_object({}) do |entry, paths|
438
+ name = File.basename(entry.fetch("path"))
439
+ raise Error, "ambiguous recipe asset filename: #{name}" if paths.key?(name)
440
+ paths[name] = staging / entry.fetch("path")
441
+ end
442
+ acquire_assets(info, nil, result.fetch("inputs"), packages: packages)
443
+ write_recipes(staging, info)
444
+ result.delete("recipes")
445
+ result["files"] = files(staging).to_h { |file| [file.relative_path_from(staging).to_s, sha256(file)] }
446
+ end
341
447
  write(staging / "build.json", JSON.pretty_generate(result) + "\n")
342
448
  write(staging / "packaging-checksums.txt", files(staging).map { |file| "#{sha256(file)} #{file.relative_path_from(staging)}\n" }.join)
343
449
  verify(staging)
@@ -13,7 +13,10 @@ module NativePackages
13
13
  build [--version VERSION | --release TAG] Build all configured packages
14
14
  [--target ID] [--format FORMAT] Select targets/formats (repeatable)
15
15
  [--output DIRECTORY] [--dry-run] Choose output or inspect the plan
16
+ [--defer-recipes] Build targets without recipe assets/tools
17
+ notarize-macos DIRECTORY --output DIRECTORY Copy, sign and notarize portable Mac code
16
18
  aggregate DIR... --output DIRECTORY Combine target builds before publishing
19
+ [--finalize-recipes] Generate recipes from deferred builds
17
20
  publish --from DIRECTORY --to github,aur Publish a complete, verified build
18
21
  migrate [--dry-run] Convert packaging/project.yml
19
22
  repositories List downstream destinations
@@ -44,17 +47,19 @@ module NativePackages
44
47
  parser = OptionParser.new do |flags|
45
48
  flags.on("--config FILE") { |value| config_path = value }
46
49
  flags.on("--help", "-h") { puts HELP; return }
47
- flags.on("--output DIRECTORY") { |value| options[:output] = Pathname.new(value).expand_path(root) } if %w[build aggregate artifacts prepare].include?(command)
50
+ flags.on("--output DIRECTORY") { |value| options[:output] = Pathname.new(value).expand_path(root) } if %w[build aggregate artifacts prepare notarize-macos].include?(command)
48
51
  flags.on("--body-file FILE") { |value| options[:body_file] = Pathname.new(value).expand_path(root) } if command == "publish"
49
52
  if %w[build doctor].include?(command)
50
53
  flags.on("--target ID") { |value| options[:ids] << value }
51
54
  flags.on("--format FORMAT") { |value| options[:formats] << value }
52
55
  flags.on("--release TAG") { |value| options[:release] = value }
56
+ flags.on("--defer-recipes") { options[:defer_recipes] = true }
53
57
  end
54
58
  if command == "build"
55
59
  flags.on("--version VERSION") { |value| options[:value] = value }
56
60
  flags.on("--dry-run") { options[:dry_run] = true }
57
61
  end
62
+ flags.on("--finalize-recipes") { options[:finalize_recipes] = true } if command == "aggregate"
58
63
  if command == "init"
59
64
  flags.on("--interactive") { options[:interactive] = true }
60
65
  flags.on("--name NAME") { |value| options[:name] = value }
@@ -74,7 +79,7 @@ module NativePackages
74
79
  parser.parse!(arguments)
75
80
  arity = { "init" => 0..0, "migrate" => 0..0, "build" => 0..0, "doctor" => 0..0, "aggregate" => 1..,
76
81
  "prepare" => 1..1, "check" => 1..1, "artifacts" => 1..1, "publish-release" => 2..2,
77
- "repositories" => 0..0, "stage" => 2..2, "diff" => 1..1, "publish" => options[:from] ? 0..0 : 1..1,
82
+ "notarize-macos" => 1..1, "repositories" => 0..0, "stage" => 2..2, "diff" => 1..1, "publish" => options[:from] ? 0..0 : 1..1,
78
83
  "publish-aur" => 1..1, "status" => 0..1, "check-version" => 1..1, "validate" => 0..0 }[command]
79
84
  raise Error, HELP unless arity&.cover?(arguments.length)
80
85
  if command == "init"
@@ -82,19 +87,23 @@ module NativePackages
82
87
  return Scaffold.new(root).init(**options.slice(:interactive, :name, :input).merge(formats: options[:init_formats]))
83
88
  end
84
89
  return Scaffold.new(root).migrate(dry_run: options[:dry_run]) if command == "migrate"
90
+ if command == "notarize-macos"
91
+ raise Error, "notarize-macos requires --output" unless options[:output]
92
+ return MacosSigning.new(root).prepare_directory(arguments.first, output: options.fetch(:output))
93
+ end
85
94
  path = Configuration.discover(root, config_path)
86
95
  configuration = path && Configuration.new(path)
87
96
  if %w[build doctor aggregate].include?(command) || options[:from]
88
97
  raise Error, "create native-packages.yaml with init, or convert existing packaging with migrate" unless configuration
89
98
  builder = Build.new(configuration)
90
99
  case command
91
- when "build" then return builder.run_build(**options.slice(:value, :release, :ids, :formats, :output, :dry_run))
100
+ when "build" then return builder.run_build(**options.slice(:value, :release, :ids, :formats, :output, :dry_run, :defer_recipes))
92
101
  when "doctor"
93
- builder.doctor(**options.slice(:ids, :formats, :release))
102
+ builder.doctor(**options.slice(:ids, :formats, :release, :defer_recipes))
94
103
  return puts "Configuration and packaging tools are ready. Build checks input files and package contents."
95
104
  when "aggregate"
96
105
  raise Error, "aggregate requires --output" unless options[:output]
97
- return builder.aggregate(arguments, output: options.fetch(:output))
106
+ return builder.aggregate(arguments, output: options.fetch(:output), finalize_recipes: options.fetch(:finalize_recipes, false))
98
107
  when "publish" then return builder.publish(options.fetch(:from), options.fetch(:destinations), body_file: options[:body_file])
99
108
  end
100
109
  end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NativePackages
4
+ # Apple credentials opt native DMG builds into Developer ID distribution.
5
+ # Only the owned staging copy is signed; caller inputs and keychains survive.
6
+ class MacosSigning
7
+ include Support
8
+ VARIABLES = %w[APPLE_CERTIFICATE_P12 APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID APPLE_TEAM_ID APPLE_APP_PASSWORD].freeze
9
+ BUNDLES = %w[.app .framework .xpc .appex .bundle .plugin].freeze
10
+ attr_reader :root
11
+
12
+ def initialize(root, environment = ENV)
13
+ @root = root
14
+ @credentials = VARIABLES.to_h { |name| [name, environment[name].to_s] }
15
+ @redactions = @credentials.values.reject(&:empty?)
16
+ end
17
+
18
+ def enabled? = @credentials.values.any? { |value| !value.empty? }
19
+
20
+ def doctor
21
+ return unless enabled?
22
+ missing = @credentials.select { |_, value| value.strip.empty? }.keys
23
+ raise Error, "incomplete Apple notarization credentials: configure #{missing.join(', ')}" unless missing.empty?
24
+ identity = @credentials.fetch("APPLE_SIGNING_IDENTITY")
25
+ team = @credentials.fetch("APPLE_TEAM_ID")
26
+ unless identity.start_with?("Developer ID Application: ") && identity.end_with?("(#{team})")
27
+ raise Error, "APPLE_SIGNING_IDENTITY must be a Developer ID Application identity for APPLE_TEAM_ID"
28
+ end
29
+ begin
30
+ @certificate = @credentials.fetch("APPLE_CERTIFICATE_P12").delete("\r\n").unpack1("m0")
31
+ raise ArgumentError if @certificate.empty?
32
+ rescue ArgumentError
33
+ raise Error, "APPLE_CERTIFICATE_P12 must contain a base64-encoded PKCS#12 certificate and private key"
34
+ end
35
+ missing_tools = %w[security codesign xcrun hdiutil ditto].reject { |tool| available?(tool) }
36
+ raise Error, "install required Apple tools: #{missing_tools.join(', ')}" unless missing_tools.empty?
37
+ execute "xcrun", "--find", "notarytool"
38
+ execute "xcrun", "--find", "stapler"
39
+ end
40
+
41
+ def with_identity
42
+ doctor
43
+ return yield(nil) unless enabled?
44
+ Dir.mktmpdir("native-packages-signing-") do |directory|
45
+ @keychain = Pathname.new(directory) / "signing.keychain-db"
46
+ certificate = Pathname.new(directory) / "certificate.p12"
47
+ File.write(certificate, @certificate, mode: "wb", perm: 0o600)
48
+ password = OpenSSL::Random.random_bytes(32).unpack1("H*")
49
+ @redactions << password
50
+ begin
51
+ execute "security", "create-keychain", "-p", password, @keychain
52
+ execute "security", "set-keychain-settings", "-lut", "21600", @keychain
53
+ execute "security", "unlock-keychain", "-p", password, @keychain
54
+ execute "security", "import", certificate, "-k", @keychain,
55
+ "-P", @credentials.fetch("APPLE_CERTIFICATE_PASSWORD"), "-T", "/usr/bin/codesign"
56
+ execute "security", "set-key-partition-list", "-S", "apple-tool:,apple:,codesign:", "-k", password, @keychain
57
+ identities = execute "security", "find-identity", "-v", "-p", "codesigning", @keychain
58
+ unless identities.include?(%Q{"#{@credentials.fetch("APPLE_SIGNING_IDENTITY")}"})
59
+ raise Error, "imported P12 has no valid private signing identity matching APPLE_SIGNING_IDENTITY"
60
+ end
61
+ execute "xcrun", "notarytool", "store-credentials", "native-packages", "--keychain", @keychain,
62
+ "--apple-id", @credentials.fetch("APPLE_ID"), "--team-id", @credentials.fetch("APPLE_TEAM_ID"),
63
+ "--password", @credentials.fetch("APPLE_APP_PASSWORD")
64
+ yield self
65
+ ensure
66
+ # Never replace the user's search list or default keychain. Deleting
67
+ # just this keychain also removes its own entry from the search list.
68
+ execute "security", "delete-keychain", @keychain if @keychain.exist?
69
+ @keychain = nil
70
+ end
71
+ end
72
+ end
73
+
74
+ def sign_payload(payload)
75
+ tree = NativeRecipe.new(root).tree(payload)
76
+ paths = tree.filter_map do |relative, kind, *|
77
+ path = payload / relative
78
+ if kind == "file" && NativeRecipe::MACHO.include?(path.binread(4).unpack1("H*"))
79
+ path
80
+ elsif kind == "directory" && (BUNDLES.include?(path.extname) || (path / "Contents/Info.plist").file?)
81
+ path
82
+ end
83
+ end
84
+ raise Error, "Apple signing needs a payload containing Mach-O code" if paths.empty?
85
+ # Files first, then their containing code bundles, including frameworks.
86
+ paths.sort_by { |path| [-path.each_filename.count, path.to_s] }.each do |path|
87
+ metadata = path.directory? ? "entitlements,requirements" : "identifier,entitlements,requirements"
88
+ execute "codesign", "--force", "--timestamp", "--options", "runtime",
89
+ "--preserve-metadata=#{metadata}", "--keychain", @keychain,
90
+ "--sign", @credentials.fetch("APPLE_SIGNING_IDENTITY"), path
91
+ execute "codesign", "--verify", "--strict", path
92
+ end
93
+ end
94
+
95
+ def notarize(package)
96
+ execute "hdiutil", "verify", package
97
+ execute "codesign", "--force", "--timestamp", "--keychain", @keychain,
98
+ "--sign", @credentials.fetch("APPLE_SIGNING_IDENTITY"), package
99
+ execute "codesign", "--verify", "--strict", package
100
+ response = submit(package)
101
+ execute "xcrun", "stapler", "staple", package
102
+ execute "xcrun", "stapler", "validate", package
103
+ execute "hdiutil", "verify", package
104
+ puts "Apple accepted #{package.basename}; its notarization ticket is stapled and validated."
105
+ { "signature" => "developer-id", "notarization" => "accepted", "submission_id" => response.fetch("id"), "stapled" => true }
106
+ end
107
+
108
+ def submit(package)
109
+ puts "Submitting #{package.basename} to Apple's notary service..."
110
+ response = JSON.parse(execute("xcrun", "notarytool", "submit", package,
111
+ "--keychain-profile", "native-packages", "--keychain", @keychain,
112
+ "--wait", "--timeout", "30m", "--output-format", "json"))
113
+ unless response["status"] == "Accepted"
114
+ raise Error, "Apple notarization was not accepted (submission #{response.fetch('id', 'unknown')}); inspect the notarytool log before distributing"
115
+ end
116
+ response.fetch("id")
117
+ response
118
+ rescue JSON::ParserError, KeyError
119
+ raise Error, "Apple notary service returned an invalid result; no package was published"
120
+ end
121
+
122
+ # A portable tarball cannot itself be submitted or stapled. Apple accepts
123
+ # the exact signed code through a temporary ZIP; the app owns the final tar.
124
+ def prepare_directory(source, output:)
125
+ source = Pathname.new(source).expand_path(root)
126
+ output = Pathname.new(output).expand_path(root)
127
+ raise Error, "output exists: #{output}; choose a fresh --output" if output.exist?
128
+ if output.to_s.start_with?(source.to_s + File::SEPARATOR)
129
+ raise Error, "signed output must be outside the input directory"
130
+ end
131
+ native = NativeRecipe.new(root)
132
+ original = native.digest(source)
133
+ raise Error, "macOS signing must run on macOS" if enabled? && NativeRecipe.host != "macos"
134
+ output.dirname.mkpath
135
+ Dir.mktmpdir(".native-packages-macos-", output.dirname) do |directory|
136
+ copied = Pathname.new(directory) / source.basename
137
+ FileUtils.cp_r(source, copied, preserve: true)
138
+ raise Error, "native input changed while staging" unless native.digest(copied) == original
139
+ with_identity do |signing|
140
+ if signing
141
+ sign_payload(copied)
142
+ archive = Pathname.new(directory) / "notarization.zip"
143
+ execute "ditto", "-c", "-k", "--keepParent", copied, archive
144
+ response = submit(archive)
145
+ native.tree(copied).each do |relative, kind, *|
146
+ bundle = copied / relative
147
+ next unless kind == "directory" && bundle.extname == ".app"
148
+ execute "xcrun", "stapler", "staple", bundle
149
+ execute "xcrun", "stapler", "validate", bundle
150
+ end
151
+ puts "Apple accepted portable code (submission #{response.fetch('id')}). Standalone executables use online ticket lookup."
152
+ else
153
+ puts "Apple credentials are absent; copying portable code without signing or notarization."
154
+ end
155
+ end
156
+ copied.rename(output)
157
+ end
158
+ output
159
+ end
160
+
161
+ def execute(*arguments)
162
+ capture(*arguments)
163
+ rescue Error => error
164
+ message = error.message.b
165
+ @redactions.sort_by { |value| -value.bytesize }.each { |value| message.gsub!(value.b, "[REDACTED]") }
166
+ raise Error, message
167
+ end
168
+ end
169
+ end
@@ -12,7 +12,7 @@ require "tmpdir"
12
12
  require "yaml"
13
13
 
14
14
  module NativePackages
15
- VERSION = "0.3.1"
15
+ VERSION = "0.5.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.3.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Carmine Paolino
@@ -18,6 +18,8 @@ extra_rdoc_files: []
18
18
  files:
19
19
  - LICENSE
20
20
  - README.md
21
+ - docs/acceptance/0.5.0-notarization.md
22
+ - docs/apple-notarization.md
21
23
  - docs/cli-design.md
22
24
  - docs/configuration.md
23
25
  - docs/legacy.md
@@ -36,6 +38,7 @@ files:
36
38
  - lib/native_packages/cli.rb
37
39
  - lib/native_packages/configuration.rb
38
40
  - lib/native_packages/inspection.rb
41
+ - lib/native_packages/macos_signing.rb
39
42
  - lib/native_packages/native_recipe.rb
40
43
  - lib/native_packages/project.rb
41
44
  - lib/native_packages/repositories.rb