Skip to content

Commit 3da147c

Browse files
authored
Merge pull request ghostunnel#629 from ghostunnel/cs/sign-and-notarize
Add macOS codesigning, notarization, and Docker tag improvements
2 parents a8a5983 + 2773cfa commit 3da147c

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)