18-task TDD plan covering scaffolding, runner abstraction, version / config / wails / archive / cleanup / codesign / notarize / upload / actions packages, the orchestrator, an end-to-end integration test with fake binaries, and the action.yml + README finalization. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3136 lines
84 KiB
Markdown
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 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:<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.
|