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 +4 -4
- data/README.md +14 -2
- data/docs/acceptance/0.5.0-notarization.md +30 -0
- data/docs/apple-notarization.md +86 -0
- data/docs/configuration.md +60 -1
- data/docs/legacy.md +1 -1
- data/docs/native-recipes.md +23 -14
- data/docs/platforms.md +2 -2
- data/docs/releasing.md +1 -1
- data/examples/native-packages-all-formats.yaml +1 -1
- data/examples/native-packages.yaml +1 -1
- data/lib/native_packages/build.rb +162 -56
- data/lib/native_packages/cli.rb +14 -5
- data/lib/native_packages/macos_signing.rb +169 -0
- data/lib/native_packages/support.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ccc727393fcd07bd511465e0b2c46b19679a387b6ffee081693231061424fbd9
|
|
4
|
+
data.tar.gz: 9a402ad593dd1035f1ab350e9cdc1a81292f0976303d6454c2ad9a3ecc53ae91
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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).
|
data/docs/configuration.md
CHANGED
|
@@ -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;
|
|
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. |
|
data/docs/native-recipes.md
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
# Native DMG and Inno recipes
|
|
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
|
|
6
|
-
automatically
|
|
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.
|
|
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.
|
|
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.
|
|
53
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
|
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,
|
|
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.
|
|
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.
|
|
@@ -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
|
-
|
|
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.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
data/lib/native_packages/cli.rb
CHANGED
|
@@ -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
|
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.
|
|
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
|