254 lines
6.9 KiB
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 = ¬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
|
|
}
|
|
|
|
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))
|
|
}
|