wails-release/docs/superpowers/plans/2026-05-02-wails-release-action.md
2026-05-02 11:47:13 +10:00

3136 lines
84 KiB
Markdown

# 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 lmika.dev/actions/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"
"lmika.dev/actions/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"
"lmika.dev/actions/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"
"lmika.dev/actions/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"
"lmika.dev/actions/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"
"lmika.dev/actions/wails-release/internal/runner"
"lmika.dev/actions/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"
"lmika.dev/actions/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"
"lmika.dev/actions/wails-release/internal/runner"
"lmika.dev/actions/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"
"lmika.dev/actions/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"
"lmika.dev/actions/wails-release/internal/archive"
"lmika.dev/actions/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"
"lmika.dev/actions/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"
"lmika.dev/actions/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"
"lmika.dev/actions/wails-release/internal/codesign"
"lmika.dev/actions/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"
"lmika.dev/actions/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"
"lmika.dev/actions/wails-release/internal/codesign"
"lmika.dev/actions/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"
"lmika.dev/actions/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"
"lmika.dev/actions/wails-release/internal/notarize"
"lmika.dev/actions/wails-release/internal/runner"
)
func TestSubmit_APIKeyArgs(t *testing.T) {
f := &runner.Fake{}
f.On("xcrun", nil).Return([]byte("status: Accepted\nid: 123\n"), nil)
err := notarize.Submit(context.Background(), f, notarize.Opts{
ZipPath: "/dist/MyApp.app.zip",
APIKey: &notarize.APIKey{KeyPath: "/tmp/key.p8", KeyID: "ABCD1234", IssuerID: "iss"},
})
if err != nil {
t.Fatalf("unexpected: %v", err)
}
want := []string{
"notarytool", "submit", "/dist/MyApp.app.zip",
"--key", "/tmp/key.p8",
"--key-id", "ABCD1234",
"--issuer", "iss",
"--wait",
"--output-format", "json",
}
if !reflect.DeepEqual(f.Calls[0].Args, want) {
t.Fatalf("args got %v want %v", f.Calls[0].Args, want)
}
}
func TestSubmit_AppleIDArgs(t *testing.T) {
f := &runner.Fake{}
f.On("xcrun", nil).Return([]byte("status: Accepted\nid: 1\n"), nil)
err := notarize.Submit(context.Background(), f, notarize.Opts{
ZipPath: "/dist/MyApp.app.zip",
AppleID: &notarize.AppleID{Username: "u@x", Password: "pw", TeamID: "TEAM1234"},
})
if err != nil {
t.Fatalf("unexpected: %v", err)
}
want := []string{
"notarytool", "submit", "/dist/MyApp.app.zip",
"--apple-id", "u@x",
"--password", "pw",
"--team-id", "TEAM1234",
"--wait",
"--output-format", "json",
}
if !reflect.DeepEqual(f.Calls[0].Args, want) {
t.Fatalf("args got %v want %v", f.Calls[0].Args, want)
}
}
func TestSubmit_RejectionFetchesLogAndReturnsError(t *testing.T) {
f := &runner.Fake{}
// First call: submit returns Invalid status with id.
f.On("xcrun", []string{
"notarytool", "submit", "/dist/MyApp.app.zip",
"--key", "/tmp/key.p8", "--key-id", "K", "--issuer", "I",
"--wait", "--output-format", "json",
}).Return([]byte(`{"id":"sub-1","status":"Invalid"}`), nil)
// Second call: log fetch.
f.On("xcrun", []string{
"notarytool", "log", "sub-1",
"--key", "/tmp/key.p8", "--key-id", "K", "--issuer", "I",
}).Return([]byte(`{"issues":[{"message":"missing entitlement"}]}`), nil)
err := notarize.Submit(context.Background(), f, notarize.Opts{
ZipPath: "/dist/MyApp.app.zip",
APIKey: &notarize.APIKey{KeyPath: "/tmp/key.p8", KeyID: "K", IssuerID: "I"},
})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "missing entitlement") {
t.Fatalf("expected log content in error, got %v", err)
}
}
func TestSubmit_NoCredentialsErrors(t *testing.T) {
err := notarize.Submit(context.Background(), &runner.Fake{}, notarize.Opts{ZipPath: "/x"})
if err == nil {
t.Fatal("expected error")
}
}
func TestSubmit_ToolFailureWraps(t *testing.T) {
f := &runner.Fake{}
f.On("xcrun", nil).Return(nil, errors.New("network down"))
err := notarize.Submit(context.Background(), f, notarize.Opts{
ZipPath: "/x",
APIKey: &notarize.APIKey{KeyPath: "/k", KeyID: "K", IssuerID: "I"},
})
if err == nil {
t.Fatal("expected error")
}
}
func TestStaple_BuildsCorrectArgs(t *testing.T) {
f := &runner.Fake{}
f.On("xcrun", nil).Return(nil, nil)
err := notarize.Staple(context.Background(), f, "/build/MyApp.app")
if err != nil {
t.Fatalf("unexpected: %v", err)
}
want := []string{"stapler", "staple", "/build/MyApp.app"}
if !reflect.DeepEqual(f.Calls[0].Args, want) {
t.Fatalf("args got %v want %v", f.Calls[0].Args, want)
}
}
```
- [ ] **Step 2: Run, verify failure**
Run: `go test ./internal/notarize/...`
Expected: undefined types and functions.
- [ ] **Step 3: Implement**
`internal/notarize/notarize.go`:
```go
package notarize
import (
"context"
"encoding/json"
"fmt"
"lmika.dev/actions/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"
"lmika.dev/actions/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"
"lmika.dev/actions/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"
"lmika.dev/actions/wails-release/internal/actions"
"lmika.dev/actions/wails-release/internal/archive"
"lmika.dev/actions/wails-release/internal/cleanup"
"lmika.dev/actions/wails-release/internal/codesign"
"lmika.dev/actions/wails-release/internal/config"
"lmika.dev/actions/wails-release/internal/notarize"
"lmika.dev/actions/wails-release/internal/runner"
"lmika.dev/actions/wails-release/internal/upload"
"lmika.dev/actions/wails-release/internal/version"
"lmika.dev/actions/wails-release/internal/wails"
)
func main() {
if runtime.GOOS != "darwin" {
fmt.Fprintln(os.Stderr, "wails-release: only macOS runners are supported")
os.Exit(1)
}
if err := run(context.Background()); err != nil {
fmt.Fprintln(os.Stderr, "wails-release:", err)
os.Exit(1)
}
}
func run(ctx context.Context) error {
cfg := config.Load(os.Getenv)
if err := cfg.Validate(); err != nil {
return err
}
maskSecrets(cfg)
r := runner.Real{}
var cs cleanup.Stack
defer func() {
for _, e := range cs.Run() {
fmt.Fprintln(os.Stderr, "cleanup:", e)
}
}()
// 1. Resolve version + app name
resolvedVersion := version.Resolve(cfg.Version, os.Getenv("GITHUB_REF"), os.Getenv("GITHUB_SHA"))
project, err := wails.ReadProject(cfg.WorkingDirectory)
if err != nil {
return err
}
appName := cfg.AppName
if appName == "" {
appName = project.Name
}
// 2. Wails CLI
wailsVersion := cfg.WailsVersion
if wailsVersion == "" {
wailsVersion, err = wails.ProjectWailsVersion(cfg.WorkingDirectory)
if err != nil {
return err
}
}
if err := wails.EnsureCLI(ctx, r, wailsVersion); err != nil {
return err
}
// 3. Build
if err := wails.Build(ctx, r, wails.BuildOpts{
Dir: cfg.WorkingDirectory,
ExtraFlags: cfg.ExtraBuildFlags,
}); err != nil {
return err
}
appPath := filepath.Join(cfg.WorkingDirectory, "build", "bin", appName+".app")
// 4. Codesign — temp keychain + import + sign
tmpDir, err := os.MkdirTemp("", "wails-release-")
if err != nil {
return fmt.Errorf("mktemp: %w", err)
}
cs.Add(func() error { return os.RemoveAll(tmpDir) })
keychainPath := filepath.Join(tmpDir, "build.keychain-db")
keychainPW := randomHex(16)
kc, err := codesign.CreateKeychain(ctx, r, keychainPath, keychainPW)
if err != nil {
return err
}
cs.Add(func() error { return codesign.DeleteKeychain(ctx, r, *kc) })
p12Path := filepath.Join(tmpDir, "cert.p12")
if err := writeBase64(p12Path, cfg.DeveloperIDCertBase64); err != nil {
return err
}
if err := codesign.ImportP12(ctx, r, *kc, p12Path, cfg.DeveloperIDCertPassword); err != nil {
return err
}
identity, err := codesignIdentityFromP12(ctx, r, *kc)
if err != nil {
return err
}
if err := codesign.Sign(ctx, r, codesign.SignOpts{
AppPath: appPath, Identity: identity, KeychainPath: kc.Path,
}); err != nil {
return err
}
if err := codesign.Verify(ctx, r, appPath); err != nil {
return err
}
// 5. Archive (pre-staple zip for notary submission)
distDir := filepath.Join(cfg.WorkingDirectory, "build", "bin")
artifactName := fmt.Sprintf("%s-%s.app.zip", appName, resolvedVersion)
zipPath := filepath.Join(distDir, artifactName)
if err := archive.ZipApp(ctx, r, appPath, zipPath); err != nil {
return err
}
// 6. Notarize + staple + re-archive
notarOpts := notarize.Opts{ZipPath: zipPath}
switch cfg.ResolvedNotarizationMethod() {
case "api-key":
p8Path := filepath.Join(tmpDir, "key.p8")
if err := writeBase64(p8Path, cfg.NotarizationAPIKeyBase64); err != nil {
return err
}
notarOpts.APIKey = &notarize.APIKey{
KeyPath: p8Path,
KeyID: cfg.NotarizationAPIKeyID,
IssuerID: cfg.NotarizationAPIIssuerID,
}
case "apple-id":
notarOpts.AppleID = &notarize.AppleID{
Username: cfg.NotarizationAppleID,
Password: cfg.NotarizationApplePassword,
TeamID: cfg.NotarizationTeamID,
}
}
if err := notarize.Submit(ctx, r, notarOpts); err != nil {
return err
}
if err := notarize.Staple(ctx, r, appPath); err != nil {
return err
}
if err := archive.ZipApp(ctx, r, appPath, zipPath); err != nil {
return err
}
// 7. Upload (optional)
s3URL := ""
if cfg.S3Bucket != "" {
client, err := upload.NewClient(ctx, cfg.S3Region, cfg.S3EndpointURL)
if err != nil {
return err
}
key := upload.RenderKey(cfg.S3Key, resolvedVersion, artifactName)
s3URL, err = upload.Upload(ctx, client, upload.Opts{
Bucket: cfg.S3Bucket, Key: key, FilePath: zipPath,
})
if err != nil {
return err
}
}
// 8. Outputs
outFile := os.Getenv("GITHUB_OUTPUT")
for k, v := range map[string]string{
"version": resolvedVersion,
"app-name": appName,
"artifact-path": zipPath,
"artifact-filename": artifactName,
"s3-url": s3URL,
} {
if err := actions.SetOutput(outFile, k, v); err != nil {
return err
}
}
return nil
}
func maskSecrets(c *config.Config) {
for _, v := range []string{
c.DeveloperIDCertPassword,
c.NotarizationAPIKeyBase64,
c.NotarizationApplePassword,
} {
actions.AddMask(os.Stdout, v)
}
}
func writeBase64(path, encoded string) error {
raw, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return fmt.Errorf("decode base64 → %s: %w", path, err)
}
return os.WriteFile(path, raw, 0o600)
}
func randomHex(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
// extremely unlikely; fall back to a less-strong but functional value
return "fallback-keychain-pw"
}
return hex.EncodeToString(b)
}
// codesignIdentityFromP12 finds the imported "Developer ID Application: …"
// identity in the keychain so the caller does not have to thread it through
// inputs.
func codesignIdentityFromP12(ctx context.Context, r runner.Runner, kc codesign.Keychain) (string, error) {
out, err := r.Run(ctx, runner.Spec{
Name: "security",
Args: []string{"find-identity", "-p", "codesigning", "-v", kc.Path},
})
if err != nil {
return "", fmt.Errorf("find-identity: %w", err)
}
return parseDeveloperIDIdentity(out)
}
```
- [ ] **Step 2: Add the parser helper for `find-identity` output**
Add to the same file:
```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:<original>` 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/<name>.log`, (b) when it's expected to write build output, fabricate it, (c) print whatever the orchestrator parses.
`cmd/wails-release/testdata/bin/wails`:
```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 `<AppName>-<version>.app.zip` and exposes its local path and (if uploaded) its `s3://` URL as outputs.
## Requirements
- A self-hosted **macOS** runner.
- `go` on `PATH` (Wails requires it).
- Network access to Apple's notary service and (if uploading) your S3 endpoint.
## Quick start
```yaml
name: Release
on:
push:
tags: ['v*']
jobs:
release:
runs-on: macos
steps:
- uses: actions/checkout@v4
- uses: leonmika/wails-release@v1
with:
developer-id-cert-base64: ${{ secrets.MAC_DEVELOPER_ID_CERT_BASE64 }}
developer-id-cert-password: ${{ secrets.MAC_DEVELOPER_ID_CERT_PASSWORD }}
notarization-api-key-base64: ${{ secrets.AC_API_KEY_BASE64 }}
notarization-api-key-id: ${{ secrets.AC_API_KEY_ID }}
notarization-api-issuer-id: ${{ secrets.AC_API_ISSUER_ID }}
s3-bucket: my-releases
s3-key: myapp/{version}/{filename}
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
```
## Inputs
| Name | Required | Default | Description |
|---|---|---|---|
| `working-directory` | no | `.` | Directory containing `wails.json` |
| `app-name` | no | `wails.json` `name` | Override for the app name in artifact filenames |
| `version` | no | derived | Override; otherwise: matching semver tag → strip `v`, else 7-char short SHA |
| `wails-version` | no | from `go.mod` | Override the Wails CLI version |
| `extra-build-flags` | no | `""` | Additional flags appended to `wails build` (shell-quoted) |
| `developer-id-cert-base64` | **yes** | — | Base64-encoded Developer ID `.p12` |
| `developer-id-cert-password` | **yes** | — | `.p12` password |
| `notarization-method` | no | `auto` | `api-key`, `apple-id`, or `auto` (auto picks whichever group is fully populated) |
| `notarization-api-key-base64` | conditional | — | Base64-encoded App Store Connect `.p8` |
| `notarization-api-key-id` | conditional | — | API key ID |
| `notarization-api-issuer-id` | conditional | — | Issuer ID |
| `notarization-apple-id` | conditional | — | Apple ID (email) |
| `notarization-apple-password` | conditional | — | App-specific password |
| `notarization-team-id` | conditional | — | Developer Team ID |
| `s3-bucket` | no | — | Bucket name. If unset, upload is skipped. |
| `s3-key` | conditional | — | Object key. Required if `s3-bucket` is set. Supports `{version}` and `{filename}` placeholders. |
| `s3-endpoint-url` | no | — | Custom endpoint for S3-compatible storage (MinIO, R2, etc.) |
| `s3-region` | no | `us-east-1` | AWS region |
AWS credentials (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, optionally `AWS_SESSION_TOKEN`) are read from the standard environment, **not** from action inputs.
## Outputs
| Name | Description |
|---|---|
| `version` | Resolved version string |
| `app-name` | Resolved app name |
| `artifact-path` | Local absolute path to the `.app.zip` |
| `artifact-filename` | Just the filename (e.g. `MyApp-1.2.3.app.zip`) |
| `s3-url` | `s3://bucket/key/...` if uploaded, else empty |
## Versioning rule
- A git ref of the form `refs/tags/vX.Y.Z` (no pre-release suffix) → version becomes `X.Y.Z`.
- Anything else → 7-character short SHA from `HEAD`.
- Override via the `version` input.
## Notarization credentials
You can use **either**:
- **App Store Connect API key** (recommended). Generate one in App Store Connect → Users and Access → Keys. You need the `.p8` file, the Key ID, and the Issuer ID.
- **Apple ID + app-specific password + team ID**. Generate the app-specific password at [appleid.apple.com](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://<account>.r2.cloudflarestorage.com` and `s3-region` to `auto`.
## How it works
1. Resolve config from `INPUT_*` env, validate, and mask secrets in logs.
2. Resolve version (tag → strip `v`, else short SHA) and app name (from `wails.json`).
3. Ensure the Wails CLI matches the version pinned in your project's `go.mod` (or the `wails-version` override).
4. Run `wails build -platform darwin/universal -clean -trimpath` plus your `extra-build-flags`.
5. Create a temporary keychain, import the `.p12`, and codesign the `.app` with the hardened runtime and a secure timestamp. Verify the signature.
6. `ditto` the `.app` into a zip for notary submission.
7. `xcrun notarytool submit --wait` (API key or Apple ID, whichever was supplied). On rejection, fetch the per-submission log and embed it in the error.
8. `xcrun stapler staple` the bundle and re-zip so the on-disk artifact is offline-verifiable.
9. Optionally upload via the AWS SDK (custom endpoint supported).
10. Always run cleanup: delete the temp keychain and remove decoded `.p12` / `.p8` files.
## Local development
```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.