diff --git a/.github/workflows/macos-release.yml b/.github/workflows/macos-release.yml new file mode 100644 index 0000000..fe5d484 --- /dev/null +++ b/.github/workflows/macos-release.yml @@ -0,0 +1,219 @@ +name: macOS Release + +on: + workflow_call: + inputs: + app-name: + description: Display name of the macOS app bundle. + required: true + type: string + team-id: + description: Apple Developer Team ID. + required: true + type: string + working-directory: + description: Caller repository working directory. + required: false + type: string + default: . + app-path: + description: Path where the app bundle should be extracted, relative to working-directory. + required: true + type: string + 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 for the signed DMG. + required: false + type: string + default: macos-release + runner-label: + description: GitHub-hosted or self-hosted macOS runner label. + required: false + type: string + default: macos-26 + upload-github-release: + description: Upload the signed DMG to a GitHub Release on tag builds. + required: false + type: boolean + default: true + github-release-prerelease: + description: Mark the GitHub Release as a prerelease when uploading on tag builds. + required: false + type: boolean + default: false + secrets: + APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_BASE64: + description: Base64-encoded Developer ID Application .p12 certificate. + required: true + APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_PASSWORD: + description: Password for APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_BASE64. + required: true + APP_STORE_CONNECT_API_KEY_P8: + description: App Store Connect API private key content for notarization. + required: true + APP_STORE_CONNECT_API_KEY_ID: + description: App Store Connect API key ID. + required: true + APP_STORE_CONNECT_API_ISSUER_ID: + description: App Store Connect issuer ID. + required: true + MACOS_CODESIGN_IDENTITY: + description: Optional full Developer ID Application signing identity. + required: false + +jobs: + release: + name: Sign, Package, and Notarize macOS App + 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.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/macos-app/${{ inputs.app-archive-name }}" \ + --app-path "${{ inputs.app-path }}" \ + --destination . + + - name: Validate Apple signing secrets + env: + 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 }} + 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 }} + APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }} + run: | + "$RUNNER_TEMP/macos-release" validate-secrets + + - name: Install Apple Developer ID certificate + env: + 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: | + "$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: | + "$RUNNER_TEMP/macos-release" write-api-key + + - name: Sign app bundle + working-directory: app/${{ inputs.working-directory }} + env: + MACOS_CODESIGN_IDENTITY: ${{ secrets.MACOS_CODESIGN_IDENTITY }} + run: | + "$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: app/${{ inputs.working-directory }} + env: + 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: | + "$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: app/${{ inputs.working-directory }} + env: + MACOS_CODESIGN_IDENTITY: ${{ secrets.MACOS_CODESIGN_IDENTITY }} + run: | + "$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: app/${{ inputs.working-directory }} + env: + 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: | + "$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: ${{ 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: ${{ steps.release-asset.outputs.path }} + prerelease: ${{ inputs.github-release-prerelease }} + generate_release_notes: true diff --git a/.github/workflows/wails-macos-release.yml b/.github/workflows/wails-macos-release.yml deleted file mode 100644 index a5ea698..0000000 --- a/.github/workflows/wails-macos-release.yml +++ /dev/null @@ -1,334 +0,0 @@ -name: Wails macOS Release - -on: - workflow_call: - inputs: - app-name: - 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 - type: string - working-directory: - description: Caller repository working directory. - required: false - type: string - default: . - package-command: - description: Command that builds a release .app bundle. - required: true - type: string - app-path: - description: Path to the built .app bundle, relative to working-directory. - required: true - type: string - dmg-name: - description: DMG file name to create. - required: true - type: string - artifact-name: - description: GitHub Actions artifact name. - required: false - type: string - default: wails-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 - type: boolean - default: true - github-release-prerelease: - description: Mark the GitHub Release as a prerelease when uploading on tag builds. - required: false - type: boolean - default: false - secrets: - APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_BASE64: - description: Base64-encoded Developer ID Application .p12 certificate. - required: true - APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_PASSWORD: - description: Password for APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_BASE64. - required: true - APP_STORE_CONNECT_API_KEY_P8: - description: App Store Connect API private key content for notarization. - required: true - APP_STORE_CONNECT_API_KEY_ID: - description: App Store Connect API key ID. - required: true - APP_STORE_CONNECT_API_ISSUER_ID: - description: App Store Connect issuer ID. - required: true - MACOS_CODESIGN_IDENTITY: - description: Optional full Developer ID Application signing identity. - required: false - -jobs: - release: - name: Build, Sign, and Notarize macOS App - runs-on: ${{ inputs.runner-label }} - timeout-minutes: 60 - permissions: - contents: write - - steps: - - name: Check out caller repository - uses: actions/checkout@v6 - - - 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 }} - 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: 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: Install create-dmg - run: brew install create-dmg - - - name: Validate Apple signing secrets - env: - 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 }} - 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 }} - 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 - - - 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 }} - 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 '"') - - - 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" - - - name: Sign app bundle - working-directory: ${{ inputs.working-directory }} - env: - APP_PATH: ${{ inputs.app-path }} - SIGNING_IDENTITY: ${{ steps.signing.outputs.identity }} - run: | - codesign \ - --force \ - --deep \ - --options runtime \ - --timestamp \ - --sign "$SIGNING_IDENTITY" \ - "$APP_PATH" - - codesign --verify --deep --strict --verbose=2 "$APP_PATH" - - - name: Notarize and staple app bundle - working-directory: ${{ 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" - - - name: Create signed DMG - working-directory: ${{ 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 }} - 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" - - - name: Notarize and staple DMG - working-directory: ${{ 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" - - - 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 }} - 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 }} - prerelease: ${{ inputs.github-release-prerelease }} - generate_release_notes: true 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 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/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..57a3129 --- /dev/null +++ b/mac/cmd/macos-release/main.go @@ -0,0 +1,115 @@ +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.") + appPath := flags.String("app-path", "", "Expected app path relative to destination.") + if err := flags.Parse(args[1:]); err != nil { + return err + } + return release.ExtractAppArchive(*archivePath, *destination, *appPath) + 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..a89e877 --- /dev/null +++ b/mac/release/release.go @@ -0,0 +1,263 @@ +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 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 new file mode 100644 index 0000000..ec1f249 --- /dev/null +++ b/mac/release/release_test.go @@ -0,0 +1,141 @@ +package release + +import ( + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/vuon9/gh-workflows/internal/archive" +) + +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) + } +} + +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 +} + +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 +} + +func createTestArchive(archivePath, source string) error { + return archive.CreateTarGz(archivePath, source) +}