wails-release/docs/superpowers/plans/2026-05-02-wails-release-action.md
Leon Mika a155c3ab7a Add implementation plan for Wails Release Action
18-task TDD plan covering scaffolding, runner abstraction, version /
config / wails / archive / cleanup / codesign / notarize / upload /
actions packages, the orchestrator, an end-to-end integration test
with fake binaries, and the action.yml + README finalization.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 09:36:52 +10:00

84 KiB

Wails Release Action Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build a composite Forgejo Action that go installs and runs a Go binary which builds, signs, notarizes, and (optionally) uploads a Wails macOS app to S3-compatible storage.

Architecture: A thin action.yml (one composite step) that installs and invokes a Go binary. The binary holds all orchestration: env-driven config, version derivation, Wails CLI install, build, codesign with a temp keychain, notarize via notarytool (API key or Apple ID), staple, archive, optional S3 upload, and deferred cleanup. Each phase lives in its own internal/* package behind a small interface; external commands are reached via a Runner abstraction so tests can inject fakes.

Tech Stack: Go (latest), AWS SDK for Go v2 (github.com/aws/aws-sdk-go-v2/service/s3), github.com/google/shlex for build-flag splitting, golang.org/x/mod/modfile for parsing the project's go.mod. macOS host tools: wails, go, codesign, security, ditto, xcrun notarytool, xcrun stapler.


File Structure

wails-release/
├── action.yml                              # composite action, one step
├── README.md                               # comprehensive user docs
├── go.mod
├── go.sum
├── .gitignore
├── cmd/wails-release/
│   ├── main.go                             # orchestrator
│   ├── integration_test.go                 # full pipeline test with fakes
│   └── testdata/
│       ├── bin/                            # fake external binaries (wails, codesign, …)
│       └── sample-app/                     # minimal Wails fixture
└── internal/
    ├── runner/runner.go        runner_test.go     # exec.Cmd abstraction + FakeRunner
    ├── version/version.go      version_test.go    # tag → strip-v / fall back to short SHA
    ├── config/config.go        config_test.go     # env → Config; validation
    ├── wails/project.go        project_test.go    # read wails.json, parse go.mod
    ├── wails/cli.go            cli_test.go        # detect / install Wails CLI
    ├── wails/build.go          build_test.go      # `wails build` invocation
    ├── archive/archive.go      archive_test.go    # ditto wrapper
    ├── codesign/keychain.go    keychain_test.go   # temp keychain create / import / delete
    ├── codesign/sign.go        sign_test.go       # codesign + verify
    ├── notarize/notarize.go    notarize_test.go   # notarytool submit, log, staple
    ├── upload/upload.go        upload_test.go     # S3 upload + key templating
    ├── cleanup/cleanup.go      cleanup_test.go    # deferred cleanup stack
    └── actions/actions.go      actions_test.go    # ::add-mask::, $GITHUB_OUTPUT helpers

Each internal/* package owns one phase. cmd/wails-release/main.go is the only place that knows the order they run in.


Task 1: Bootstrap repo

Files:

  • Create: go.mod

  • Create: .gitignore

  • Create: cmd/wails-release/main.go (stub)

  • Create: action.yml (stub)

  • Step 1: Initialise Go module

Run from repo root:

go mod init github.com/leonmika/wails-release
  • Step 2: Create .gitignore
# Build output
/dist/
/bin/

# Go
*.test
*.out

# Editor
.idea/
.vscode/
.DS_Store
  • Step 3: Create cmd/wails-release/main.go stub
package main

import (
	"fmt"
	"os"
	"runtime"
)

func main() {
	if runtime.GOOS != "darwin" {
		fmt.Fprintln(os.Stderr, "wails-release: only macOS runners are supported")
		os.Exit(1)
	}
	fmt.Println("wails-release: stub")
}
  • Step 4: Create action.yml stub
name: Wails Release
description: Build, sign, notarize and (optionally) upload a Wails macOS app
runs:
  using: composite
  steps:
    - shell: bash
      run: echo "wails-release stub"
  • Step 5: Verify it builds

Run:

go build ./...

Expected: no output, exit 0.

  • Step 6: Commit
git add go.mod .gitignore cmd action.yml
git commit -m "Bootstrap Go module and action skeleton"

Task 2: internal/runner — exec.Cmd abstraction

Files:

  • Create: internal/runner/runner.go

  • Create: internal/runner/runner_test.go

  • Step 1: Write the failing tests

internal/runner/runner_test.go:

package runner_test

import (
	"context"
	"errors"
	"strings"
	"testing"

	"github.com/leonmika/wails-release/internal/runner"
)

func TestRealRunner_RunsCommandAndReturnsCombinedOutput(t *testing.T) {
	r := runner.Real{}
	out, err := r.Run(context.Background(), runner.Spec{Name: "echo", Args: []string{"hello"}})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if !strings.Contains(string(out), "hello") {
		t.Fatalf("output %q does not contain 'hello'", out)
	}
}

func TestRealRunner_NonZeroExitReturnsError(t *testing.T) {
	r := runner.Real{}
	_, err := r.Run(context.Background(), runner.Spec{Name: "false"})
	if err == nil {
		t.Fatal("expected error from `false`, got nil")
	}
}

func TestFakeRunner_RecordsCallsAndReturnsConfiguredResult(t *testing.T) {
	f := &runner.Fake{}
	f.On("greet", []string{"alice"}).Return([]byte("hi alice"), nil)
	f.On("fail", nil).Return(nil, errors.New("boom"))

	out, err := f.Run(context.Background(), runner.Spec{Name: "greet", Args: []string{"alice"}})
	if err != nil || string(out) != "hi alice" {
		t.Fatalf("unexpected: out=%q err=%v", out, err)
	}

	_, err = f.Run(context.Background(), runner.Spec{Name: "fail"})
	if err == nil {
		t.Fatal("expected error from configured fake")
	}

	if len(f.Calls) != 2 {
		t.Fatalf("expected 2 calls, got %d", len(f.Calls))
	}
	if f.Calls[0].Name != "greet" || f.Calls[0].Args[0] != "alice" {
		t.Fatalf("unexpected first call: %+v", f.Calls[0])
	}
}

func TestFakeRunner_UnconfiguredCallFailsLoudly(t *testing.T) {
	f := &runner.Fake{}
	_, err := f.Run(context.Background(), runner.Spec{Name: "unknown"})
	if err == nil {
		t.Fatal("expected error for unconfigured call")
	}
}
  • Step 2: Run, verify failure

Run: go test ./internal/runner/... Expected: build error (package does not exist) or test failures.

  • Step 3: Implement the Runner

internal/runner/runner.go:

package runner

import (
	"context"
	"fmt"
	"os/exec"
)

// Spec describes a single command to run.
type Spec struct {
	Name  string
	Args  []string
	Dir   string
	Env   []string
	Stdin []byte
}

// Runner runs external commands. Production code uses Real; tests use Fake.
type Runner interface {
	Run(ctx context.Context, s Spec) ([]byte, error)
}

// Real executes via os/exec and returns combined stdout+stderr.
type Real struct{}

func (Real) Run(ctx context.Context, s Spec) ([]byte, error) {
	cmd := exec.CommandContext(ctx, s.Name, s.Args...)
	if s.Dir != "" {
		cmd.Dir = s.Dir
	}
	if len(s.Env) > 0 {
		cmd.Env = s.Env
	}
	if len(s.Stdin) > 0 {
		cmd.Stdin = bytesReader(s.Stdin)
	}
	out, err := cmd.CombinedOutput()
	if err != nil {
		return out, fmt.Errorf("%s %v: %w (output: %s)", s.Name, s.Args, err, string(out))
	}
	return out, nil
}

// bytesReader avoids a bytes import in this small file.
func bytesReader(b []byte) *bytesBuf { return &bytesBuf{b: b} }

type bytesBuf struct {
	b []byte
	i int
}

func (r *bytesBuf) Read(p []byte) (int, error) {
	if r.i >= len(r.b) {
		return 0, eof
	}
	n := copy(p, r.b[r.i:])
	r.i += n
	return n, nil
}

var eof = fmt.Errorf("EOF")

// Call is a recorded invocation made against a Fake.
type Call struct {
	Name string
	Args []string
	Dir  string
}

type fakeResult struct {
	out []byte
	err error
}

// Fake records calls and returns configured results.
type Fake struct {
	Calls   []Call
	results map[string]fakeResult
}

// On configures a result for the given name + args. nil args matches any.
func (f *Fake) On(name string, args []string) *FakeBuilder {
	return &FakeBuilder{f: f, name: name, args: args}
}

type FakeBuilder struct {
	f    *Fake
	name string
	args []string
}

func (b *FakeBuilder) Return(out []byte, err error) {
	if b.f.results == nil {
		b.f.results = map[string]fakeResult{}
	}
	b.f.results[fakeKey(b.name, b.args)] = fakeResult{out: out, err: err}
}

func fakeKey(name string, args []string) string {
	if args == nil {
		return name + "\x00*"
	}
	k := name
	for _, a := range args {
		k += "\x00" + a
	}
	return k
}

func (f *Fake) Run(ctx context.Context, s Spec) ([]byte, error) {
	f.Calls = append(f.Calls, Call{Name: s.Name, Args: append([]string(nil), s.Args...), Dir: s.Dir})
	if r, ok := f.results[fakeKey(s.Name, s.Args)]; ok {
		return r.out, r.err
	}
	if r, ok := f.results[fakeKey(s.Name, nil)]; ok {
		return r.out, r.err
	}
	return nil, fmt.Errorf("fake runner: no result configured for %s %v", s.Name, s.Args)
}

(Note: the bytesReader helper is intentionally kept dependency-light. If you'd rather use bytes.NewReader, swap it — same behaviour.)

  • Step 4: Run tests, verify pass

Run: go test ./internal/runner/... Expected: PASS.

  • Step 5: Commit
git add internal/runner
git commit -m "Add Runner abstraction with Real and Fake implementations"

Task 3: internal/version — version derivation

Files:

  • Create: internal/version/version.go

  • Create: internal/version/version_test.go

  • Step 1: Write the failing test

internal/version/version_test.go:

package version_test

import (
	"testing"

	"github.com/leonmika/wails-release/internal/version"
)

func TestResolve(t *testing.T) {
	cases := []struct {
		name     string
		override string
		ref      string
		sha      string
		want     string
	}{
		{"override wins", "9.9.9", "refs/tags/v1.2.3", "abcdefg1234", "9.9.9"},
		{"clean tag strips v", "", "refs/tags/v1.2.3", "abcdefg1234", "1.2.3"},
		{"prerelease tag falls back to sha", "", "refs/tags/v1.2.3-rc1", "abcdefg1234", "abcdefg"},
		{"non-tag ref falls back to sha", "", "refs/heads/main", "abcdefg1234", "abcdefg"},
		{"empty ref falls back to sha", "", "", "abcdefg1234", "abcdefg"},
		{"short sha shorter than 7 returned verbatim", "", "", "abc", "abc"},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			got := version.Resolve(c.override, c.ref, c.sha)
			if got != c.want {
				t.Fatalf("got %q, want %q", got, c.want)
			}
		})
	}
}
  • Step 2: Run, verify failure

Run: go test ./internal/version/... Expected: build error (package does not exist).

  • Step 3: Implement

internal/version/version.go:

package version

import "regexp"

var semverTag = regexp.MustCompile(`^refs/tags/v(\d+\.\d+\.\d+)$`)

// Resolve returns the version string to use in artifact names.
// Precedence: override > matching semver tag > short SHA.
func Resolve(override, ref, sha string) string {
	if override != "" {
		return override
	}
	if m := semverTag.FindStringSubmatch(ref); m != nil {
		return m[1]
	}
	if len(sha) > 7 {
		return sha[:7]
	}
	return sha
}
  • Step 4: Run tests, verify pass

Run: go test ./internal/version/... Expected: PASS.

  • Step 5: Commit
git add internal/version
git commit -m "Add version resolver (tag → strip-v / fall back to short SHA)"

Task 4: internal/config — env-driven config + validation

Files:

  • Create: internal/config/config.go

  • Create: internal/config/config_test.go

  • Step 1: Write the failing tests

internal/config/config_test.go:

package config_test

import (
	"strings"
	"testing"

	"github.com/leonmika/wails-release/internal/config"
)

func TestLoad_PopulatesAllFieldsFromEnv(t *testing.T) {
	env := map[string]string{
		"INPUT_WORKING_DIRECTORY":           "apps/desktop",
		"INPUT_APP_NAME":                    "MyApp",
		"INPUT_VERSION":                     "1.2.3",
		"INPUT_WAILS_VERSION":               "v2.11.0",
		"INPUT_EXTRA_BUILD_FLAGS":           "-tags release",
		"INPUT_DEVELOPER_ID_CERT_BASE64":    "Y2VydA==",
		"INPUT_DEVELOPER_ID_CERT_PASSWORD":  "pw",
		"INPUT_NOTARIZATION_METHOD":         "api-key",
		"INPUT_NOTARIZATION_API_KEY_BASE64": "a2V5",
		"INPUT_NOTARIZATION_API_KEY_ID":     "ABCD1234",
		"INPUT_NOTARIZATION_API_ISSUER_ID":  "12345678-aaaa-bbbb-cccc-111111111111",
		"INPUT_S3_BUCKET":                   "my-bucket",
		"INPUT_S3_KEY":                      "releases/{version}/{filename}",
		"INPUT_S3_REGION":                   "us-east-1",
	}
	c := config.Load(envGetter(env))
	if c.WorkingDirectory != "apps/desktop" || c.AppName != "MyApp" || c.Version != "1.2.3" {
		t.Fatalf("basic fields wrong: %+v", c)
	}
	if c.NotarizationMethod != "api-key" || c.NotarizationAPIKeyID != "ABCD1234" {
		t.Fatalf("notarization fields wrong: %+v", c)
	}
	if c.S3Bucket != "my-bucket" || c.S3Key != "releases/{version}/{filename}" {
		t.Fatalf("s3 fields wrong: %+v", c)
	}
}

func TestLoad_AppliesDefaults(t *testing.T) {
	c := config.Load(envGetter(nil))
	if c.WorkingDirectory != "." {
		t.Fatalf("expected default working-directory '.', got %q", c.WorkingDirectory)
	}
	if c.S3Region != "us-east-1" {
		t.Fatalf("expected default s3-region us-east-1, got %q", c.S3Region)
	}
	if c.NotarizationMethod != "auto" {
		t.Fatalf("expected default notarization-method auto, got %q", c.NotarizationMethod)
	}
}

func TestValidate(t *testing.T) {
	base := func() *config.Config {
		return &config.Config{
			WorkingDirectory:        ".",
			DeveloperIDCertBase64:   "x",
			DeveloperIDCertPassword: "x",
			NotarizationMethod:      "auto",
			S3Region:                "us-east-1",
		}
	}

	cases := []struct {
		name   string
		mutate func(*config.Config)
		errMsg string // substring expected in error; empty means must succeed
	}{
		{
			name:   "valid with api-key group",
			mutate: func(c *config.Config) { fillAPIKey(c) },
		},
		{
			name:   "valid with apple-id group",
			mutate: func(c *config.Config) { fillAppleID(c) },
		},
		{
			name:   "missing cert base64",
			mutate: func(c *config.Config) { fillAPIKey(c); c.DeveloperIDCertBase64 = "" },
			errMsg: "developer-id-cert-base64",
		},
		{
			name:   "missing cert password",
			mutate: func(c *config.Config) { fillAPIKey(c); c.DeveloperIDCertPassword = "" },
			errMsg: "developer-id-cert-password",
		},
		{
			name:   "method=api-key with missing field",
			mutate: func(c *config.Config) { fillAPIKey(c); c.NotarizationMethod = "api-key"; c.NotarizationAPIKeyID = "" },
			errMsg: "notarization-api-key-id",
		},
		{
			name:   "method=apple-id with missing field",
			mutate: func(c *config.Config) { fillAppleID(c); c.NotarizationMethod = "apple-id"; c.NotarizationTeamID = "" },
			errMsg: "notarization-team-id",
		},
		{
			name:   "auto with both groups populated is ambiguous",
			mutate: func(c *config.Config) { fillAPIKey(c); fillAppleID(c) },
			errMsg: "ambiguous",
		},
		{
			name:   "auto with no group populated",
			mutate: func(c *config.Config) {},
			errMsg: "no notarization credentials",
		},
		{
			name:   "s3 bucket without key",
			mutate: func(c *config.Config) { fillAPIKey(c); c.S3Bucket = "b" },
			errMsg: "s3-key",
		},
		{
			name:   "unknown notarization method",
			mutate: func(c *config.Config) { fillAPIKey(c); c.NotarizationMethod = "smoke-signals" },
			errMsg: "notarization-method",
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			c := base()
			tc.mutate(c)
			err := c.Validate()
			if tc.errMsg == "" {
				if err != nil {
					t.Fatalf("expected ok, got %v", err)
				}
				return
			}
			if err == nil {
				t.Fatalf("expected error containing %q, got nil", tc.errMsg)
			}
			if !strings.Contains(err.Error(), tc.errMsg) {
				t.Fatalf("error %q does not contain %q", err.Error(), tc.errMsg)
			}
		})
	}
}

func envGetter(m map[string]string) func(string) string {
	return func(k string) string { return m[k] }
}

func fillAPIKey(c *config.Config) {
	c.NotarizationAPIKeyBase64 = "k"
	c.NotarizationAPIKeyID = "id"
	c.NotarizationAPIIssuerID = "issuer"
}

func fillAppleID(c *config.Config) {
	c.NotarizationAppleID = "apple"
	c.NotarizationApplePassword = "pw"
	c.NotarizationTeamID = "team"
}
  • Step 2: Run, verify failure

Run: go test ./internal/config/... Expected: build/test failures.

  • Step 3: Implement

internal/config/config.go:

package config

import (
	"fmt"
	"strings"
)

// Config is the complete set of inputs to the action, parsed from env.
type Config struct {
	WorkingDirectory string
	AppName          string
	Version          string
	WailsVersion     string
	ExtraBuildFlags  string

	DeveloperIDCertBase64   string
	DeveloperIDCertPassword string

	NotarizationMethod        string // "api-key" | "apple-id" | "auto"
	NotarizationAPIKeyBase64  string
	NotarizationAPIKeyID      string
	NotarizationAPIIssuerID   string
	NotarizationAppleID       string
	NotarizationApplePassword string
	NotarizationTeamID        string

	S3Bucket      string
	S3Key         string
	S3EndpointURL string
	S3Region      string
}

// Load reads the action's INPUT_* environment variables.
func Load(get func(string) string) *Config {
	c := &Config{
		WorkingDirectory:          getOr(get, "INPUT_WORKING_DIRECTORY", "."),
		AppName:                   get("INPUT_APP_NAME"),
		Version:                   get("INPUT_VERSION"),
		WailsVersion:              get("INPUT_WAILS_VERSION"),
		ExtraBuildFlags:           get("INPUT_EXTRA_BUILD_FLAGS"),
		DeveloperIDCertBase64:     get("INPUT_DEVELOPER_ID_CERT_BASE64"),
		DeveloperIDCertPassword:   get("INPUT_DEVELOPER_ID_CERT_PASSWORD"),
		NotarizationMethod:        getOr(get, "INPUT_NOTARIZATION_METHOD", "auto"),
		NotarizationAPIKeyBase64:  get("INPUT_NOTARIZATION_API_KEY_BASE64"),
		NotarizationAPIKeyID:      get("INPUT_NOTARIZATION_API_KEY_ID"),
		NotarizationAPIIssuerID:   get("INPUT_NOTARIZATION_API_ISSUER_ID"),
		NotarizationAppleID:       get("INPUT_NOTARIZATION_APPLE_ID"),
		NotarizationApplePassword: get("INPUT_NOTARIZATION_APPLE_PASSWORD"),
		NotarizationTeamID:        get("INPUT_NOTARIZATION_TEAM_ID"),
		S3Bucket:                  get("INPUT_S3_BUCKET"),
		S3Key:                     get("INPUT_S3_KEY"),
		S3EndpointURL:             get("INPUT_S3_ENDPOINT_URL"),
		S3Region:                  getOr(get, "INPUT_S3_REGION", "us-east-1"),
	}
	return c
}

func getOr(get func(string) string, key, def string) string {
	if v := get(key); v != "" {
		return v
	}
	return def
}

// Validate checks structural rules. It does NOT touch the filesystem.
func (c *Config) Validate() error {
	var missing []string
	if c.DeveloperIDCertBase64 == "" {
		missing = append(missing, "developer-id-cert-base64")
	}
	if c.DeveloperIDCertPassword == "" {
		missing = append(missing, "developer-id-cert-password")
	}
	if len(missing) > 0 {
		return fmt.Errorf("missing required input(s): %s", strings.Join(missing, ", "))
	}

	apiFields := []struct{ name, value string }{
		{"notarization-api-key-base64", c.NotarizationAPIKeyBase64},
		{"notarization-api-key-id", c.NotarizationAPIKeyID},
		{"notarization-api-issuer-id", c.NotarizationAPIIssuerID},
	}
	appleFields := []struct{ name, value string }{
		{"notarization-apple-id", c.NotarizationAppleID},
		{"notarization-apple-password", c.NotarizationApplePassword},
		{"notarization-team-id", c.NotarizationTeamID},
	}

	apiFilled, apiMissing := groupStatus(apiFields)
	appleFilled, appleMissing := groupStatus(appleFields)

	switch c.NotarizationMethod {
	case "api-key":
		if !apiFilled {
			return fmt.Errorf("notarization-method=api-key requires: %s (missing: %s)",
				joinNames(apiFields), strings.Join(apiMissing, ", "))
		}
	case "apple-id":
		if !appleFilled {
			return fmt.Errorf("notarization-method=apple-id requires: %s (missing: %s)",
				joinNames(appleFields), strings.Join(appleMissing, ", "))
		}
	case "auto", "":
		switch {
		case apiFilled && appleFilled:
			return fmt.Errorf("notarization credentials are ambiguous: both api-key and apple-id groups are populated; set notarization-method explicitly")
		case !apiFilled && !appleFilled:
			return fmt.Errorf("no notarization credentials supplied: populate either the api-key group (%s) or the apple-id group (%s)",
				joinNames(apiFields), joinNames(appleFields))
		}
	default:
		return fmt.Errorf("notarization-method must be one of api-key, apple-id, auto (got %q)", c.NotarizationMethod)
	}

	if c.S3Bucket != "" && c.S3Key == "" {
		return fmt.Errorf("s3-bucket is set but s3-key is empty")
	}
	return nil
}

// ResolvedNotarizationMethod returns "api-key" or "apple-id" once Validate has succeeded.
func (c *Config) ResolvedNotarizationMethod() string {
	if c.NotarizationMethod == "api-key" || c.NotarizationMethod == "apple-id" {
		return c.NotarizationMethod
	}
	if c.NotarizationAPIKeyID != "" {
		return "api-key"
	}
	return "apple-id"
}

func groupStatus(fields []struct{ name, value string }) (filled bool, missing []string) {
	any := false
	for _, f := range fields {
		if f.value != "" {
			any = true
		} else {
			missing = append(missing, f.name)
		}
	}
	return any && len(missing) == 0, missing
}

func joinNames(fields []struct{ name, value string }) string {
	names := make([]string, len(fields))
	for i, f := range fields {
		names[i] = f.name
	}
	return strings.Join(names, ", ")
}
  • Step 4: Run tests, verify pass

Run: go test ./internal/config/... Expected: PASS.

  • Step 5: Commit
git add internal/config
git commit -m "Add config loader and validator"

Task 5: internal/wails — project metadata (wails.json + go.mod)

Files:

  • Create: internal/wails/project.go

  • Create: internal/wails/project_test.go

  • Create: internal/wails/testdata/sample/wails.json

  • Create: internal/wails/testdata/sample/go.mod

  • Step 1: Add golang.org/x/mod dependency

go get golang.org/x/mod/modfile
  • Step 2: Create test fixtures

internal/wails/testdata/sample/wails.json:

{
  "name": "SampleApp",
  "outputfilename": "SampleApp",
  "frontend:install": "npm install",
  "frontend:build": "npm run build"
}

internal/wails/testdata/sample/go.mod:

module example.com/sample

go 1.22

require github.com/wailsapp/wails/v2 v2.11.0
  • Step 3: Write the failing tests

internal/wails/project_test.go:

package wails_test

import (
	"testing"

	"github.com/leonmika/wails-release/internal/wails"
)

func TestReadProject_ReturnsAppName(t *testing.T) {
	p, err := wails.ReadProject("testdata/sample")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if p.Name != "SampleApp" {
		t.Fatalf("name: got %q want SampleApp", p.Name)
	}
}

func TestReadProject_MissingWailsJSONErrors(t *testing.T) {
	_, err := wails.ReadProject("testdata/missing")
	if err == nil {
		t.Fatal("expected error for missing wails.json")
	}
}

func TestProjectWailsVersion_FromGoMod(t *testing.T) {
	v, err := wails.ProjectWailsVersion("testdata/sample")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if v != "v2.11.0" {
		t.Fatalf("got %q, want v2.11.0", v)
	}
}

func TestProjectWailsVersion_NoWailsDependencyErrors(t *testing.T) {
	_, err := wails.ProjectWailsVersion("testdata/no-wails")
	if err == nil {
		t.Fatal("expected error for project with no wails dep")
	}
}

Add a second fixture at internal/wails/testdata/no-wails/go.mod:

module example.com/sample

go 1.22
  • Step 4: Run, verify failure

Run: go test ./internal/wails/... Expected: build error (package not yet created).

  • Step 5: Implement

internal/wails/project.go:

package wails

import (
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"

	"golang.org/x/mod/modfile"
)

// Project is the subset of wails.json we care about.
type Project struct {
	Name string `json:"name"`
}

// ReadProject parses wails.json from dir.
func ReadProject(dir string) (*Project, error) {
	path := filepath.Join(dir, "wails.json")
	b, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("read %s: %w", path, err)
	}
	var p Project
	if err := json.Unmarshal(b, &p); err != nil {
		return nil, fmt.Errorf("parse %s: %w", path, err)
	}
	if p.Name == "" {
		return nil, fmt.Errorf("%s: name field is empty", path)
	}
	return &p, nil
}

// ProjectWailsVersion returns the require'd Wails version from go.mod.
func ProjectWailsVersion(dir string) (string, error) {
	path := filepath.Join(dir, "go.mod")
	b, err := os.ReadFile(path)
	if err != nil {
		return "", fmt.Errorf("read %s: %w", path, err)
	}
	mf, err := modfile.Parse(path, b, nil)
	if err != nil {
		return "", fmt.Errorf("parse %s: %w", path, err)
	}
	for _, r := range mf.Require {
		if r.Mod.Path == "github.com/wailsapp/wails/v2" {
			return r.Mod.Version, nil
		}
	}
	return "", fmt.Errorf("%s: no require for github.com/wailsapp/wails/v2", path)
}
  • Step 6: Run tests, verify pass

Run: go test ./internal/wails/... Expected: PASS.

  • Step 7: Commit
git add internal/wails go.mod go.sum
git commit -m "Add Wails project metadata reader"

Task 6: internal/wails — CLI version detection + install

Files:

  • Create: internal/wails/cli.go

  • Create: internal/wails/cli_test.go

  • Step 1: Write the failing tests

internal/wails/cli_test.go:

package wails_test

import (
	"context"
	"errors"
	"strings"
	"testing"

	"github.com/leonmika/wails-release/internal/runner"
	"github.com/leonmika/wails-release/internal/wails"
)

func TestParseInstalledVersion(t *testing.T) {
	cases := map[string]string{
		"Wails CLI v2.11.0":                                      "v2.11.0",
		"Wails CLI v2.10.1\n":                                    "v2.10.1",
		"Wails v2.9.0":                                           "v2.9.0",
	}
	for in, want := range cases {
		got, err := wails.ParseCLIVersion([]byte(in))
		if err != nil {
			t.Fatalf("unexpected error parsing %q: %v", in, err)
		}
		if got != want {
			t.Fatalf("input %q: got %q want %q", in, got, want)
		}
	}
}

func TestParseInstalledVersion_Unparseable(t *testing.T) {
	_, err := wails.ParseCLIVersion([]byte("nope"))
	if err == nil {
		t.Fatal("expected error")
	}
}

func TestEnsureCLI_AlreadyInstalledMatchingSkipsInstall(t *testing.T) {
	f := &runner.Fake{}
	f.On("wails", []string{"-v"}).Return([]byte("Wails CLI v2.11.0"), nil)

	err := wails.EnsureCLI(context.Background(), f, "v2.11.0")
	if err != nil {
		t.Fatalf("unexpected: %v", err)
	}

	for _, c := range f.Calls {
		if c.Name == "go" {
			t.Fatalf("expected no `go install`, got call: %+v", c)
		}
	}
}

func TestEnsureCLI_MismatchTriggersInstall(t *testing.T) {
	f := &runner.Fake{}
	f.On("wails", []string{"-v"}).Return([]byte("Wails CLI v2.10.0"), nil)
	f.On("go", []string{"install", "github.com/wailsapp/wails/v2/cmd/wails@v2.11.0"}).Return(nil, nil)

	err := wails.EnsureCLI(context.Background(), f, "v2.11.0")
	if err != nil {
		t.Fatalf("unexpected: %v", err)
	}

	saw := false
	for _, c := range f.Calls {
		if c.Name == "go" && c.Args[0] == "install" && strings.Contains(c.Args[1], "@v2.11.0") {
			saw = true
		}
	}
	if !saw {
		t.Fatal("expected go install call, did not see one")
	}
}

func TestEnsureCLI_NotInstalledTriggersInstall(t *testing.T) {
	f := &runner.Fake{}
	f.On("wails", []string{"-v"}).Return(nil, errors.New("exec: not found"))
	f.On("go", []string{"install", "github.com/wailsapp/wails/v2/cmd/wails@v2.11.0"}).Return(nil, nil)

	err := wails.EnsureCLI(context.Background(), f, "v2.11.0")
	if err != nil {
		t.Fatalf("unexpected: %v", err)
	}
}
  • Step 2: Run, verify failure

Run: go test ./internal/wails/... Expected: undefined: wails.ParseCLIVersion, wails.EnsureCLI.

  • Step 3: Implement

internal/wails/cli.go:

package wails

import (
	"context"
	"fmt"
	"regexp"
	"strings"

	"github.com/leonmika/wails-release/internal/runner"
)

var cliVersionRE = regexp.MustCompile(`v\d+\.\d+\.\d+`)

// ParseCLIVersion extracts the version from `wails -v` output.
func ParseCLIVersion(out []byte) (string, error) {
	m := cliVersionRE.FindString(strings.TrimSpace(string(out)))
	if m == "" {
		return "", fmt.Errorf("could not parse Wails CLI version from %q", string(out))
	}
	return m, nil
}

// EnsureCLI installs the Wails CLI at `want` (e.g. "v2.11.0") if it is not
// already installed at exactly that version.
func EnsureCLI(ctx context.Context, r runner.Runner, want string) error {
	out, err := r.Run(ctx, runner.Spec{Name: "wails", Args: []string{"-v"}})
	if err == nil {
		got, perr := ParseCLIVersion(out)
		if perr == nil && got == want {
			return nil
		}
	}
	_, err = r.Run(ctx, runner.Spec{
		Name: "go",
		Args: []string{"install", "github.com/wailsapp/wails/v2/cmd/wails@" + want},
	})
	if err != nil {
		return fmt.Errorf("install wails CLI %s: %w", want, err)
	}
	return nil
}
  • Step 4: Run tests, verify pass

Run: go test ./internal/wails/... Expected: PASS.

  • Step 5: Commit
git add internal/wails/cli.go internal/wails/cli_test.go
git commit -m "Add Wails CLI install/detection helper"

Task 7: internal/wails — Build invocation

Files:

  • Create: internal/wails/build.go

  • Create: internal/wails/build_test.go

  • Step 1: Add shlex dependency

go get github.com/google/shlex
  • Step 2: Write the failing tests

internal/wails/build_test.go:

package wails_test

import (
	"context"
	"reflect"
	"testing"

	"github.com/leonmika/wails-release/internal/runner"
	"github.com/leonmika/wails-release/internal/wails"
)

func TestBuild_DefaultArgs(t *testing.T) {
	f := &runner.Fake{}
	f.On("wails", nil).Return(nil, nil)

	err := wails.Build(context.Background(), f, wails.BuildOpts{Dir: "/work"})
	if err != nil {
		t.Fatalf("unexpected: %v", err)
	}
	want := []string{"build", "-platform", "darwin/universal", "-clean", "-trimpath"}
	if !reflect.DeepEqual(f.Calls[0].Args, want) {
		t.Fatalf("args got %v want %v", f.Calls[0].Args, want)
	}
	if f.Calls[0].Dir != "/work" {
		t.Fatalf("dir got %q want /work", f.Calls[0].Dir)
	}
}

func TestBuild_AppendsExtraFlags(t *testing.T) {
	f := &runner.Fake{}
	f.On("wails", nil).Return(nil, nil)

	err := wails.Build(context.Background(), f, wails.BuildOpts{
		Dir:        "/work",
		ExtraFlags: `-tags release -ldflags "-X main.commit=abc"`,
	})
	if err != nil {
		t.Fatalf("unexpected: %v", err)
	}
	want := []string{
		"build", "-platform", "darwin/universal", "-clean", "-trimpath",
		"-tags", "release", "-ldflags", "-X main.commit=abc",
	}
	if !reflect.DeepEqual(f.Calls[0].Args, want) {
		t.Fatalf("args got %v want %v", f.Calls[0].Args, want)
	}
}

func TestBuild_MalformedExtraFlagsErrors(t *testing.T) {
	err := wails.Build(context.Background(), &runner.Fake{}, wails.BuildOpts{
		Dir:        "/work",
		ExtraFlags: `-foo "unterminated`,
	})
	if err == nil {
		t.Fatal("expected parse error")
	}
}
  • Step 3: Run, verify failure

Run: go test ./internal/wails/... Expected: undefined: wails.Build, wails.BuildOpts.

  • Step 4: Implement

internal/wails/build.go:

package wails

import (
	"context"
	"fmt"

	"github.com/google/shlex"
	"github.com/leonmika/wails-release/internal/runner"
)

// BuildOpts configures a Wails build.
type BuildOpts struct {
	Dir        string
	ExtraFlags string
}

// Build runs `wails build` with our mandatory flags plus any extras.
func Build(ctx context.Context, r runner.Runner, opts BuildOpts) error {
	args := []string{"build", "-platform", "darwin/universal", "-clean", "-trimpath"}
	if opts.ExtraFlags != "" {
		extra, err := shlex.Split(opts.ExtraFlags)
		if err != nil {
			return fmt.Errorf("parse extra-build-flags: %w", err)
		}
		args = append(args, extra...)
	}
	if _, err := r.Run(ctx, runner.Spec{Name: "wails", Args: args, Dir: opts.Dir}); err != nil {
		return fmt.Errorf("wails build: %w", err)
	}
	return nil
}
  • Step 5: Run tests, verify pass

Run: go test ./internal/wails/... Expected: PASS.

  • Step 6: Commit
git add internal/wails/build.go internal/wails/build_test.go go.mod go.sum
git commit -m "Add `wails build` invocation"

Task 8: internal/archive — ditto wrapper

Files:

  • Create: internal/archive/archive.go

  • Create: internal/archive/archive_test.go

  • Step 1: Write the failing test

internal/archive/archive_test.go:

package archive_test

import (
	"context"
	"reflect"
	"testing"

	"github.com/leonmika/wails-release/internal/archive"
	"github.com/leonmika/wails-release/internal/runner"
)

func TestZipApp_BuildsCorrectDittoArgs(t *testing.T) {
	f := &runner.Fake{}
	f.On("ditto", nil).Return(nil, nil)

	err := archive.ZipApp(context.Background(), f, "/build/MyApp.app", "/dist/MyApp-1.2.3.app.zip")
	if err != nil {
		t.Fatalf("unexpected: %v", err)
	}
	want := []string{"-c", "-k", "--keepParent", "/build/MyApp.app", "/dist/MyApp-1.2.3.app.zip"}
	if !reflect.DeepEqual(f.Calls[0].Args, want) {
		t.Fatalf("args got %v want %v", f.Calls[0].Args, want)
	}
}
  • Step 2: Run, verify failure

Run: go test ./internal/archive/... Expected: undefined: archive.ZipApp.

  • Step 3: Implement

internal/archive/archive.go:

package archive

import (
	"context"
	"fmt"

	"github.com/leonmika/wails-release/internal/runner"
)

// ZipApp wraps a .app bundle into a .app.zip via ditto, preserving
// extended attributes and producing an archive Apple's notary service
// will accept.
func ZipApp(ctx context.Context, r runner.Runner, app, zipPath string) error {
	_, err := r.Run(ctx, runner.Spec{
		Name: "ditto",
		Args: []string{"-c", "-k", "--keepParent", app, zipPath},
	})
	if err != nil {
		return fmt.Errorf("ditto archive %s -> %s: %w", app, zipPath, err)
	}
	return nil
}
  • Step 4: Run tests, verify pass

Run: go test ./internal/archive/... Expected: PASS.

  • Step 5: Commit
git add internal/archive
git commit -m "Add ditto archive helper"

Task 9: internal/cleanup — deferred cleanup stack

Files:

  • Create: internal/cleanup/cleanup.go

  • Create: internal/cleanup/cleanup_test.go

  • Step 1: Write the failing tests

internal/cleanup/cleanup_test.go:

package cleanup_test

import (
	"errors"
	"strings"
	"testing"

	"github.com/leonmika/wails-release/internal/cleanup"
)

func TestStack_RunsInReverseOrder(t *testing.T) {
	var s cleanup.Stack
	calls := []string{}
	s.Add(func() error { calls = append(calls, "first"); return nil })
	s.Add(func() error { calls = append(calls, "second"); return nil })

	s.Run()

	want := []string{"second", "first"}
	for i := range want {
		if calls[i] != want[i] {
			t.Fatalf("call %d: got %q want %q (full: %v)", i, calls[i], want[i], calls)
		}
	}
}

func TestStack_ContinuesAfterError(t *testing.T) {
	var s cleanup.Stack
	called := []string{}
	s.Add(func() error { called = append(called, "a"); return nil })
	s.Add(func() error { called = append(called, "b"); return errors.New("boom") })
	s.Add(func() error { called = append(called, "c"); return nil })

	errs := s.Run()

	if len(errs) != 1 || !strings.Contains(errs[0].Error(), "boom") {
		t.Fatalf("expected one boom error, got %v", errs)
	}
	if len(called) != 3 {
		t.Fatalf("expected all three to run, got %v", called)
	}
}
  • Step 2: Run, verify failure

Run: go test ./internal/cleanup/... Expected: undefined: cleanup.Stack.

  • Step 3: Implement

internal/cleanup/cleanup.go:

package cleanup

// Stack holds deferred cleanup functions and runs them in LIFO order.
// Errors are collected; one cleanup failure does not stop later ones.
type Stack struct {
	fns []func() error
}

// Add registers fn to run on Run().
func (s *Stack) Add(fn func() error) {
	s.fns = append(s.fns, fn)
}

// Run executes all registered functions in reverse order and returns
// every error produced.
func (s *Stack) Run() []error {
	var errs []error
	for i := len(s.fns) - 1; i >= 0; i-- {
		if err := s.fns[i](); err != nil {
			errs = append(errs, err)
		}
	}
	return errs
}
  • Step 4: Run tests, verify pass

Run: go test ./internal/cleanup/... Expected: PASS.

  • Step 5: Commit
git add internal/cleanup
git commit -m "Add cleanup stack"

Task 10: internal/codesign — temporary keychain lifecycle

Files:

  • Create: internal/codesign/keychain.go

  • Create: internal/codesign/keychain_test.go

  • Step 1: Write the failing tests

internal/codesign/keychain_test.go:

package codesign_test

import (
	"context"
	"reflect"
	"testing"

	"github.com/leonmika/wails-release/internal/codesign"
	"github.com/leonmika/wails-release/internal/runner"
)

func TestCreateKeychain_BuildsCorrectArgs(t *testing.T) {
	f := &runner.Fake{}
	f.On("security", nil).Return(nil, nil)

	kc, err := codesign.CreateKeychain(context.Background(), f, "/tmp/foo.keychain", "pw")
	if err != nil {
		t.Fatalf("unexpected: %v", err)
	}
	if kc.Path != "/tmp/foo.keychain" {
		t.Fatalf("kc.Path got %q want /tmp/foo.keychain", kc.Path)
	}
	want := [][]string{
		{"create-keychain", "-p", "pw", "/tmp/foo.keychain"},
		{"set-keychain-settings", "-lut", "21600", "/tmp/foo.keychain"},
		{"unlock-keychain", "-p", "pw", "/tmp/foo.keychain"},
	}
	for i, w := range want {
		if !reflect.DeepEqual(f.Calls[i].Args, w) {
			t.Fatalf("call %d: got %v want %v", i, f.Calls[i].Args, w)
		}
	}
}

func TestImportP12_BuildsCorrectArgs(t *testing.T) {
	f := &runner.Fake{}
	f.On("security", nil).Return(nil, nil)

	err := codesign.ImportP12(context.Background(), f, codesign.Keychain{Path: "/tmp/k", Password: "pw"}, "/tmp/c.p12", "certpw")
	if err != nil {
		t.Fatalf("unexpected: %v", err)
	}
	want := []string{
		"import", "/tmp/c.p12", "-k", "/tmp/k", "-P", "certpw",
		"-T", "/usr/bin/codesign",
	}
	if !reflect.DeepEqual(f.Calls[0].Args, want) {
		t.Fatalf("args got %v want %v", f.Calls[0].Args, want)
	}

	// Partition list set so codesign can use the imported key non-interactively.
	wantPartition := []string{
		"set-key-partition-list", "-S", "apple-tool:,apple:,codesign:",
		"-s", "-k", "pw", "/tmp/k",
	}
	if !reflect.DeepEqual(f.Calls[1].Args, wantPartition) {
		t.Fatalf("partition args got %v want %v", f.Calls[1].Args, wantPartition)
	}
}

func TestDeleteKeychain_BuildsCorrectArgs(t *testing.T) {
	f := &runner.Fake{}
	f.On("security", nil).Return(nil, nil)

	err := codesign.DeleteKeychain(context.Background(), f, codesign.Keychain{Path: "/tmp/k"})
	if err != nil {
		t.Fatalf("unexpected: %v", err)
	}
	want := []string{"delete-keychain", "/tmp/k"}
	if !reflect.DeepEqual(f.Calls[0].Args, want) {
		t.Fatalf("args got %v want %v", f.Calls[0].Args, want)
	}
}
  • Step 2: Run, verify failure

Run: go test ./internal/codesign/... Expected: undefined types and functions.

  • Step 3: Implement

internal/codesign/keychain.go:

package codesign

import (
	"context"
	"fmt"

	"github.com/leonmika/wails-release/internal/runner"
)

// Keychain identifies a temporary keychain we created.
type Keychain struct {
	Path     string
	Password string
}

// CreateKeychain creates a new keychain at path with the given password
// and unlocks it. The keychain's auto-lock timeout is set to 6 hours so
// it does not relock during a long notarization.
func CreateKeychain(ctx context.Context, r runner.Runner, path, password string) (*Keychain, error) {
	steps := [][]string{
		{"create-keychain", "-p", password, path},
		{"set-keychain-settings", "-lut", "21600", path},
		{"unlock-keychain", "-p", password, path},
	}
	for _, args := range steps {
		if _, err := r.Run(ctx, runner.Spec{Name: "security", Args: args}); err != nil {
			return nil, fmt.Errorf("security %s: %w", args[0], err)
		}
	}
	return &Keychain{Path: path, Password: password}, nil
}

// ImportP12 imports the .p12 at certPath into kc using certPassword and
// authorises codesign to use the resulting key without prompting.
func ImportP12(ctx context.Context, r runner.Runner, kc Keychain, certPath, certPassword string) error {
	if _, err := r.Run(ctx, runner.Spec{
		Name: "security",
		Args: []string{"import", certPath, "-k", kc.Path, "-P", certPassword, "-T", "/usr/bin/codesign"},
	}); err != nil {
		return fmt.Errorf("security import: %w", err)
	}
	if _, err := r.Run(ctx, runner.Spec{
		Name: "security",
		Args: []string{"set-key-partition-list", "-S", "apple-tool:,apple:,codesign:", "-s", "-k", kc.Password, kc.Path},
	}); err != nil {
		return fmt.Errorf("security set-key-partition-list: %w", err)
	}
	return nil
}

// DeleteKeychain removes the keychain. Safe to call from cleanup.
func DeleteKeychain(ctx context.Context, r runner.Runner, kc Keychain) error {
	if _, err := r.Run(ctx, runner.Spec{
		Name: "security",
		Args: []string{"delete-keychain", kc.Path},
	}); err != nil {
		return fmt.Errorf("security delete-keychain: %w", err)
	}
	return nil
}
  • Step 4: Run tests, verify pass

Run: go test ./internal/codesign/... Expected: PASS.

  • Step 5: Commit
git add internal/codesign/keychain.go internal/codesign/keychain_test.go
git commit -m "Add temporary keychain lifecycle for codesigning"

Task 11: internal/codesign — codesign + verify

Files:

  • Create: internal/codesign/sign.go

  • Create: internal/codesign/sign_test.go

  • Step 1: Write the failing tests

internal/codesign/sign_test.go:

package codesign_test

import (
	"context"
	"reflect"
	"testing"

	"github.com/leonmika/wails-release/internal/codesign"
	"github.com/leonmika/wails-release/internal/runner"
)

func TestSign_BuildsCorrectArgs(t *testing.T) {
	f := &runner.Fake{}
	f.On("codesign", nil).Return(nil, nil)

	err := codesign.Sign(context.Background(), f, codesign.SignOpts{
		AppPath:      "/build/MyApp.app",
		Identity:     "Developer ID Application: Acme Inc (ABCD1234)",
		KeychainPath: "/tmp/k",
	})
	if err != nil {
		t.Fatalf("unexpected: %v", err)
	}
	want := []string{
		"--deep", "--force", "--options", "runtime", "--timestamp",
		"--sign", "Developer ID Application: Acme Inc (ABCD1234)",
		"--keychain", "/tmp/k",
		"/build/MyApp.app",
	}
	if !reflect.DeepEqual(f.Calls[0].Args, want) {
		t.Fatalf("args got %v want %v", f.Calls[0].Args, want)
	}
}

func TestVerify_BuildsCorrectArgs(t *testing.T) {
	f := &runner.Fake{}
	f.On("codesign", nil).Return(nil, nil)

	err := codesign.Verify(context.Background(), f, "/build/MyApp.app")
	if err != nil {
		t.Fatalf("unexpected: %v", err)
	}
	want := []string{"--verify", "--deep", "--strict", "/build/MyApp.app"}
	if !reflect.DeepEqual(f.Calls[0].Args, want) {
		t.Fatalf("args got %v want %v", f.Calls[0].Args, want)
	}
}
  • Step 2: Run, verify failure

Run: go test ./internal/codesign/... Expected: undefined: codesign.Sign, codesign.Verify, codesign.SignOpts.

  • Step 3: Implement

internal/codesign/sign.go:

package codesign

import (
	"context"
	"fmt"

	"github.com/leonmika/wails-release/internal/runner"
)

// SignOpts configures a codesign invocation.
type SignOpts struct {
	AppPath      string
	Identity     string
	KeychainPath string
}

// Sign runs `codesign` against a .app bundle, signing recursively with
// the hardened runtime and a secure timestamp.
func Sign(ctx context.Context, r runner.Runner, opts SignOpts) error {
	args := []string{
		"--deep", "--force", "--options", "runtime", "--timestamp",
		"--sign", opts.Identity,
		"--keychain", opts.KeychainPath,
		opts.AppPath,
	}
	if _, err := r.Run(ctx, runner.Spec{Name: "codesign", Args: args}); err != nil {
		return fmt.Errorf("codesign sign: %w", err)
	}
	return nil
}

// Verify runs `codesign --verify --deep --strict` against the bundle.
func Verify(ctx context.Context, r runner.Runner, appPath string) error {
	if _, err := r.Run(ctx, runner.Spec{
		Name: "codesign",
		Args: []string{"--verify", "--deep", "--strict", appPath},
	}); err != nil {
		return fmt.Errorf("codesign verify: %w", err)
	}
	return nil
}
  • Step 4: Run tests, verify pass

Run: go test ./internal/codesign/... Expected: PASS.

  • Step 5: Commit
git add internal/codesign/sign.go internal/codesign/sign_test.go
git commit -m "Add codesign sign + verify"

Task 12: internal/notarize — notarytool + stapler

Files:

  • Create: internal/notarize/notarize.go

  • Create: internal/notarize/notarize_test.go

  • Step 1: Write the failing tests

internal/notarize/notarize_test.go:

package notarize_test

import (
	"context"
	"errors"
	"reflect"
	"strings"
	"testing"

	"github.com/leonmika/wails-release/internal/notarize"
	"github.com/leonmika/wails-release/internal/runner"
)

func TestSubmit_APIKeyArgs(t *testing.T) {
	f := &runner.Fake{}
	f.On("xcrun", nil).Return([]byte("status: Accepted\nid: 123\n"), nil)

	err := notarize.Submit(context.Background(), f, notarize.Opts{
		ZipPath: "/dist/MyApp.app.zip",
		APIKey:  &notarize.APIKey{KeyPath: "/tmp/key.p8", KeyID: "ABCD1234", IssuerID: "iss"},
	})
	if err != nil {
		t.Fatalf("unexpected: %v", err)
	}
	want := []string{
		"notarytool", "submit", "/dist/MyApp.app.zip",
		"--key", "/tmp/key.p8",
		"--key-id", "ABCD1234",
		"--issuer", "iss",
		"--wait",
		"--output-format", "json",
	}
	if !reflect.DeepEqual(f.Calls[0].Args, want) {
		t.Fatalf("args got %v want %v", f.Calls[0].Args, want)
	}
}

func TestSubmit_AppleIDArgs(t *testing.T) {
	f := &runner.Fake{}
	f.On("xcrun", nil).Return([]byte("status: Accepted\nid: 1\n"), nil)

	err := notarize.Submit(context.Background(), f, notarize.Opts{
		ZipPath: "/dist/MyApp.app.zip",
		AppleID: &notarize.AppleID{Username: "u@x", Password: "pw", TeamID: "TEAM1234"},
	})
	if err != nil {
		t.Fatalf("unexpected: %v", err)
	}
	want := []string{
		"notarytool", "submit", "/dist/MyApp.app.zip",
		"--apple-id", "u@x",
		"--password", "pw",
		"--team-id", "TEAM1234",
		"--wait",
		"--output-format", "json",
	}
	if !reflect.DeepEqual(f.Calls[0].Args, want) {
		t.Fatalf("args got %v want %v", f.Calls[0].Args, want)
	}
}

func TestSubmit_RejectionFetchesLogAndReturnsError(t *testing.T) {
	f := &runner.Fake{}
	// First call: submit returns Invalid status with id.
	f.On("xcrun", []string{
		"notarytool", "submit", "/dist/MyApp.app.zip",
		"--key", "/tmp/key.p8", "--key-id", "K", "--issuer", "I",
		"--wait", "--output-format", "json",
	}).Return([]byte(`{"id":"sub-1","status":"Invalid"}`), nil)

	// Second call: log fetch.
	f.On("xcrun", []string{
		"notarytool", "log", "sub-1",
		"--key", "/tmp/key.p8", "--key-id", "K", "--issuer", "I",
	}).Return([]byte(`{"issues":[{"message":"missing entitlement"}]}`), nil)

	err := notarize.Submit(context.Background(), f, notarize.Opts{
		ZipPath: "/dist/MyApp.app.zip",
		APIKey:  &notarize.APIKey{KeyPath: "/tmp/key.p8", KeyID: "K", IssuerID: "I"},
	})
	if err == nil {
		t.Fatal("expected error")
	}
	if !strings.Contains(err.Error(), "missing entitlement") {
		t.Fatalf("expected log content in error, got %v", err)
	}
}

func TestSubmit_NoCredentialsErrors(t *testing.T) {
	err := notarize.Submit(context.Background(), &runner.Fake{}, notarize.Opts{ZipPath: "/x"})
	if err == nil {
		t.Fatal("expected error")
	}
}

func TestSubmit_ToolFailureWraps(t *testing.T) {
	f := &runner.Fake{}
	f.On("xcrun", nil).Return(nil, errors.New("network down"))
	err := notarize.Submit(context.Background(), f, notarize.Opts{
		ZipPath: "/x",
		APIKey:  &notarize.APIKey{KeyPath: "/k", KeyID: "K", IssuerID: "I"},
	})
	if err == nil {
		t.Fatal("expected error")
	}
}

func TestStaple_BuildsCorrectArgs(t *testing.T) {
	f := &runner.Fake{}
	f.On("xcrun", nil).Return(nil, nil)

	err := notarize.Staple(context.Background(), f, "/build/MyApp.app")
	if err != nil {
		t.Fatalf("unexpected: %v", err)
	}
	want := []string{"stapler", "staple", "/build/MyApp.app"}
	if !reflect.DeepEqual(f.Calls[0].Args, want) {
		t.Fatalf("args got %v want %v", f.Calls[0].Args, want)
	}
}
  • Step 2: Run, verify failure

Run: go test ./internal/notarize/... Expected: undefined types and functions.

  • Step 3: Implement

internal/notarize/notarize.go:

package notarize

import (
	"context"
	"encoding/json"
	"fmt"

	"github.com/leonmika/wails-release/internal/runner"
)

// APIKey is the App Store Connect API key credential set.
type APIKey struct {
	KeyPath  string
	KeyID    string
	IssuerID string
}

// AppleID is the legacy Apple ID + app-specific password credential set.
type AppleID struct {
	Username string
	Password string
	TeamID   string
}

// Opts configures a notarization submission. Exactly one of APIKey or
// AppleID must be set.
type Opts struct {
	ZipPath string
	APIKey  *APIKey
	AppleID *AppleID
}

// Submit ships ZipPath to Apple's notary service and waits for the result.
// Rejected submissions cause the per-submission log to be fetched and
// embedded in the returned error.
func Submit(ctx context.Context, r runner.Runner, opts Opts) error {
	if (opts.APIKey == nil) == (opts.AppleID == nil) {
		return fmt.Errorf("notarize: exactly one of APIKey or AppleID must be set")
	}
	credArgs := credentialArgs(opts)

	args := append([]string{"notarytool", "submit", opts.ZipPath}, credArgs...)
	args = append(args, "--wait", "--output-format", "json")

	out, err := r.Run(ctx, runner.Spec{Name: "xcrun", Args: args})
	if err != nil {
		return fmt.Errorf("notarytool submit: %w", err)
	}

	var result struct {
		ID     string `json:"id"`
		Status string `json:"status"`
	}
	if jerr := json.Unmarshal(out, &result); jerr != nil {
		return fmt.Errorf("notarytool submit: parse output: %w (raw: %s)", jerr, string(out))
	}
	if result.Status == "Accepted" {
		return nil
	}

	logOut, _ := r.Run(ctx, runner.Spec{
		Name: "xcrun",
		Args: append([]string{"notarytool", "log", result.ID}, credArgs...),
	})
	return fmt.Errorf("notarization %s: %s\n%s", result.Status, result.ID, string(logOut))
}

// Staple runs `xcrun stapler staple` against the bundle.
func Staple(ctx context.Context, r runner.Runner, appPath string) error {
	if _, err := r.Run(ctx, runner.Spec{
		Name: "xcrun",
		Args: []string{"stapler", "staple", appPath},
	}); err != nil {
		return fmt.Errorf("stapler staple: %w", err)
	}
	return nil
}

func credentialArgs(opts Opts) []string {
	if opts.APIKey != nil {
		return []string{
			"--key", opts.APIKey.KeyPath,
			"--key-id", opts.APIKey.KeyID,
			"--issuer", opts.APIKey.IssuerID,
		}
	}
	return []string{
		"--apple-id", opts.AppleID.Username,
		"--password", opts.AppleID.Password,
		"--team-id", opts.AppleID.TeamID,
	}
}
  • Step 4: Run tests, verify pass

Run: go test ./internal/notarize/... Expected: PASS.

  • Step 5: Commit
git add internal/notarize
git commit -m "Add notarytool wrapper supporting api-key and apple-id"

Task 13: internal/upload — S3 upload + key templating

Files:

  • Create: internal/upload/upload.go

  • Create: internal/upload/upload_test.go

  • Step 1: Add AWS SDK dependency

go get github.com/aws/aws-sdk-go-v2/config
go get github.com/aws/aws-sdk-go-v2/service/s3
  • Step 2: Write the failing tests

internal/upload/upload_test.go:

package upload_test

import (
	"context"
	"strings"
	"testing"

	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/service/s3"
	"github.com/leonmika/wails-release/internal/upload"
)

func TestRenderKey(t *testing.T) {
	cases := []struct {
		template string
		version  string
		filename string
		want     string
	}{
		{"releases/{version}/{filename}", "1.2.3", "App.app.zip", "releases/1.2.3/App.app.zip"},
		{"{filename}", "1.0.0", "App.app.zip", "App.app.zip"},
		{"app/{version}-{filename}", "1.2.3", "App.app.zip", "app/1.2.3-App.app.zip"},
	}
	for _, c := range cases {
		got := upload.RenderKey(c.template, c.version, c.filename)
		if got != c.want {
			t.Fatalf("template %q: got %q want %q", c.template, got, c.want)
		}
	}
}

type fakePut struct {
	calls []*s3.PutObjectInput
}

func (f *fakePut) PutObject(ctx context.Context, in *s3.PutObjectInput, _ ...func(*s3.Options)) (*s3.PutObjectOutput, error) {
	f.calls = append(f.calls, in)
	return &s3.PutObjectOutput{}, nil
}

func TestUpload_CallsPutObjectWithCorrectInputs(t *testing.T) {
	tmp := writeTempFile(t, "hello")
	c := &fakePut{}

	url, err := upload.Upload(context.Background(), c, upload.Opts{
		Bucket:   "my-bucket",
		Key:      "releases/1.2.3/App.app.zip",
		FilePath: tmp,
	})
	if err != nil {
		t.Fatalf("unexpected: %v", err)
	}
	if url != "s3://my-bucket/releases/1.2.3/App.app.zip" {
		t.Fatalf("url got %q", url)
	}
	if len(c.calls) != 1 {
		t.Fatalf("expected 1 call, got %d", len(c.calls))
	}
	if aws.ToString(c.calls[0].Bucket) != "my-bucket" || aws.ToString(c.calls[0].Key) != "releases/1.2.3/App.app.zip" {
		t.Fatalf("bucket/key wrong: %+v", c.calls[0])
	}
}

func TestUpload_FailsWhenFileMissing(t *testing.T) {
	_, err := upload.Upload(context.Background(), &fakePut{}, upload.Opts{
		Bucket:   "b",
		Key:      "k",
		FilePath: "/does/not/exist",
	})
	if err == nil || !strings.Contains(err.Error(), "open") {
		t.Fatalf("expected open error, got %v", err)
	}
}

// writeTempFile creates a small temp file and returns its path.
func writeTempFile(t *testing.T, contents string) string {
	t.Helper()
	dir := t.TempDir()
	path := dir + "/blob"
	if err := writeAll(path, contents); err != nil {
		t.Fatal(err)
	}
	return path
}

func writeAll(path, s string) error {
	return upload.WriteFileForTest(path, []byte(s))
}

(upload.WriteFileForTest is a thin wrapper to keep the test free of os imports — it is a one-liner you'll define alongside the implementation.)

  • Step 3: Run, verify failure

Run: go test ./internal/upload/... Expected: build error.

  • Step 4: Implement

internal/upload/upload.go:

package upload

import (
	"context"
	"fmt"
	"os"
	"strings"

	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/service/s3"
)

// PutObjectAPI is the subset of *s3.Client used here. Tests inject a fake.
type PutObjectAPI interface {
	PutObject(ctx context.Context, in *s3.PutObjectInput, opts ...func(*s3.Options)) (*s3.PutObjectOutput, error)
}

// Opts configures a single upload.
type Opts struct {
	Bucket   string
	Key      string
	FilePath string
}

// RenderKey substitutes {version} and {filename} placeholders.
func RenderKey(template, version, filename string) string {
	r := strings.NewReplacer("{version}", version, "{filename}", filename)
	return r.Replace(template)
}

// Upload PUTs FilePath at s3://Bucket/Key and returns the s3:// URL.
func Upload(ctx context.Context, c PutObjectAPI, o Opts) (string, error) {
	f, err := os.Open(o.FilePath)
	if err != nil {
		return "", fmt.Errorf("open %s: %w", o.FilePath, err)
	}
	defer f.Close()

	if _, err := c.PutObject(ctx, &s3.PutObjectInput{
		Bucket: aws.String(o.Bucket),
		Key:    aws.String(o.Key),
		Body:   f,
	}); err != nil {
		return "", fmt.Errorf("s3 put: %w", err)
	}
	return fmt.Sprintf("s3://%s/%s", o.Bucket, o.Key), nil
}

// WriteFileForTest is exposed for use from this package's tests.
func WriteFileForTest(path string, b []byte) error {
	return os.WriteFile(path, b, 0o600)
}
  • Step 5: Add the client constructor

Append to internal/upload/upload.go:

import (
	awsconfig "github.com/aws/aws-sdk-go-v2/config"
)

// NewClient builds an *s3.Client honouring an optional custom endpoint
// for S3-compatible storage.
func NewClient(ctx context.Context, region, endpointURL string) (*s3.Client, error) {
	cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region))
	if err != nil {
		return nil, fmt.Errorf("aws config: %w", err)
	}
	opts := []func(*s3.Options){}
	if endpointURL != "" {
		opts = append(opts, func(o *s3.Options) {
			o.BaseEndpoint = aws.String(endpointURL)
			o.UsePathStyle = true
		})
	}
	return s3.NewFromConfig(cfg, opts...), nil
}

(Keep the awsconfig import grouped with the existing imports rather than declaring a second import block.)

  • Step 6: Run tests, verify pass

Run: go test ./internal/upload/... Expected: PASS.

  • Step 7: Commit
git add internal/upload go.mod go.sum
git commit -m "Add S3 upload with key templating and custom endpoint"

Task 14: internal/actions — Forgejo Actions output + log masking

Files:

  • Create: internal/actions/actions.go

  • Create: internal/actions/actions_test.go

  • Step 1: Write the failing tests

internal/actions/actions_test.go:

package actions_test

import (
	"bytes"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"github.com/leonmika/wails-release/internal/actions"
)

func TestSetOutput_AppendsKeyValueLine(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "outputs")

	if err := actions.SetOutput(path, "version", "1.2.3"); err != nil {
		t.Fatalf("unexpected: %v", err)
	}
	if err := actions.SetOutput(path, "artifact-path", "/dist/x.zip"); err != nil {
		t.Fatalf("unexpected: %v", err)
	}

	got, err := os.ReadFile(path)
	if err != nil {
		t.Fatalf("read: %v", err)
	}
	want := "version=1.2.3\nartifact-path=/dist/x.zip\n"
	if string(got) != want {
		t.Fatalf("got %q want %q", got, want)
	}
}

func TestAddMask_WritesDirective(t *testing.T) {
	var buf bytes.Buffer
	actions.AddMask(&buf, "supersecret")
	if !strings.Contains(buf.String(), "::add-mask::supersecret") {
		t.Fatalf("unexpected output: %q", buf.String())
	}
}

func TestSetOutput_NoFileSetIsNoop(t *testing.T) {
	if err := actions.SetOutput("", "k", "v"); err != nil {
		t.Fatalf("expected no error when path empty, got %v", err)
	}
}
  • Step 2: Run, verify failure

Run: go test ./internal/actions/... Expected: undefined: actions.SetOutput, actions.AddMask.

  • Step 3: Implement

internal/actions/actions.go:

package actions

import (
	"fmt"
	"io"
	"os"
)

// SetOutput appends "name=value\n" to the file referenced by $GITHUB_OUTPUT.
// If outputFile is empty the call is a no-op (e.g. running locally).
func SetOutput(outputFile, name, value string) error {
	if outputFile == "" {
		return nil
	}
	f, err := os.OpenFile(outputFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
	if err != nil {
		return fmt.Errorf("open %s: %w", outputFile, err)
	}
	defer f.Close()
	if _, err := fmt.Fprintf(f, "%s=%s\n", name, value); err != nil {
		return fmt.Errorf("write %s: %w", outputFile, err)
	}
	return nil
}

// AddMask emits a workflow command that redacts the given value from logs.
func AddMask(w io.Writer, value string) {
	if value == "" {
		return
	}
	fmt.Fprintf(w, "::add-mask::%s\n", value)
}
  • Step 4: Run tests, verify pass

Run: go test ./internal/actions/... Expected: PASS.

  • Step 5: Commit
git add internal/actions
git commit -m "Add Forgejo Actions output + masking helpers"

Task 15: cmd/wails-release/main.go — orchestrator

Files:

  • Modify: cmd/wails-release/main.go

  • Step 1: Replace stub with full orchestrator

package main

import (
	"context"
	"crypto/rand"
	"encoding/base64"
	"encoding/hex"
	"fmt"
	"os"
	"path/filepath"
	"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() {
	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 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)
}

// 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)
}
  • Step 2: Add the parser helper for find-identity output

Add to the same file:

import "regexp"

var devIDRE = regexp.MustCompile(`"(Developer ID Application: [^"]+)"`)

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

(Move the regexp import into the existing import block.)

  • Step 3: Add a small unit test for the identity parser

Create cmd/wails-release/main_test.go:

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")
	}
}
  • Step 4: Run tests, verify pass

Run: go test ./... Expected: PASS across all packages.

  • Step 5: Commit
git add cmd
git commit -m "Wire orchestrator end-to-end"

Task 16: End-to-end integration test with fake binaries

Files:

  • Create: cmd/wails-release/integration_test.go
  • Create: cmd/wails-release/testdata/sample-app/wails.json
  • Create: cmd/wails-release/testdata/sample-app/go.mod
  • Create: cmd/wails-release/testdata/bin/wails
  • Create: cmd/wails-release/testdata/bin/codesign
  • Create: cmd/wails-release/testdata/bin/security
  • Create: cmd/wails-release/testdata/bin/ditto
  • Create: cmd/wails-release/testdata/bin/xcrun
  • Create: cmd/wails-release/testdata/bin/go

This task verifies the orchestrator wiring end-to-end without touching real Apple infrastructure or the real go toolchain. Each fake records its argv to a file under $RECORD_DIR. The test sets PATH=testdata/bin:<original> so the orchestrator picks up the fakes first.

  • Step 1: Create sample fixture

cmd/wails-release/testdata/sample-app/wails.json:

{ "name": "SampleApp" }

cmd/wails-release/testdata/sample-app/go.mod:

module example.com/sample

go 1.22

require github.com/wailsapp/wails/v2 v2.11.0
  • Step 2: Create fake binaries

Each fake is #!/bin/bash with three responsibilities: (a) record its argv to $RECORD_DIR/<name>.log, (b) when it's expected to write build output, fabricate it, (c) print whatever the orchestrator parses.

cmd/wails-release/testdata/bin/wails:

#!/bin/bash
echo "$0 $*" >> "$RECORD_DIR/wails.log"
case "$1" in
  -v) echo "Wails CLI v2.11.0" ;;
  build)
    mkdir -p "$WORK_DIR/build/bin/SampleApp.app/Contents/MacOS"
    touch "$WORK_DIR/build/bin/SampleApp.app/Contents/MacOS/SampleApp"
    ;;
esac

cmd/wails-release/testdata/bin/security:

#!/bin/bash
echo "$0 $*" >> "$RECORD_DIR/security.log"
if [[ "$1" == "find-identity" ]]; then
  echo '  1) AAAA1111 "Developer ID Application: Acme Inc (TEAM1234)"'
fi
exit 0

cmd/wails-release/testdata/bin/codesign:

#!/bin/bash
echo "$0 $*" >> "$RECORD_DIR/codesign.log"
exit 0

cmd/wails-release/testdata/bin/ditto:

#!/bin/bash
echo "$0 $*" >> "$RECORD_DIR/ditto.log"
# Last argument is the output path; create it.
out="${@: -1}"
mkdir -p "$(dirname "$out")"
echo "fake-zip" > "$out"
exit 0

cmd/wails-release/testdata/bin/xcrun:

#!/bin/bash
echo "$0 $*" >> "$RECORD_DIR/xcrun.log"
case "$1" in
  notarytool)
    case "$2" in
      submit) echo '{"id":"sub-1","status":"Accepted"}' ;;
      log)    echo '{}' ;;
    esac
    ;;
  stapler) ;;
esac
exit 0

cmd/wails-release/testdata/bin/go:

#!/bin/bash
echo "$0 $*" >> "$RECORD_DIR/go.log"
# install: pretend success.
# env GOPATH: print a tmp value (not actually used in the test path).
case "$1" in
  install) ;;
  env)     echo "/tmp" ;;
esac
exit 0

After creating, mark them executable:

chmod +x cmd/wails-release/testdata/bin/*
  • Step 3: Write the integration test

cmd/wails-release/integration_test.go:

package main

import (
	"context"
	"encoding/base64"
	"os"
	"path/filepath"
	"strings"
	"testing"
)

func TestRun_EndToEnd_FakeBinaries(t *testing.T) {
	if testing.Short() {
		t.Skip("integration test skipped in -short mode")
	}

	repoRoot, _ := os.Getwd() // cmd/wails-release
	binDir := filepath.Join(repoRoot, "testdata", "bin")
	sampleSrc := filepath.Join(repoRoot, "testdata", "sample-app")

	// Copy the sample app to a writable temp dir so the build step can
	// drop output into it without mutating testdata.
	work := t.TempDir()
	copyTree(t, sampleSrc, work)

	record := t.TempDir()
	outputs := filepath.Join(record, "outputs")
	must(t, os.WriteFile(outputs, nil, 0o600))

	t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
	t.Setenv("RECORD_DIR", record)
	t.Setenv("WORK_DIR", work)
	t.Setenv("GITHUB_OUTPUT", outputs)
	t.Setenv("GITHUB_REF", "refs/tags/v1.2.3")
	t.Setenv("GITHUB_SHA", "0123456789abcdef")

	for k, v := range map[string]string{
		"INPUT_WORKING_DIRECTORY":           work,
		"INPUT_DEVELOPER_ID_CERT_BASE64":    base64.StdEncoding.EncodeToString([]byte("not-a-real-p12")),
		"INPUT_DEVELOPER_ID_CERT_PASSWORD":  "x",
		"INPUT_NOTARIZATION_METHOD":         "api-key",
		"INPUT_NOTARIZATION_API_KEY_BASE64": base64.StdEncoding.EncodeToString([]byte("not-a-real-p8")),
		"INPUT_NOTARIZATION_API_KEY_ID":     "K",
		"INPUT_NOTARIZATION_API_ISSUER_ID":  "I",
	} {
		t.Setenv(k, v)
	}

	if err := run(context.Background()); err != nil {
		t.Fatalf("run: %v", err)
	}

	// Each external command must have been called.
	for _, name := range []string{"wails.log", "security.log", "codesign.log", "ditto.log", "xcrun.log"} {
		path := filepath.Join(record, name)
		b, err := os.ReadFile(path)
		if err != nil {
			t.Fatalf("expected %s, got error: %v", name, err)
		}
		if len(b) == 0 {
			t.Fatalf("%s was empty", name)
		}
	}

	// Outputs file should contain version=1.2.3.
	out, err := os.ReadFile(outputs)
	if err != nil {
		t.Fatalf("read outputs: %v", err)
	}
	if !strings.Contains(string(out), "version=1.2.3") {
		t.Fatalf("expected version=1.2.3 in outputs, got %q", out)
	}
	if !strings.Contains(string(out), "app-name=SampleApp") {
		t.Fatalf("expected app-name=SampleApp in outputs, got %q", out)
	}
	if !strings.Contains(string(out), "artifact-filename=SampleApp-1.2.3.app.zip") {
		t.Fatalf("expected artifact-filename in outputs, got %q", out)
	}

	// ditto must have been invoked twice (pre-staple + post-staple).
	dittoLog, _ := os.ReadFile(filepath.Join(record, "ditto.log"))
	if strings.Count(string(dittoLog), "\n") < 2 {
		t.Fatalf("expected ditto called at least twice, log:\n%s", dittoLog)
	}

	// security delete-keychain must have run during cleanup.
	secLog, _ := os.ReadFile(filepath.Join(record, "security.log"))
	if !strings.Contains(string(secLog), "delete-keychain") {
		t.Fatalf("expected security delete-keychain in cleanup, log:\n%s", secLog)
	}
}

func must(t *testing.T, err error) {
	t.Helper()
	if err != nil {
		t.Fatal(err)
	}
}

func copyTree(t *testing.T, src, dst string) {
	t.Helper()
	must(t, filepath.Walk(src, func(p string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		rel, _ := filepath.Rel(src, p)
		out := filepath.Join(dst, rel)
		if info.IsDir() {
			return os.MkdirAll(out, 0o755)
		}
		b, err := os.ReadFile(p)
		if err != nil {
			return err
		}
		return os.WriteFile(out, b, info.Mode())
	}))
}
  • Step 4: Run the integration test

Run: go test ./cmd/wails-release/... Expected: PASS.

  • Step 5: Commit
git add cmd/wails-release/integration_test.go cmd/wails-release/testdata
git commit -m "Add end-to-end integration test with fake external binaries"

Task 17: Finalize action.yml

Files:

  • Modify: action.yml

  • Step 1: Replace the stub with the full action

name: Wails Release
description: Build, sign, notarize and (optionally) upload a Wails macOS app
branding:
  icon: package
  color: blue

inputs:
  working-directory:           { description: "Directory containing wails.json", required: false, default: "." }
  app-name:                    { description: "Override the app name (defaults to wails.json `name`)", required: false }
  version:                     { description: "Override the version (defaults to tag/SHA logic)", required: false }
  wails-version:               { description: "Override the Wails CLI version (defaults to project go.mod)", required: false }
  extra-build-flags:           { description: "Additional flags appended to `wails build`", required: false }

  developer-id-cert-base64:    { description: "Base64-encoded Developer ID .p12", required: true }
  developer-id-cert-password:  { description: ".p12 password", required: true }

  notarization-method:         { description: "api-key | apple-id | auto", required: false, default: "auto" }
  notarization-api-key-base64: { description: "Base64-encoded App Store Connect .p8", required: false }
  notarization-api-key-id:     { description: "App Store Connect API key ID", required: false }
  notarization-api-issuer-id:  { description: "App Store Connect issuer ID", required: false }
  notarization-apple-id:       { description: "Apple ID (e.g. user@example.com)", required: false }
  notarization-apple-password: { description: "App-specific password", required: false }
  notarization-team-id:        { description: "Apple developer Team ID", required: false }

  s3-bucket:        { description: "S3 bucket name. If empty, upload is skipped.", required: false }
  s3-key:           { description: "Object key. Supports {version} and {filename} placeholders.", required: false }
  s3-endpoint-url:  { description: "Custom endpoint for S3-compatible storage (MinIO, R2, etc.)", required: false }
  s3-region:        { description: "AWS region", required: false, default: "us-east-1" }

outputs:
  version:           { description: "Resolved version string", value: ${{ steps.run.outputs.version }} }
  app-name:          { description: "Resolved app name", value: ${{ steps.run.outputs.app-name }} }
  artifact-path:     { description: "Local absolute path to the .app.zip", value: ${{ steps.run.outputs.artifact-path }} }
  artifact-filename: { description: "Filename of the .app.zip", value: ${{ steps.run.outputs.artifact-filename }} }
  s3-url:            { description: "s3://… URL if uploaded, else empty", value: ${{ steps.run.outputs.s3-url }} }

runs:
  using: composite
  steps:
    - id: run
      shell: bash
      env:
        INPUT_WORKING_DIRECTORY:           ${{ inputs.working-directory }}
        INPUT_APP_NAME:                    ${{ inputs.app-name }}
        INPUT_VERSION:                     ${{ inputs.version }}
        INPUT_WAILS_VERSION:               ${{ inputs.wails-version }}
        INPUT_EXTRA_BUILD_FLAGS:           ${{ inputs.extra-build-flags }}
        INPUT_DEVELOPER_ID_CERT_BASE64:    ${{ inputs.developer-id-cert-base64 }}
        INPUT_DEVELOPER_ID_CERT_PASSWORD:  ${{ inputs.developer-id-cert-password }}
        INPUT_NOTARIZATION_METHOD:         ${{ inputs.notarization-method }}
        INPUT_NOTARIZATION_API_KEY_BASE64: ${{ inputs.notarization-api-key-base64 }}
        INPUT_NOTARIZATION_API_KEY_ID:     ${{ inputs.notarization-api-key-id }}
        INPUT_NOTARIZATION_API_ISSUER_ID:  ${{ inputs.notarization-api-issuer-id }}
        INPUT_NOTARIZATION_APPLE_ID:       ${{ inputs.notarization-apple-id }}
        INPUT_NOTARIZATION_APPLE_PASSWORD: ${{ inputs.notarization-apple-password }}
        INPUT_NOTARIZATION_TEAM_ID:        ${{ inputs.notarization-team-id }}
        INPUT_S3_BUCKET:                   ${{ inputs.s3-bucket }}
        INPUT_S3_KEY:                      ${{ inputs.s3-key }}
        INPUT_S3_ENDPOINT_URL:             ${{ inputs.s3-endpoint-url }}
        INPUT_S3_REGION:                   ${{ inputs.s3-region }}
      run: |
        set -euo pipefail
        go install "${{ github.action_repository }}/cmd/wails-release@${{ github.action_ref }}"
        "$(go env GOPATH)/bin/wails-release"        
  • Step 2: Commit
git add action.yml
git commit -m "Finalize action.yml with full inputs and outputs"

Task 18: Documentation

Files:

  • Modify: README.md

  • Step 1: Replace the README with comprehensive docs

# Wails Release Action

Build, sign, notarize, and (optionally) upload a [Wails](https://wails.io/) macOS app from a Forgejo Actions workflow on a self-hosted macOS runner.

The action produces a notarized, stapled `<AppName>-<version>.app.zip` and exposes its local path and (if uploaded) its `s3://` URL as outputs.

## Requirements

- A self-hosted **macOS** runner.
- `go` on `PATH` (Wails requires it).
- Network access to Apple's notary service and (if uploading) your S3 endpoint.

## Quick start

```yaml
name: Release

on:
  push:
    tags: ['v*']

jobs:
  release:
    runs-on: macos
    steps:
      - uses: actions/checkout@v4

      - uses: leonmika/wails-release@v1
        with:
          developer-id-cert-base64:    ${{ secrets.MAC_DEVELOPER_ID_CERT_BASE64 }}
          developer-id-cert-password:  ${{ secrets.MAC_DEVELOPER_ID_CERT_PASSWORD }}
          notarization-api-key-base64: ${{ secrets.AC_API_KEY_BASE64 }}
          notarization-api-key-id:     ${{ secrets.AC_API_KEY_ID }}
          notarization-api-issuer-id:  ${{ secrets.AC_API_ISSUER_ID }}
          s3-bucket:                   my-releases
          s3-key:                      myapp/{version}/{filename}
        env:
          AWS_ACCESS_KEY_ID:     ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

Inputs

Name Required Default Description
working-directory no . Directory containing wails.json
app-name no wails.json name Override for the app name in artifact filenames
version no derived Override; otherwise: matching semver tag → strip v, else 7-char short SHA
wails-version no from go.mod Override the Wails CLI version
extra-build-flags no "" Additional flags appended to wails build (shell-quoted)
developer-id-cert-base64 yes Base64-encoded Developer ID .p12
developer-id-cert-password yes .p12 password
notarization-method no auto api-key, apple-id, or auto (auto picks whichever group is fully populated)
notarization-api-key-base64 conditional Base64-encoded App Store Connect .p8
notarization-api-key-id conditional API key ID
notarization-api-issuer-id conditional Issuer ID
notarization-apple-id conditional Apple ID (email)
notarization-apple-password conditional App-specific password
notarization-team-id conditional Developer Team ID
s3-bucket no Bucket name. If unset, upload is skipped.
s3-key conditional Object key. Required if s3-bucket is set. Supports {version} and {filename} placeholders.
s3-endpoint-url no Custom endpoint for S3-compatible storage (MinIO, R2, etc.)
s3-region no us-east-1 AWS region

AWS credentials (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, optionally AWS_SESSION_TOKEN) are read from the standard environment, not from action inputs.

Outputs

Name Description
version Resolved version string
app-name Resolved app name
artifact-path Local absolute path to the .app.zip
artifact-filename Just the filename (e.g. MyApp-1.2.3.app.zip)
s3-url s3://bucket/key/... if uploaded, else empty

Versioning rule

  • A git ref of the form refs/tags/vX.Y.Z (no pre-release suffix) → version becomes X.Y.Z.
  • Anything else → 7-character short SHA from HEAD.
  • Override via the version input.

Notarization credentials

You can use either:

  • App Store Connect API key (recommended). Generate one in App Store Connect → Users and Access → Keys. You need the .p8 file, the Key ID, and the Issuer ID.
  • Apple ID + app-specific password + team ID. Generate the app-specific password at appleid.apple.com → Sign-In and Security → App-Specific Passwords.

If both groups are populated and notarization-method is auto, the action errors with an ambiguity message — set notarization-method explicitly to disambiguate.

S3-compatible storage

Set s3-endpoint-url to point at your storage:

- uses: leonmika/wails-release@v1
  with:
    # …
    s3-bucket:        releases
    s3-key:           myapp/{version}/{filename}
    s3-endpoint-url:  https://my-minio.example.com
    s3-region:        auto
  env:
    AWS_ACCESS_KEY_ID:     ${{ secrets.MINIO_KEY }}
    AWS_SECRET_ACCESS_KEY: ${{ secrets.MINIO_SECRET }}

For Cloudflare R2, set the endpoint to https://<account>.r2.cloudflarestorage.com and s3-region to auto.

How it works

  1. Resolve config from INPUT_* env, validate, and mask secrets in logs.
  2. Resolve version (tag → strip v, else short SHA) and app name (from wails.json).
  3. Ensure the Wails CLI matches the version pinned in your project's go.mod (or the wails-version override).
  4. Run wails build -platform darwin/universal -clean -trimpath plus your extra-build-flags.
  5. Create a temporary keychain, import the .p12, and codesign the .app with the hardened runtime and a secure timestamp. Verify the signature.
  6. ditto the .app into a zip for notary submission.
  7. xcrun notarytool submit --wait (API key or Apple ID, whichever was supplied). On rejection, fetch the per-submission log and embed it in the error.
  8. xcrun stapler staple the bundle and re-zip so the on-disk artifact is offline-verifiable.
  9. Optionally upload via the AWS SDK (custom endpoint supported).
  10. Always run cleanup: delete the temp keychain and remove decoded .p12 / .p8 files.

Local development

go test ./...

Smoke-testing real signing requires real credentials and is documented inline in cmd/wails-release/integration_test.go. The integration test itself uses fake external binaries on PATH and runs hermetically.


- [ ] **Step 2: Commit**

```bash
git add README.md
git commit -m "Document inputs, outputs, and usage"

Self-Review Checklist

After completing all 18 tasks, verify:

Spec coverage:

  • Repository layout matches spec — cmd/, internal/{runner,version,config,wails,archive,codesign,notarize,upload,cleanup,actions}.
  • action.yml is a single composite step that go installs and runs the binary using go env GOPATH.
  • All inputs from the spec table are wired through the action.
  • All outputs from the spec table are written to $GITHUB_OUTPUT.
  • Validation rules from the spec all have unit tests in internal/config.
  • Version resolution is the spec rule (tag-then-SHA, no pre-release matching).
  • Wails CLI resolution prefers input → go.mod, and skips install when already at the right version.
  • Codesign uses temporary keychain, hardened runtime, secure timestamp.
  • Notarization supports both api-key and apple-id, with --wait.
  • On rejection, the notary log is fetched and surfaced.
  • Stapler is run and the artifact is re-archived.
  • S3 upload supports custom endpoint and key placeholders.
  • Cleanup runs in reverse order even on failure.
  • Secrets are masked via ::add-mask::.
  • Linux/Windows cause an early exit with a clear message.
  • README documents all inputs, outputs, and includes worked examples.

Placeholder scan: none of "TBD", "TODO", "implement later", "see Task N", "fill in" appear in the plan.

Type consistency:

  • runner.Spec / runner.Runner / runner.Real / runner.Fake — same names used everywhere.
  • codesign.Keychain, codesign.SignOpts.
  • notarize.Opts, notarize.APIKey, notarize.AppleID.
  • upload.Opts, upload.PutObjectAPI, upload.NewClient.
  • wails.Project, wails.BuildOpts, wails.EnsureCLI, wails.ProjectWailsVersion.
  • cleanup.Stack with Add and Run.
  • actions.SetOutput / actions.AddMask.

If anything fails the checklist, fix it before declaring the implementation complete.