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

17 KiB
Raw Permalink Blame History

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/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 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
      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:

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.31.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.