diff --git a/docs/superpowers/plans/2026-05-02-wails-release-action.md b/docs/superpowers/plans/2026-05-02-wails-release-action.md new file mode 100644 index 0000000..6e3be09 --- /dev/null +++ b/docs/superpowers/plans/2026-05-02-wails-release-action.md @@ -0,0 +1,3135 @@ +# 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 install`s 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: +```bash +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** + +```go +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** + +```yaml +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: +```bash +go build ./... +``` +Expected: no output, exit 0. + +- [ ] **Step 6: Commit** + +```bash +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`: + +```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`: + +```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** + +```bash +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`: + +```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`: + +```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** + +```bash +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`: + +```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`: + +```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** + +```bash +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** + +```bash +go get golang.org/x/mod/modfile +``` + +- [ ] **Step 2: Create test fixtures** + +`internal/wails/testdata/sample/wails.json`: +```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`: + +```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`: + +```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** + +```bash +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`: + +```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`: + +```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** + +```bash +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** + +```bash +go get github.com/google/shlex +``` + +- [ ] **Step 2: Write the failing tests** + +`internal/wails/build_test.go`: + +```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`: + +```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** + +```bash +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`: + +```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`: + +```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** + +```bash +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`: + +```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`: + +```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** + +```bash +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`: + +```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`: + +```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** + +```bash +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`: + +```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`: + +```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** + +```bash +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`: + +```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: ¬arize.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: ¬arize.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: ¬arize.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: ¬arize.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`: + +```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** + +```bash +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** + +```bash +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`: + +```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`: + +```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`: + +```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** + +```bash +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`: + +```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`: + +```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** + +```bash +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** + +```go +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 = ¬arize.APIKey{ + KeyPath: p8Path, + KeyID: cfg.NotarizationAPIKeyID, + IssuerID: cfg.NotarizationAPIIssuerID, + } + case "apple-id": + notarOpts.AppleID = ¬arize.AppleID{ + Username: cfg.NotarizationAppleID, + Password: cfg.NotarizationApplePassword, + TeamID: cfg.NotarizationTeamID, + } + } + if err := notarize.Submit(ctx, r, notarOpts); err != nil { + return err + } + if err := notarize.Staple(ctx, r, appPath); err != nil { + return err + } + if err := archive.ZipApp(ctx, r, appPath, zipPath); err != nil { + return err + } + + // 7. Upload (optional) + s3URL := "" + if cfg.S3Bucket != "" { + client, err := upload.NewClient(ctx, cfg.S3Region, cfg.S3EndpointURL) + if err != nil { + return err + } + key := upload.RenderKey(cfg.S3Key, resolvedVersion, artifactName) + s3URL, err = upload.Upload(ctx, client, upload.Opts{ + Bucket: cfg.S3Bucket, Key: key, FilePath: zipPath, + }) + if err != nil { + return err + } + } + + // 8. Outputs + outFile := os.Getenv("GITHUB_OUTPUT") + for k, v := range map[string]string{ + "version": resolvedVersion, + "app-name": appName, + "artifact-path": zipPath, + "artifact-filename": artifactName, + "s3-url": s3URL, + } { + if err := actions.SetOutput(outFile, k, v); err != nil { + return err + } + } + return nil +} + +func maskSecrets(c *config.Config) { + for _, v := range []string{ + c.DeveloperIDCertPassword, + c.NotarizationAPIKeyBase64, + c.NotarizationApplePassword, + } { + actions.AddMask(os.Stdout, v) + } +} + +func writeBase64(path, encoded string) error { + raw, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return fmt.Errorf("decode base64 → %s: %w", path, err) + } + return os.WriteFile(path, raw, 0o600) +} + +func randomHex(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + // extremely unlikely; fall back to a less-strong but functional value + return "fallback-keychain-pw" + } + return hex.EncodeToString(b) +} + +// 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: + +```go +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`: + +```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** + +```bash +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:` so the orchestrator picks up the fakes first. + +- [ ] **Step 1: Create sample fixture** + +`cmd/wails-release/testdata/sample-app/wails.json`: +```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/.log`, (b) when it's expected to write build output, fabricate it, (c) print whatever the orchestrator parses. + +`cmd/wails-release/testdata/bin/wails`: +```bash +#!/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`: +```bash +#!/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`: +```bash +#!/bin/bash +echo "$0 $*" >> "$RECORD_DIR/codesign.log" +exit 0 +``` + +`cmd/wails-release/testdata/bin/ditto`: +```bash +#!/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`: +```bash +#!/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`: +```bash +#!/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: + +```bash +chmod +x cmd/wails-release/testdata/bin/* +``` + +- [ ] **Step 3: Write the integration test** + +`cmd/wails-release/integration_test.go`: + +```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** + +```bash +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** + +```yaml +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** + +```bash +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** + +```markdown +# 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 `-.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](https://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: + +```yaml +- 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://.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 + +```bash +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 install`s 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.