1
0
mirror of https://github.com/goreleaser/goreleaser.git synced 2026-06-19 23:24:39 +02:00

fix(nodesea): adhoc sign with quill

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
This commit is contained in:
Carlos Alexandro Becker
2026-05-01 14:52:42 -03:00
parent 4f505fafb1
commit bc46dde2b3
5 changed files with 64 additions and 118 deletions
+3 -4
View File
@@ -5,10 +5,9 @@
// once per host into the user cache) and invokes `node --build-sea
// sea-config.json` against the per-target Node binary GoReleaser
// fetches from https://nodejs.org/dist (verifying SHA-256). On macOS
// targets the produced Mach-O is ad-hoc signed via codesign(1); when
// codesign is unavailable (cross-compile from non-darwin hosts) the
// binary is left unsigned and must be re-signed via the `signs:` pipe
// before it will execute on macOS.
// targets the produced Mach-O is ad-hoc signed via quill (pure-Go) so
// it loads on Apple Silicon out of the box; users with a Developer ID
// can layer real signing on top via the signs: pipe.
//
// Concurrent builds are enabled — each target runs --build-sea against
// its own scratch directory and outputs to its own path; nothing is
+8 -9
View File
@@ -3,7 +3,6 @@ package nodesea
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
@@ -65,7 +64,7 @@ type BuildOptions struct {
SEAConfig SEAConfig
// CodeSignID is the ad-hoc CMS signing identifier applied to
// darwin outputs. Empty → derived from filepath.Base(OutPath).
// darwin outputs by quill. Empty → derived from filepath.Base(OutPath).
CodeSignID string
}
@@ -75,11 +74,11 @@ type BuildOptions struct {
// cached per-target Node binary downloaded for opts.Version+opts.Target.
//
// On darwin targets the resulting Mach-O is ad-hoc CMS-signed via
// codesign(1) before it lands at OutPath. When codesign(1) is not on
// PATH (typical when cross-compiling for darwin from non-darwin hosts),
// the binary is left unsigned: it is well-formed but the macOS kernel
// will refuse to exec it until it is re-signed (e.g. via goreleaser's
// signs: pipe on a darwin runner).
// quill (pure-Go) before it lands at OutPath, so the macOS kernel will
// exec the binary on Apple Silicon without further action. Real
// Developer ID signing and notarization are layered on top via the
// signs: and notarize: pipes — quill strips the ad-hoc signature
// before re-signing.
//
// OutPath is written atomically: --build-sea generates into a sibling
// tempfile, signing happens in place on the temp, then a rename
@@ -129,8 +128,8 @@ func BuildViaBuildSEA(ctx context.Context, opts BuildOptions) error {
base := filepath.Base(opts.OutPath)
id = strings.TrimSuffix(base, filepath.Ext(base))
}
if err := adHocSignMachO(ctx, tmpOut, id); err != nil && !errors.Is(err, ErrCodeSignUnavailable) {
return fmt.Errorf("nodesea: ad-hoc sign: %w", err)
if err := signMachO(tmpOut, id); err != nil {
return err
}
}
+24 -42
View File
@@ -1,54 +1,36 @@
package nodesea
import (
"context"
"errors"
"fmt"
"os/exec"
"strings"
"github.com/goreleaser/quill/quill"
)
// ErrCodeSignUnavailable is returned by adHocSignMachO when codesign(1)
// is not present on PATH (e.g. cross-compiling a darwin SEA from a
// linux host). Callers may treat this as a soft failure: the produced
// binary is well-formed but kernel-rejected on macOS until re-signed.
var ErrCodeSignUnavailable = errors.New("nodesea: codesign(1) not available; output left unsigned")
// codeSignBinary names the executable used to ad-hoc sign Mach-O
// outputs. Variable so tests can stub or skip it.
// signMachO ad-hoc signs the Mach-O at path with identifier id using
// quill (pure-Go Mach-O signer). Works on any host OS — no codesign(1)
// dependency, so cross-compiling darwin SEAs from linux/windows hosts
// produces a kernel-loadable binary.
//
// `node --build-sea` leaves a placeholder LC_CODE_SIGNATURE pointing at
// end-of-file with no signature bytes appended. quill's signSingleBinary
// calls RemoveSigningContent before signing, so the placeholder is
// stripped and replaced with a valid ad-hoc CMS superblob.
//
// Ad-hoc only — no developer cert involved. Users with a Developer ID
// can layer real signing on top via the signs: pipe; quill removes the
// ad-hoc signature first there too.
//
// Variable so tests can stub without a real Mach-O fixture.
//
//nolint:gochecknoglobals
var codeSignBinary = "codesign"
// runCodeSign is the executor for codesign(1). Variable so tests can
// record argv without invoking the real binary.
//
//nolint:gochecknoglobals
var runCodeSign = func(ctx context.Context, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, codeSignBinary, args...)
return cmd.CombinedOutput()
}
// adHocSignMachO applies an ad-hoc signature to the Mach-O at path via
// `codesign --sign - --force --identifier <id> <path>`.
//
// We use codesign(1) (Apple's signer) because `node --build-sea`
// produces a Mach-O with a placeholder LC_CODE_SIGNATURE load command
// that points at end-of-file with no signature bytes appended; an
// in-process signer that does not recognize that placeholder will
// corrupt the layout. codesign(1) handles this correctly.
//
// When codesign(1) is not on PATH (typical on non-darwin build hosts
// cross-compiling for darwin), this returns ErrCodeSignUnavailable
// without modifying path. Callers that need a runnable binary on macOS
// must re-sign via goreleaser's signs: pipe on a darwin runner.
func adHocSignMachO(ctx context.Context, path, id string) error {
if _, err := exec.LookPath(codeSignBinary); err != nil {
return ErrCodeSignUnavailable
}
out, err := runCodeSign(ctx, "--sign", "-", "--force", "--identifier", id, path)
var signMachO = func(path, id string) error {
cfg, err := quill.NewSigningConfigFromPEMs(path, "", "", "", false)
if err != nil {
return fmt.Errorf("codesign %s: %w (output: %s)", path, err, strings.TrimSpace(string(out)))
return fmt.Errorf("nodesea: quill config for %s: %w", path, err)
}
cfg.WithIdentity(id)
if err := quill.Sign(*cfg); err != nil {
return fmt.Errorf("nodesea: ad-hoc sign %s: %w", path, err)
}
return nil
}
+25 -60
View File
@@ -1,7 +1,6 @@
package nodesea
import (
"context"
"errors"
"os"
"path/filepath"
@@ -10,76 +9,42 @@ import (
"github.com/stretchr/testify/require"
)
func TestAdHocSignMachO_CodesignMissing(t *testing.T) {
// Force lookups to miss by emptying PATH.
t.Setenv("PATH", filepath.Join(t.TempDir(), "definitely-empty"))
func TestSignMachO_RecordsArgs(t *testing.T) {
prev := signMachO
t.Cleanup(func() { signMachO = prev })
err := adHocSignMachO(t.Context(), "/some/path", "id")
require.ErrorIs(t, err, ErrCodeSignUnavailable)
}
func TestAdHocSignMachO_RecordsArgv(t *testing.T) {
// Stage a fake codesign on PATH so LookPath succeeds.
bin := filepath.Join(t.TempDir(), "codesign")
require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755))
t.Setenv("PATH", filepath.Dir(bin))
prev := runCodeSign
t.Cleanup(func() { runCodeSign = prev })
var got []string
runCodeSign = func(_ context.Context, args ...string) ([]byte, error) {
got = args
return nil, nil
var gotPath, gotID string
signMachO = func(path, id string) error {
gotPath, gotID = path, id
return nil
}
require.NoError(t, adHocSignMachO(t.Context(), "/path/to/bin", "my.bundle.id"))
require.Equal(t,
[]string{"--sign", "-", "--force", "--identifier", "my.bundle.id", "/path/to/bin"},
got)
require.NoError(t, signMachO("/path/to/bin", "my.bundle.id"))
require.Equal(t, "/path/to/bin", gotPath)
require.Equal(t, "my.bundle.id", gotID)
}
func TestAdHocSignMachO_CodesignFailure(t *testing.T) {
bin := filepath.Join(t.TempDir(), "codesign")
require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755))
t.Setenv("PATH", filepath.Dir(bin))
func TestSignMachO_PropagatesError(t *testing.T) {
prev := signMachO
t.Cleanup(func() { signMachO = prev })
prev := runCodeSign
t.Cleanup(func() { runCodeSign = prev })
runCodeSign = func(_ context.Context, _ ...string) ([]byte, error) {
return []byte("codesign: bad bag o' bits"), errors.New("exit status 1")
signMachO = func(string, string) error {
return errors.New("quill: bad bag o' bits")
}
err := adHocSignMachO(t.Context(), "/p", "id")
err := signMachO("/p", "id")
require.Error(t, err)
require.NotErrorIs(t, err, ErrCodeSignUnavailable)
require.Contains(t, err.Error(), "bad bag o' bits")
}
// TestBuildViaBuildSEA_Darwin_NoCodesign verifies the "cross-compile from
// linux for darwin" fallback: when codesign(1) is missing the build
// completes successfully and leaves the binary unsigned.
func TestBuildViaBuildSEA_Darwin_NoCodesign(t *testing.T) {
t.Setenv("PATH", filepath.Join(t.TempDir(), "no-codesign-here"))
// TestSignMachO_RealQuill exercises the live quill signer on a tiny
// fake non-Mach-O file to confirm the wiring fails with a real quill
// error (not a panic) when the input isn't a valid Mach-O. Ad-hoc
// signing of a real Mach-O is covered by quill's own test suite.
func TestSignMachO_RealQuill(t *testing.T) {
bin := filepath.Join(t.TempDir(), "not-a-macho")
require.NoError(t, os.WriteFile(bin, []byte("not a mach-o file"), 0o644))
const version = "v22.20.0"
target := Target("darwin-arm64")
stageTargetNode(t, version, target)
mainPath := filepath.Join(t.TempDir(), "main.js")
require.NoError(t, os.WriteFile(mainPath, []byte(`console.log("ok");`), 0o644))
outPath := filepath.Join(t.TempDir(), "myapp")
stubRunBuildSEA(t, nil)
require.NoError(t, BuildViaBuildSEA(t.Context(), BuildOptions{
BuildToolNode: "/fake/node",
Target: target,
Version: version,
MainJS: mainPath,
OutPath: outPath,
}))
require.FileExists(t, outPath)
err := signMachO(bin, "id")
require.Error(t, err)
}
+4 -3
View File
@@ -7,9 +7,10 @@
// sea-config.json`. That command injects the SEA blob into a copy of
// the per-target Node binary GoReleaser fetches from
// https://nodejs.org/dist (verifying SHA-256). On macOS targets the
// produced binary is ad-hoc signed via codesign(1) so the kernel
// loader will accept it. The package owns the cache layout, the
// download + verify path, and the `--build-sea` orchestration.
// produced binary is ad-hoc signed via quill (pure-Go, host-OS
// independent) so the kernel loader will accept it. The package owns
// the cache layout, the download + verify path, and the `--build-sea`
// orchestration.
package nodesea
// Format identifies the container format of a Node.js host binary.