wails-release/cmd/wails-release/main.go

254 lines
6.9 KiB
Go

package main
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"lmika.dev/actions/wails-release/internal/actions"
"lmika.dev/actions/wails-release/internal/archive"
"lmika.dev/actions/wails-release/internal/cleanup"
"lmika.dev/actions/wails-release/internal/codesign"
"lmika.dev/actions/wails-release/internal/config"
"lmika.dev/actions/wails-release/internal/notarize"
"lmika.dev/actions/wails-release/internal/runner"
"lmika.dev/actions/wails-release/internal/upload"
"lmika.dev/actions/wails-release/internal/version"
"lmika.dev/actions/wails-release/internal/wails"
)
func main() {
if runtime.GOOS != "darwin" {
fmt.Fprintln(os.Stderr, "wails-release: only macOS runners are supported")
os.Exit(1)
}
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 abs, err := filepath.Abs(zipPath); err == nil {
zipPath = abs
}
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
}
for _, keyPart := range strings.Split(cfg.S3Key, ",") {
key := upload.RenderKey(keyPart, resolvedVersion, artifactName)
s3URL, err = upload.Upload(ctx, client, upload.Opts{
Bucket: cfg.S3Bucket, Key: key, FilePath: zipPath, ACL: cfg.S3ACL,
})
if err != nil {
return err
}
fmt.Println("Uploaded to:", upload.HTTPSURL(cfg.S3Bucket, key, cfg.S3Region, cfg.S3EndpointURL))
}
}
// 8. Outputs (fixed order so partial-write failures are reproducible)
outFile := os.Getenv("GITHUB_OUTPUT")
outs := []struct{ name, value string }{
{"version", resolvedVersion},
{"app-name", appName},
{"artifact-path", zipPath},
{"artifact-filename", artifactName},
{"s3-url", s3URL},
}
for _, o := range outs {
if err := actions.SetOutput(outFile, o.name, o.value); err != nil {
return err
}
}
return nil
}
// maskSecrets registers every credential value with the runner's log
// scrubber via ::add-mask:: directives. These directives MUST be written
// to stdout (not stderr) — the runner's command-parser only reads stdout.
func maskSecrets(c *config.Config) {
for _, v := range []string{
c.DeveloperIDCertBase64,
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))
}