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) -}