native-packages 0.4.0 → 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: db11bd5b39c572e4983697e34112948cf182169145c1504a5c2223bd6c01bad0
4
- data.tar.gz: 379244306eeb13a7e501cd3808dbe3fd81337079e70b129ca446e243a311daaa
3
+ metadata.gz: ccc727393fcd07bd511465e0b2c46b19679a387b6ffee081693231061424fbd9
4
+ data.tar.gz: 9a402ad593dd1035f1ab350e9cdc1a81292f0976303d6454c2ad9a3ecc53ae91
5
5
  SHA512:
6
- metadata.gz: 4169d1495a88f410c99a59ff1de20b9d6162468e5ddd4a3082a89e843ded13f84d74401cd91f4fde3b6e4457dfa91aa9ea78e19c7c0560c7fd89c3bd18785ec8
7
- data.tar.gz: 2e89f5a56a85d642e8139c1c6725c3f90db7d664580c33ac64c6a432cac1998f5fa1e7cb220e415597532a83802b068c72f3be47c36a301ce51c795e89494244
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
 
@@ -103,7 +107,7 @@ packaging:
103
107
  needs: release
104
108
  permissions:
105
109
  contents: write
106
- uses: crmne/native-packages/.github/workflows/package.yml@v0.4.0
110
+ uses: crmne/native-packages/.github/workflows/package.yml@v0.5.0
107
111
  with:
108
112
  version: ${{ github.ref_name }}
109
113
  publish: true
@@ -129,7 +133,7 @@ Migration combines `packaging/project.yml`, its nFPM definition and repository r
129
133
  bundle install
130
134
  bundle exec ruby -Ilib -e 'Dir["test/*_test.rb"].sort.each { |path| require_relative path }'
131
135
  gem build native-packages.gemspec
132
- ruby test/gem_install.rb native-packages-0.4.0.gem
136
+ ruby test/gem_install.rb native-packages-0.5.0.gem
133
137
  ```
134
138
 
135
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
 
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
@@ -55,9 +57,9 @@ the configuration for compatibility with mixed Linux/native builds.
55
57
 
56
58
  `native.command` is a nonempty argument array, including `@PACKAGE@`, which
57
59
  resolves to the exact output filename in a fresh directory. `@PAYLOAD@` is an
58
- owned copy of the input directory's contents. It does not retain the original
59
- directory's basename: a DMG script should copy it into its own temporary
60
- `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.
61
63
  Commands run in the application configuration directory and receive the same
62
64
  target/version environment as other build hooks.
63
65
 
@@ -70,17 +72,18 @@ must check its own additional tools. Mac architecture inspection also needs
70
72
  Apple's `lipo`. Supported Mac targets are `amd64`, `arm64` and `universal` (both).
71
73
 
72
74
  Input copying preserves file modes and internal relative symlinks, including
73
- framework links. Escaping, dangling and special-file entries fail. The staged
74
- tree must match the original digest and remain unchanged after packaging and
75
- 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
76
78
  build from a global location. Recipes should preserve existing destinations,
77
79
  clean only their own temporary files and use argument arrays for subprocesses.
78
80
 
79
81
  The command must create exactly its declared `.dmg` or `.exe` file. The adapter
80
82
  checks a UDIF trailer or PE container respectively. This is a container check,
81
- not proof of a valid installer, signature or application. The existing
82
- `after_package` hook can sign/notarize the output in place before the final hash
83
- 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
84
87
  stapling changes bytes and must run inside that hook if the manifest is to verify.
85
88
 
86
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)
@@ -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.4.0'
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.4.0'
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
@@ -35,6 +36,7 @@ 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" }
@@ -129,52 +131,58 @@ module NativePackages
129
131
  digest = native ? native.digest(source) : input_digest(source)
130
132
  sources << { "target" => id, "path" => source.to_s, "sha256" => digest }
131
133
  Dir.mktmpdir("native-packages-input-") do |directory|
132
- payload = Pathname.new(directory) / "payload"
134
+ payload = Pathname.new(directory) / (native && source.extname == ".app" ? source.basename : "payload")
133
135
  if native
134
136
  FileUtils.cp_r(source, payload, preserve: true)
135
137
  raise Error, "native input changed while staging" unless native.digest(payload) == digest
136
138
  else
137
139
  copy_input(source, payload, input.fetch("kind", "archive"))
138
140
  end
