Skip to content

Commit 2773cfa

Browse files
csstaubclaude
andcommitted
Add macOS codesigning, notarization, and Docker tag improvements
Add apple:codesign and apple:notarize mage targets for signing and notarizing macOS binaries. Codesign uses the macOS codesign tool with hardened runtime enabled. Notarize submits to Apple's notary service via xcrun notarytool with App Store Connect API key auth. For CI, CODESIGN_CERTIFICATE (base64 .p12) triggers temporary keychain creation with automatic cleanup. NOTARIZE_KEY (base64 .p8) is written to a temp file for notarytool. Sensitive security commands use runSilent to suppress argument echoing in CI logs. Add codesign and notarize steps to the Darwin release workflow. Change Docker tagging so "latest" is only applied on release tag pushes (e.g. v1.9.0), not on master branch pushes. Master pushes now produce "master" tagged images instead. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 011c83c commit 2773cfa

2 files changed

Lines changed: 298 additions & 16 deletions

File tree

.github/workflows/release.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,24 @@ jobs:
7272
CGO_ENABLED=1 GOARCH=arm64 ./mage-bin -v go:build
7373
mv ghostunnel ghostunnel-darwin-arm64
7474
lipo -create -output ghostunnel-darwin-universal ghostunnel-darwin-amd64 ghostunnel-darwin-arm64
75+
- name: Codesign binaries
76+
env:
77+
CODESIGN_IDENTITY: ${{ secrets.CODESIGN_IDENTITY }}
78+
CODESIGN_CERTIFICATE: ${{ secrets.CODESIGN_CERTIFICATE }}
79+
CODESIGN_CERTIFICATE_PASSWORD: ${{ secrets.CODESIGN_CERTIFICATE_PASSWORD }}
80+
run: |
81+
./mage-bin -v apple:codesign ghostunnel-darwin-amd64
82+
./mage-bin -v apple:codesign ghostunnel-darwin-arm64
83+
./mage-bin -v apple:codesign ghostunnel-darwin-universal
84+
- name: Notarize binaries
85+
env:
86+
NOTARIZE_ISSUER_ID: ${{ secrets.NOTARIZE_ISSUER_ID }}
87+
NOTARIZE_KEY_ID: ${{ secrets.NOTARIZE_KEY_ID }}
88+
NOTARIZE_KEY: ${{ secrets.NOTARIZE_KEY }}
89+
run: |
90+
./mage-bin -v apple:notarize ghostunnel-darwin-amd64
91+
./mage-bin -v apple:notarize ghostunnel-darwin-arm64
92+
./mage-bin -v apple:notarize ghostunnel-darwin-universal
7593
- name: Upload artifact
7694
uses: actions/upload-artifact@v6
7795
with:

magefile.go

