@@ -5,6 +5,8 @@ package main
55import (
66 "bytes"
77 "context"
8+ "crypto/rand"
9+ "encoding/base64"
810 "fmt"
911 "os"
1012 "os/exec"
@@ -18,12 +20,22 @@ import (
1820)
1921
2022type Go mg.Namespace
23+ type Apple mg.Namespace
2124type Git mg.Namespace
2225type Test mg.Namespace
2326type Docker mg.Namespace
2427
2528var 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.
2840func 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.
81339func (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.
367625func 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