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) <noreply@anthropic.com>
16 KiB
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:
- uses: leonmika/wails-release@v1
with:
# …inputs…
Scope
In scope
- macOS only (
darwin/universalartifact). - Output: notarized, stapled
.app.zip(aditto-archived.appbundle). - Build via Wails' standard build pipeline (
wails build). - Code signing using a Developer ID certificate supplied as a base64-encoded
.p12plus 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.
- App Store Connect API key (
- 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 (
.pkgfor 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 installs 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)
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-bucketis set,s3-keymust also be set. - Resolve
versionearly so it is available fors3-keysubstitution 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
- If
INPUT_VERSIONis non-empty → use verbatim. - Else if the workflow's git ref is a tag matching
^v(\d+\.\d+\.\d+)$→ use the captured group (strip thev). - 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
- If
INPUT_WAILS_VERSIONis non-empty → use that. - Else read
github.com/wailsapp/wails/v2 vX.Y.Zfrom the project'sgo.modand use that. - Check whether the
wailsbinary onPATHalready matches; if yes, skip. - 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 staplemodifies the.appin 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 --waitblocks 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/.p8files are written to a private subdir ofos.TempDir()with0600perms and unconditionally deleted at exit. - Notarization rejection: if
notarytoolreturnsInvalidorRejected, the binary fetches the log vianotarytool 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, malformeds3-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?). Thego installinvocation is behind an interface so tests use a fake.codesign,notarize,archive— each shells out, so tests target command construction (Runnerinterface produces*exec.Cmd; tests assert flags/args). Higher-level orchestration tests inject fake Runners. We do not run realcodesign/notarytoolin unit tests.upload— uses S3 SDK with an interface; tests use a fake satisfyingS3PutObjectAPI. 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 fakewails,codesign,notarytool,ditto,staplershell 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-urlinput. - 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.