From 97fb47d023e272b85102cf387126cdddd7eb0b2a Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sat, 2 May 2026 10:29:08 +1000 Subject: [PATCH] Wire orchestrator end-to-end Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/wails-release/main.go | 227 ++++++++++++++++++++++++++++++++- cmd/wails-release/main_test.go | 22 ++++ 2 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 cmd/wails-release/main_test.go diff --git a/cmd/wails-release/main.go b/cmd/wails-release/main.go index 7be299d..0ca73fe 100644 --- a/cmd/wails-release/main.go +++ b/cmd/wails-release/main.go @@ -1,9 +1,26 @@ package main import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/hex" "fmt" "os" + "path/filepath" + "regexp" "runtime" + + "github.com/leonmika/wails-release/internal/actions" + "github.com/leonmika/wails-release/internal/archive" + "github.com/leonmika/wails-release/internal/cleanup" + "github.com/leonmika/wails-release/internal/codesign" + "github.com/leonmika/wails-release/internal/config" + "github.com/leonmika/wails-release/internal/notarize" + "github.com/leonmika/wails-release/internal/runner" + "github.com/leonmika/wails-release/internal/upload" + "github.com/leonmika/wails-release/internal/version" + "github.com/leonmika/wails-release/internal/wails" ) func main() { @@ -11,5 +28,213 @@ func main() { fmt.Fprintln(os.Stderr, "wails-release: only macOS runners are supported") os.Exit(1) } - fmt.Println("wails-release: stub") + if err := run(context.Background()); err != nil { + fmt.Fprintln(os.Stderr, "wails-release:", err) + os.Exit(1) + } +} + +func run(ctx context.Context) error { + cfg := config.Load(os.Getenv) + if err := cfg.Validate(); err != nil { + return err + } + + maskSecrets(cfg) + + r := runner.Real{} + var cs cleanup.Stack + defer func() { + for _, e := range cs.Run() { + fmt.Fprintln(os.Stderr, "cleanup:", e) + } + }() + + // 1. Resolve version + app name + resolvedVersion := version.Resolve(cfg.Version, os.Getenv("GITHUB_REF"), os.Getenv("GITHUB_SHA")) + + project, err := wails.ReadProject(cfg.WorkingDirectory) + if err != nil { + return err + } + appName := cfg.AppName + if appName == "" { + appName = project.Name + } + + // 2. Wails CLI + wailsVersion := cfg.WailsVersion + if wailsVersion == "" { + wailsVersion, err = wails.ProjectWailsVersion(cfg.WorkingDirectory) + if err != nil { + return err + } + } + if err := wails.EnsureCLI(ctx, r, wailsVersion); err != nil { + return err + } + + // 3. Build + if err := wails.Build(ctx, r, wails.BuildOpts{ + Dir: cfg.WorkingDirectory, + ExtraFlags: cfg.ExtraBuildFlags, + }); err != nil { + return err + } + appPath := filepath.Join(cfg.WorkingDirectory, "build", "bin", appName+".app") + + // 4. Codesign — temp keychain + import + sign + tmpDir, err := os.MkdirTemp("", "wails-release-") + if err != nil { + return fmt.Errorf("mktemp: %w", err) + } + cs.Add(func() error { return os.RemoveAll(tmpDir) }) + + keychainPath := filepath.Join(tmpDir, "build.keychain-db") + keychainPW := randomHex(16) + kc, err := codesign.CreateKeychain(ctx, r, keychainPath, keychainPW) + if err != nil { + return err + } + cs.Add(func() error { return codesign.DeleteKeychain(ctx, r, *kc) }) + + p12Path := filepath.Join(tmpDir, "cert.p12") + if err := writeBase64(p12Path, cfg.DeveloperIDCertBase64); err != nil { + return err + } + if err := codesign.ImportP12(ctx, r, *kc, p12Path, cfg.DeveloperIDCertPassword); err != nil { + return err + } + + identity, err := codesignIdentityFromP12(ctx, r, *kc) + if err != nil { + return err + } + if err := codesign.Sign(ctx, r, codesign.SignOpts{ + AppPath: appPath, Identity: identity, KeychainPath: kc.Path, + }); err != nil { + return err + } + if err := codesign.Verify(ctx, r, appPath); err != nil { + return err + } + + // 5. Archive (pre-staple zip for notary submission) + distDir := filepath.Join(cfg.WorkingDirectory, "build", "bin") + artifactName := fmt.Sprintf("%s-%s.app.zip", appName, resolvedVersion) + zipPath := filepath.Join(distDir, artifactName) + if err := archive.ZipApp(ctx, r, appPath, zipPath); err != nil { + return err + } + + // 6. Notarize + staple + re-archive + notarOpts := notarize.Opts{ZipPath: zipPath} + switch cfg.ResolvedNotarizationMethod() { + case "api-key": + p8Path := filepath.Join(tmpDir, "key.p8") + if err := writeBase64(p8Path, cfg.NotarizationAPIKeyBase64); err != nil { + return err + } + notarOpts.APIKey = ¬arize.APIKey{ + KeyPath: p8Path, + KeyID: cfg.NotarizationAPIKeyID, + IssuerID: cfg.NotarizationAPIIssuerID, + } + case "apple-id": + notarOpts.AppleID = ¬arize.AppleID{ + Username: cfg.NotarizationAppleID, + Password: cfg.NotarizationApplePassword, + TeamID: cfg.NotarizationTeamID, + } + } + if err := notarize.Submit(ctx, r, notarOpts); err != nil { + return err + } + if err := notarize.Staple(ctx, r, appPath); err != nil { + return err + } + if err := archive.ZipApp(ctx, r, appPath, zipPath); err != nil { + return err + } + + // 7. Upload (optional) + s3URL := "" + if cfg.S3Bucket != "" { + client, err := upload.NewClient(ctx, cfg.S3Region, cfg.S3EndpointURL) + if err != nil { + return err + } + key := upload.RenderKey(cfg.S3Key, resolvedVersion, artifactName) + s3URL, err = upload.Upload(ctx, client, upload.Opts{ + Bucket: cfg.S3Bucket, Key: key, FilePath: zipPath, + }) + if err != nil { + return err + } + } + + // 8. Outputs + outFile := os.Getenv("GITHUB_OUTPUT") + for k, v := range map[string]string{ + "version": resolvedVersion, + "app-name": appName, + "artifact-path": zipPath, + "artifact-filename": artifactName, + "s3-url": s3URL, + } { + if err := actions.SetOutput(outFile, k, v); err != nil { + return err + } + } + return nil +} + +func maskSecrets(c *config.Config) { + for _, v := range []string{ + c.DeveloperIDCertPassword, + c.NotarizationAPIKeyBase64, + c.NotarizationApplePassword, + } { + actions.AddMask(os.Stdout, v) + } +} + +func writeBase64(path, encoded string) error { + raw, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return fmt.Errorf("decode base64 → %s: %w", path, err) + } + return os.WriteFile(path, raw, 0o600) +} + +func randomHex(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + // extremely unlikely; fall back to a less-strong but functional value + return "fallback-keychain-pw" + } + return hex.EncodeToString(b) +} + +var devIDRE = regexp.MustCompile(`"(Developer ID Application: [^"]+)"`) + +// codesignIdentityFromP12 finds the imported "Developer ID Application: …" +// identity in the keychain so the caller does not have to thread it through +// inputs. +func codesignIdentityFromP12(ctx context.Context, r runner.Runner, kc codesign.Keychain) (string, error) { + out, err := r.Run(ctx, runner.Spec{ + Name: "security", + Args: []string{"find-identity", "-p", "codesigning", "-v", kc.Path}, + }) + if err != nil { + return "", fmt.Errorf("find-identity: %w", err) + } + return parseDeveloperIDIdentity(out) +} + +func parseDeveloperIDIdentity(out []byte) (string, error) { + if m := devIDRE.FindStringSubmatch(string(out)); m != nil { + return m[1], nil + } + return "", fmt.Errorf("no Developer ID Application identity in keychain (output: %s)", string(out)) } diff --git a/cmd/wails-release/main_test.go b/cmd/wails-release/main_test.go new file mode 100644 index 0000000..cde374b --- /dev/null +++ b/cmd/wails-release/main_test.go @@ -0,0 +1,22 @@ +package main + +import "testing" + +func TestParseDeveloperIDIdentity(t *testing.T) { + out := []byte(` 1) ABCD1234ABCDEF "Developer ID Application: Acme Inc (TEAM1234)" + 1 valid identities found +`) + got, err := parseDeveloperIDIdentity(out) + if err != nil { + t.Fatalf("unexpected: %v", err) + } + if got != "Developer ID Application: Acme Inc (TEAM1234)" { + t.Fatalf("got %q", got) + } +} + +func TestParseDeveloperIDIdentity_NoneFound(t *testing.T) { + if _, err := parseDeveloperIDIdentity([]byte("0 valid identities found")); err == nil { + t.Fatal("expected error") + } +}