cli/tasks/plans/2026-01-29-code-signing-impl.md
Snider a1306bc321 feat/ml-integration (#2)
Co-authored-by: Charon (snider-linux) <charon@lethean.io>
Co-authored-by: Snider <snider@host.uk.com>
Co-authored-by: Virgil <virgil@lethean.io>
Co-authored-by: Claude <developers@lethean.io>
Reviewed-on: #2
Co-authored-by: Snider <snider@lethean.io>
Co-committed-by: Snider <snider@lethean.io>
2026-02-16 06:19:09 +00:00

22 KiB

Code Signing Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Add GPG checksums signing and macOS codesign/notarization to the build pipeline.

Architecture: pkg/build/signing/ package with Signer interface. GPG signs CHECKSUMS.txt. macOS codesign runs after binary compilation, before archiving. Config in .core/build.yaml with env var fallbacks.

Tech Stack: Go, os/exec for gpg/codesign/xcrun CLI tools


Task 1: Create Signing Package Structure

Files:

  • Create: pkg/build/signing/signer.go

Step 1: Create signer.go with interface and config types

// Package signing provides code signing for build artifacts.
package signing

import (
	"context"
	"os"
	"strings"
)

// Signer defines the interface for code signing implementations.
type Signer interface {
	// Name returns the signer's identifier.
	Name() string
	// Available checks if this signer can be used.
	Available() bool
	// Sign signs the artifact at the given path.
	Sign(ctx context.Context, path string) error
}

// SignConfig holds signing configuration from .core/build.yaml.
type SignConfig struct {
	Enabled bool        `yaml:"enabled"`
	GPG     GPGConfig   `yaml:"gpg,omitempty"`
	MacOS   MacOSConfig `yaml:"macos,omitempty"`
	Windows WindowsConfig `yaml:"windows,omitempty"`
}

// GPGConfig holds GPG signing configuration.
type GPGConfig struct {
	Key string `yaml:"key"` // Key ID or fingerprint, supports $ENV
}

// MacOSConfig holds macOS codesign configuration.
type MacOSConfig struct {
	Identity    string `yaml:"identity"`      // Developer ID Application: ...
	Notarize    bool   `yaml:"notarize"`      // Submit to Apple for notarization
	AppleID     string `yaml:"apple_id"`      // Apple account email
	TeamID      string `yaml:"team_id"`       // Team ID
	AppPassword string `yaml:"app_password"`  // App-specific password
}

// WindowsConfig holds Windows signtool configuration (placeholder).
type WindowsConfig struct {
	Certificate string `yaml:"certificate"` // Path to .pfx
	Password    string `yaml:"password"`    // Certificate password
}

// DefaultSignConfig returns sensible defaults.
func DefaultSignConfig() SignConfig {
	return SignConfig{
		Enabled: true,
		GPG: GPGConfig{
			Key: os.Getenv("GPG_KEY_ID"),
		},
		MacOS: MacOSConfig{
			Identity:    os.Getenv("CODESIGN_IDENTITY"),
			AppleID:     os.Getenv("APPLE_ID"),
			TeamID:      os.Getenv("APPLE_TEAM_ID"),
			AppPassword: os.Getenv("APPLE_APP_PASSWORD"),
		},
	}
}

// ExpandEnv expands environment variables in config values.
func (c *SignConfig) ExpandEnv() {
	c.GPG.Key = expandEnv(c.GPG.Key)
	c.MacOS.Identity = expandEnv(c.MacOS.Identity)
	c.MacOS.AppleID = expandEnv(c.MacOS.AppleID)
	c.MacOS.TeamID = expandEnv(c.MacOS.TeamID)
	c.MacOS.AppPassword = expandEnv(c.MacOS.AppPassword)
	c.Windows.Certificate = expandEnv(c.Windows.Certificate)
	c.Windows.Password = expandEnv(c.Windows.Password)
}

// expandEnv expands $VAR or ${VAR} in a string.
func expandEnv(s string) string {
	if strings.HasPrefix(s, "$") {
		return os.ExpandEnv(s)
	}
	return s
}

Step 2: Verify it compiles

Run: cd /Users/snider/Code/Core && go build ./pkg/build/signing/... Expected: No errors

Step 3: Commit

git add pkg/build/signing/signer.go
git commit -m "feat(signing): add Signer interface and config types

Defines interface for GPG, macOS, and Windows signing.
Config supports env var expansion for secrets.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Task 2: Implement GPG Signer

Files:

  • Create: pkg/build/signing/gpg.go
  • Create: pkg/build/signing/gpg_test.go

Step 1: Write the failing test

package signing

import (
	"testing"
)

func TestGPGSigner_Good_Name(t *testing.T) {
	s := NewGPGSigner("ABCD1234")
	if s.Name() != "gpg" {
		t.Errorf("expected name 'gpg', got %q", s.Name())
	}
}

func TestGPGSigner_Good_Available(t *testing.T) {
	s := NewGPGSigner("ABCD1234")
	// Available depends on gpg being installed
	_ = s.Available()
}

func TestGPGSigner_Bad_NoKey(t *testing.T) {
	s := NewGPGSigner("")
	if s.Available() {
		t.Error("expected Available() to be false when key is empty")
	}
}

Step 2: Run test to verify it fails

Run: cd /Users/snider/Code/Core && go test ./pkg/build/signing/... -run TestGPGSigner -v Expected: FAIL (NewGPGSigner not defined)

Step 3: Write implementation

package signing

import (
	"context"
	"fmt"
	"os/exec"
)

// GPGSigner signs files using GPG.
type GPGSigner struct {
	KeyID string
}

// Compile-time interface check.
var _ Signer = (*GPGSigner)(nil)

// NewGPGSigner creates a new GPG signer.
func NewGPGSigner(keyID string) *GPGSigner {
	return &GPGSigner{KeyID: keyID}
}

// Name returns "gpg".
func (s *GPGSigner) Name() string {
	return "gpg"
}

// Available checks if gpg is installed and key is configured.
func (s *GPGSigner) Available() bool {
	if s.KeyID == "" {
		return false
	}
	_, err := exec.LookPath("gpg")
	return err == nil
}

// Sign creates a detached ASCII-armored signature.
// For file.txt, creates file.txt.asc
func (s *GPGSigner) Sign(ctx context.Context, file string) error {
	if !s.Available() {
		return fmt.Errorf("gpg.Sign: gpg not available or key not configured")
	}

	cmd := exec.CommandContext(ctx, "gpg",
		"--detach-sign",
		"--armor",
		"--local-user", s.KeyID,
		"--output", file+".asc",
		file,
	)

	output, err := cmd.CombinedOutput()
	if err != nil {
		return fmt.Errorf("gpg.Sign: %w\nOutput: %s", err, string(output))
	}

	return nil
}

Step 4: Run tests

Run: cd /Users/snider/Code/Core && go test ./pkg/build/signing/... -run TestGPGSigner -v Expected: PASS

Step 5: Commit

git add pkg/build/signing/gpg.go pkg/build/signing/gpg_test.go
git commit -m "feat(signing): add GPG signer

Signs files with detached ASCII-armored signatures (.asc).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Task 3: Implement macOS Codesign

Files:

  • Create: pkg/build/signing/codesign.go
  • Create: pkg/build/signing/codesign_test.go

Step 1: Write the failing test

package signing

import (
	"runtime"
	"testing"
)

func TestMacOSSigner_Good_Name(t *testing.T) {
	s := NewMacOSSigner(MacOSConfig{Identity: "Developer ID Application: Test"})
	if s.Name() != "codesign" {
		t.Errorf("expected name 'codesign', got %q", s.Name())
	}
}

func TestMacOSSigner_Good_Available(t *testing.T) {
	s := NewMacOSSigner(MacOSConfig{Identity: "Developer ID Application: Test"})

	// Only available on macOS with identity set
	if runtime.GOOS == "darwin" {
		// May or may not be available depending on Xcode
		_ = s.Available()
	} else {
		if s.Available() {
			t.Error("expected Available() to be false on non-macOS")
		}
	}
}

func TestMacOSSigner_Bad_NoIdentity(t *testing.T) {
	s := NewMacOSSigner(MacOSConfig{})
	if s.Available() {
		t.Error("expected Available() to be false when identity is empty")
	}
}

Step 2: Run test to verify it fails

Run: cd /Users/snider/Code/Core && go test ./pkg/build/signing/... -run TestMacOSSigner -v Expected: FAIL (NewMacOSSigner not defined)

Step 3: Write implementation

package signing

import (
	"context"
	"fmt"
	"os"
	"os/exec"
	"runtime"
)

// MacOSSigner signs binaries using macOS codesign.
type MacOSSigner struct {
	config MacOSConfig
}

// Compile-time interface check.
var _ Signer = (*MacOSSigner)(nil)

// NewMacOSSigner creates a new macOS signer.
func NewMacOSSigner(cfg MacOSConfig) *MacOSSigner {
	return &MacOSSigner{config: cfg}
}

// Name returns "codesign".
func (s *MacOSSigner) Name() string {
	return "codesign"
}

// Available checks if running on macOS with codesign and identity configured.
func (s *MacOSSigner) Available() bool {
	if runtime.GOOS != "darwin" {
		return false
	}
	if s.config.Identity == "" {
		return false
	}
	_, err := exec.LookPath("codesign")
	return err == nil
}

// Sign codesigns a binary with hardened runtime.
func (s *MacOSSigner) Sign(ctx context.Context, binary string) error {
	if !s.Available() {
		return fmt.Errorf("codesign.Sign: codesign not available")
	}

	cmd := exec.CommandContext(ctx, "codesign",
		"--sign", s.config.Identity,
		"--timestamp",
		"--options", "runtime", // Hardened runtime for notarization
		"--force",
		binary,
	)

	output, err := cmd.CombinedOutput()
	if err != nil {
		return fmt.Errorf("codesign.Sign: %w\nOutput: %s", err, string(output))
	}

	return nil
}

// Notarize submits binary to Apple for notarization and staples the ticket.
// This blocks until Apple responds (typically 1-5 minutes).
func (s *MacOSSigner) Notarize(ctx context.Context, binary string) error {
	if s.config.AppleID == "" || s.config.TeamID == "" || s.config.AppPassword == "" {
		return fmt.Errorf("codesign.Notarize: missing Apple credentials (apple_id, team_id, app_password)")
	}

	// Create ZIP for submission
	zipPath := binary + ".zip"
	zipCmd := exec.CommandContext(ctx, "zip", "-j", zipPath, binary)
	if output, err := zipCmd.CombinedOutput(); err != nil {
		return fmt.Errorf("codesign.Notarize: failed to create zip: %w\nOutput: %s", err, string(output))
	}
	defer os.Remove(zipPath)

	// Submit to Apple and wait
	submitCmd := exec.CommandContext(ctx, "xcrun", "notarytool", "submit",
		zipPath,
		"--apple-id", s.config.AppleID,
		"--team-id", s.config.TeamID,
		"--password", s.config.AppPassword,
		"--wait",
	)
	if output, err := submitCmd.CombinedOutput(); err != nil {
		return fmt.Errorf("codesign.Notarize: notarization failed: %w\nOutput: %s", err, string(output))
	}

	// Staple the ticket
	stapleCmd := exec.CommandContext(ctx, "xcrun", "stapler", "staple", binary)
	if output, err := stapleCmd.CombinedOutput(); err != nil {
		return fmt.Errorf("codesign.Notarize: failed to staple: %w\nOutput: %s", err, string(output))
	}

	return nil
}

// ShouldNotarize returns true if notarization is enabled.
func (s *MacOSSigner) ShouldNotarize() bool {
	return s.config.Notarize
}

Step 4: Run tests

Run: cd /Users/snider/Code/Core && go test ./pkg/build/signing/... -run TestMacOSSigner -v Expected: PASS

Step 5: Commit

git add pkg/build/signing/codesign.go pkg/build/signing/codesign_test.go
git commit -m "feat(signing): add macOS codesign + notarization

Signs binaries with Developer ID and hardened runtime.
Notarization submits to Apple and staples ticket.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Task 4: Add Windows Placeholder

Files:

  • Create: pkg/build/signing/signtool.go

Step 1: Create placeholder implementation

package signing

import (
	"context"
)

// WindowsSigner signs binaries using Windows signtool (placeholder).
type WindowsSigner struct {
	config WindowsConfig
}

// Compile-time interface check.
var _ Signer = (*WindowsSigner)(nil)

// NewWindowsSigner creates a new Windows signer.
func NewWindowsSigner(cfg WindowsConfig) *WindowsSigner {
	return &WindowsSigner{config: cfg}
}

// Name returns "signtool".
func (s *WindowsSigner) Name() string {
	return "signtool"
}

// Available returns false (not yet implemented).
func (s *WindowsSigner) Available() bool {
	return false
}

// Sign is a placeholder that does nothing.
func (s *WindowsSigner) Sign(ctx context.Context, binary string) error {
	// TODO: Implement Windows signing
	return nil
}

Step 2: Verify it compiles

Run: cd /Users/snider/Code/Core && go build ./pkg/build/signing/... Expected: No errors

Step 3: Commit

git add pkg/build/signing/signtool.go
git commit -m "feat(signing): add Windows signtool placeholder

Placeholder for future Windows code signing support.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Task 5: Add SignConfig to BuildConfig

Files:

  • Modify: pkg/build/config.go
  • Modify: pkg/build/config_test.go

Step 1: Add Sign field to BuildConfig

In pkg/build/config.go, add to the BuildConfig struct:

// Add import
import "forge.lthn.ai/core/cli/pkg/build/signing"

// Add to BuildConfig struct after Targets field:
	// Sign contains code signing configuration.
	Sign signing.SignConfig `yaml:"sign,omitempty"`

Step 2: Update DefaultConfig

In DefaultConfig(), add:

	Sign: signing.DefaultSignConfig(),

Step 3: Update applyDefaults

In applyDefaults(), add:

	// Expand environment variables in sign config
	cfg.Sign.ExpandEnv()

Step 4: Add test for sign config loading

Add to pkg/build/config_test.go:

func TestLoadConfig_Good_SignConfig(t *testing.T) {
	tmpDir := t.TempDir()
	coreDir := filepath.Join(tmpDir, ".core")
	os.MkdirAll(coreDir, 0755)

	configContent := `version: 1
sign:
  enabled: true
  gpg:
    key: "ABCD1234"
  macos:
    identity: "Developer ID Application: Test"
    notarize: true
`
	os.WriteFile(filepath.Join(coreDir, "build.yaml"), []byte(configContent), 0644)

	cfg, err := LoadConfig(tmpDir)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	if !cfg.Sign.Enabled {
		t.Error("expected Sign.Enabled to be true")
	}
	if cfg.Sign.GPG.Key != "ABCD1234" {
		t.Errorf("expected GPG.Key 'ABCD1234', got %q", cfg.Sign.GPG.Key)
	}
	if cfg.Sign.MacOS.Identity != "Developer ID Application: Test" {
		t.Errorf("expected MacOS.Identity, got %q", cfg.Sign.MacOS.Identity)
	}
	if !cfg.Sign.MacOS.Notarize {
		t.Error("expected MacOS.Notarize to be true")
	}
}

Step 5: Run tests

Run: cd /Users/snider/Code/Core && go test ./pkg/build/... -run TestLoadConfig -v Expected: PASS

Step 6: Commit

git add pkg/build/config.go pkg/build/config_test.go
git commit -m "feat(build): add SignConfig to BuildConfig

Loads signing configuration from .core/build.yaml.
Expands environment variables for secrets.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Task 6: Create Sign Helper Functions

Files:

  • Create: pkg/build/signing/sign.go

Step 1: Create orchestration helpers

package signing

import (
	"context"
	"fmt"
	"runtime"

	"forge.lthn.ai/core/cli/pkg/build"
)

// SignBinaries signs macOS binaries in the artifacts list.
// Only signs darwin binaries when running on macOS with a configured identity.
func SignBinaries(ctx context.Context, cfg SignConfig, artifacts []build.Artifact) error {
	if !cfg.Enabled {
		return nil
	}

	// Only sign on macOS
	if runtime.GOOS != "darwin" {
		return nil
	}

	signer := NewMacOSSigner(cfg.MacOS)
	if !signer.Available() {
		return nil // Silently skip if not configured
	}

	for _, artifact := range artifacts {
		if artifact.OS != "darwin" {
			continue
		}

		fmt.Printf("  Signing %s...\n", artifact.Path)
		if err := signer.Sign(ctx, artifact.Path); err != nil {
			return fmt.Errorf("failed to sign %s: %w", artifact.Path, err)
		}
	}

	return nil
}

// NotarizeBinaries notarizes macOS binaries if enabled.
func NotarizeBinaries(ctx context.Context, cfg SignConfig, artifacts []build.Artifact) error {
	if !cfg.Enabled || !cfg.MacOS.Notarize {
		return nil
	}

	if runtime.GOOS != "darwin" {
		return nil
	}

	signer := NewMacOSSigner(cfg.MacOS)
	if !signer.Available() {
		return fmt.Errorf("notarization requested but codesign not available")
	}

	for _, artifact := range artifacts {
		if artifact.OS != "darwin" {
			continue
		}

		fmt.Printf("  Notarizing %s (this may take a few minutes)...\n", artifact.Path)
		if err := signer.Notarize(ctx, artifact.Path); err != nil {
			return fmt.Errorf("failed to notarize %s: %w", artifact.Path, err)
		}
	}

	return nil
}

// SignChecksums signs the checksums file with GPG.
func SignChecksums(ctx context.Context, cfg SignConfig, checksumFile string) error {
	if !cfg.Enabled {
		return nil
	}

	signer := NewGPGSigner(cfg.GPG.Key)
	if !signer.Available() {
		return nil // Silently skip if not configured
	}

	fmt.Printf("  Signing %s with GPG...\n", checksumFile)
	if err := signer.Sign(ctx, checksumFile); err != nil {
		return fmt.Errorf("failed to sign checksums: %w", err)
	}

	return nil
}

Step 2: Verify it compiles

Run: cd /Users/snider/Code/Core && go build ./pkg/build/signing/... Expected: No errors

Step 3: Commit

git add pkg/build/signing/sign.go
git commit -m "feat(signing): add orchestration helpers

SignBinaries, NotarizeBinaries, SignChecksums for pipeline integration.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Task 7: Integrate Signing into CLI

Files:

  • Modify: cmd/core/cmd/build.go

Step 1: Add --no-sign and --notarize flags

After the existing flag declarations (around line 74), add:

	var noSign bool
	var notarize bool

	buildCmd.BoolFlag("no-sign", "Skip all code signing", &noSign)
	buildCmd.BoolFlag("notarize", "Enable macOS notarization (requires Apple credentials)", &notarize)

Step 2: Update runProjectBuild signature

Update the function signature and call:

// Update function signature:
func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDir string, doArchive bool, doChecksum bool, configPath string, format string, push bool, imageName string, noSign bool, notarize bool) error {

// Update the Action call:
buildCmd.Action(func() error {
	return runProjectBuild(buildType, ciMode, targets, outputDir, doArchive, doChecksum, configPath, format, push, imageName, noSign, notarize)
})

Step 3: Add signing import

Add to imports:

	"forge.lthn.ai/core/cli/pkg/build/signing"

Step 4: Add signing after build, before archive

After the build succeeds (around line 228), add:

	// Sign macOS binaries if enabled
	signCfg := buildCfg.Sign
	if notarize {
		signCfg.MacOS.Notarize = true
	}
	if noSign {
		signCfg.Enabled = false
	}

	if signCfg.Enabled && runtime.GOOS == "darwin" {
		if !ciMode {
			fmt.Println()
			fmt.Printf("%s Signing binaries...\n", buildHeaderStyle.Render("Sign:"))
		}

		if err := signing.SignBinaries(ctx, signCfg, artifacts); err != nil {
			if !ciMode {
				fmt.Printf("%s Signing failed: %v\n", buildErrorStyle.Render("Error:"), err)
			}
			return err
		}

		if signCfg.MacOS.Notarize {
			if err := signing.NotarizeBinaries(ctx, signCfg, artifacts); err != nil {
				if !ciMode {
					fmt.Printf("%s Notarization failed: %v\n", buildErrorStyle.Render("Error:"), err)
				}
				return err
			}
		}
	}

Step 5: Add GPG signing after checksums

After WriteChecksumFile (around line 297), add:

		// Sign checksums with GPG
		if signCfg.Enabled {
			if err := signing.SignChecksums(ctx, signCfg, checksumPath); err != nil {
				if !ciMode {
					fmt.Printf("%s GPG signing failed: %v\n", buildErrorStyle.Render("Error:"), err)
				}
				return err
			}
		}

Step 6: Verify it compiles

Run: cd /Users/snider/Code/Core && go build ./cmd/core/... Expected: No errors

Step 7: Commit

git add cmd/core/cmd/build.go
git commit -m "feat(cli): integrate signing into build command

Adds --no-sign and --notarize flags.
Signs macOS binaries after build, GPG signs checksums.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Task 8: Add Integration Test

Files:

  • Create: pkg/build/signing/signing_test.go

Step 1: Create integration test

package signing

import (
	"context"
	"os"
	"path/filepath"
	"runtime"
	"testing"

	"forge.lthn.ai/core/cli/pkg/build"
)

func TestSignBinaries_Good_SkipsNonDarwin(t *testing.T) {
	ctx := context.Background()
	cfg := SignConfig{
		Enabled: true,
		MacOS: MacOSConfig{
			Identity: "Developer ID Application: Test",
		},
	}

	// Create fake artifact for linux
	artifacts := []build.Artifact{
		{Path: "/tmp/test-binary", OS: "linux", Arch: "amd64"},
	}

	// Should not error even though binary doesn't exist (skips non-darwin)
	err := SignBinaries(ctx, cfg, artifacts)
	if err != nil {
		t.Errorf("unexpected error: %v", err)
	}
}

func TestSignBinaries_Good_DisabledConfig(t *testing.T) {
	ctx := context.Background()
	cfg := SignConfig{
		Enabled: false,
	}

	artifacts := []build.Artifact{
		{Path: "/tmp/test-binary", OS: "darwin", Arch: "arm64"},
	}

	err := SignBinaries(ctx, cfg, artifacts)
	if err != nil {
		t.Errorf("unexpected error: %v", err)
	}
}

func TestSignChecksums_Good_SkipsNoKey(t *testing.T) {
	ctx := context.Background()
	cfg := SignConfig{
		Enabled: true,
		GPG: GPGConfig{
			Key: "", // No key configured
		},
	}

	// Should silently skip when no key
	err := SignChecksums(ctx, cfg, "/tmp/CHECKSUMS.txt")
	if err != nil {
		t.Errorf("unexpected error: %v", err)
	}
}

func TestSignChecksums_Good_Disabled(t *testing.T) {
	ctx := context.Background()
	cfg := SignConfig{
		Enabled: false,
	}

	err := SignChecksums(ctx, cfg, "/tmp/CHECKSUMS.txt")
	if err != nil {
		t.Errorf("unexpected error: %v", err)
	}
}

Step 2: Run all signing tests

Run: cd /Users/snider/Code/Core && go test ./pkg/build/signing/... -v Expected: All tests pass

Step 3: Commit

git add pkg/build/signing/signing_test.go
git commit -m "test(signing): add integration tests

Tests for skip conditions and disabled configs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Task 9: Update TODO.md and Final Verification

Step 1: Build CLI

Run: cd /Users/snider/Code/Core && go build -o bin/core ./cmd/core Expected: No errors

Step 2: Test help output

Run: ./bin/core build --help Expected: Shows --no-sign and --notarize flags

Step 3: Run all tests

Run: cd /Users/snider/Code/Core && go test ./pkg/build/... -v Expected: All tests pass

Step 4: Update TODO.md

Mark S3.3 tasks as complete in tasks/TODO.md:

### S3.3 Code Signing (Standard) ✅
- [x] macOS codesign integration
- [x] macOS notarization
- [ ] Windows signtool integration (placeholder added)
- [x] GPG signing (standard tools)

Step 5: Final commit

git add tasks/TODO.md
git commit -m "chore(signing): finalize S3.3 code signing

Implemented:
- GPG signing of CHECKSUMS.txt
- macOS codesign with hardened runtime
- macOS notarization via notarytool
- Windows signtool placeholder

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

Summary

9 tasks covering:

  1. Signing package structure (Signer interface, SignConfig)
  2. GPG signer implementation
  3. macOS codesign + notarization
  4. Windows signtool placeholder
  5. Add SignConfig to BuildConfig
  6. Orchestration helpers (SignBinaries, SignChecksums)
  7. CLI integration (--no-sign, --notarize)
  8. Integration tests
  9. Final verification and TODO update