Wire orchestrator end-to-end

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leon Mika 2026-05-02 10:29:08 +10:00
parent 9c4e4675c7
commit 97fb47d023
2 changed files with 248 additions and 1 deletions

View file

@ -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 = &notarize.APIKey{
KeyPath: p8Path,
KeyID: cfg.NotarizationAPIKeyID,
IssuerID: cfg.NotarizationAPIIssuerID,
}
case "apple-id":
notarOpts.AppleID = &notarize.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))
}

View file

@ -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")
}
}