From 39f5bad966ca5c5b7049fd7a08e60067a9058569 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sat, 2 May 2026 09:24:12 +1000 Subject: [PATCH] Add design doc for Wails Release Action Captures the validated brainstorming output: composite Forgejo Action that go installs and runs a Go binary to build, sign, notarize, and optionally upload a Wails macOS app to S3-compatible storage. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-02-wails-release-action-design.md | 368 ++++++++++++++++++ 1 file changed, 368 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-02-wails-release-action-design.md diff --git a/docs/superpowers/specs/2026-05-02-wails-release-action-design.md b/docs/superpowers/specs/2026-05-02-wails-release-action-design.md new file mode 100644 index 0000000..2d955e1 --- /dev/null +++ b/docs/superpowers/specs/2026-05-02-wails-release-action-design.md @@ -0,0 +1,368 @@ +# 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 + run: | + go install "${{ github.action_repository }}/cmd/wails-release@${{ github.action_ref }}" + - 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: wails-release +``` + +### 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: exactly one credential group must be complete; mixed or + partial groups → fail fast with a message naming the missing fields. +- 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. + +## 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@`. + +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@` + +3. Build cd + wails build -platform darwin/universal -clean -trimpath + → build/bin/.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 + Verify: codesign --verify --deep --strict .app + +5. Archive ditto -c -k --keepParent .app -.app.zip + +6. Notarize notarytool submit --wait + (api-key path: --key --key-id … --issuer …) + (apple-id path: --apple-id … --password … --team-id …) + On accepted: stapler staple .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 ` 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 ` 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.