Files
hermes-agent/.github/workflows/bootstrap-installer-build.yml
ethernet 0217a2a324 feat(icons): custom drive artwork as the volume icon of both installer DMGs
The desktop DMG's volume icon defaulted to the packager's icns, which is
actool's 256px fallback whenever mac.icon is the Icon Composer package, and
dmgbuild's badge option can only paste onto the stock removable-drive icon.
Ship our own drive-with-the-girl artwork instead: assets/dmg-volume.png
(hand-made, one artwork since volume icons have no appearance variants) is
emitted as apps/desktop/packaging/dmg-volume.icns, beside the DMG background
so the `files` whitelist keeps it out of the app bundle, and dmg.icon points
at it.

Tauri always uses the app icns as the volume icon and offers no override, so
the bootstrap workflow runs scripts/bundles/set_dmg_volume_icon.sh on the
built DMG before signing (the same .VolumeIcon.icns + custom-icon flag
dmgbuild sets), and the verify step checks the icon is on the mounted image.
Verified on macOS 26.6.2: both DMGs render the identical volume icon.
2026-10-02 10:33:44 -04:00

287 lines
12 KiB
YAML

name: Bootstrap installer build
# Hand-dispatched, signed builds of the Tauri bootstrap installer
# (apps/bootstrap-installer, "Hermes-Setup"): Windows x64 exe signed with
# Azure Trusted Signing, macOS arm64 dmg signed with the Developer ID cert and
# notarized. Uploaded as run artifacts only; nothing is published.
#
# No caches of any kind: no actions/cache, no setup-node cache, and fresh
# npm/cargo/electron-builder cache dirs under RUNNER_TEMP.
on:
workflow_dispatch:
inputs:
ref:
description: Commit, branch or tag to build
required: true
default: main
pin_branch:
description: Branch the installer follows at install time (HERMES_BUILD_PIN_BRANCH)
required: true
default: main
pin_commit:
description: Optional commit pin baked in instead (HERMES_BUILD_PIN_COMMIT)
required: false
default: ''
permissions:
contents: read
id-token: write
concurrency:
group: bootstrap-installer-build-${{ github.run_id }}
cancel-in-progress: false
env:
HERMES_BUILD_PIN_BRANCH: ${{ inputs.pin_branch }}
HERMES_BUILD_PIN_COMMIT: ${{ inputs.pin_commit }}
CARGO_INCREMENTAL: '0'
SCCACHE_DISABLE: '1'
jobs:
windows-x64:
name: Hermes-Setup.exe (windows x64, signed)
runs-on: windows-latest
environment: release-signing
timeout-minutes: 90
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7.0.1
with:
ref: ${{ inputs.ref }}
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # 7.0.0
with:
node-version: 24.11.1
- name: Fresh cache dirs
shell: bash
run: |
{
echo "CARGO_HOME=$RUNNER_TEMP/cargo-home"
echo "CARGO_TARGET_DIR=$RUNNER_TEMP/target"
echo "npm_config_cache=$RUNNER_TEMP/npm-cache"
echo "ELECTRON_BUILDER_CACHE=$RUNNER_TEMP/electron-builder-cache"
} >> "$GITHUB_ENV"
echo "$USERPROFILE/.cargo/bin" >> "$GITHUB_PATH"
- name: Require Azure signing vars
shell: bash
env:
AZURE_SIGN_ENDPOINT: ${{ vars.AZURE_SIGN_ENDPOINT }}
AZURE_SIGN_ACCOUNT: ${{ vars.AZURE_SIGN_ACCOUNT }}
AZURE_SIGN_PROFILE: ${{ vars.AZURE_SIGN_PROFILE }}
AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }}
run: |
missing=()
for name in AZURE_SIGN_ENDPOINT AZURE_SIGN_ACCOUNT AZURE_SIGN_PROFILE AZURE_CLIENT_ID AZURE_TENANT_ID; do
[ -z "${!name}" ] && missing+=("$name")
done
if [ ${#missing[@]} -gt 0 ]; then
echo "::error::Azure Trusted Signing vars missing from release-signing: ${missing[*]}"
exit 1
fi
- name: Build
shell: bash
run: |
rustup target add x86_64-pc-windows-msvc
npm ci --workspace apps/bootstrap-installer --include-workspace-root=false --prefer-online --no-audit --no-fund
cd apps/bootstrap-installer
npx tauri build --target x86_64-pc-windows-msvc --no-bundle
exe="$CARGO_TARGET_DIR/x86_64-pc-windows-msvc/release/Hermes-Setup.exe"
mkdir -p "$RUNNER_TEMP/out"
cp "$exe" "$RUNNER_TEMP/out/Hermes-Setup.exe"
- name: Azure login (OIDC)
uses: azure/login@f5d393ae46f8fde4be8b75f32e3fc50e654ad0ca # v3.0.1
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
allow-no-subscriptions: true
- name: Mint federated token for the signing dlib
shell: bash
run: |
file="$RUNNER_TEMP/azure-federated-token"
mint() {
curl -sSf -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=api://AzureADTokenExchange" \
| jq -r .value > "$file.tmp" && mv -f "$file.tmp" "$file"
}
mint
( while sleep 240; do mint || true; done ) &
echo "AZURE_FEDERATED_TOKEN_FILE=$file" >> "$GITHUB_ENV"
- name: Sign (same Azure Trusted Signing path as the desktop MSIX)
shell: bash
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: '1'
AZURE_SIGN_ENDPOINT: ${{ vars.AZURE_SIGN_ENDPOINT }}
AZURE_SIGN_ACCOUNT: ${{ vars.AZURE_SIGN_ACCOUNT }}
AZURE_SIGN_PROFILE: ${{ vars.AZURE_SIGN_PROFILE }}
AZURE_SIGN_PUBLISHER: ${{ vars.AZURE_SIGN_PUBLISHER }}
AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }}
AZURE_TOKEN_CREDENTIALS: prod
run: |
npm ci --workspace apps/desktop --include-workspace-root=false --prefer-online --no-audit --no-fund
# shellcheck disable=SC2016 # JS template literal, not shell
EXE="$(cygpath -w "$RUNNER_TEMP/out/Hermes-Setup.exe")" node --input-type=module -e '
import { pathToFileURL } from "node:url"
const m = await import(pathToFileURL("apps/desktop/scripts/batch-sign-binaries.mjs").href)
const r = await m.batchSignBinaries([process.env.EXE], { cache: null })
if (r.skipped || r.signed !== 1) throw new Error(`not signed: ${JSON.stringify(r)}`)
'
- name: Verify signature
shell: pwsh
run: |
$sig = Get-AuthenticodeSignature "$env:RUNNER_TEMP\out\Hermes-Setup.exe"
$sig | Format-List Status, StatusMessage, SignerCertificate, TimeStamperCertificate
if ($sig.Status -ne 'Valid') { throw "signature status: $($sig.Status)" }
if (-not $sig.TimeStamperCertificate) { throw "signature is not timestamped" }
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Hermes-Setup-windows-x64
path: ${{ runner.temp }}/out/Hermes-Setup.exe
if-no-files-found: error
macos-arm64:
name: Hermes-Setup.dmg (macos arm64, signed + notarized)
runs-on: macos-latest
environment: release-signing
timeout-minutes: 90
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7.0.1
with:
ref: ${{ inputs.ref }}
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # 7.0.0
with:
node-version: 24.11.1
- name: Fresh cache dirs
run: |
{
echo "CARGO_HOME=$RUNNER_TEMP/cargo-home"
echo "CARGO_TARGET_DIR=$RUNNER_TEMP/target"
echo "npm_config_cache=$RUNNER_TEMP/npm-cache"
} >> "$GITHUB_ENV"
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Import Developer ID certificate
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: |
missing=()
for name in CSC_LINK CSC_KEY_PASSWORD APPLE_API_KEY_P8 APPLE_API_KEY_ID APPLE_API_ISSUER; do
[ -z "${!name}" ] && missing+=("$name")
done
if [ ${#missing[@]} -gt 0 ]; then
echo "::error::signing/notarization secrets missing from release-signing: ${missing[*]}"
exit 1
fi
p12="$RUNNER_TEMP/cert.p12"
printf '%s' "$CSC_LINK" | base64 --decode > "$p12"
kc="$RUNNER_TEMP/signing.keychain-db"
pw="$(uuidgen)"
security create-keychain -p "$pw" "$kc"
security set-keychain-settings -lut 21600 "$kc"
security unlock-keychain -p "$pw" "$kc"
security import "$p12" -k "$kc" -P "$CSC_KEY_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$pw" "$kc" >/dev/null
# shellcheck disable=SC2046 # the existing search list must split
security list-keychains -d user -s "$kc" $(security list-keychains -d user | tr -d '"')
rm -f "$p12"
identity="$(security find-identity -v -p codesigning "$kc" | grep -o '"Developer ID Application: [^"]*"' | head -1 | tr -d '"')"
test -n "$identity" || { echo "::error::no Developer ID Application identity in CSC_LINK"; exit 1; }
printf '%s\n' "$APPLE_API_KEY_P8" > "$RUNNER_TEMP/apple-api-key.p8"
{
echo "APPLE_SIGNING_IDENTITY=$identity"
echo "APPLE_API_KEY_PATH=$RUNNER_TEMP/apple-api-key.p8"
} >> "$GITHUB_ENV"
- name: Select Xcode 26 for the macOS 26 layered icon
# Tauri's bundler compiles src-tauri/icons/icon.icon to Assets.car with
# actool, which only Xcode >= 26 ships; on an older Xcode it logs an
# error and ships the .icns alone. The runner keeps an older Xcode
# selected, so pick 26 and assert it.
run: |
xcode="$(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V | tail -1)"
if [ -z "$xcode" ]; then
echo "::error::no Xcode 26 on this runner; the macOS 26 layered icon cannot be compiled"
exit 1
fi
echo "DEVELOPER_DIR=$xcode/Contents/Developer" >> "$GITHUB_ENV"
export DEVELOPER_DIR="$xcode/Contents/Developer"
version="$(actool --version | python3 -c 'import plistlib, sys; print(plistlib.loads(sys.stdin.buffer.read())["com.apple.actool.version"]["short-bundle-version"])')"
echo "actool $version from $xcode"
case "$version" in
2[6-9].*|[3-9][0-9].*) ;;
*) echo "::error::actool $version cannot compile .icon packages (need >= 26)"; exit 1 ;;
esac
- name: Build, sign and notarize
env:
# Tauri notarizes the .app with an App Store Connect API key.
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: |
rustup target add aarch64-apple-darwin
npm ci --workspace apps/bootstrap-installer --include-workspace-root=false --prefer-online --no-audit --no-fund
cd apps/bootstrap-installer
npx tauri build --target aarch64-apple-darwin --bundles app,dmg
- name: Sign, notarize and staple the dmg
run: |
shopt -s nullglob
bundle="$CARGO_TARGET_DIR/aarch64-apple-darwin/release/bundle"
dmgs=("$bundle"/dmg/*.dmg)
test ${#dmgs[@]} -eq 1
mkdir -p "$RUNNER_TEMP/out"
dmg="$RUNNER_TEMP/out/Hermes-Setup.dmg"
# Tauri always uses the app icns as the volume icon; give the DMG the
# same drive artwork as the desktop installer before signing.
scripts/bundles/set_dmg_volume_icon.sh "${dmgs[0]}" apps/desktop/packaging/dmg-volume.icns "$dmg"
codesign --force --timestamp --sign "$APPLE_SIGNING_IDENTITY" "$dmg"
xcrun notarytool submit "$dmg" --wait \
--key "$APPLE_API_KEY_PATH" --key-id "$APPLE_API_KEY_ID" --issuer "$APPLE_API_ISSUER"
xcrun stapler staple "$dmg"
env:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
- name: Verify the app and dmg are signed and notarized
run: |
shopt -s nullglob
apps=("$CARGO_TARGET_DIR"/aarch64-apple-darwin/release/bundle/macos/*.app)
test ${#apps[@]} -eq 1
codesign --verify --strict --verbose=2 "${apps[0]}"
xcrun stapler validate "${apps[0]}"
spctl -a -vv -t exec "${apps[0]}"
# The macOS 26 layered icon: Tauri only logs when actool is missing,
# so prove the catalog and its plist key made it into the bundle.
test -f "${apps[0]}/Contents/Resources/Assets.car"
test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIconName' "${apps[0]}/Contents/Info.plist")" = "Icon"
dmg="$RUNNER_TEMP/out/Hermes-Setup.dmg"
codesign --verify --verbose=2 "$dmg"
xcrun stapler validate "$dmg"
spctl -a -vv -t open --context context:primary-signature "$dmg"
mount=$(hdiutil attach -nobrowse -readonly "$dmg" | awk -F'\t' '/\/Volumes\//{print $NF; exit}')
test -f "$mount/.VolumeIcon.icns"
hdiutil detach "$mount"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Hermes-Setup-macos-arm64
path: ${{ runner.temp }}/out/Hermes-Setup.dmg
if-no-files-found: error