Set up or troubleshoot GitHub Actions workflows for releasing cross-platform Qt applications. Use when building, signing, notarizing, or packaging Qt apps for macOS, Linux, or Windows.
This skill helps set up and troubleshoot GitHub Actions workflows for releasing Qt applications on macOS, Linux, and Windows. It covers Qt installation, platform-specific bundling, code signing, notarization, and common pitfalls.
A complete Qt release workflow should include:
name: Build
on:
push:
branches: [main]
tags: ['v*']
pull_request:
branches: [main]
env:
QT_VERSION: '6.8.0'
BUILD_TYPE: Release
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-22.04 # NOT ubuntu-latest (see Linux section)
name: Linux
- os: macos-latest
name: macOS
- os: windows-latest
name: Windows
runs-on: ${{ matrix.os }}
Use jurplel/install-qt-action@v4:
- name: Install Qt
uses: jurplel/install-qt-action@v4
with:
version: ${{ env.QT_VERSION }}
cache: true
modules: 'qtimageformats' # Add modules as needed
- name: Bundle Qt Frameworks (macOS)
if: runner.os == 'macOS'
run: |
macdeployqt build/qt/myapp.app -verbose=2
# Remove Headers and .prl files that can cause signing issues
APP_PATH="build/qt/myapp.app"
find "$APP_PATH/Contents/Frameworks" -name "Headers" -exec rm -rf {} + 2>/dev/null || true
find "$APP_PATH/Contents/Frameworks" -name "*.prl" -delete
Required GitHub Secrets:
MACOS_CERTIFICATE: Base64-encoded .p12 certificateMACOS_CERTIFICATE_PWD: Certificate passwordMACOS_SIGNING_IDENTITY: e.g., "Developer ID Application: Your Name (TEAMID)"APPLE_ID: Apple ID email for notarizationAPPLE_APP_PASSWORD: App-specific passwordAPPLE_TEAM_ID: Your team IDCertificate Export Steps:
base64 -i certificate.p12 | pbcopySigning Workflow:
- name: Import Code Signing Certificate
id: codesign
if: runner.os == 'macOS'
env:
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }}
run: |
if [ -z "$MACOS_CERTIFICATE" ]; then
echo "has_certificate=false" >> $GITHUB_OUTPUT
exit 0
fi
echo "has_certificate=true" >> $GITHUB_OUTPUT
KEYCHAIN_PATH=$RUNNER_TEMP/signing.keychain-db
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
echo "$MACOS_CERTIFICATE" | base64 --decode > $RUNNER_TEMP/certificate.p12
security import $RUNNER_TEMP/certificate.p12 -P "$MACOS_CERTIFICATE_PWD" \
-A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
security list-keychain -d user -s "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
- name: Sign App Bundle
if: runner.os == 'macOS' && steps.codesign.outputs.has_certificate == 'true'
env:
SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }}
run: |
APP_PATH="build/qt/myapp.app"
# Sign Qt frameworks
find "$APP_PATH/Contents/Frameworks" -name "*.framework" -type d | while read framework; do
codesign --force --options runtime --sign "$SIGNING_IDENTITY" --timestamp "$framework"
done
# Sign Qt plugins
find "$APP_PATH/Contents/PlugIns" -name "*.dylib" -type f | while read plugin; do
codesign --force --options runtime --sign "$SIGNING_IDENTITY" --timestamp "$plugin"
done
# Sign dylibs in Frameworks
find "$APP_PATH/Contents/Frameworks" -name "*.dylib" -type f | while read dylib; do
codesign --force --options runtime --sign "$SIGNING_IDENTITY" --timestamp "$dylib"
done
# Sign main executable
codesign --force --options runtime --sign "$SIGNING_IDENTITY" --timestamp "$APP_PATH/Contents/MacOS/myapp"
# Sign app bundle
codesign --force --options runtime --sign "$SIGNING_IDENTITY" --timestamp "$APP_PATH"
# Verify
codesign --verify --verbose=4 "$APP_PATH"
spctl -a -vvv "$APP_PATH" || echo "spctl check completed"
- name: Notarize App Bundle
if: runner.os == 'macOS' && steps.codesign.outputs.has_certificate == 'true' && startsWith(github.ref, 'refs/tags/v')
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
APP_PATH="build/qt/myapp.app"
ditto -c -k --keepParent "$APP_PATH" $RUNNER_TEMP/myapp-notarize.zip
xcrun notarytool submit $RUNNER_TEMP/myapp-notarize.zip \
--apple-id "$APPLE_ID" \
--password "$APPLE_APP_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--wait
xcrun stapler staple "$APP_PATH"
Use ditto, NOT zip! The zip command follows symlinks and creates copies, corrupting Qt framework structure.
- name: Package (macOS)
if: runner.os == 'macOS'
run: |
cd build/qt
# ditto preserves symlinks, resource forks, and extended attributes
ditto -c -k --sequesterRsrc --keepParent myapp.app myapp-macos.zip
linuxdeployqt requires glibc 2.35 or older for broad compatibility:
- os: ubuntu-22.04 # Required for linuxdeployqt
name: Linux
- name: Install Linux dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libgl1-mesa-dev libxkbcommon-dev libxkbcommon-x11-0 \
imagemagick libfuse2 libxcb-cursor0
- name: Package (Linux AppImage)
if: runner.os == 'Linux'
run: |
mkdir -p AppDir/usr/bin
mkdir -p AppDir/usr/share/applications
mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps
cp build/qt/myapp AppDir/usr/bin/
cp qt/myapp.desktop AppDir/usr/share/applications/
cp qt/myapp.desktop AppDir/
# Create placeholder icon (replace with real icon)
convert -size 256x256 xc:'#6B46C1' AppDir/usr/share/icons/hicolor/256x256/apps/myapp.png
cp AppDir/usr/share/icons/hicolor/256x256/apps/myapp.png AppDir/myapp.png
wget -q https://github.com/probonopd/linuxdeployqt/releases/download/continuous/linuxdeployqt-continuous-x86_64.AppImage
chmod +x linuxdeployqt-continuous-x86_64.AppImage
./linuxdeployqt-continuous-x86_64.AppImage AppDir/usr/share/applications/myapp.desktop -appimage -verbose=2
mv MyApp*.AppImage myapp-linux.AppImage
Create qt/myapp.desktop:
[Desktop Entry]
Type=Application
Name=MyApp
Comment=Description of your app
Exec=myapp
Icon=myapp
Categories=Game;Utility;
Terminal=false
Add to CMakeLists.txt:
if(WIN32)
target_link_libraries(myapp PRIVATE opengl32)
endif()
- name: Package (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
cd build/qt/Release
mkdir package
copy myapp.exe package/
windeployqt --release --no-translations package/myapp.exe
Compress-Archive -Path package/* -DestinationPath ../myapp-windows.zip
Cause: Framework symlinks were converted to copies (usually by zip).
Fix: Use ditto for packaging:
ditto -c -k --sequesterRsrc --keepParent myapp.app myapp.zip
Check:
xcrun stapler validate myapp.appspctl -a -vvv myapp.appditto not zip for packagingCause: linuxdeployqt requires older glibc for compatibility.
Fix: Use ubuntu-22.04 instead of ubuntu-latest.
Cause: Font packages not installed.
Fix: Use simple icon without text:
convert -size 256x256 xc:'#6B46C1' icon.png
Add to source files as needed:
#include <cstddef> // for size_t
#include <string> // for std::string
#include <algorithm> // for std::find
if:Wrong:
if: secrets.MY_SECRET != '' # Doesn't work
Right:
- id: check
run: |
if [ -n "$MY_SECRET" ]; then
echo "has_secret=true" >> $GITHUB_OUTPUT
fi
env:
MY_SECRET: ${{ secrets.MY_SECRET }}
- if: steps.check.outputs.has_secret == 'true'
release:
needs: build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Create Release
uses: softprops/action-gh-release@v1
with:
files: |
artifacts/myapp-linux/myapp-linux.AppImage
artifacts/myapp-macos/myapp-macos.zip
artifacts/myapp-windows/myapp-windows.zip
draft: true
generate_release_notes: true
After downloading a macOS release:
# Check code signature
codesign --verify --verbose=4 myapp.app
# Check Gatekeeper acceptance
spctl -a -vvv myapp.app
# Verify framework symlinks are intact
ls -la myapp.app/Contents/Frameworks/QtCore.framework/
# QtCore should be symlink -> Versions/Current/QtCore