- 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>
390 lines
17 KiB
Markdown
390 lines
17 KiB
Markdown
# 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 1–10
|
||
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.
|