Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
250 changes: 15 additions & 235 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,245 +1,25 @@
name: Release
name: macOS Release

on:
# Run on version tags (releases)
push:
tags:
- "v*"
# Allow manual trigger
- "macos/devtoolbox/v*"
workflow_dispatch:

permissions:
contents: write
pull-requests: write

env:
# Necessary for most environments
CGO_ENABLED: 1

jobs:
release-please:
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
steps:
- uses: googleapis/release-please-action@v4
id: release
with:
token: ${{ secrets.GITHUB_TOKEN }}

release-build:
needs: release-please
if: ${{ always() && (needs.release-please.outputs.release_created == 'true' || github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')) }}
name: Release Build
strategy:
fail-fast: false
matrix:
build: [linux, windows, macos]
include:
- build: linux
os: ubuntu-latest
platform: linux/amd64
- build: windows
os: windows-latest
platform: windows/amd64
- build: macos
os: macos-latest
platform: darwin/amd64

runs-on: ${{ matrix.os }}
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v6.0.3
with:
submodules: recursive
ref: ${{ needs.release-please.outputs.tag_name || github.ref }}

- name: Setup Go
uses: actions/setup-go@v6.4.0
with:
go-version: "1.25.0"
check-latest: true

- name: Setup Bun
uses: oven-sh/setup-bun@v2.2.0
with:
bun-version: latest

- name: Install Linux dependencies
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-4-dev libwebkitgtk-6.0-dev libvulkan-dev libgraphene-1.0-dev

- name: Install Frontend Dependencies
run: |
cd frontend && bun install
shell: bash

- name: Check macOS signing inputs
id: macos_signing
if: matrix.os == 'macos-latest'
env:
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
MACOS_SIGN_IDENTITY: ${{ secrets.MACOS_SIGN_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
missing=0
for name in MACOS_CERTIFICATE MACOS_CERTIFICATE_PASSWORD MACOS_SIGN_IDENTITY APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_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

echo "available=true" >> "$GITHUB_OUTPUT"
shell: bash

- name: Import macOS Developer ID certificate
if: matrix.os == 'macos-latest' && steps.macos_signing.outputs.available == 'true'
uses: apple-actions/import-codesign-certs@v2
with:
p12-file-base64: ${{ secrets.MACOS_CERTIFICATE }}
p12-password: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
keychain: build
keychain-password: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}

- name: Build Application
run: |
cd frontend
bun run build
cd ..

mkdir -p bin

if [ "${{ matrix.os }}" = "macos-latest" ]; then
export GOOS=darwin
export CGO_ENABLED=1
export CGO_CFLAGS="-mmacosx-version-min=10.15"
export CGO_LDFLAGS="-mmacosx-version-min=10.15"
export MACOSX_DEPLOYMENT_TARGET="10.15"

GOARCH=amd64 go build -tags production -trimpath -buildvcs=false -ldflags="-w -s" -o bin/DevToolbox-amd64 .
GOARCH=arm64 go build -tags production -trimpath -buildvcs=false -ldflags="-w -s" -o bin/DevToolbox-arm64 .
lipo -create -output bin/DevToolbox bin/DevToolbox-amd64 bin/DevToolbox-arm64
rm bin/DevToolbox-amd64 bin/DevToolbox-arm64

mkdir -p "bin/DevToolbox.app/Contents/MacOS"
mkdir -p "bin/DevToolbox.app/Contents/Resources"
cp "bin/DevToolbox" "bin/DevToolbox.app/Contents/MacOS/"
cp "build/darwin/Info.plist" "bin/DevToolbox.app/Contents/"
cp "build/darwin/icons.icns" "bin/DevToolbox.app/Contents/Resources/"
if [ -f "build/darwin/Assets.car" ]; then
cp "build/darwin/Assets.car" "bin/DevToolbox.app/Contents/Resources/"
fi
else
if [ "${{ matrix.os }}" = "windows-latest" ]; then
go build -tags production -trimpath -buildvcs=false -ldflags="-w -s" -o bin/DevToolbox.exe .
else
go build -tags production -trimpath -buildvcs=false -ldflags="-w -s" -o bin/DevToolbox .
fi
fi
shell: bash

- name: Sign macOS app
if: matrix.os == 'macos-latest' && steps.macos_signing.outputs.available == 'true'
env:
MACOS_SIGN_IDENTITY: ${{ secrets.MACOS_SIGN_IDENTITY }}
run: |
APP_BUNDLE="bin/DevToolbox.app"
test -d "$APP_BUNDLE"

codesign \
--force \
--deep \
--options runtime \
--timestamp \
--sign "$MACOS_SIGN_IDENTITY" \
"$APP_BUNDLE"

codesign --verify --deep --strict --verbose=2 "$APP_BUNDLE"
shell: bash

- name: Notarize macOS App
if: matrix.os == 'macos-latest' && steps.macos_signing.outputs.available == 'true'
run: |
APP_BUNDLE="bin/DevToolbox.app"
NOTARY_ZIP="bin/DevToolbox-notary.zip"

ditto -c -k --keepParent "$APP_BUNDLE" "$NOTARY_ZIP"

xcrun notarytool submit "$NOTARY_ZIP" \
--apple-id "$APPLE_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--wait

xcrun stapler staple "$APP_BUNDLE"
xcrun stapler validate "$APP_BUNDLE"
spctl --assess --type execute --verbose=4 "$APP_BUNDLE"
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
shell: bash

# Package and upload build artifacts
- name: Package Artifacts
run: |
mkdir -p release

if [ "${{ matrix.os }}" = "macos-latest" ]; then
APP_BUNDLE="bin/DevToolbox.app"
test -d "$APP_BUNDLE"
brew install create-dmg
create-dmg \
--volname "DevToolbox" \
--window-pos 200 120 \
--window-size 800 400 \
--icon-size 100 \
--app-drop-link 600 185 \
"release/DevToolbox-${{ matrix.build }}.dmg" \
"$APP_BUNDLE"
hdiutil verify "release/DevToolbox-${{ matrix.build }}.dmg"
elif [ "${{ matrix.os }}" = "windows-latest" ]; then
BINARY_NAME=$(ls bin/ | grep -i "devtoolbox.*\.exe$" | head -1)
test -n "$BINARY_NAME"
cp "bin/$BINARY_NAME" "release/DevToolbox-${{ matrix.build }}.exe"
else
BINARY_NAME=$(ls bin/ | grep -i "devtoolbox" | head -1)
test -n "$BINARY_NAME"
tar -czf "release/DevToolbox-${{ matrix.build }}.tar.gz" -C bin "$BINARY_NAME"
fi

echo "=== Release contents ==="
ls -la release/
shell: bash

- name: Upload Artifacts
uses: actions/upload-artifact@v7.0.1
with:
name: devtoolbox-${{ matrix.build }}
path: release/*

# Create Release and upload assets (only on tags)
- name: Create Release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v3.0.0
with:
files: release/*
draft: false
prerelease: false
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
release:
uses: vuon9/gh-workflows/.github/workflows/macos-desktop-release.yml@khoa/macos-desktop-release-workflow
with:
app-name: DevToolbox
bundle-id: com.vuon9.devtoolbox
team-id: 256XRVYZ9V
package-command: scripts/package-macos-universal.sh
app-path: bin/DevToolbox.app
dmg-name: DevToolbox-macos-universal.dmg
artifact-name: devtoolbox-macos-release-${{ github.run_id }}
go-version-file: go.mod
runner-label: macos-26
secrets: inherit
38 changes: 22 additions & 16 deletions docs/MACOS_RELEASE.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
# macOS Signed Release

This project ships macOS releases as a signed, notarized, and stapled
`DevToolbox-macos.dmg` from `.github/workflows/release.yml`.
`DevToolbox-macos-universal.dmg` from `.github/workflows/release.yml`.

The first signed release is intentionally macOS-only. Linux and Windows release
artifacts are skipped until a later release pass.

## Required GitHub Secrets

Configure these repository secrets before running a release:

- `MACOS_CERTIFICATE`: base64 encoded Developer ID Application `.p12`
- `MACOS_CERTIFICATE_PASSWORD`: password for the `.p12`
- `MACOS_SIGN_IDENTITY`: full Developer ID Application identity name
- `APPLE_ID`: Apple ID used for notarization
- `APPLE_APP_SPECIFIC_PASSWORD`: app-specific password for the Apple ID
- `APPLE_TEAM_ID`: Apple Developer Team ID
- `APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_BASE64`: base64 encoded Developer ID Application `.p12`
- `APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_PASSWORD`: password for the `.p12`
- `APP_STORE_CONNECT_API_KEY_P8`: App Store Connect API private key content
- `APP_STORE_CONNECT_API_KEY_ID`: App Store Connect API key ID
- `APP_STORE_CONNECT_API_ISSUER_ID`: App Store Connect issuer ID

Optional:

- `MACOS_CODESIGN_IDENTITY`: full `Developer ID Application: ... (TEAMID)` identity name. If omitted, the reusable workflow finds the imported Developer ID Application identity for team `256XRVYZ9V`.

The release workflow fails early on the macOS job if any of these secrets are
The release workflow fails early if required signing or notarization secrets are
missing. Unsigned macOS release artifacts are not uploaded by the release job.

## What the Workflow Does
Expand All @@ -25,22 +31,22 @@ On macOS runners, the release job:
2. Imports the Developer ID Application certificate into a temporary keychain.
3. Signs the app with hardened runtime and timestamping.
4. Verifies the signature with `codesign --verify`.
5. Submits the app to Apple notarization and waits for completion.
6. Staples and validates the notarization ticket.
5. Submits the app to Apple notarization through App Store Connect API keys.
6. Staples and validates the app notarization ticket.
7. Runs `spctl --assess --type execute`.
8. Packages the stapled app into `DevToolbox-macos.dmg`.
9. Verifies the DMG with `hdiutil verify`.
8. Packages the stapled app into `DevToolbox-macos-universal.dmg`.
9. Signs, notarizes, staples, and verifies the DMG.

Mini owns certificate setup, notarization credentials, and final local Gatekeeper
verification for the released artifact.
Mini owns Apple Developer certificate export, repository secret setup, and final
local Gatekeeper verification for the released artifact.

## Local macOS Verification

After downloading the release DMG on macOS:

```bash
hdiutil verify DevToolbox-macos.dmg
hdiutil attach DevToolbox-macos.dmg
hdiutil verify DevToolbox-macos-universal.dmg
hdiutil attach DevToolbox-macos-universal.dmg
spctl --assess --type execute --verbose=4 /Volumes/DevToolbox/DevToolbox.app
codesign --verify --deep --strict --verbose=2 /Volumes/DevToolbox/DevToolbox.app
open /Volumes/DevToolbox/DevToolbox.app
Expand Down
44 changes: 44 additions & 0 deletions scripts/package-macos-universal.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -euo pipefail

app_name="${APP_NAME:-DevToolbox}"
bin_dir="${BIN_DIR:-bin}"

export GOOS=darwin
export CGO_ENABLED=1
export CGO_CFLAGS="-mmacosx-version-min=10.15"
export CGO_LDFLAGS="-mmacosx-version-min=10.15"
export MACOSX_DEPLOYMENT_TARGET="10.15"

go mod download

test -f build/darwin/Info.plist
test -f build/darwin/icons.icns

(
cd frontend
bun install --frozen-lockfile
PRODUCTION=true bun run build
)

mkdir -p "$bin_dir"

GOARCH=amd64 go build -tags production -trimpath -buildvcs=false -ldflags="-w -s" -o "$bin_dir/$app_name-amd64" .
GOARCH=arm64 go build -tags production -trimpath -buildvcs=false -ldflags="-w -s" -o "$bin_dir/$app_name-arm64" .

lipo -create -output "$bin_dir/$app_name" "$bin_dir/$app_name-amd64" "$bin_dir/$app_name-arm64"
rm "$bin_dir/$app_name-amd64" "$bin_dir/$app_name-arm64"

app_bundle="$bin_dir/$app_name.app"
rm -rf "$app_bundle"
mkdir -p "$app_bundle/Contents/MacOS"
mkdir -p "$app_bundle/Contents/Resources"

cp "$bin_dir/$app_name" "$app_bundle/Contents/MacOS/"
cp build/darwin/Info.plist "$app_bundle/Contents/"
cp build/darwin/icons.icns "$app_bundle/Contents/Resources/"
if [[ -f build/darwin/Assets.car ]]; then
cp build/darwin/Assets.car "$app_bundle/Contents/Resources/"
fi

echo "Created $app_bundle"
Loading