# 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.