Lines changed: 280 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ package main
55
import (
66
"bytes"
77
"context"
8+
"crypto/rand"
9+
"encoding/base64"
810
"fmt"
911
"os"
1012
"os/exec"
@@ -18,12 +20,22 @@ import (
1820
)
1921

2022
type Go mg.Namespace
23+
type Apple mg.Namespace
2124
type Git mg.Namespace
2225
type Test mg.Namespace
2326
type Docker mg.Namespace
2427

2528
var Default = Go.Build
2629

30+
// runSilent executes a command without echoing it to stdout, to avoid
31+
// leaking sensitive arguments (passwords, secrets) in CI logs.
32+
func runSilent(name string, args ...string) error {
33+
cmd := exec.Command(name, args...)
34+
cmd.Stdout = os.Stdout
35+
cmd.Stderr = os.Stderr
36+
return cmd.Run()
37+
}
38+
2739
// printf prints the given format and args if verbose mode is enabled.
2840
func printf(format string, args ...interface{}) {
2941
if mg.Verbose() {
@@ -77,6 +89,252 @@ func (Go) Man(ctx context.Context) error {
7789
return nil
7890
}
7991

92+
// Codesign signs a macOS binary using the codesign tool. The binary argument
93+
// specifies which file to sign. Requires macOS. If CODESIGN_CERTIFICATE is
94+
// set, a temporary keychain is created, the certificate is imported, and the
95+
// keychain is cleaned up after signing.
96+
//
97+
// Environment variables:
98+
// - CODESIGN_IDENTITY: Signing identity (required, e.g. "Developer ID Application: Name (TEAMID)")
99+
// - CODESIGN_CERTIFICATE: Base64-encoded .p12 certificate to import into a temporary keychain (optional, for CI)
100+
// - CODESIGN_CERTIFICATE_PASSWORD: Password for the .p12 certificate (required if CODESIGN_CERTIFICATE is set)
101+
func (Apple) Codesign(ctx context.Context, binary string) error {
102+
if runtime.GOOS != "darwin" {
103+
return fmt.Errorf("codesigning is only supported on macOS")
104+
}
105+
106+
identity := os.Getenv("CODESIGN_IDENTITY")
107+
if identity == "" {
108+
return fmt.Errorf("CODESIGN_IDENTITY must be set")
109+
}
110+
111+
certData := os.Getenv("CODESIGN_CERTIFICATE")
112+
if certData != "" {
113+
cleanup, err := setupCodesignKeychain(certData)
114+
if err != nil {
115+
return err
116+
}
117+
defer cleanup()
118+
}
119+
120+
printf("Signing binary %s with identity %s\n", binary, identity)
121+
122+
if err := sh.Run("codesign", "--force", "--options", "runtime", "--sign", identity, binary); err != nil {
123+
return fmt.Errorf("codesign %s failed: %w", binary, err)
124+
}
125+
126+
if err := sh.Run("codesign", "--verify", "--verbose", binary); err != nil {
127+
return fmt.Errorf("codesign verification of %s failed: %w", binary, err)
128+
}
129+
130+
printf("Binary %s signed and verified successfully\n", binary)
131+
return nil
132+
}
133+
134+
// Notarize submits a signed macOS binary to Apple's notary service. Requires
135+
// macOS. If NOTARIZE_KEY is set, the .p8 key is written to a temp file and
136+
// cleaned up after notarization.
137+
//
138+
// The binary is zipped for submission and the zip is removed afterward.
139+
// Note: stapling only works for .app, .pkg, and .dmg — for bare binaries the
140+
// notarization is registered with Apple but cannot be stapled. The staple step
141+
// is attempted but a failure is not treated as an error.
142+
//
143+
// Environment variables:
144+
// - NOTARIZE_ISSUER_ID: App Store Connect API issuer ID (required)
145+
// - NOTARIZE_KEY_ID: App Store Connect API key ID (required)
146+
// - NOTARIZE_KEY: Base64-encoded .p8 private key (optional, for CI; if not set, key must already exist)
147+
func (Apple) Notarize(ctx context.Context, binary string) error {
148+
if runtime.GOOS != "darwin" {
149+
return fmt.Errorf("notarization is only supported on macOS")
150+
}
151+
152+
issuerID := os.Getenv("NOTARIZE_ISSUER_ID")
153+
keyID := os.Getenv("NOTARIZE_KEY_ID")
154+
if issuerID == "" || keyID == "" {
155+
return fmt.Errorf("NOTARIZE_ISSUER_ID and NOTARIZE_KEY_ID must be set")
156+
}
157+
158+
// If NOTARIZE_KEY is set, write the .p8 key to a temp file for notarytool
159+
keyPath, err := setupNotarizeKey(keyID)
160+
if err != nil {
161+
return err
162+
}
163+
if keyPath != "" {
164+
defer os.Remove(keyPath)
165+
}
166+
167+
// Create zip for submission
168+
zipPath := binary + ".zip"
169+
if err := sh.Run("ditto", "-c", "-k", "--sequesterRsrc", binary, zipPath); err != nil {
170+
return fmt.Errorf("failed to create zip for %s: %w", binary, err)
171+
}
172+
173+
printf("Submitting %s for notarization...\n", binary)
174+
175+
submitArgs := []string{"notarytool", "submit", zipPath,
176+
"--issuer", issuerID,
177+
"--key-id", keyID,
178+
}
179+
if keyPath != "" {
180+
submitArgs = append(submitArgs, "--key", keyPath)
181+
}
182+
submitArgs = append(submitArgs, "--wait")
183+
184+
err = sh.Run("xcrun", submitArgs...)
185+
os.Remove(zipPath)
186+
if err != nil {
187+
return fmt.Errorf("notarization of %s failed: %w", binary, err)
188+
}
189+
190+
// Attempt to staple — this only works for .app/.pkg/.dmg, not bare binaries
191+
if err := sh.Run("xcrun", "stapler", "staple", binary); err != nil {
192+
printf("Stapling skipped for %s (not supported for bare binaries): %v\n", binary, err)
193+
}
194+
195+
printf("Notarization of %s completed successfully\n", binary)
196+
return nil
197+
}
198+
199+
// setupCodesignKeychain creates a temporary keychain, imports the signing
200+
// certificate, and configures the keychain search list. Returns a cleanup
201+
// function that removes the temporary keychain and restores the original
202+
// search list.
203+
func setupCodesignKeychain(certBase64 string) (func(), error) {
204+
password := os.Getenv("CODESIGN_CERTIFICATE_PASSWORD")
205+
if password == "" {
206+
return nil, fmt.Errorf("CODESIGN_CERTIFICATE_PASSWORD must be set when CODESIGN_CERTIFICATE is set")
207+
}
208+
209+
// Decode certificate
210+
certBytes, err := base64.StdEncoding.DecodeString(certBase64)
211+
if err != nil {
212+
return nil, fmt.Errorf("failed to decode CODESIGN_CERTIFICATE: %w", err)
213+
}
214+
215+
// Write certificate to temp file
216+
certFile, err := os.CreateTemp("", "codesign-*.p12")
217+
if err != nil {
218+
return nil, fmt.Errorf("failed to create temp file: %w", err)
219+
}
220+
if _, err := certFile.Write(certBytes); err != nil {
221+
os.Remove(certFile.Name())
222+
return nil, fmt.Errorf("failed to write certificate: %w", err)
223+
}
224+
certFile.Close()
225+
226+
// Generate random keychain password
227+
keychainPassBytes := make([]byte, 32)
228+
if _, err := rand.Read(keychainPassBytes); err != nil {
229+
os.Remove(certFile.Name())
230+
return nil, fmt.Errorf("failed to generate keychain password: %w", err)
231+
}
232+
keychainPassword := base64.StdEncoding.EncodeToString(keychainPassBytes)
233+
234+
keychainPath := "ghostunnel-signing.keychain-db"
235+
236+
// Save original keychain search list
237+
originalKeychains, err := sh.Output("security", "list-keychains", "-d", "user")
238+
if err != nil {
239+
os.Remove(certFile.Name())
240+
return nil, fmt.Errorf("failed to list keychains: %w", err)
241+
}
242+
243+
cleanup := func() {
244+
// Restore original keychain search list
245+
restoreArgs := []string{"list-keychains", "-d", "user", "-s"}
246+
restoreArgs = append(restoreArgs, parseKeychainPaths(originalKeychains)...)
247+
sh.Run("security", restoreArgs...)
248+
sh.Run("security", "delete-keychain", keychainPath)
249+
os.Remove(certFile.Name())
250+
}
251+
252+
// Create temporary keychain (suppress command echo to avoid leaking keychain password)
253+
if err := runSilent("security", "create-keychain", "-p", keychainPassword, keychainPath); err != nil {
254+
cleanup()
255+
return nil, fmt.Errorf("failed to create keychain: %w", err)
256+
}
257+
258+
// Set keychain settings (no auto-lock)
259+
if err := sh.Run("security", "set-keychain-settings", keychainPath); err != nil {
260+
cleanup()
261+
return nil, fmt.Errorf("failed to set keychain settings: %w", err)
262+
}
263+
264+
// Unlock keychain (suppress command echo to avoid leaking keychain password)
265+
if err := runSilent("security", "unlock-keychain", "-p", keychainPassword, keychainPath); err != nil {
266+
cleanup()
267+
return nil, fmt.Errorf("failed to unlock keychain: %w", err)
268+
}
269+
270+
// Import certificate into keychain (suppress command echo to avoid leaking certificate password)
271+
if err := runSilent("security", "import", certFile.Name(), "-k", keychainPath, "-f", "pkcs12", "-P", password, "-T", "/usr/bin/codesign"); err != nil {
272+
cleanup()
273+
return nil, fmt.Errorf("failed to import certificate: %w", err)
274+
}
275+
276+
// Set key partition list to allow codesign access (suppress command echo to avoid leaking keychain password)
277+
if err := runSilent("security", "set-key-partition-list", "-S", "apple-tool:,apple:,codesign:", "-s", "-k", keychainPassword, keychainPath); err != nil {
278+
cleanup()
279+
return nil, fmt.Errorf("failed to set key partition list: %w", err)
280+
}
281+
282+
// Add temporary keychain to search list (prepend to existing)
283+
keychainArgs := []string{"list-keychains", "-d", "user", "-s", keychainPath}
284+
keychainArgs = append(keychainArgs, parseKeychainPaths(originalKeychains)...)
285+
if err := sh.Run("security", keychainArgs...); err != nil {
286+
cleanup()
287+
return nil, fmt.Errorf("failed to update keychain search list: %w", err)
288+
}
289+
290+
return cleanup, nil
291+
}
292+
293+
// setupNotarizeKey writes the NOTARIZE_KEY env var (base64-encoded .p8) to a
294+
// temp file and returns its path. Returns an empty path if NOTARIZE_KEY is not
295+
// set (assumes the key file is already available locally).
296+
func setupNotarizeKey(keyID string) (string, error) {
297+
keyData := os.Getenv("NOTARIZE_KEY")
298+
if keyData == "" {
299+
return "", nil
300+
}
301+
302+
keyBytes, err := base64.StdEncoding.DecodeString(keyData)
303+
if err != nil {
304+
return "", fmt.Errorf("failed to decode NOTARIZE_KEY: %w", err)
305+
}
306+
307+
homeDir, err := os.UserHomeDir()
308+
if err != nil {
309+
return "", fmt.Errorf("failed to get home directory: %w", err)
310+
}
311+
312+
keyDir := filepath.Join(homeDir, "private_keys")
313+
if err := os.MkdirAll(keyDir, 0700); err != nil {
314+
return "", fmt.Errorf("failed to create private_keys directory: %w", err)
315+
}
316+
317+
keyPath := filepath.Join(keyDir, fmt.Sprintf("AuthKey_%s.p8", keyID))
318+
if err := os.WriteFile(keyPath, keyBytes, 0600); err != nil {
319+
return "", fmt.Errorf("failed to write API key: %w", err)
320+
}
321+
322+
return keyPath, nil
323+
}
324+
325+
// parseKeychainPaths parses the output of `security list-keychains` into
326+
// a list of unquoted keychain paths.
327+
func parseKeychainPaths(output string) []string {
328+
var paths []string
329+
for _, line := range strings.Split(output, "\n") {
330+
kc := strings.TrimSpace(strings.Trim(strings.TrimSpace(line), "\""))
331+
if kc != "" {
332+
paths = append(paths, kc)
333+
}
334+
}
335+
return paths
336+
}
337+
80338
// Clean removes build artifacts.
81339
func (Git) Clean(ctx context.Context) error {
82340
return sh.Run("git", "clean", "-Xdf")
@@ -365,19 +623,23 @@ func (Docker) Push(ctx context.Context) error {
365623

366624
// buildDocker builds and tags all Docker containers, optionally pushing them to Docker Hub.
367625
func buildDocker(ctx context.Context, push bool) error {
368-
// Determine base tag (latest for master, version tag otherwise)
369-
baseTag, err := getDockerTag()
626+
baseTags, err := getDockerTags()
370627
if err != nil {
371628
return err
372629
}
373630

374-
builds := map[string][]string{
375-
"Dockerfile-alpine": []string{
631+
builds := map[string][]string{}
632+
for _, baseTag := range baseTags {
633+
builds["Dockerfile-alpine"] = append(builds["Dockerfile-alpine"],
376634
fmt.Sprintf("ghostunnel/ghostunnel:%s", baseTag),
377635
fmt.Sprintf("ghostunnel/ghostunnel:%s-alpine", baseTag),
378-
},
379-
"Dockerfile-debian": []string{fmt.Sprintf("ghostunnel/ghostunnel:%s-debian", baseTag)},
380-
"Dockerfile-distroless": []string{fmt.Sprintf("ghostunnel/ghostunnel:%s-distroless", baseTag)},
636+
)
637+
builds["Dockerfile-debian"] = append(builds["Dockerfile-debian"],
638+
fmt.Sprintf("ghostunnel/ghostunnel:%s-debian", baseTag),
639+
)
640+
builds["Dockerfile-distroless"] = append(builds["Dockerfile-distroless"],
641+
fmt.Sprintf("ghostunnel/ghostunnel:%s-distroless", baseTag),
642+
)
381643
}
382644

383645
for dockerfile, tags := range builds {
@@ -425,37 +687,39 @@ func getVersion() string {
425687
return strings.TrimSpace(output)
426688
}
427689

428-
// getDockerTag determines the Docker tag to use based on git state.
429-
// Returns "latest" if on master branch, otherwise returns the most recent tag.
430-
func getDockerTag() (string, error) {
690+
// getDockerTags determines the Docker tags to use based on git state.
691+
// For release tags (refs/tags/v*), returns both the version tag and "latest".
692+
// For master branch, returns "master". For local non-master branches, returns
693+
// the most recent git tag.
694+
func getDockerTags() ([]string, error) {
431695
// Check if we're on a tag (for GitHub Actions when triggered by tag push)
432696
// In GitHub Actions, GITHUB_REF will be set, but locally we check git
433697
githubRef := os.Getenv("GITHUB_REF")
434698
if githubRef != "" {
435699
// GitHub Actions: refs/heads/master or refs/tags/v1.2.3
436700
if strings.HasPrefix(githubRef, "refs/heads/master") {
437-
return "latest", nil
701+
return []string{"master"}, nil
438702
}
439703
if strings.HasPrefix(githubRef, "refs/tags/") {
440704
tag := strings.TrimPrefix(githubRef, "refs/tags/")
441-
return tag, nil
705+
return []string{tag, "latest"}, nil
442706
}
443707
}
444708

445709
// Check current branch
446710
branch, err := sh.Output("git", "rev-parse", "--abbrev-ref", "HEAD")
447711
if err != nil {
448-
return "", fmt.Errorf("failed to determine git ref: %w", err)
712+
return nil, fmt.Errorf("failed to determine git ref: %w", err)
449713
}
450714
if strings.TrimSpace(branch) == "master" {
451-
return "latest", nil
715+
return []string{"master"}, nil
452716
}
453717

454718
// Not on master, get the most recent tag
455719
tag, err := sh.Output("git", "describe", "--tags", "--abbrev=0")
456720
if err != nil {
457-
return "", fmt.Errorf("failed to get git tag: %w", err)
721+
return nil, fmt.Errorf("failed to get git tag: %w", err)
458722
}
459723

460-
return strings.TrimSpace(tag), nil
724+
return []string{strings.TrimSpace(tag)}, nil
461725
}

0 commit comments

Comments
 (0)