From 22b883e75829be617e82bba0d94dcd4cb1b506ae Mon Sep 17 00:00:00 2001
From: Vuong <3168632+vuon9@users.noreply.github.com>
Date: Wed, 17 Jun 2026 01:40:23 +0700
Subject: [PATCH 1/6] Ignore local worktrees
---
.gitignore | 1 +
1 file changed, 1 insertion(+)
diff --git a/.gitignore b/.gitignore
index 27c031b..acff26b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,7 @@
build/
dist/
tmp/
+.worktrees/
/ios-testflight
*.xcarchive
*.ipa
From bd8ea16c4b84c2d35f6d6920bfcd6c16a78b2ab2 Mon Sep 17 00:00:00 2001
From: Vuong <3168632+vuon9@users.noreply.github.com>
Date: Wed, 17 Jun 2026 01:48:12 +0700
Subject: [PATCH 2/6] Refactor Wails macOS release workflow
---
.github/workflows/wails-macos-release.yml | 263 ++++++++++------------
internal/archive/archive.go | 147 ++++++++++++
internal/archive/archive_test.go | 54 +++++
mac/cmd/macos-release/main.go | 114 ++++++++++
mac/release/release.go | 250 ++++++++++++++++++++
mac/release/release_test.go | 110 +++++++++
wails/cmd/wails-build/main.go | 33 +++
7 files changed, 823 insertions(+), 148 deletions(-)
create mode 100644 internal/archive/archive.go
create mode 100644 internal/archive/archive_test.go
create mode 100644 mac/cmd/macos-release/main.go
create mode 100644 mac/release/release.go
create mode 100644 mac/release/release_test.go
create mode 100644 wails/cmd/wails-build/main.go
diff --git a/.github/workflows/wails-macos-release.yml b/.github/workflows/wails-macos-release.yml
index a5ea698..20b33e3 100644
--- a/.github/workflows/wails-macos-release.yml
+++ b/.github/workflows/wails-macos-release.yml
@@ -93,22 +93,31 @@ on:
required: false
jobs:
- release:
- name: Build, Sign, and Notarize macOS App
+ build:
+ name: Build Wails App
runs-on: ${{ inputs.runner-label }}
- timeout-minutes: 60
+ timeout-minutes: 45
permissions:
- contents: write
+ contents: read
steps:
- name: Check out caller repository
uses: actions/checkout@v6
+ with:
+ path: app
+
+ - name: Check out workflow tools
+ uses: actions/checkout@v6
+ with:
+ repository: ${{ job.workflow_repository }}
+ ref: ${{ job.workflow_sha }}
+ path: gh-workflows
- name: Set up Go from version file
if: ${{ inputs.go-version-file != '' }}
uses: actions/setup-go@v6
with:
- go-version-file: ${{ inputs.working-directory }}/${{ inputs.go-version-file }}
+ go-version-file: app/${{ inputs.working-directory }}/${{ inputs.go-version-file }}
check-latest: true
- name: Set up Go
@@ -123,6 +132,14 @@ jobs:
with:
bun-version: ${{ inputs.bun-version }}
+ - name: Test workflow tools
+ working-directory: gh-workflows
+ run: go test ./...
+
+ - name: Build Wails build CLI
+ working-directory: gh-workflows
+ run: go build -o "$RUNNER_TEMP/wails-build" ./wails/cmd/wails-build
+
- name: Install Task
run: |
go install github.com/go-task/task/v3/cmd/task@${{ inputs.task-version }}
@@ -134,9 +151,74 @@ jobs:
ln -sf "$(go env GOPATH)/bin/wails3" "$(go env GOPATH)/bin/wails"
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
+ - name: Build app bundle
+ run: |
+ "$RUNNER_TEMP/wails-build" \
+ --working-directory "app/${{ inputs.working-directory }}" \
+ --package-command "${{ inputs.package-command }}" \
+ --app-path "${{ inputs.app-path }}" \
+ --app-name "${{ inputs.app-name }}" \
+ --bundle-id "${{ inputs.bundle-id }}" \
+ --archive-path "$RUNNER_TEMP/wails-app.tar.gz"
+
+ - name: Upload app bundle artifact
+ uses: actions/upload-artifact@v7.0.1
+ with:
+ name: ${{ inputs.artifact-name }}-app-bundle
+ path: ${{ runner.temp }}/wails-app.tar.gz
+ retention-days: 1
+
+ sign-package-release:
+ name: Sign, Package, and Notarize macOS App
+ needs: build
+ runs-on: ${{ inputs.runner-label }}
+ timeout-minutes: 60
+ permissions:
+ contents: write
+
+ steps:
+ - name: Check out caller repository
+ uses: actions/checkout@v6
+ with:
+ path: app
+
+ - name: Check out workflow tools
+ uses: actions/checkout@v6
+ with:
+ repository: ${{ job.workflow_repository }}
+ ref: ${{ job.workflow_sha }}
+ path: gh-workflows
+
+ - name: Set up Go
+ uses: actions/setup-go@v6
+ with:
+ go-version-file: gh-workflows/go.mod
+ cache: false
+
+ - name: Test workflow tools
+ working-directory: gh-workflows
+ run: go test ./...
+
+ - name: Build macOS release CLI
+ working-directory: gh-workflows
+ run: go build -o "$RUNNER_TEMP/macos-release" ./mac/cmd/macos-release
+
- name: Install create-dmg
run: brew install create-dmg
+ - name: Download app bundle artifact
+ uses: actions/download-artifact@v7.0.0
+ with:
+ name: ${{ inputs.artifact-name }}-app-bundle
+ path: ${{ runner.temp }}/wails-app
+
+ - name: Extract app bundle
+ working-directory: app/${{ inputs.working-directory }}
+ run: |
+ "$RUNNER_TEMP/macos-release" extract-app \
+ --archive-path "$RUNNER_TEMP/wails-app/wails-app.tar.gz" \
+ --destination .
+
- name: Validate Apple signing secrets
env:
APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_BASE64 }}
@@ -145,190 +227,75 @@ jobs:
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
run: |
- missing=0
- for name in \
- APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_BASE64 \
- APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_PASSWORD \
- APP_STORE_CONNECT_API_KEY_P8 \
- APP_STORE_CONNECT_API_KEY_ID \
- APP_STORE_CONNECT_API_ISSUER_ID
- do
- if [[ -z "${!name}" ]]; then
- echo "::error::$name is required for signed and notarized macOS releases"
- missing=1
- fi
- done
-
- if [[ "$missing" -ne 0 ]]; then
- exit 1
- fi
+ "$RUNNER_TEMP/macos-release" validate-secrets
- name: Install Apple Developer ID certificate
env:
- CERTIFICATE_BASE64: ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_BASE64 }}
- CERTIFICATE_PASSWORD: ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_PASSWORD }}
+ APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_BASE64 }}
+ APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_PASSWORD }}
SIGNING_KEYCHAIN_PASSWORD: ${{ github.run_id }}-${{ github.run_attempt }}
run: |
- certificate_path="$RUNNER_TEMP/developer-id-application.p12"
- keychain_path="$RUNNER_TEMP/developer-id-signing.keychain-db"
-
- security create-keychain -p "$SIGNING_KEYCHAIN_PASSWORD" "$keychain_path"
- security set-keychain-settings -lut 21600 "$keychain_path"
- security unlock-keychain -p "$SIGNING_KEYCHAIN_PASSWORD" "$keychain_path"
-
- printf '%s' "$CERTIFICATE_BASE64" | base64 -D > "$certificate_path"
- security import "$certificate_path" -P "$CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$keychain_path"
- security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$SIGNING_KEYCHAIN_PASSWORD" "$keychain_path"
- security list-keychains -d user -s "$keychain_path" $(security list-keychains -d user | tr -d '"')
+ "$RUNNER_TEMP/macos-release" install-certificate
- name: Write App Store Connect API key
env:
APP_STORE_CONNECT_API_KEY_P8: ${{ secrets.APP_STORE_CONNECT_API_KEY_P8 }}
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
run: |
- private_keys_dir="$RUNNER_TEMP/private_keys"
- mkdir -p "$private_keys_dir"
- umask 077
- printf '%s' "$APP_STORE_CONNECT_API_KEY_P8" > "$private_keys_dir/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8"
- echo "APP_STORE_CONNECT_API_KEY_PATH=$private_keys_dir/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8" >> "$GITHUB_ENV"
-
- - name: Build app bundle
- working-directory: ${{ inputs.working-directory }}
- run: ${{ inputs.package-command }}
-
- - name: Validate app bundle metadata
- working-directory: ${{ inputs.working-directory }}
- env:
- APP_PATH: ${{ inputs.app-path }}
- APP_NAME: ${{ inputs.app-name }}
- BUNDLE_ID: ${{ inputs.bundle-id }}
- run: |
- test -d "$APP_PATH"
- plist="$APP_PATH/Contents/Info.plist"
- test -f "$plist"
-
- actual_name=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleName' "$plist")
- actual_bundle_id=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$plist")
-
- if [[ "$actual_name" != "$APP_NAME" ]]; then
- echo "::error::Expected CFBundleName '$APP_NAME', got '$actual_name'"
- exit 1
- fi
-
- if [[ "$actual_bundle_id" != "$BUNDLE_ID" ]]; then
- echo "::error::Expected CFBundleIdentifier '$BUNDLE_ID', got '$actual_bundle_id'"
- exit 1
- fi
-
- - name: Resolve signing identity
- id: signing
- env:
- MACOS_CODESIGN_IDENTITY: ${{ secrets.MACOS_CODESIGN_IDENTITY }}
- TEAM_ID: ${{ inputs.team-id }}
- run: |
- identity="$MACOS_CODESIGN_IDENTITY"
- if [[ -z "$identity" ]]; then
- identity=$(security find-identity -v -p codesigning | awk -v team="($TEAM_ID)" '/Developer ID Application/ && index($0, team) { sub(/^.*"Developer ID Application: /, "Developer ID Application: "); sub(/"$/, ""); print; exit }')
- fi
-
- if [[ -z "$identity" ]]; then
- echo "::error::Could not find Developer ID Application identity for team $TEAM_ID"
- security find-identity -v -p codesigning
- exit 1
- fi
-
- echo "identity=$identity" >> "$GITHUB_OUTPUT"
+ "$RUNNER_TEMP/macos-release" write-api-key
- name: Sign app bundle
- working-directory: ${{ inputs.working-directory }}
+ working-directory: app/${{ inputs.working-directory }}
env:
- APP_PATH: ${{ inputs.app-path }}
- SIGNING_IDENTITY: ${{ steps.signing.outputs.identity }}
+ MACOS_CODESIGN_IDENTITY: ${{ secrets.MACOS_CODESIGN_IDENTITY }}
run: |
- codesign \
- --force \
- --deep \
- --options runtime \
- --timestamp \
- --sign "$SIGNING_IDENTITY" \
- "$APP_PATH"
-
- codesign --verify --deep --strict --verbose=2 "$APP_PATH"
+ "$RUNNER_TEMP/macos-release" sign-app \
+ --app-path "${{ inputs.app-path }}" \
+ --app-name "${{ inputs.app-name }}" \
+ --team-id "${{ inputs.team-id }}"
- name: Notarize and staple app bundle
- working-directory: ${{ inputs.working-directory }}
+ working-directory: app/${{ inputs.working-directory }}
env:
- APP_PATH: ${{ inputs.app-path }}
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
run: |
- notary_zip="$RUNNER_TEMP/${{ inputs.app-name }}-notary.zip"
- ditto -c -k --keepParent "$APP_PATH" "$notary_zip"
-
- xcrun notarytool submit "$notary_zip" \
- --key "$APP_STORE_CONNECT_API_KEY_PATH" \
- --key-id "$APP_STORE_CONNECT_API_KEY_ID" \
- --issuer "$APP_STORE_CONNECT_API_ISSUER_ID" \
- --wait
-
- xcrun stapler staple "$APP_PATH"
- xcrun stapler validate "$APP_PATH"
- spctl --assess --type execute --verbose=4 "$APP_PATH"
+ "$RUNNER_TEMP/macos-release" notarize-app \
+ --app-path "${{ inputs.app-path }}" \
+ --app-name "${{ inputs.app-name }}" \
+ --team-id "${{ inputs.team-id }}"
- name: Create signed DMG
- working-directory: ${{ inputs.working-directory }}
+ working-directory: app/${{ inputs.working-directory }}
env:
- APP_PATH: ${{ inputs.app-path }}
- APP_NAME: ${{ inputs.app-name }}
- DMG_NAME: ${{ inputs.dmg-name }}
- SIGNING_IDENTITY: ${{ steps.signing.outputs.identity }}
+ MACOS_CODESIGN_IDENTITY: ${{ secrets.MACOS_CODESIGN_IDENTITY }}
run: |
- mkdir -p release
- dmg_path="release/$DMG_NAME"
-
- create-dmg \
- --volname "$APP_NAME" \
- --window-pos 200 120 \
- --window-size 800 400 \
- --icon-size 100 \
- --app-drop-link 600 185 \
- "$dmg_path" \
- "$APP_PATH"
-
- codesign --force --timestamp --sign "$SIGNING_IDENTITY" "$dmg_path"
- codesign --verify --verbose=2 "$dmg_path"
- hdiutil verify "$dmg_path"
+ "$RUNNER_TEMP/macos-release" create-dmg \
+ --app-path "${{ inputs.app-path }}" \
+ --app-name "${{ inputs.app-name }}" \
+ --dmg-name "${{ inputs.dmg-name }}" \
+ --team-id "${{ inputs.team-id }}"
- name: Notarize and staple DMG
- working-directory: ${{ inputs.working-directory }}
+ working-directory: app/${{ inputs.working-directory }}
env:
- DMG_NAME: ${{ inputs.dmg-name }}
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
run: |
- dmg_path="release/$DMG_NAME"
-
- xcrun notarytool submit "$dmg_path" \
- --key "$APP_STORE_CONNECT_API_KEY_PATH" \
- --key-id "$APP_STORE_CONNECT_API_KEY_ID" \
- --issuer "$APP_STORE_CONNECT_API_ISSUER_ID" \
- --wait
-
- xcrun stapler staple "$dmg_path"
- xcrun stapler validate "$dmg_path"
- spctl --assess --type open --context context:primary-signature --verbose=4 "$dmg_path"
+ "$RUNNER_TEMP/macos-release" notarize-dmg \
+ --dmg-name "${{ inputs.dmg-name }}"
- name: Upload signed release artifact
uses: actions/upload-artifact@v7.0.1
with:
name: ${{ inputs.artifact-name }}
- path: ${{ inputs.working-directory }}/release/${{ inputs.dmg-name }}
+ path: app/${{ inputs.working-directory }}/release/${{ inputs.dmg-name }}
retention-days: 90
- name: Upload GitHub release asset
if: ${{ inputs.upload-github-release && startsWith(github.ref, 'refs/tags/') }}
uses: softprops/action-gh-release@v3.0.0
with:
- files: ${{ inputs.working-directory }}/release/${{ inputs.dmg-name }}
+ files: app/${{ inputs.working-directory }}/release/${{ inputs.dmg-name }}
prerelease: ${{ inputs.github-release-prerelease }}
generate_release_notes: true
diff --git a/internal/archive/archive.go b/internal/archive/archive.go
new file mode 100644
index 0000000..8a1fd54
--- /dev/null
+++ b/internal/archive/archive.go
@@ -0,0 +1,147 @@
+package archive
+
+import (
+ "archive/tar"
+ "compress/gzip"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+func CreateTarGz(archivePath, sourcePath string) error {
+ out, err := os.Create(archivePath)
+ if err != nil {
+ return err
+ }
+ defer out.Close()
+
+ gz := gzip.NewWriter(out)
+ defer gz.Close()
+
+ tw := tar.NewWriter(gz)
+ defer tw.Close()
+
+ sourcePath = filepath.Clean(sourcePath)
+ base := filepath.Base(sourcePath)
+ return filepath.WalkDir(sourcePath, func(path string, d fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ info, err := os.Lstat(path)
+ if err != nil {
+ return err
+ }
+ link := ""
+ if info.Mode()&os.ModeSymlink != 0 {
+ link, err = os.Readlink(path)
+ if err != nil {
+ return err
+ }
+ }
+ header, err := tar.FileInfoHeader(info, link)
+ if err != nil {
+ return err
+ }
+ rel, err := filepath.Rel(sourcePath, path)
+ if err != nil {
+ return err
+ }
+ if rel == "." {
+ header.Name = base
+ } else {
+ header.Name = filepath.ToSlash(filepath.Join(base, rel))
+ }
+ if err := tw.WriteHeader(header); err != nil {
+ return err
+ }
+ if info.Mode().IsRegular() {
+ in, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ _, copyErr := io.Copy(tw, in)
+ closeErr := in.Close()
+ if copyErr != nil {
+ return copyErr
+ }
+ if closeErr != nil {
+ return closeErr
+ }
+ }
+ return nil
+ })
+}
+
+func ExtractTarGz(archivePath, destination string) error {
+ in, err := os.Open(archivePath)
+ if err != nil {
+ return err
+ }
+ defer in.Close()
+
+ gz, err := gzip.NewReader(in)
+ if err != nil {
+ return err
+ }
+ defer gz.Close()
+
+ tr := tar.NewReader(gz)
+ for {
+ header, err := tr.Next()
+ if err == io.EOF {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ target, err := safeTarget(destination, header.Name)
+ if err != nil {
+ return err
+ }
+ switch header.Typeflag {
+ case tar.TypeDir:
+ if err := os.MkdirAll(target, fs.FileMode(header.Mode)); err != nil {
+ return err
+ }
+ case tar.TypeReg:
+ if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+ return err
+ }
+ out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, fs.FileMode(header.Mode))
+ if err != nil {
+ return err
+ }
+ _, copyErr := io.Copy(out, tr)
+ closeErr := out.Close()
+ if copyErr != nil {
+ return copyErr
+ }
+ if closeErr != nil {
+ return closeErr
+ }
+ if err := os.Chmod(target, fs.FileMode(header.Mode)); err != nil {
+ return err
+ }
+ case tar.TypeSymlink:
+ if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+ return err
+ }
+ if err := os.Symlink(header.Linkname, target); err != nil {
+ return err
+ }
+ default:
+ return fmt.Errorf("unsupported tar entry %s type %d", header.Name, header.Typeflag)
+ }
+ }
+}
+
+func safeTarget(destination, name string) (string, error) {
+ clean := filepath.Clean(name)
+ if clean == "." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) || filepath.IsAbs(clean) {
+ return "", fmt.Errorf("unsafe archive path %q", name)
+ }
+ return filepath.Join(destination, clean), nil
+}
diff --git a/internal/archive/archive_test.go b/internal/archive/archive_test.go
new file mode 100644
index 0000000..cc42b0f
--- /dev/null
+++ b/internal/archive/archive_test.go
@@ -0,0 +1,54 @@
+package archive
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestCreateAndExtractTarGzPreservesBundleFiles(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ appPath := filepath.Join(dir, "Example.app")
+ contentsPath := filepath.Join(appPath, "Contents", "MacOS")
+ if err := os.MkdirAll(contentsPath, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ executable := filepath.Join(contentsPath, "Example")
+ if err := os.WriteFile(executable, []byte("#!/bin/sh\n"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(executable, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink("MacOS/Example", filepath.Join(appPath, "Contents", "Current")); err != nil {
+ t.Fatal(err)
+ }
+
+ archivePath := filepath.Join(dir, "app.tar.gz")
+ if err := CreateTarGz(archivePath, appPath); err != nil {
+ t.Fatal(err)
+ }
+
+ outDir := filepath.Join(dir, "out")
+ if err := ExtractTarGz(archivePath, outDir); err != nil {
+ t.Fatal(err)
+ }
+
+ extractedExecutable := filepath.Join(outDir, "Example.app", "Contents", "MacOS", "Example")
+ info, err := os.Stat(extractedExecutable)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := info.Mode().Perm(); got != 0o755 {
+ t.Fatalf("executable mode = %v, want 0755", got)
+ }
+ linkTarget, err := os.Readlink(filepath.Join(outDir, "Example.app", "Contents", "Current"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if linkTarget != "MacOS/Example" {
+ t.Fatalf("link target = %q, want MacOS/Example", linkTarget)
+ }
+}
diff --git a/mac/cmd/macos-release/main.go b/mac/cmd/macos-release/main.go
new file mode 100644
index 0000000..7415791
--- /dev/null
+++ b/mac/cmd/macos-release/main.go
@@ -0,0 +1,114 @@
+package main
+
+import (
+ "errors"
+ "flag"
+ "fmt"
+ "os"
+
+ "github.com/vuon9/gh-workflows/mac/release"
+)
+
+func main() {
+ if err := run(os.Args[1:]); err != nil {
+ fmt.Fprintf(os.Stderr, "macos-release: %v\n", err)
+ os.Exit(1)
+ }
+}
+
+func run(args []string) error {
+ if len(args) == 0 {
+ return errors.New("subcommand is required")
+ }
+ runner := release.ExecRunner{}
+ switch args[0] {
+ case "validate-secrets":
+ return release.ValidateSecrets(os.Getenv,
+ "APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_BASE64",
+ "APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_PASSWORD",
+ "APP_STORE_CONNECT_API_KEY_P8",
+ "APP_STORE_CONNECT_API_KEY_ID",
+ "APP_STORE_CONNECT_API_ISSUER_ID",
+ )
+ case "install-certificate":
+ return release.InstallCertificate(runner, release.CertificateConfig{
+ TempDir: env("RUNNER_TEMP"),
+ CertificateBase64: env("APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_BASE64"),
+ CertificatePass: env("APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_PASSWORD"),
+ KeychainPassword: env("SIGNING_KEYCHAIN_PASSWORD"),
+ })
+ case "write-api-key":
+ _, err := release.WriteAPIKey(release.APIKeyConfig{
+ TempDir: env("RUNNER_TEMP"),
+ Key: env("APP_STORE_CONNECT_API_KEY_P8"),
+ KeyID: env("APP_STORE_CONNECT_API_KEY_ID"),
+ EnvPath: env("GITHUB_ENV"),
+ })
+ return err
+ case "extract-app":
+ flags := flag.NewFlagSet("extract-app", flag.ContinueOnError)
+ archivePath := flags.String("archive-path", "", "App bundle .tar.gz archive path.")
+ destination := flags.String("destination", ".", "Extraction destination.")
+ if err := flags.Parse(args[1:]); err != nil {
+ return err
+ }
+ return release.ExtractArchive(*archivePath, *destination)
+ case "sign-app":
+ cfg, err := parseAppConfig(args[1:])
+ if err != nil {
+ return err
+ }
+ return release.SignApp(runner, cfg)
+ case "notarize-app":
+ cfg, err := parseAppConfig(args[1:])
+ if err != nil {
+ return err
+ }
+ return release.NotarizeApp(runner, cfg)
+ case "create-dmg":
+ cfg, err := parseDMGConfig(args[1:])
+ if err != nil {
+ return err
+ }
+ return release.CreateSignedDMG(runner, cfg)
+ case "notarize-dmg":
+ cfg, err := parseDMGConfig(args[1:])
+ if err != nil {
+ return err
+ }
+ return release.NotarizeDMG(runner, cfg)
+ default:
+ return fmt.Errorf("unknown subcommand %q", args[0])
+ }
+}
+
+func parseAppConfig(args []string) (release.AppConfig, error) {
+ flags := flag.NewFlagSet("app", flag.ContinueOnError)
+ var cfg release.AppConfig
+ flags.StringVar(&cfg.AppPath, "app-path", "", "Path to .app bundle.")
+ flags.StringVar(&cfg.AppName, "app-name", "", "App display name.")
+ flags.StringVar(&cfg.TeamID, "team-id", "", "Apple Developer team ID.")
+ flags.StringVar(&cfg.SigningIdentity, "signing-identity", env("MACOS_CODESIGN_IDENTITY"), "Developer ID Application signing identity.")
+ flags.StringVar(&cfg.APIKeyPath, "api-key-path", env("APP_STORE_CONNECT_API_KEY_PATH"), "App Store Connect API key path.")
+ flags.StringVar(&cfg.APIKeyID, "api-key-id", env("APP_STORE_CONNECT_API_KEY_ID"), "App Store Connect API key ID.")
+ flags.StringVar(&cfg.APIIssuerID, "api-issuer-id", env("APP_STORE_CONNECT_API_ISSUER_ID"), "App Store Connect API issuer ID.")
+ return cfg, flags.Parse(args)
+}
+
+func parseDMGConfig(args []string) (release.DMGConfig, error) {
+ flags := flag.NewFlagSet("dmg", flag.ContinueOnError)
+ var cfg release.DMGConfig
+ flags.StringVar(&cfg.AppPath, "app-path", "", "Path to .app bundle.")
+ flags.StringVar(&cfg.AppName, "app-name", "", "App display name.")
+ flags.StringVar(&cfg.DMGName, "dmg-name", "", "DMG file name.")
+ flags.StringVar(&cfg.TeamID, "team-id", "", "Apple Developer team ID.")
+ flags.StringVar(&cfg.SigningIdentity, "signing-identity", env("MACOS_CODESIGN_IDENTITY"), "Developer ID Application signing identity.")
+ flags.StringVar(&cfg.APIKeyPath, "api-key-path", env("APP_STORE_CONNECT_API_KEY_PATH"), "App Store Connect API key path.")
+ flags.StringVar(&cfg.APIKeyID, "api-key-id", env("APP_STORE_CONNECT_API_KEY_ID"), "App Store Connect API key ID.")
+ flags.StringVar(&cfg.APIIssuerID, "api-issuer-id", env("APP_STORE_CONNECT_API_ISSUER_ID"), "App Store Connect API issuer ID.")
+ return cfg, flags.Parse(args)
+}
+
+func env(name string) string {
+ return os.Getenv(name)
+}
diff --git a/mac/release/release.go b/mac/release/release.go
new file mode 100644
index 0000000..d805c13
--- /dev/null
+++ b/mac/release/release.go
@@ -0,0 +1,250 @@
+package release
+
+import (
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+
+ "github.com/vuon9/gh-workflows/internal/archive"
+)
+
+type Command struct {
+ Name string
+ Args []string
+ Dir string
+ Env []string
+}
+
+type Runner interface {
+ Run(Command) error
+ Output(Command) (string, error)
+}
+
+type ExecRunner struct{}
+
+func (ExecRunner) Run(command Command) error {
+ cmd := exec.Command(command.Name, command.Args...)
+ cmd.Dir = command.Dir
+ cmd.Env = append(os.Environ(), command.Env...)
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ cmd.Stdin = os.Stdin
+ return cmd.Run()
+}
+
+func (ExecRunner) Output(command Command) (string, error) {
+ cmd := exec.Command(command.Name, command.Args...)
+ cmd.Dir = command.Dir
+ cmd.Env = append(os.Environ(), command.Env...)
+ output, err := cmd.CombinedOutput()
+ return string(output), err
+}
+
+type CertificateConfig struct {
+ TempDir string
+ CertificateBase64 string
+ CertificatePass string
+ KeychainPassword string
+}
+
+type APIKeyConfig struct {
+ TempDir string
+ Key string
+ KeyID string
+ EnvPath string
+}
+
+type AppConfig struct {
+ AppPath string
+ AppName string
+ TeamID string
+ SigningIdentity string
+ APIKeyPath string
+ APIKeyID string
+ APIIssuerID string
+}
+
+type DMGConfig struct {
+ AppPath string
+ AppName string
+ DMGName string
+ TeamID string
+ SigningIdentity string
+ APIKeyPath string
+ APIKeyID string
+ APIIssuerID string
+}
+
+func ValidateSecrets(getenv func(string) string, names ...string) error {
+ var missing []string
+ for _, name := range names {
+ if getenv(name) == "" {
+ missing = append(missing, name)
+ }
+ }
+ if len(missing) > 0 {
+ return fmt.Errorf("missing required secrets: %s", strings.Join(missing, ", "))
+ }
+ return nil
+}
+
+func InstallCertificate(runner Runner, cfg CertificateConfig) error {
+ if cfg.TempDir == "" || cfg.CertificateBase64 == "" || cfg.CertificatePass == "" || cfg.KeychainPassword == "" {
+ return errors.New("temp dir, certificate, certificate password, and keychain password are required")
+ }
+ cert, err := base64.StdEncoding.DecodeString(cfg.CertificateBase64)
+ if err != nil {
+ return fmt.Errorf("decoding certificate: %w", err)
+ }
+ certPath := filepath.Join(cfg.TempDir, "developer-id-application.p12")
+ keychainPath := filepath.Join(cfg.TempDir, "developer-id-signing.keychain-db")
+ if err := os.WriteFile(certPath, cert, 0o600); err != nil {
+ return err
+ }
+ commands := []Command{
+ {Name: "security", Args: []string{"create-keychain", "-p", cfg.KeychainPassword, keychainPath}},
+ {Name: "security", Args: []string{"set-keychain-settings", "-lut", "21600", keychainPath}},
+ {Name: "security", Args: []string{"unlock-keychain", "-p", cfg.KeychainPassword, keychainPath}},
+ {Name: "security", Args: []string{"import", certPath, "-P", cfg.CertificatePass, "-A", "-t", "cert", "-f", "pkcs12", "-k", keychainPath}},
+ {Name: "security", Args: []string{"set-key-partition-list", "-S", "apple-tool:,apple:,codesign:", "-s", "-k", cfg.KeychainPassword, keychainPath}},
+ {Name: "security", Args: []string{"list-keychains", "-d", "user", "-s", keychainPath}},
+ }
+ for _, command := range commands {
+ if err := runner.Run(command); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func WriteAPIKey(cfg APIKeyConfig) (string, error) {
+ if cfg.TempDir == "" || cfg.Key == "" || cfg.KeyID == "" {
+ return "", errors.New("temp dir, api key, and api key id are required")
+ }
+ privateKeysDir := filepath.Join(cfg.TempDir, "private_keys")
+ if err := os.MkdirAll(privateKeysDir, 0o700); err != nil {
+ return "", err
+ }
+ keyPath := filepath.Join(privateKeysDir, "AuthKey_"+cfg.KeyID+".p8")
+ if err := os.WriteFile(keyPath, []byte(cfg.Key), 0o600); err != nil {
+ return "", err
+ }
+ if cfg.EnvPath != "" {
+ f, err := os.OpenFile(cfg.EnvPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
+ if err != nil {
+ return "", err
+ }
+ if _, err := fmt.Fprintf(f, "APP_STORE_CONNECT_API_KEY_PATH=%s\n", keyPath); err != nil {
+ _ = f.Close()
+ return "", err
+ }
+ if err := f.Close(); err != nil {
+ return "", err
+ }
+ }
+ return keyPath, nil
+}
+
+func ResolveSigningIdentity(runner Runner, explicit, teamID string) (string, error) {
+ if explicit != "" {
+ return explicit, nil
+ }
+ if teamID == "" {
+ return "", errors.New("team id is required when signing identity is not explicit")
+ }
+ output, err := runner.Output(Command{Name: "security", Args: []string{"find-identity", "-v", "-p", "codesigning"}})
+ if err != nil {
+ return "", err
+ }
+ needle := "(" + teamID + ")"
+ for _, line := range strings.Split(output, "\n") {
+ if !strings.Contains(line, "Developer ID Application:") || !strings.Contains(line, needle) {
+ continue
+ }
+ start := strings.Index(line, "\"Developer ID Application:")
+ end := strings.LastIndex(line, "\"")
+ if start >= 0 && end > start {
+ return line[start+1 : end], nil
+ }
+ }
+ return "", fmt.Errorf("could not find Developer ID Application identity for team %s", teamID)
+}
+
+func SignApp(runner Runner, cfg AppConfig) error {
+ identity, err := ResolveSigningIdentity(runner, cfg.SigningIdentity, cfg.TeamID)
+ if err != nil {
+ return err
+ }
+ if err := runner.Run(Command{Name: "codesign", Args: []string{"--force", "--deep", "--options", "runtime", "--timestamp", "--sign", identity, cfg.AppPath}}); err != nil {
+ return err
+ }
+ return runner.Run(Command{Name: "codesign", Args: []string{"--verify", "--deep", "--strict", "--verbose=2", cfg.AppPath}})
+}
+
+func NotarizeApp(runner Runner, cfg AppConfig) error {
+ notaryZip := filepath.Join(os.TempDir(), cfg.AppName+"-notary.zip")
+ if err := runner.Run(Command{Name: "ditto", Args: []string{"-c", "-k", "--keepParent", cfg.AppPath, notaryZip}}); err != nil {
+ return err
+ }
+ if err := notarySubmit(runner, cfg.APIKeyPath, cfg.APIKeyID, cfg.APIIssuerID, notaryZip); err != nil {
+ return err
+ }
+ if err := runner.Run(Command{Name: "xcrun", Args: []string{"stapler", "staple", cfg.AppPath}}); err != nil {
+ return err
+ }
+ if err := runner.Run(Command{Name: "xcrun", Args: []string{"stapler", "validate", cfg.AppPath}}); err != nil {
+ return err
+ }
+ return runner.Run(Command{Name: "spctl", Args: []string{"--assess", "--type", "execute", "--verbose=4", cfg.AppPath}})
+}
+
+func CreateSignedDMG(runner Runner, cfg DMGConfig) error {
+ if err := os.MkdirAll("release", 0o755); err != nil {
+ return err
+ }
+ dmgPath := filepath.Join("release", cfg.DMGName)
+ if err := runner.Run(Command{Name: "create-dmg", Args: []string{"--volname", cfg.AppName, "--window-pos", "200", "120", "--window-size", "800", "400", "--icon-size", "100", "--app-drop-link", "600", "185", dmgPath, cfg.AppPath}}); err != nil {
+ return err
+ }
+ identity := cfg.SigningIdentity
+ if identity == "" {
+ resolved, err := ResolveSigningIdentity(runner, "", cfg.TeamID)
+ if err != nil {
+ return err
+ }
+ identity = resolved
+ }
+ if err := runner.Run(Command{Name: "codesign", Args: []string{"--force", "--timestamp", "--sign", identity, dmgPath}}); err != nil {
+ return err
+ }
+ if err := runner.Run(Command{Name: "codesign", Args: []string{"--verify", "--verbose=2", dmgPath}}); err != nil {
+ return err
+ }
+ return runner.Run(Command{Name: "hdiutil", Args: []string{"verify", dmgPath}})
+}
+
+func NotarizeDMG(runner Runner, cfg DMGConfig) error {
+ dmgPath := filepath.Join("release", cfg.DMGName)
+ if err := notarySubmit(runner, cfg.APIKeyPath, cfg.APIKeyID, cfg.APIIssuerID, dmgPath); err != nil {
+ return err
+ }
+ if err := runner.Run(Command{Name: "xcrun", Args: []string{"stapler", "staple", dmgPath}}); err != nil {
+ return err
+ }
+ if err := runner.Run(Command{Name: "xcrun", Args: []string{"stapler", "validate", dmgPath}}); err != nil {
+ return err
+ }
+ return runner.Run(Command{Name: "spctl", Args: []string{"--assess", "--type", "open", "--context", "context:primary-signature", "--verbose=4", dmgPath}})
+}
+
+func ExtractArchive(archivePath, destination string) error {
+ return archive.ExtractTarGz(archivePath, destination)
+}
+
+func notarySubmit(runner Runner, keyPath, keyID, issuerID, artifact string) error {
+ return runner.Run(Command{Name: "xcrun", Args: []string{"notarytool", "submit", artifact, "--key", keyPath, "--key-id", keyID, "--issuer", issuerID, "--wait"}})
+}
diff --git a/mac/release/release_test.go b/mac/release/release_test.go
new file mode 100644
index 0000000..aa97fab
--- /dev/null
+++ b/mac/release/release_test.go
@@ -0,0 +1,110 @@
+package release
+
+import (
+ "os"
+ "path/filepath"
+ "reflect"
+ "testing"
+)
+
+func TestResolveSigningIdentityUsesExplicitIdentity(t *testing.T) {
+ t.Parallel()
+
+ runner := &recordingRunner{}
+ identity, err := ResolveSigningIdentity(runner, "Developer ID Application: Example (TEAMID1234)", "TEAMID1234")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if identity != "Developer ID Application: Example (TEAMID1234)" {
+ t.Fatalf("identity = %q", identity)
+ }
+ if len(runner.commands) != 0 {
+ t.Fatalf("runner commands = %v, want none", runner.commands)
+ }
+}
+
+func TestResolveSigningIdentityFindsTeamIdentity(t *testing.T) {
+ t.Parallel()
+
+ runner := &recordingRunner{output: ` 1) ABC "Developer ID Application: Other (OTHER12345)"
+ 2) DEF "Developer ID Application: Example (TEAMID1234)"
+ 2 valid identities found`}
+ identity, err := ResolveSigningIdentity(runner, "", "TEAMID1234")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if identity != "Developer ID Application: Example (TEAMID1234)" {
+ t.Fatalf("identity = %q", identity)
+ }
+}
+
+func TestInstallCertificateWritesCertAndRunsKeychainCommands(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ runner := &recordingRunner{}
+ cfg := CertificateConfig{
+ TempDir: dir,
+ CertificateBase64: "Y2VydA==",
+ CertificatePass: "cert-pass",
+ KeychainPassword: "keychain-pass",
+ }
+ if err := InstallCertificate(runner, cfg); err != nil {
+ t.Fatal(err)
+ }
+
+ certPath := filepath.Join(dir, "developer-id-application.p12")
+ if got, err := os.ReadFile(certPath); err != nil || string(got) != "cert" {
+ t.Fatalf("cert file = %q, %v; want cert", got, err)
+ }
+ wantNames := []string{"security", "security", "security", "security", "security", "security"}
+ if got := runner.commandNames(); !reflect.DeepEqual(got, wantNames) {
+ t.Fatalf("command names = %v, want %v", got, wantNames)
+ }
+}
+
+func TestCreateSignedDMGPlansCreateDmgAndVerificationCommands(t *testing.T) {
+ t.Parallel()
+
+ runner := &recordingRunner{}
+ cfg := DMGConfig{
+ AppPath: "bin/Example.app",
+ AppName: "Example",
+ DMGName: "Example.dmg",
+ SigningIdentity: "Developer ID Application: Example (TEAMID1234)",
+ }
+ if err := CreateSignedDMG(runner, cfg); err != nil {
+ t.Fatal(err)
+ }
+
+ wantNames := []string{"create-dmg", "codesign", "codesign", "hdiutil"}
+ if got := runner.commandNames(); !reflect.DeepEqual(got, wantNames) {
+ t.Fatalf("command names = %v, want %v", got, wantNames)
+ }
+ if runner.commands[0].Dir != "" {
+ t.Fatalf("create-dmg dir = %q, want empty", runner.commands[0].Dir)
+ }
+}
+
+type recordingRunner struct {
+ output string
+ commands []Command
+}
+
+func (r *recordingRunner) Run(command Command) error {
+ r.commands = append(r.commands, command)
+ return nil
+}
+
+func (r *recordingRunner) Output(command Command) (string, error) {
+ r.commands = append(r.commands, command)
+ return r.output, nil
+}
+
+func (r *recordingRunner) commandNames() []string {
+ names := make([]string, len(r.commands))
+ for i, command := range r.commands {
+ names[i] = command.Name
+ }
+ return names
+}
diff --git a/wails/cmd/wails-build/main.go b/wails/cmd/wails-build/main.go
new file mode 100644
index 0000000..f3f484b
--- /dev/null
+++ b/wails/cmd/wails-build/main.go
@@ -0,0 +1,33 @@
+package main
+
+import (
+ "flag"
+ "fmt"
+ "os"
+
+ "github.com/vuon9/gh-workflows/wails/build"
+)
+
+func main() {
+ if err := run(os.Args[1:]); err != nil {
+ fmt.Fprintf(os.Stderr, "wails-build: %v\n", err)
+ os.Exit(1)
+ }
+}
+
+func run(args []string) error {
+ flags := flag.NewFlagSet("wails-build", flag.ContinueOnError)
+ flags.SetOutput(os.Stderr)
+
+ var cfg build.Config
+ flags.StringVar(&cfg.WorkingDirectory, "working-directory", ".", "Caller repository working directory.")
+ flags.StringVar(&cfg.PackageCommand, "package-command", "", "Command that builds a release .app bundle.")
+ flags.StringVar(&cfg.AppPath, "app-path", "", "Path to the built .app bundle, relative to working-directory.")
+ flags.StringVar(&cfg.AppName, "app-name", "", "Expected CFBundleName.")
+ flags.StringVar(&cfg.BundleID, "bundle-id", "", "Expected CFBundleIdentifier.")
+ flags.StringVar(&cfg.ArchivePath, "archive-path", "", "Output .tar.gz path for the app bundle.")
+ if err := flags.Parse(args); err != nil {
+ return err
+ }
+ return build.Run(cfg)
+}
From 157c6f8a21c2f9fb77dc179d0833eae60d8ca1e7 Mon Sep 17 00:00:00 2001
From: Vuong <3168632+vuon9@users.noreply.github.com>
Date: Wed, 17 Jun 2026 01:50:33 +0700
Subject: [PATCH 3/6] Track Wails app build package
---
wails/appbuild/build.go | 115 ++++++++++++++++++++++++++++++++++
wails/appbuild/build_test.go | 52 +++++++++++++++
wails/cmd/wails-build/main.go | 6 +-
3 files changed, 170 insertions(+), 3 deletions(-)
create mode 100644 wails/appbuild/build.go
create mode 100644 wails/appbuild/build_test.go
diff --git a/wails/appbuild/build.go b/wails/appbuild/build.go
new file mode 100644
index 0000000..fb9bfd5
--- /dev/null
+++ b/wails/appbuild/build.go
@@ -0,0 +1,115 @@
+package appbuild
+
+import (
+ "bytes"
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+
+ "github.com/vuon9/gh-workflows/internal/archive"
+)
+
+type Config struct {
+ WorkingDirectory string
+ PackageCommand string
+ AppPath string
+ AppName string
+ BundleID string
+ ArchivePath string
+}
+
+func Run(cfg Config) error {
+ if cfg.WorkingDirectory == "" {
+ cfg.WorkingDirectory = "."
+ }
+ if cfg.PackageCommand == "" {
+ return errors.New("package command is required")
+ }
+ if cfg.AppPath == "" || cfg.AppName == "" || cfg.BundleID == "" || cfg.ArchivePath == "" {
+ return errors.New("app path, app name, bundle id, and archive path are required")
+ }
+
+ cmd := exec.Command("bash", "-lc", cfg.PackageCommand)
+ cmd.Dir = cfg.WorkingDirectory
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ cmd.Stdin = os.Stdin
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("running package command: %w", err)
+ }
+
+ fullAppPath := filepath.Join(cfg.WorkingDirectory, cfg.AppPath)
+ if err := ValidateApp(fullAppPath, cfg.AppName, cfg.BundleID); err != nil {
+ return err
+ }
+ if err := os.MkdirAll(filepath.Dir(cfg.ArchivePath), 0o755); err != nil {
+ return err
+ }
+ return archive.CreateTarGz(cfg.ArchivePath, fullAppPath)
+}
+
+func ValidateApp(appPath, appName, bundleID string) error {
+ info, err := os.Stat(appPath)
+ if err != nil {
+ return err
+ }
+ if !info.IsDir() {
+ return fmt.Errorf("%s is not a directory", appPath)
+ }
+ plistPath := filepath.Join(appPath, "Contents", "Info.plist")
+ values, err := readInfoPlist(plistPath)
+ if err != nil {
+ return err
+ }
+ if got := values["CFBundleName"]; got != appName {
+ return fmt.Errorf("expected CFBundleName %q, got %q", appName, got)
+ }
+ if got := values["CFBundleIdentifier"]; got != bundleID {
+ return fmt.Errorf("expected CFBundleIdentifier %q, got %q", bundleID, got)
+ }
+ return nil
+}
+
+func readInfoPlist(path string) (map[string]string, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+ decoder := xml.NewDecoder(bytes.NewReader(data))
+ values := make(map[string]string)
+ var lastKey string
+ for {
+ token, err := decoder.Token()
+ if errors.Is(err, io.EOF) {
+ return values, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ start, ok := token.(xml.StartElement)
+ if !ok {
+ continue
+ }
+ switch start.Name.Local {
+ case "key":
+ var key string
+ if err := decoder.DecodeElement(&key, &start); err != nil {
+ return nil, err
+ }
+ lastKey = key
+ case "string":
+ var value string
+ if err := decoder.DecodeElement(&value, &start); err != nil {
+ return nil, err
+ }
+ if lastKey != "" {
+ values[lastKey] = value
+ lastKey = ""
+ }
+ }
+ }
+}
diff --git a/wails/appbuild/build_test.go b/wails/appbuild/build_test.go
new file mode 100644
index 0000000..7b2848d
--- /dev/null
+++ b/wails/appbuild/build_test.go
@@ -0,0 +1,52 @@
+package appbuild
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestRunBuildCommandValidatesAppAndArchivesIt(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ archivePath := filepath.Join(dir, "wails-app.tar.gz")
+ cfg := Config{
+ WorkingDirectory: dir,
+ PackageCommand: `mkdir -p bin/Example.app/Contents && printf '%s' 'CFBundleNameExampleCFBundleIdentifiercom.example.app' > bin/Example.app/Contents/Info.plist`,
+ AppPath: "bin/Example.app",
+ AppName: "Example",
+ BundleID: "com.example.app",
+ ArchivePath: archivePath,
+ }
+
+ if err := Run(cfg); err != nil {
+ t.Fatal(err)
+ }
+
+ if _, err := os.Stat(archivePath); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestRunRejectsMismatchedBundleID(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ cfg := Config{
+ WorkingDirectory: dir,
+ PackageCommand: `mkdir -p bin/Example.app/Contents && printf '%s' 'CFBundleNameExampleCFBundleIdentifiercom.example.other' > bin/Example.app/Contents/Info.plist`,
+ AppPath: "bin/Example.app",
+ AppName: "Example",
+ BundleID: "com.example.app",
+ ArchivePath: filepath.Join(dir, "wails-app.tar.gz"),
+ }
+
+ err := Run(cfg)
+ if err == nil {
+ t.Fatal("Run returned nil, want bundle id mismatch error")
+ }
+ if got := err.Error(); got != `expected CFBundleIdentifier "com.example.app", got "com.example.other"` {
+ t.Fatalf("error = %q", got)
+ }
+}
diff --git a/wails/cmd/wails-build/main.go b/wails/cmd/wails-build/main.go
index f3f484b..6a598b9 100644
--- a/wails/cmd/wails-build/main.go
+++ b/wails/cmd/wails-build/main.go
@@ -5,7 +5,7 @@ import (
"fmt"
"os"
- "github.com/vuon9/gh-workflows/wails/build"
+ "github.com/vuon9/gh-workflows/wails/appbuild"
)
func main() {
@@ -19,7 +19,7 @@ func run(args []string) error {
flags := flag.NewFlagSet("wails-build", flag.ContinueOnError)
flags.SetOutput(os.Stderr)
- var cfg build.Config
+ var cfg appbuild.Config
flags.StringVar(&cfg.WorkingDirectory, "working-directory", ".", "Caller repository working directory.")
flags.StringVar(&cfg.PackageCommand, "package-command", "", "Command that builds a release .app bundle.")
flags.StringVar(&cfg.AppPath, "app-path", "", "Path to the built .app bundle, relative to working-directory.")
@@ -29,5 +29,5 @@ func run(args []string) error {
if err := flags.Parse(args); err != nil {
return err
}
- return build.Run(cfg)
+ return appbuild.Run(cfg)
}
From fed1e498cff4447332ab62466c322c79a46a397e Mon Sep 17 00:00:00 2001
From: Vuong <3168632+vuon9@users.noreply.github.com>
Date: Wed, 17 Jun 2026 01:59:05 +0700
Subject: [PATCH 4/6] Extract app artifact to configured app path
---
.github/workflows/wails-macos-release.yml | 1 +
mac/cmd/macos-release/main.go | 3 ++-
mac/release/release.go | 13 ++++++++++
mac/release/release_test.go | 31 +++++++++++++++++++++++
4 files changed, 47 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/wails-macos-release.yml b/.github/workflows/wails-macos-release.yml
index 20b33e3..0b1b053 100644
--- a/.github/workflows/wails-macos-release.yml
+++ b/.github/workflows/wails-macos-release.yml
@@ -217,6 +217,7 @@ jobs:
run: |
"$RUNNER_TEMP/macos-release" extract-app \
--archive-path "$RUNNER_TEMP/wails-app/wails-app.tar.gz" \
+ --app-path "${{ inputs.app-path }}" \
--destination .
- name: Validate Apple signing secrets
diff --git a/mac/cmd/macos-release/main.go b/mac/cmd/macos-release/main.go
index 7415791..57a3129 100644
--- a/mac/cmd/macos-release/main.go
+++ b/mac/cmd/macos-release/main.go
@@ -49,10 +49,11 @@ func run(args []string) error {
flags := flag.NewFlagSet("extract-app", flag.ContinueOnError)
archivePath := flags.String("archive-path", "", "App bundle .tar.gz archive path.")
destination := flags.String("destination", ".", "Extraction destination.")
+ appPath := flags.String("app-path", "", "Expected app path relative to destination.")
if err := flags.Parse(args[1:]); err != nil {
return err
}
- return release.ExtractArchive(*archivePath, *destination)
+ return release.ExtractAppArchive(*archivePath, *destination, *appPath)
case "sign-app":
cfg, err := parseAppConfig(args[1:])
if err != nil {
diff --git a/mac/release/release.go b/mac/release/release.go
index d805c13..a89e877 100644
--- a/mac/release/release.go
+++ b/mac/release/release.go
@@ -245,6 +245,19 @@ func ExtractArchive(archivePath, destination string) error {
return archive.ExtractTarGz(archivePath, destination)
}
+func ExtractAppArchive(archivePath, destination, appPath string) error {
+ if appPath != "" {
+ parent := filepath.Dir(filepath.Clean(appPath))
+ if parent != "." {
+ destination = filepath.Join(destination, parent)
+ }
+ }
+ if err := os.MkdirAll(destination, 0o755); err != nil {
+ return err
+ }
+ return ExtractArchive(archivePath, destination)
+}
+
func notarySubmit(runner Runner, keyPath, keyID, issuerID, artifact string) error {
return runner.Run(Command{Name: "xcrun", Args: []string{"notarytool", "submit", artifact, "--key", keyPath, "--key-id", keyID, "--issuer", issuerID, "--wait"}})
}
diff --git a/mac/release/release_test.go b/mac/release/release_test.go
index aa97fab..ec1f249 100644
--- a/mac/release/release_test.go
+++ b/mac/release/release_test.go
@@ -5,6 +5,8 @@ import (
"path/filepath"
"reflect"
"testing"
+
+ "github.com/vuon9/gh-workflows/internal/archive"
)
func TestResolveSigningIdentityUsesExplicitIdentity(t *testing.T) {
@@ -86,6 +88,31 @@ func TestCreateSignedDMGPlansCreateDmgAndVerificationCommands(t *testing.T) {
}
}
+func TestExtractAppArchivePlacesBundleAtExpectedAppPath(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ source := filepath.Join(dir, "Example.app")
+ if err := os.MkdirAll(filepath.Join(source, "Contents"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(source, "Contents", "Info.plist"), []byte("plist"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ archivePath := filepath.Join(dir, "app.tar.gz")
+ if err := createTestArchive(archivePath, source); err != nil {
+ t.Fatal(err)
+ }
+
+ destination := filepath.Join(dir, "out")
+ if err := ExtractAppArchive(archivePath, destination, "bin/Example.app"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(filepath.Join(destination, "bin", "Example.app", "Contents", "Info.plist")); err != nil {
+ t.Fatal(err)
+ }
+}
+
type recordingRunner struct {
output string
commands []Command
@@ -108,3 +135,7 @@ func (r *recordingRunner) commandNames() []string {
}
return names
}
+
+func createTestArchive(archivePath, source string) error {
+ return archive.CreateTarGz(archivePath, source)
+}
From fc4af8298a51558f3d020d933d7c1ab1d6249273 Mon Sep 17 00:00:00 2001
From: Vuong <3168632+vuon9@users.noreply.github.com>
Date: Wed, 17 Jun 2026 09:37:07 +0700
Subject: [PATCH 5/6] Normalize Wails release artifact path
---
.github/workflows/wails-macos-release.yml | 16 ++++++++++++++--
1 file changed, 14 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/wails-macos-release.yml b/.github/workflows/wails-macos-release.yml
index 0b1b053..892ab1f 100644
--- a/.github/workflows/wails-macos-release.yml
+++ b/.github/workflows/wails-macos-release.yml
@@ -286,17 +286,29 @@ jobs:
"$RUNNER_TEMP/macos-release" notarize-dmg \
--dmg-name "${{ inputs.dmg-name }}"
+ - name: Resolve release asset path
+ id: release-asset
+ env:
+ WORKING_DIRECTORY: ${{ inputs.working-directory }}
+ DMG_NAME: ${{ inputs.dmg-name }}
+ run: |
+ if [[ "$WORKING_DIRECTORY" == "." ]]; then
+ echo "path=app/release/$DMG_NAME" >> "$GITHUB_OUTPUT"
+ else
+ echo "path=app/$WORKING_DIRECTORY/release/$DMG_NAME" >> "$GITHUB_OUTPUT"
+ fi
+
- name: Upload signed release artifact
uses: actions/upload-artifact@v7.0.1
with:
name: ${{ inputs.artifact-name }}
- path: app/${{ inputs.working-directory }}/release/${{ inputs.dmg-name }}
+ path: ${{ steps.release-asset.outputs.path }}
retention-days: 90
- name: Upload GitHub release asset
if: ${{ inputs.upload-github-release && startsWith(github.ref, 'refs/tags/') }}
uses: softprops/action-gh-release@v3.0.0
with:
- files: app/${{ inputs.working-directory }}/release/${{ inputs.dmg-name }}
+ files: ${{ steps.release-asset.outputs.path }}
prerelease: ${{ inputs.github-release-prerelease }}
generate_release_notes: true
From a25ff8431a91b18c1fff70f9fd1dbbb167773b22 Mon Sep 17 00:00:00 2001
From: Vuong <3168632+vuon9@users.noreply.github.com>
Date: Wed, 17 Jun 2026 10:15:36 +0700
Subject: [PATCH 6/6] Extract generic macOS release workflow
---
...ls-macos-release.yml => macos-release.yml} | 127 +++---------------
README.md | 30 +++--
wails/appbuild/build.go | 115 ----------------
wails/appbuild/build_test.go | 52 -------
wails/cmd/wails-build/main.go | 33 -----
5 files changed, 36 insertions(+), 321 deletions(-)
rename .github/workflows/{wails-macos-release.yml => macos-release.yml} (68%)
delete mode 100644 wails/appbuild/build.go
delete mode 100644 wails/appbuild/build_test.go
delete mode 100644 wails/cmd/wails-build/main.go
diff --git a/.github/workflows/wails-macos-release.yml b/.github/workflows/macos-release.yml
similarity index 68%
rename from .github/workflows/wails-macos-release.yml
rename to .github/workflows/macos-release.yml
index 892ab1f..fe5d484 100644
--- a/.github/workflows/wails-macos-release.yml
+++ b/.github/workflows/macos-release.yml
@@ -1,4 +1,4 @@
-name: Wails macOS Release
+name: macOS Release
on:
workflow_call:
@@ -7,10 +7,6 @@ on:
description: Display name of the macOS app bundle.
required: true
type: string
- bundle-id:
- description: macOS app bundle identifier.
- required: true
- type: string
team-id:
description: Apple Developer Team ID.
required: true
@@ -20,48 +16,33 @@ on:
required: false
type: string
default: .
- package-command:
- description: Command that builds a release .app bundle.
+ app-path:
+ description: Path where the app bundle should be extracted, relative to working-directory.
required: true
type: string
- app-path:
- description: Path to the built .app bundle, relative to working-directory.
+ app-artifact-name:
+ description: Name of the uploaded .app archive artifact from the caller build job.
required: true
type: string
+ app-archive-name:
+ description: File name of the .tar.gz archive inside app-artifact-name.
+ required: false
+ type: string
+ default: macos-app.tar.gz
dmg-name:
description: DMG file name to create.
required: true
type: string
artifact-name:
- description: GitHub Actions artifact name.
+ description: GitHub Actions artifact name for the signed DMG.
required: false
type: string
- default: wails-macos-release
+ default: macos-release
runner-label:
description: GitHub-hosted or self-hosted macOS runner label.
required: false
type: string
default: macos-26
- go-version-file:
- description: Optional Go version file path relative to working-directory.
- required: false
- type: string
- default: ""
- go-version:
- description: Go version to install when go-version-file is not set.
- required: false
- type: string
- default: "1.25.0"
- bun-version:
- description: Bun version to install.
- required: false
- type: string
- default: latest
- task-version:
- description: Go Task version to install.
- required: false
- type: string
- default: v3.49.0
upload-github-release:
description: Upload the signed DMG to a GitHub Release on tag builds.
required: false
@@ -93,84 +74,8 @@ on:
required: false
jobs:
- build:
- name: Build Wails App
- runs-on: ${{ inputs.runner-label }}
- timeout-minutes: 45
- permissions:
- contents: read
-
- steps:
- - name: Check out caller repository
- uses: actions/checkout@v6
- with:
- path: app
-
- - name: Check out workflow tools
- uses: actions/checkout@v6
- with:
- repository: ${{ job.workflow_repository }}
- ref: ${{ job.workflow_sha }}
- path: gh-workflows
-
- - name: Set up Go from version file
- if: ${{ inputs.go-version-file != '' }}
- uses: actions/setup-go@v6
- with:
- go-version-file: app/${{ inputs.working-directory }}/${{ inputs.go-version-file }}
- check-latest: true
-
- - name: Set up Go
- if: ${{ inputs.go-version-file == '' }}
- uses: actions/setup-go@v6
- with:
- go-version: ${{ inputs.go-version }}
- check-latest: true
-
- - name: Set up Bun
- uses: oven-sh/setup-bun@v2
- with:
- bun-version: ${{ inputs.bun-version }}
-
- - name: Test workflow tools
- working-directory: gh-workflows
- run: go test ./...
-
- - name: Build Wails build CLI
- working-directory: gh-workflows
- run: go build -o "$RUNNER_TEMP/wails-build" ./wails/cmd/wails-build
-
- - name: Install Task
- run: |
- go install github.com/go-task/task/v3/cmd/task@${{ inputs.task-version }}
- echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
-
- - name: Install Wails CLI
- run: |
- go install github.com/wailsapp/wails/v3/cmd/wails3@latest
- ln -sf "$(go env GOPATH)/bin/wails3" "$(go env GOPATH)/bin/wails"
- echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
-
- - name: Build app bundle
- run: |
- "$RUNNER_TEMP/wails-build" \
- --working-directory "app/${{ inputs.working-directory }}" \
- --package-command "${{ inputs.package-command }}" \
- --app-path "${{ inputs.app-path }}" \
- --app-name "${{ inputs.app-name }}" \
- --bundle-id "${{ inputs.bundle-id }}" \
- --archive-path "$RUNNER_TEMP/wails-app.tar.gz"
-
- - name: Upload app bundle artifact
- uses: actions/upload-artifact@v7.0.1
- with:
- name: ${{ inputs.artifact-name }}-app-bundle
- path: ${{ runner.temp }}/wails-app.tar.gz
- retention-days: 1
-
- sign-package-release:
+ release:
name: Sign, Package, and Notarize macOS App
- needs: build
runs-on: ${{ inputs.runner-label }}
timeout-minutes: 60
permissions:
@@ -209,14 +114,14 @@ jobs:
- name: Download app bundle artifact
uses: actions/download-artifact@v7.0.0
with:
- name: ${{ inputs.artifact-name }}-app-bundle
- path: ${{ runner.temp }}/wails-app
+ name: ${{ inputs.app-artifact-name }}
+ path: ${{ runner.temp }}/macos-app
- name: Extract app bundle
working-directory: app/${{ inputs.working-directory }}
run: |
"$RUNNER_TEMP/macos-release" extract-app \
- --archive-path "$RUNNER_TEMP/wails-app/wails-app.tar.gz" \
+ --archive-path "$RUNNER_TEMP/macos-app/${{ inputs.app-archive-name }}" \
--app-path "${{ inputs.app-path }}" \
--destination .
diff --git a/README.md b/README.md
index 77a6703..3d7375e 100644
--- a/README.md
+++ b/README.md
@@ -6,10 +6,10 @@ The goal is to keep product repositories clean: app repos keep small workflow wr
## Workflows
-### `wails-macos-release.yml`
+### `macos-release.yml`
-Caller repositories can build, sign, notarize, staple, and publish a Developer ID
-Wails macOS DMG with a thin wrapper:
+Caller repositories can sign, notarize, staple, package, and publish a Developer ID
+macOS DMG after an app-specific build job uploads a `.app` bundle archive.
```yaml
name: macOS Release
@@ -21,17 +21,27 @@ on:
workflow_dispatch:
jobs:
+ build:
+ runs-on: macos-26
+ steps:
+ - uses: actions/checkout@v6
+ - run: task darwin:package:universal
+ - run: tar -czf "$RUNNER_TEMP/macos-app.tar.gz" -C bin MyApp.app
+ - uses: actions/upload-artifact@v7.0.1
+ with:
+ name: myapp-macos-app-${{ github.run_id }}
+ path: ${{ runner.temp }}/macos-app.tar.gz
+
release:
- uses: vuon9/gh-workflows/.github/workflows/wails-macos-release.yml@v0.1.9
+ needs: build
+ uses: vuon9/gh-workflows/.github/workflows/macos-release.yml@v0.2.0
with:
app-name: MyApp
- bundle-id: com.example.myapp
team-id: ABCDE12345
- package-command: task darwin:package:universal
app-path: bin/MyApp.app
+ app-artifact-name: myapp-macos-app-${{ github.run_id }}
dmg-name: MyApp-macos-universal.dmg
artifact-name: myapp-macos-release-${{ github.run_id }}
- go-version-file: go.mod
runner-label: macos-26
github-release-prerelease: ${{ contains(github.ref_name, '-') }}
secrets: inherit
@@ -51,10 +61,9 @@ Optional caller secret:
What the workflow does:
-- Installs Go, Bun, Task, Wails v3 CLI, and `create-dmg`.
+- Downloads an app bundle archive uploaded by a caller-owned build job.
+- Installs Go and `create-dmg`.
- Imports the Developer ID Application certificate into a temporary keychain.
-- Runs the caller `package-command` to produce the `.app` bundle.
-- Validates `CFBundleName` and `CFBundleIdentifier`.
- Signs the app with hardened runtime and timestamping.
- Notarizes and staples the app.
- Creates, signs, notarizes, staples, and verifies the DMG.
@@ -66,6 +75,7 @@ Recommended caller controls:
- Use a product/platform tag namespace such as `macos/myapp/v1.0.0`.
- Reference a version tag after the workflow is released, not `@main`.
- Keep Apple secrets in the app repository or a protected GitHub Environment.
+- Keep app-specific build systems such as Wails, Xcode, Electron, or custom scripts in the caller repository.
- Run the first release manually with `workflow_dispatch` before cutting the public tag.
- Do final Gatekeeper verification by downloading the uploaded DMG on a clean macOS machine.
diff --git a/wails/appbuild/build.go b/wails/appbuild/build.go
deleted file mode 100644
index fb9bfd5..0000000
--- a/wails/appbuild/build.go
+++ /dev/null
@@ -1,115 +0,0 @@
-package appbuild
-
-import (
- "bytes"
- "encoding/xml"
- "errors"
- "fmt"
- "io"
- "os"
- "os/exec"
- "path/filepath"
-
- "github.com/vuon9/gh-workflows/internal/archive"
-)
-
-type Config struct {
- WorkingDirectory string
- PackageCommand string
- AppPath string
- AppName string
- BundleID string
- ArchivePath string
-}
-
-func Run(cfg Config) error {
- if cfg.WorkingDirectory == "" {
- cfg.WorkingDirectory = "."
- }
- if cfg.PackageCommand == "" {
- return errors.New("package command is required")
- }
- if cfg.AppPath == "" || cfg.AppName == "" || cfg.BundleID == "" || cfg.ArchivePath == "" {
- return errors.New("app path, app name, bundle id, and archive path are required")
- }
-
- cmd := exec.Command("bash", "-lc", cfg.PackageCommand)
- cmd.Dir = cfg.WorkingDirectory
- cmd.Stdout = os.Stdout
- cmd.Stderr = os.Stderr
- cmd.Stdin = os.Stdin
- if err := cmd.Run(); err != nil {
- return fmt.Errorf("running package command: %w", err)
- }
-
- fullAppPath := filepath.Join(cfg.WorkingDirectory, cfg.AppPath)
- if err := ValidateApp(fullAppPath, cfg.AppName, cfg.BundleID); err != nil {
- return err
- }
- if err := os.MkdirAll(filepath.Dir(cfg.ArchivePath), 0o755); err != nil {
- return err
- }
- return archive.CreateTarGz(cfg.ArchivePath, fullAppPath)
-}
-
-func ValidateApp(appPath, appName, bundleID string) error {
- info, err := os.Stat(appPath)
- if err != nil {
- return err
- }
- if !info.IsDir() {
- return fmt.Errorf("%s is not a directory", appPath)
- }
- plistPath := filepath.Join(appPath, "Contents", "Info.plist")
- values, err := readInfoPlist(plistPath)
- if err != nil {
- return err
- }
- if got := values["CFBundleName"]; got != appName {
- return fmt.Errorf("expected CFBundleName %q, got %q", appName, got)
- }
- if got := values["CFBundleIdentifier"]; got != bundleID {
- return fmt.Errorf("expected CFBundleIdentifier %q, got %q", bundleID, got)
- }
- return nil
-}
-
-func readInfoPlist(path string) (map[string]string, error) {
- data, err := os.ReadFile(path)
- if err != nil {
- return nil, err
- }
- decoder := xml.NewDecoder(bytes.NewReader(data))
- values := make(map[string]string)
- var lastKey string
- for {
- token, err := decoder.Token()
- if errors.Is(err, io.EOF) {
- return values, nil
- }
- if err != nil {
- return nil, err
- }
- start, ok := token.(xml.StartElement)
- if !ok {
- continue
- }
- switch start.Name.Local {
- case "key":
- var key string
- if err := decoder.DecodeElement(&key, &start); err != nil {
- return nil, err
- }
- lastKey = key
- case "string":
- var value string
- if err := decoder.DecodeElement(&value, &start); err != nil {
- return nil, err
- }
- if lastKey != "" {
- values[lastKey] = value
- lastKey = ""
- }
- }
- }
-}
diff --git a/wails/appbuild/build_test.go b/wails/appbuild/build_test.go
deleted file mode 100644
index 7b2848d..0000000
--- a/wails/appbuild/build_test.go
+++ /dev/null
@@ -1,52 +0,0 @@
-package appbuild
-
-import (
- "os"
- "path/filepath"
- "testing"
-)
-
-func TestRunBuildCommandValidatesAppAndArchivesIt(t *testing.T) {
- t.Parallel()
-
- dir := t.TempDir()
- archivePath := filepath.Join(dir, "wails-app.tar.gz")
- cfg := Config{
- WorkingDirectory: dir,
- PackageCommand: `mkdir -p bin/Example.app/Contents && printf '%s' 'CFBundleNameExampleCFBundleIdentifiercom.example.app' > bin/Example.app/Contents/Info.plist`,
- AppPath: "bin/Example.app",
- AppName: "Example",
- BundleID: "com.example.app",
- ArchivePath: archivePath,
- }
-
- if err := Run(cfg); err != nil {
- t.Fatal(err)
- }
-
- if _, err := os.Stat(archivePath); err != nil {
- t.Fatal(err)
- }
-}
-
-func TestRunRejectsMismatchedBundleID(t *testing.T) {
- t.Parallel()
-
- dir := t.TempDir()
- cfg := Config{
- WorkingDirectory: dir,
- PackageCommand: `mkdir -p bin/Example.app/Contents && printf '%s' 'CFBundleNameExampleCFBundleIdentifiercom.example.other' > bin/Example.app/Contents/Info.plist`,
- AppPath: "bin/Example.app",
- AppName: "Example",
- BundleID: "com.example.app",
- ArchivePath: filepath.Join(dir, "wails-app.tar.gz"),
- }
-
- err := Run(cfg)
- if err == nil {
- t.Fatal("Run returned nil, want bundle id mismatch error")
- }
- if got := err.Error(); got != `expected CFBundleIdentifier "com.example.app", got "com.example.other"` {
- t.Fatalf("error = %q", got)
- }
-}
diff --git a/wails/cmd/wails-build/main.go b/wails/cmd/wails-build/main.go
deleted file mode 100644
index 6a598b9..0000000
--- a/wails/cmd/wails-build/main.go
+++ /dev/null
@@ -1,33 +0,0 @@
-package main
-
-import (
- "flag"
- "fmt"
- "os"
-
- "github.com/vuon9/gh-workflows/wails/appbuild"
-)
-
-func main() {
- if err := run(os.Args[1:]); err != nil {
- fmt.Fprintf(os.Stderr, "wails-build: %v\n", err)
- os.Exit(1)
- }
-}
-
-func run(args []string) error {
- flags := flag.NewFlagSet("wails-build", flag.ContinueOnError)
- flags.SetOutput(os.Stderr)
-
- var cfg appbuild.Config
- flags.StringVar(&cfg.WorkingDirectory, "working-directory", ".", "Caller repository working directory.")
- flags.StringVar(&cfg.PackageCommand, "package-command", "", "Command that builds a release .app bundle.")
- flags.StringVar(&cfg.AppPath, "app-path", "", "Path to the built .app bundle, relative to working-directory.")
- flags.StringVar(&cfg.AppName, "app-name", "", "Expected CFBundleName.")
- flags.StringVar(&cfg.BundleID, "bundle-id", "", "Expected CFBundleIdentifier.")
- flags.StringVar(&cfg.ArchivePath, "archive-path", "", "Output .tar.gz path for the app bundle.")
- if err := flags.Parse(args); err != nil {
- return err
- }
- return appbuild.Run(cfg)
-}