native-packages 0.4.0 → 0.5.1

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: cfd604f6beae10d9254c34bb9f9f438532320bca67086e6847e2961c37f761ac
4
+ data.tar.gz: 31a755fe957f5d9005dfeecb6cb35178511e62475b344cda9b2bbf23b8189231
5
5
  SHA512:
6
- metadata.gz: 4169d1495a88f410c99a59ff1de20b9d6162468e5ddd4a3082a89e843ded13f84d74401cd91f4fde3b6e4457dfa91aa9ea78e19c7c0560c7fd89c3bd18785ec8
7
- data.tar.gz: 2e89f5a56a85d642e8139c1c6725c3f90db7d664580c33ac64c6a432cac1998f5fa1e7cb220e415597532a83802b068c72f3be47c36a301ce51c795e89494244
6
+ metadata.gz: 3806ff4be71bb784ffada047da5a082dafd401ab47bdb9e71b99f4deae6160969bd0bd67ce67d20dbfed67a95cd69df0f404e5600e8252bb6dfc937e71446a6d
7
+ data.tar.gz: '09ee792159279f6a10f1bfc3f0685bfb773e912e56ff59b6aa983bb283e4efa9fca14ce3f596f9243262c14a1a55e5a2a417f62b65f5aa939f1b6a79abbe0048'
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.1
107
111
  with:
108
112
  version: ${{ github.ref_name }}
109
113
  publish: true
@@ -112,6 +116,8 @@ packaging:
112
116
 
113
117
  Pin the corresponding commit SHA for an immutable workflow reference. With no version, the workflow validates configuration only. With a version, it installs the configured gem and nFPM, builds packages and uploads an Actions artifact. `publish: true` attaches packages to the existing release.
114
118
 
119
+ Separate configurations have independent concurrency groups. A workflow can validate its stable, alpha and macOS configurations without one queued call cancelling another. The released `v0.5.0` workflow predates this fix; serialize multiple calls with `needs` when using that tag or its commit.
120
+
115
121
  To consume an Actions artifact instead, set `source-artifact` and, if necessary, `source-directory` (default `dist`). Its files must match the local input paths in the configuration. This workflow packages on Linux, including MSIX creation from Windows binaries; native application builds and platform-specific signing jobs can use the CLI separately.
116
122
 
117
123
  AUR publication additionally requires `PUBLISH_AUR=true`, `AUR_SSH_KEY` and `AUR_KNOWN_HOSTS`. Homebrew publication requires `PUBLISH_HOMEBREW=true` and `HOMEBREW_TAP_GITHUB_TOKEN`. Keep existing GoReleaser publishers for destinations they already own.
@@ -129,7 +135,7 @@ Migration combines `packaging/project.yml`, its nFPM definition and repository r
129
135
  bundle install
130
136
  bundle exec ruby -Ilib -e 'Dir["test/*_test.rb"].sort.each { |path| require_relative path }'
131
137
  gem build native-packages.gemspec
132
- ruby test/gem_install.rb native-packages-0.4.0.gem
138
+ ruby test/gem_install.rb native-packages-0.5.1.gem
133
139
  ```
134
140
 
135
141
  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,90 @@
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. Since 0.5.1, it adds that keychain to the user's search list when needed
35
+ on a clean runner, retaining existing entries and leaving the default keychain
36
+ unchanged. Signing calls under the same OS user share a lock, so parallel native
37
+ packaging hooks wait for the active signing call instead of racing search-list
38
+ updates. It deletes only its own keychain and search-list entry after success or
39
+ failure, preserving keychains added by other processes. Credential values are excluded from
40
+ manifests and redacted from tool errors.
41
+
42
+ The input is copied and checked before signing. All Mach-O code and nested
43
+ frameworks/app bundles in the owned copy are signed from the inside out with a
44
+ secure timestamp and hardened runtime. Existing entitlements and requirements
45
+ are preserved; the gem does not invent exceptions such as disabling library
46
+ validation. The application remains responsible for any required entitlements.
47
+
48
+ Signing finishes **before `native.command`**. Its recipe must copy `@PAYLOAD@`
49
+ unchanged, create the configured DMG, and avoid replacing signatures with ad-hoc
50
+ ones. The original application input remains unchanged. `after_package` still
51
+ runs, then the gem signs the DMG, submits it with `notarytool`, waits for Apple's
52
+ `Accepted` result, staples and validates the ticket, and verifies the image.
53
+ Only then are package hashes and the build manifest written. The manifest's
54
+ package validation records Apple acceptance, submission ID and stapling.
55
+
56
+ Remove redundant Apple signing and notarization from application hooks when
57
+ adopting this flow. Existing Inno/MSIX signing hooks remain independent.
58
+ Submission waits up to 30 minutes; failure or timeout prevents publication of a
59
+ completed build. Apple may continue processing a timed-out submission.
60
+
61
+ ## Portable macOS archives
62
+
63
+ A standalone executable cannot carry a stapled ticket, and Apple does not accept
64
+ `.tar.gz` as a submission. Prepare the entire portable tree, including binaries,
65
+ dylibs, models and notices, then use:
66
+
67
+ ```sh
68
+ native-packages notarize-macos portable-input \
69
+ --output signed/Example-1.2.3-macos-arm64
70
+ tar czf Example-1.2.3-macos-arm64.tar.gz \
71
+ -C signed Example-1.2.3-macos-arm64
72
+ ```
73
+
74
+ The destination must be new and outside the input directory. This command needs
75
+ no application configuration. With credentials, it signs only its copy, submits
76
+ that exact code through a temporary ZIP, waits for acceptance, and staples and
77
+ validates any `.app` bundles. It then publishes the signed directory atomically.
78
+ Archive only that returned tree; do not modify its signed contents. Without
79
+ credentials it copies the input unchanged and explicitly reports that signing
80
+ and notarization were skipped.
81
+
82
+ Standalone binaries rely on Gatekeeper's online ticket lookup. An accepted
83
+ portable submission does not claim offline stapling support for raw executables.
84
+ Notarization also does not replace app startup, model/GPU, entitlement, OS-version
85
+ or installation testing. Test a fresh downloaded package with Gatekeeper before
86
+ announcing the first notarized release.
87
+
88
+ Apple references: [Developer ID certificates](https://developer.apple.com/help/account/certificates/create-developer-id-certificates/),
89
+ [signing distribution code](https://developer.apple.com/documentation/xcode/creating-distribution-signed-code-for-the-mac/),
90
+ [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.1'
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)
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.4.0 --target main --title 'native-packages 0.4.0' --notes-file release-notes.md
26
+ gh release create v0.5.1 --target main --title 'native-packages 0.5.1' --notes-file release-notes.md
27
27
  ```