139
- target.fetch("formats").each do |format|
140
- destination = staging / "packages" / id / format
141
- destination.mkpath
142
- if native
143
- inspection = native.inspect(payload, target)
144
- recipe = target.fetch("native")
145
- tokens = configuration.target_tokens(info, id, target, payload)
146
- package_path = destination / render(recipe.fetch("output"), tokens)
147
- run(*render_tree(recipe.fetch("command"), tokens.merge("PACKAGE" => package_path.to_s, "FORMAT" => format)),
148
- env: { "SOURCE_DATE_EPOCH" => info.fetch("SOURCE_DATE_EPOCH").to_s, "NATIVE_PACKAGES_TARGET" => id, "NATIVE_PACKAGES_VERSION" => number })
149
- packages = files(destination)
150
- raise Error, "native recipe must create only its declared output" unless packages == [package_path]
151
- native.check_output(package_path, format)
152
- else
153
- package = render_tree(configuration.package(target), configuration.target_tokens(info, id, target, payload))
154
- if number.include?("-") && (package.fetch("version_schema", "semver") != "semver" || %w[prerelease version_metadata].any? { |key| package.key?(key) && !package[key].to_s.empty? })
155
- 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
156
172
  end
157
- package.merge!("name" => configuration.name, "version" => number, "arch" => target.fetch("arch"),
158
- "platform" => target.fetch("platform"), "mtime" => info.fetch("DATE"))
159
- inspection = Inspection.new(root, configuration.data.fetch("libraries")).check(package, target, format)
160
- config_path = Pathname.new(directory) / "nfpm.yaml"
161
- write(config_path, YAML.dump(package))
162
- run "nfpm", "package", "--config", config_path, "--packager", format, "--target", destination,
163
- env: { "SOURCE_DATE_EPOCH" => info.fetch("SOURCE_DATE_EPOCH").to_s }
164
- packages = files(destination)
165
- raise Error, "nFPM did not create exactly one #{format} package" unless packages.length == 1
166
- end
167
- if target["after_package"]
168
- signing = configuration.target_tokens(info, id, target, payload).merge("PACKAGE" => packages.first.to_s, "FORMAT" => format)
169
- run(*render_tree(target.fetch("after_package"), signing), env: { "NATIVE_PACKAGES_TARGET" => id, "NATIVE_PACKAGES_VERSION" => number })
170
- raise Error, "after_package must preserve the package path and output set" unless files(destination) == packages
171
- end
172
- if native
173
- native.check_output(packages.first, format)
174
- 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 }
175
185
  end
176
- records << { "target" => id, "format" => format, "path" => packages.first.relative_path_from(staging).to_s,
177
- "sha256" => sha256(packages.first), "validation" => inspection }
178
186
  end
179
187
  end
180
188
  end
@@ -194,6 +202,14 @@ module NativePackages
194
202
  output
195
203
  end
196
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
+
197
213
  def release_cache(info)
198
214
  repository = configuration.data.fetch("release").fetch("repository")
199
215
  raise Error, "release.repository must be owner/repository" unless /\A[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\z/.match?(repository)
@@ -14,6 +14,7 @@ module NativePackages
14
14
  [--target ID] [--format FORMAT] Select targets/formats (repeatable)
15
15
  [--output DIRECTORY] [--dry-run] Choose output or inspect the plan
16
16
  [--defer-recipes] Build targets without recipe assets/tools
17
+ notarize-macos DIRECTORY --output DIRECTORY Copy, sign and notarize portable Mac code
17
18
  aggregate DIR... --output DIRECTORY Combine target builds before publishing
18
19
  [--finalize-recipes] Generate recipes from deferred builds
19
20
  publish --from DIRECTORY --to github,aur Publish a complete, verified build
@@ -46,7 +47,7 @@ module NativePackages
46
47
  parser = OptionParser.new do |flags|
47
48
  flags.on("--config FILE") { |value| config_path = value }
48
49
  flags.on("--help", "-h") { puts HELP; return }
49
- 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)
50
51
  flags.on("--body-file FILE") { |value| options[:body_file] = Pathname.new(value).expand_path(root) } if command == "publish"
51
52
  if %w[build doctor].include?(command)
52
53
  flags.on("--target ID") { |value| options[:ids] << value }
@@ -78,7 +79,7 @@ module NativePackages
78
79
  parser.parse!(arguments)
79
80
  arity = { "init" => 0..0, "migrate" => 0..0, "build" => 0..0, "doctor" => 0..0, "aggregate" => 1..,
80
81
  "prepare" => 1..1, "check" => 1..1, "artifacts" => 1..1, "publish-release" => 2..2,
81
- "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,
82
83
  "publish-aur" => 1..1, "status" => 0..1, "check-version" => 1..1, "validate" => 0..0 }[command]
83
84
  raise Error, HELP unless arity&.cover?(arguments.length)
84
85
  if command == "init"
@@ -86,6 +87,10 @@ module NativePackages
86
87
  return Scaffold.new(root).init(**options.slice(:interactive, :name, :input).merge(formats: options[:init_formats]))
87
88
  end
88
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
89
94
  path = Configuration.discover(root, config_path)
90
95
  configuration = path && Configuration.new(path)
91
96
  if %w[build doctor aggregate].include?(command) || options[:from]
@@ -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.4.0"
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.4.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