wails-release/docs/superpowers/specs/2026-05-02-wails-release-action-design.md
Leon Mika 8b812e72a5 Tighten Wails release design after self-review
- Fold action.yml into a single composite step so the binary's
  resolved path is computed in the same shell that just installed it.
- Specify shell-style splitting for extra-build-flags.
- Spell out auto-detection edge cases for notarization-method.

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

390 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Wails Release Action — Design
**Date:** 2026-05-02
**Status:** Approved (pending implementation)
## Goal
A reusable Forgejo Action that, on a self-hosted macOS runner, builds a Wails
project, signs and notarizes it for distribution outside the Mac App Store,
and optionally uploads the resulting artifact to an S3-compatible bucket.
The action is consumed from other repositories via:
```yaml
- uses: leonmika/wails-release@v1
with:
# …inputs…
```
## Scope
**In scope**
- macOS only (`darwin/universal` artifact).
- Output: notarized, stapled `.app.zip` (a `ditto`-archived `.app` bundle).
- Build via Wails' standard build pipeline (`wails build`).
- Code signing using a Developer ID certificate supplied as a base64-encoded
`.p12` plus password — imported into a temporary keychain at runtime.
- Notarization via Apple's `notarytool`, supporting both:
- **App Store Connect API key** (`.p8` + key ID + issuer ID), and
- **Apple ID + app-specific password + team ID**.
- Optional upload to AWS S3 *or* an S3-compatible endpoint (MinIO, R2, B2,
Garage, etc.).
- No assumed keychain access. All signing material is supplied via env vars
(action inputs) and removed at the end of the run.
- Configurable via inputs from any caller workflow.
**Out of scope**
- Windows code signing.
- Mac App Store distribution (`.pkg` for App Store).
- DMG creation. (Primary output is `.app.zip`; can be added later if needed.)
- Linux runners. The binary hard-exits on `runtime.GOOS != "darwin"`.
- Creating Forgejo releases or attaching artifacts to releases. The workflow
can layer that on top using whatever release-management action it likes.
- Multi-architecture matrix output. We ship one universal binary.
- Long-term artifact retention or pruning of S3 objects.
## Architecture
The action is a **composite action that `go install`s a Go binary and runs
it.** The composite shell stays mechanical; the Go binary owns all
orchestration, error handling, and cleanup.
### Repository layout
```
wails-release/
├── action.yml # composite action, two steps
├── cmd/wails-release/main.go # binary entry point
├── internal/
│ ├── config/ # parse INPUT_* env → typed Config; validate
│ ├── version/ # tag → strip-v / fall back to short SHA
│ ├── wails/ # detect/install Wails CLI; invoke `wails build`
│ ├── codesign/ # temp keychain create, P12 import, codesign, teardown
│ ├── notarize/ # notarytool submit --wait; api-key + apple-id paths
│ ├── archive/ # ditto-based .app → .app.zip
│ └── upload/ # S3 (SDK Go v2), endpoint-url, key templating
├── docs/superpowers/specs/
│ └── 2026-05-02-wails-release-action-design.md
├── go.mod
└── README.md
```
### action.yml (composite)
```yaml
name: Wails Release
description: Build, sign, notarize and (optionally) upload a Wails macOS app
inputs:
working-directory: { required: false, default: "." }
app-name: { required: false }
version: { required: false }
wails-version: { required: false }
extra-build-flags: { required: false }
developer-id-cert-base64: { required: true }
developer-id-cert-password: { required: true }
notarization-method: { required: false } # api-key | apple-id | auto
notarization-api-key-base64: { required: false }
notarization-api-key-id: { required: false }
notarization-api-issuer-id: { required: false }
notarization-apple-id: { required: false }
notarization-apple-password: { required: false }
notarization-team-id: { required: false }
s3-bucket: { required: false }
s3-key: { required: false }
s3-endpoint-url: { required: false }
s3-region: { required: false, default: "us-east-1" }
outputs:
version: { description: "Resolved version string" }
app-name: { description: "Resolved app name" }
artifact-path: { description: "Local absolute path to the .app.zip" }
artifact-filename: { description: "Filename of the .app.zip" }
s3-url: { description: "s3://… URL if uploaded, else empty" }
runs:
using: composite
steps:
- 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: |
go install "${{ github.action_repository }}/cmd/wails-release@${{ github.action_ref }}"
"$(go env GOPATH)/bin/wails-release"
```
The install and the binary invocation are kept in a single step so we
don't depend on `$GOPATH/bin` being on `PATH` between steps. The binary
is invoked by absolute path resolved via `go env GOPATH`.
### Boundaries
Each `internal/*` package owns one phase, exposes a small interface, and
can be unit-tested without invoking real `codesign` / `notarytool` / S3.
External commands are reached via a `Runner` interface so tests assert
constructed `*exec.Cmd` arguments and inject fakes for higher-level
orchestration tests.
## Inputs
| Name | Required | Default | Notes |
|---|---|---|---|
| `working-directory` | no | `.` | Where `wails.json` lives |
| `app-name` | no | from `wails.json` `name` | Override for filename |
| `version` | no | derived | Override; otherwise tag-then-SHA logic |
| `wails-version` | no | from project's `go.mod` | Override |
| `extra-build-flags` | no | `""` | Pass-through to `wails build` |
| `developer-id-cert-base64` | **yes** | — | base64-encoded `.p12` |
| `developer-id-cert-password` | **yes** | — | `.p12` password |
| `notarization-method` | no | `auto` | `api-key`, `apple-id`, or auto-detect |
| `notarization-api-key-base64` | conditional | — | `.p8` contents, base64 |
| `notarization-api-key-id` | conditional | — | |
| `notarization-api-issuer-id` | conditional | — | |
| `notarization-apple-id` | conditional | — | |
| `notarization-apple-password` | conditional | — | App-specific password |
| `notarization-team-id` | conditional | — | |
| `s3-bucket` | no | — | If unset → upload skipped |
| `s3-key` | conditional | — | Required if `s3-bucket` set; supports `{version}`, `{filename}` |
| `s3-endpoint-url` | no | — | For S3-compatible storage |
| `s3-region` | no | `us-east-1` | |
AWS credentials (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, optional
`AWS_SESSION_TOKEN`) are read from the standard env, **not** action inputs.
This matches the conventional split and lets the SDK's credential chain
work as designed.
### Validation rules (config package, before any side effects)
- Both cert inputs must be present.
- Notarization:
- If `notarization-method` is `api-key` or `apple-id`, the named
group's fields must all be present; missing fields → error listing
them by name.
- If `notarization-method` is empty or `auto`, exactly one group must
be **fully** populated. Both populated → ambiguity error. Neither
populated → missing-credentials error.
- If `s3-bucket` is set, `s3-key` must also be set.
- Resolve `version` early so it is available for `s3-key` substitution and
the artifact filename.
### `extra-build-flags` parsing
The value is split with shell-style word rules (Go's
`github.com/google/shlex` or equivalent) so the workflow can write
quoted arguments naturally:
```yaml
extra-build-flags: -tags release -ldflags "-X main.commit=$SHA"
```
The split tokens are appended to the `wails build` argv after the
action's mandatory flags (`-platform darwin/universal -clean -trimpath`).
## Outputs
| Name | Description |
|---|---|
| `version` | Resolved version string (e.g. `1.2.3` or `a1b2c3d`) |
| `app-name` | Resolved app name |
| `artifact-path` | Absolute path to the `.app.zip` on the runner |
| `artifact-filename` | Just the filename, e.g. `MyApp-1.2.3.app.zip` |
| `s3-url` | `s3://bucket/key/...` if uploaded, else empty |
Outputs are written to `$GITHUB_OUTPUT` from the binary using the
`name=value` format.
## Version derivation
1. If `INPUT_VERSION` is non-empty → use verbatim.
2. Else if the workflow's git ref is a tag matching `^v(\d+\.\d+\.\d+)$`
use the captured group (strip the `v`).
3. Else → 7-character short SHA from `HEAD`.
Pre-release tags like `v1.2.3-rc1` do **not** match the regex and fall
through to short SHA. If that becomes a problem we can extend the regex
later — the rule is deliberately conservative for now.
## Wails CLI resolution
1. If `INPUT_WAILS_VERSION` is non-empty → use that.
2. Else read `github.com/wailsapp/wails/v2 vX.Y.Z` from the project's
`go.mod` and use that.
3. Check whether the `wails` binary on `PATH` already matches; if yes, skip.
4. Else: `go install github.com/wailsapp/wails/v2/cmd/wails@<version>`.
This avoids CLI / runtime drift, which has caused subtle issues in past
Wails releases.
## Runtime data flow
```
1. Load config INPUT_* env → typed Config (validated)
Resolve version (input → tag → short SHA)
Resolve app name (input → wails.json)
2. Ensure Wails CLI `wails -v` matches required version? skip
else `go install …/cmd/wails@<ver>`
3. Build cd <working-directory>
wails build -platform darwin/universal -clean -trimpath <extra>
→ build/bin/<AppName>.app
4. Codesign Create temp keychain (random name, random password)
Import P12 (decoded from base64) into keychain
codesign --deep --force --options runtime --timestamp
--sign "Developer ID Application: …" <App>.app
Verify: codesign --verify --deep --strict <App>.app
5. Archive ditto -c -k --keepParent <App>.app <AppName>-<version>.app.zip
6. Notarize notarytool submit --wait
(api-key path: --key <p8> --key-id … --issuer …)
(apple-id path: --apple-id … --password … --team-id …)
On accepted: stapler staple <App>.app
Re-archive (ditto) so the zip contains the stapled bundle
7. Upload (optional) If s3-bucket set:
Substitute {version}/{filename} in s3-key
PutObject via S3 SDK (BaseEndpoint if endpoint-url set)
8. Emit outputs Write resolved values to $GITHUB_OUTPUT
9. Cleanup (always) Delete temp keychain
Wipe decoded .p12 / .p8 files from disk
(deferred from creation; runs even on failure)
```
### Subtleties worth flagging
- **Stapling requires a re-archive.** `stapler staple` modifies the `.app`
in place; the zip from step 5 was used only to ship to Apple. After
stapling we re-zip so the artifact end-users download is fully
offline-verifiable.
- **Notarization wait time is unbounded by the action.**
`notarytool submit --wait` blocks until Apple returns; typically 110
min, occasionally longer. The runner-side timeout governs this — we
do not impose our own.
### Cleanup guarantees
Every resource (temp keychain, decoded credential files) is registered
with a deferred-cleanup stack at creation time. Even a panic in the
middle of notarization removes the keychain. Cleanup errors are logged
but never override the original error.
## Error handling
**Principle:** fail fast at the earliest phase that can detect the
problem. Surface a clear actionable message; never leak secrets to logs.
- **Validation errors (phase 1):** the config package rejects the run
before any side effects with messages like:
```
notarization-method=api-key requires notarization-api-key-base64,
notarization-api-key-id, notarization-api-issuer-id (missing: api-key-id)
```
- **Phase failures:** each phase wraps lower-level errors with phase
context (`fmt.Errorf("notarize: %w", err)`). The CLI exits non-zero
with the wrapped chain. No retries; the whole pipeline reruns on
workflow retry.
- **Secret hygiene:** the binary uses Forgejo Actions' `::add-mask::`
directive at startup for every credential value (cert password, P12
prefix, P8 key, Apple password) so accidental log output redacts.
Decoded `.p12` / `.p8` files are written to a private subdir of
`os.TempDir()` with `0600` perms and unconditionally deleted at exit.
- **Notarization rejection:** if `notarytool` returns `Invalid` or
`Rejected`, the binary fetches the log via `notarytool log <id>` and
prints it before exiting non-zero. This is the single most common
end-user pain point with Apple's pipeline.
- **Cleanup ordering:** registered at creation time, run in reverse on
exit. Cleanup errors are logged at warn level. The temp keychain
teardown is `security delete-keychain <path>` plus removing it from
the search list.
## Testing
### Unit tests
- `config` — table-driven tests of every validation rule (missing cert,
mixed notarization groups, malformed `s3-key`, etc.). Pure-function,
no I/O. The most important package to cover thoroughly because
misconfiguration is the dominant failure mode.
- `version` — table-driven: `v1.2.3``1.2.3`, `v1.2.3-rc1` → fall back
to SHA, no tag → SHA, malformed tag → SHA.
- `wails` — version comparison logic (is installed CLI ≥ required?). The
`go install` invocation is behind an interface so tests use a fake.
- `codesign`, `notarize`, `archive` — each shells out, so tests target
command construction (`Runner` interface produces `*exec.Cmd`; tests
assert flags/args). Higher-level orchestration tests inject fake
Runners. We do not run real `codesign`/`notarytool` in unit tests.
- `upload` — uses S3 SDK with an interface; tests use a fake satisfying
`S3PutObjectAPI`. Verifies key templating, endpoint handling.
### Integration test
- One end-to-end test that runs the binary against a fixture project
with stubs for every external command (PATH manipulation: a
`testdata/bin/` containing fake `wails`, `codesign`, `notarytool`,
`ditto`, `stapler` shell scripts that record their args and return
success). Verifies the full pipeline orchestration including cleanup
order. No real Apple infrastructure.
### Manual smoke test
- `testdata/sample-app/` — minimal Wails project. Documented in the
README as: "to verify a real signing path before tagging a release of
this action, run the binary locally against this fixture with your
real credentials." Deliberately manual; no good way to make it
hermetic.
### Out of scope
- We do not test on Linux/Windows. The binary hard-exits if
`runtime.GOOS != "darwin"` with a clear error.
## Documentation
The README must comprehensively document:
- All inputs (table form, with required/default/notes columns).
- All outputs.
- Required env vars (AWS credentials).
- Notarization credential setup (both API key and Apple ID paths) with
pointers to Apple's documentation for obtaining each.
- A worked example workflow showing a tag-triggered release that builds,
signs, notarizes, and uploads to S3.
- A second worked example for S3-compatible storage (MinIO/R2) showing
the `s3-endpoint-url` input.
- Notes on runner requirements (macOS, Go installed, network access to
Apple notarization endpoints + S3).
## Open questions
None at design time. Implementation will surface details about Wails
build output paths and exact `codesign` flag combinations.
## Future extensions (deliberately deferred)
- DMG output alongside `.app.zip`.
- Sparkle appcast generation.
- Windows code signing.
- Forgejo release attachment.
- Build matrix support for multiple Wails projects in a single repo.