28
28
 
29
29
  Adding `--draft` permits review before publication and does not trigger the publisher. The tag must match the exact gem version and point to a commit on `main`. Set `--prerelease` only for a prerelease gem version.
@@ -1,7 +1,7 @@
1
1
  # Supply the listed build outputs before running this example.
2
2
  schema: 1
3
3
  tool:
4
- version: '0.4.0'
4
+ version: '0.5.1'
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.1'
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,190 @@
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
+ # The search list belongs to the OS user, not this project. Coordinate
45
+ # our signing calls across checkouts, including parallel packaging hooks.
46
+ lock_path = signing_lock_path
47
+ FileUtils.mkdir_p(lock_path.dirname, mode: 0o700)
48
+ File.open(lock_path, File::RDWR | File::CREAT, 0o600) do |lock|
49
+ lock.flock(File::LOCK_EX)
50
+ with_temporary_identity { yield self }
51
+ end
52
+ end
53
+
54
+ private def signing_lock_path
55
+ Pathname.new(Dir.home) / "Library/Caches/native-packages/macos-signing.lock"
56
+ end
57
+
58
+ private def with_temporary_identity
59
+ Dir.mktmpdir("native-packages-signing-") do |directory|
60
+ @keychain = Pathname.new(directory) / "signing.keychain-db"
61
+ certificate = Pathname.new(directory) / "certificate.p12"
62
+ File.write(certificate, @certificate, mode: "wb", perm: 0o600)
63
+ password = OpenSSL::Random.random_bytes(32).unpack1("H*")
64
+ @redactions << password
65
+ begin
66
+ execute "security", "create-keychain", "-p", password, @keychain
67
+ # Clean runners may not add new keychains to the user search list.
68
+ # codesign needs that entry even when given an explicit --keychain.
69
+ search_list = Shellwords.shellsplit(execute("security", "list-keychains", "-d", "user"))
70
+ unless search_list.any? { |path| File.identical?(path, @keychain) }
71
+ execute "security", "list-keychains", "-d", "user", "-s", *search_list, @keychain
72
+ end
73
+ execute "security", "set-keychain-settings", "-lut", "21600", @keychain
74
+ execute "security", "unlock-keychain", "-p", password, @keychain
75
+ execute "security", "import", certificate, "-k", @keychain,
76
+ "-P", @credentials.fetch("APPLE_CERTIFICATE_PASSWORD"), "-T", "/usr/bin/codesign"
77
+ execute "security", "set-key-partition-list", "-S", "apple-tool:,apple:,codesign:", "-k", password, @keychain
78
+ identities = execute "security", "find-identity", "-v", "-p", "codesigning", @keychain
79
+ unless identities.include?(%Q{"#{@credentials.fetch("APPLE_SIGNING_IDENTITY")}"})
80
+ raise Error, "imported P12 has no valid private signing identity matching APPLE_SIGNING_IDENTITY"
81
+ end
82
+ execute "xcrun", "notarytool", "store-credentials", "native-packages", "--keychain", @keychain,
83
+ "--apple-id", @credentials.fetch("APPLE_ID"), "--team-id", @credentials.fetch("APPLE_TEAM_ID"),
84
+ "--password", @credentials.fetch("APPLE_APP_PASSWORD")
85
+ yield self
86
+ ensure
87
+ # delete-keychain removes only this keychain and its search-list entry.
88
+ # Never restore an old list over keychains added by another process.
89
+ execute "security", "delete-keychain", @keychain if @keychain.exist?
90
+ @keychain = nil
91
+ end
92
+ end
93
+ end
94
+
95
+ def sign_payload(payload)
96
+ tree = NativeRecipe.new(root).tree(payload)
97
+ paths = tree.filter_map do |relative, kind, *|
98
+ path = payload / relative
99
+ if kind == "file" && NativeRecipe::MACHO.include?(path.binread(4).unpack1("H*"))
100
+ path
101
+ elsif kind == "directory" && (BUNDLES.include?(path.extname) || (path / "Contents/Info.plist").file?)
102
+ path
103
+ end
104
+ end
105
+ raise Error, "Apple signing needs a payload containing Mach-O code" if paths.empty?
106
+ # Files first, then their containing code bundles, including frameworks.
107
+ paths.sort_by { |path| [-path.each_filename.count, path.to_s] }.each do |path|
108
+ metadata = path.directory? ? "entitlements,requirements" : "identifier,entitlements,requirements"
109
+ execute "codesign", "--force", "--timestamp", "--options", "runtime",
110
+ "--preserve-metadata=#{metadata}", "--keychain", @keychain,
111
+ "--sign", @credentials.fetch("APPLE_SIGNING_IDENTITY"), path
112
+ execute "codesign", "--verify", "--strict", path
113
+ end
114
+ end
115
+
116
+ def notarize(package)
117
+ execute "hdiutil", "verify", package
118
+ execute "codesign", "--force", "--timestamp", "--keychain", @keychain,
119
+ "--sign", @credentials.fetch("APPLE_SIGNING_IDENTITY"), package
120
+ execute "codesign", "--verify", "--strict", package
121
+ response = submit(package)
122
+ execute "xcrun", "stapler", "staple", package
123
+ execute "xcrun", "stapler", "validate", package
124
+ execute "hdiutil", "verify", package
125
+ puts "Apple accepted #{package.basename}; its notarization ticket is stapled and validated."
126
+ { "signature" => "developer-id", "notarization" => "accepted", "submission_id" => response.fetch("id"), "stapled" => true }
127
+ end
128
+
129
+ def submit(package)
130
+ puts "Submitting #{package.basename} to Apple's notary service..."
131
+ response = JSON.parse(execute("xcrun", "notarytool", "submit", package,
132
+ "--keychain-profile", "native-packages", "--keychain", @keychain,
133
+ "--wait", "--timeout", "30m", "--output-format", "json"))
134
+ unless response["status"] == "Accepted"
135
+ raise Error, "Apple notarization was not accepted (submission #{response.fetch('id', 'unknown')}); inspect the notarytool log before distributing"
136
+ end
137
+ response.fetch("id")
138
+ response
139
+ rescue JSON::ParserError, KeyError
140
+ raise Error, "Apple notary service returned an invalid result; no package was published"
141
+ end
142
+
143
+ # A portable tarball cannot itself be submitted or stapled. Apple accepts
144
+ # the exact signed code through a temporary ZIP; the app owns the final tar.
145
+ def prepare_directory(source, output:)
146
+ source = Pathname.new(source).expand_path(root)
147
+ output = Pathname.new(output).expand_path(root)
148
+ raise Error, "output exists: #{output}; choose a fresh --output" if output.exist?
149
+ if output.to_s.start_with?(source.to_s + File::SEPARATOR)
150
+ raise Error, "signed output must be outside the input directory"
151
+ end
152
+ native = NativeRecipe.new(root)
153
+ original = native.digest(source)
154
+ raise Error, "macOS signing must run on macOS" if enabled? && NativeRecipe.host != "macos"
155
+ output.dirname.mkpath
156
+ Dir.mktmpdir(".native-packages-macos-", output.dirname) do |directory|
157
+ copied = Pathname.new(directory) / source.basename
158
+ FileUtils.cp_r(source, copied, preserve: true)
159
+ raise Error, "native input changed while staging" unless native.digest(copied) == original
160
+ with_identity do |signing|
161
+ if signing
162
+ sign_payload(copied)
163
+ archive = Pathname.new(directory) / "notarization.zip"
164
+ execute "ditto", "-c", "-k", "--keepParent", copied, archive
165
+ response = submit(archive)
166
+ native.tree(copied).each do |relative, kind, *|
167
+ bundle = copied / relative
168
+ next unless kind == "directory" && bundle.extname == ".app"
169
+ execute "xcrun", "stapler", "staple", bundle
170
+ execute "xcrun", "stapler", "validate", bundle
171
+ end
172
+ puts "Apple accepted portable code (submission #{response.fetch('id')}). Standalone executables use online ticket lookup."
173
+ else
174
+ puts "Apple credentials are absent; copying portable code without signing or notarization."
175
+ end
176
+ end
177
+ copied.rename(output)
178
+ end
179
+ output
180
+ end
181
+
182
+ def execute(*arguments)
183
+ capture(*arguments)
184
+ rescue Error => error
185
+ message = error.message.b
186
+ @redactions.sort_by { |value| -value.bytesize }.each { |value| message.gsub!(value.b, "[REDACTED]") }
187
+ raise Error, message
188
+ end
189
+ end
190
+ 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.1"
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.1
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