Skip to content

Build Mac App Store Release #29

Build Mac App Store Release

Build Mac App Store Release #29

name: Build Mac App Store Release
on:
workflow_dispatch:
inputs:
releaseVersion:
description: "Version to title release with (like: 1.0rc3), blank for project's version"
type: string
required: false
uploadToStore:
description: "Upload to App Store Connect"
type: boolean
required: true
default: false
env:
uploadToStoreDefault: true
projectfile: Maccy.xcodeproj
buildscheme: "Cleepp (App Store)"
productname: "Batch Clipboard"
bundlename: "Batch Clipboard.app"
builddir: Build/Products/Release
branch: forkmain
jobs:
build:
name: Build and Upload Cleepp AppStore Variant
runs-on: macos-15
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # required for 'git show-ref --tags' to work
ref: "${{ env.branch }}"
- name: Patch Xcode 15.3
uses: jpmhouston/patch-package-resolved@v1
# this fixes a mysterious build failure
# xcodebuild: error: Could not resolve package dependencies:
# Package.resolved file is corrupted or malformed; fix or delete the file
# to continue: unknown 'PinsStorage' version '3'
# should probably remove this when upgrading the "runs-on" platform
- name: Install tools
# pandoc is used by sparkle step and by one of the xcode project's build rules
# create-dmg is to define dmg entirely from script below instead of using a tempplate
run: |
:
brew update
brew install pandoc create-dmg coreutils timelimit
if ! command -v xcodebuild >/dev/null 2>&1 || ! command -v xcrun >/dev/null 2>&1 \
|| ! command -v security >/dev/null 2>&1 || ! command -v plutil >/dev/null 2>&1 \
|| ! command -v xcbeautify >/dev/null 2>&1
then
echo "::error::Required executables not found: xcodebuild, xcrun, security, plutil, xcbeautify"
exit 1
fi
- name: Validate
id: version
run: |
:
echo "- Extract version and bundle id from the project"
xcodebuild -scheme "${{ env.buildscheme }}" -configuration Release \
-project "${{ env.projectfile }}" -showBuildSettings 2>/dev/null > buildsettings.txt
version=$(sed -nr 's/^.*MARKETING_VERSION = (.*)$/\1/p' < buildsettings.txt)
if [[ -z $version ]] ; then
echo "::error::Unable to determine a version number for the current state of the xcode project"
exit 1
fi
bundleID=$(sed -nr 's/^.*PRODUCT_BUNDLE_IDENTIFIER = (.*)$/\1/p' < buildsettings.txt)
if [[ -z $bundleID ]] ; then
echo "::error::Unable to extract bundle id from the xcode project"
exit 1
fi
echo "- Check script inputs"
if [[ -z "${{ inputs.releaseVersion }}" || $version == "${{ inputs.releaseVersion }}" ]] ; then
echo "- Build version is $version"
else
echo "- Build version is $version but overriding with ${{ inputs.releaseVersion }} for release & file names"
version="${{ inputs.releaseVersion }}"
fi
releaseName="${{ env.productname }} $version"
releaseNameNoSpaces="$(echo "${{ env.productname }}" | sed "s/ /./").$version"
if [[ -z "${{ inputs.uploadToStore }}" ]] ; then
echo "- Use default value for uploadToStore: ${{ env.uploadToStoreDefault }}"
uploadToStore=${{ env.uploadToStoreDefault }}
else
echo "- Use supplied value for uploadToStore: ${{ inputs.uploadToStore }}"
uploadToStore=${{ inputs.uploadToStore }}
fi
echo "- Parse trigger" # NOTE: only support triggering manually for now
if [[ "${{ github.event_name }}" == workflow_dispatch ]] ; then
if [[ "${{ github.ref }}" != "refs/heads/${{ env.branch }}" ]] ; then
echo "::error::Manually triggered workflow supports ${{ env.branch }} only, gihub.ref == ${{ github.ref }})"
exit 1
fi
if $uploadToStore == "true" ; then
echo "- Will build and save as artifacts verison \"$releaseName\" and associated release notes"
else
echo "- Will build and deplay verison \"$releaseName\" and save as as artifact with associated release notes"
fi
else
echo "::error::Not triggered manually or by a tag (github.event_name == ${{ github.event_name }}, gihub.ref == ${{ github.ref }})"
exit 1
fi
# parse version to set these
bareversion=$(echo $version | sed -r 's/([0-9](\.[0-9]){1,2}).*/\1/')
versionsuffix=$(echo $version | sed -r 's/[0-9\.]+(.*)/\1/')
echo "version=$version" >> $GITHUB_OUTPUT
echo "bareversion=$bareversion" >> $GITHUB_OUTPUT
echo "versionsuffix=$versionsuffix" >> $GITHUB_OUTPUT
echo "bundleID=$bundleID" >> $GITHUB_OUTPUT
echo "releaseName=$releaseName" >> $GITHUB_OUTPUT
echo "releaseArchivename=$releaseNameNoSpaces" >> $GITHUB_OUTPUT
echo "uploadToStore=$uploadToStore" >> $GITHUB_OUTPUT
if [[ -n $tag ]] ; then
echo "tag=$tag" >> $GITHUB_OUTPUT
fi
- name: Build
id: build
run: |
:
buildlogfile=xcodebuild-out.txt
echo "- Build with xcodebuild from $(xcodebuild -version)"
# requires that env.projectfile is the name of the .xcodeproj, env.buildscheme is
# a valid build scheme, and and env.bundlename is name of the produced .app
# note: not sure why ONLY_ACTIVE_ARCH=NO is required for xcodebuild, it should
# already be NO for Release configuration
set -o pipefail && xcodebuild ONLY_ACTIVE_ARCH=NO clean build analyze \
-scheme "${{ env.buildscheme }}" -configuration Release \
-project "${{ env.projectfile }}" -derivedDataPath . | \
tee "$buildlogfile" | xcbeautify --renderer github-actions
if [[ ! -d "${{ env.builddir }}/${{ env.bundlename }}" ]]; then
echo "::error::Unable to find the built app bundle"
exit 1
fi
echo "- Extract bundle version from app"
plutil -extract CFBundleVersion raw \
"${{ env.builddir }}/${{ env.bundlename }}/Contents/Info.plist"
bundleVersion=$(plutil -extract CFBundleVersion raw \
"${{ env.builddir }}/${{ env.bundlename }}/Contents/Info.plist" 2> /dev/null)
if [[ -z $bundleVersion ]] ; then
echo "::warning::Unable to find the app's bundle version"
fi
echo "version=$bundleVersion" >> $GITHUB_OUTPUT
echo "log=$buildlogfile" >> $GITHUB_OUTPUT
echo "appbundle=${{ env.builddir }}/${{ env.bundlename }}" >> $GITHUB_OUTPUT
- name: Save Build Log as Artifact
if: ${{ success() || failure() }}
uses: actions/upload-artifact@v4
with:
name: Build log
path: ${{ steps.build.outputs.log }}
- name: Patch Version String
if: ${{ steps.version.outputs.versionsuffix != '' && steps.version.outputs.bareversion != '' }}
run: |
:
echo "- Removing suffix \"${{ steps.version.outputs.versionsuffix }}\" from Info.plist CFBundleShortVersionString"
plutil -replace CFBundleShortVersionString -string \
"${{ steps.version.outputs.bareversion }}" \
"${{ steps.build.outputs.appbundle }}/Contents/Info.plist"
- name: Setup Keychain
run: |
:
if [[ -z "${{ secrets.PROD_MACOS_CI_KEYCHAIN_PWD }}" ]] ; then
echo "::error::Secret PROD_MACOS_CI_KEYCHAIN_PWD not defined"
exit 1
fi
if [[ -z "${{ secrets.STORE_MACOS_CERTIFICATE }}" ]] ; then
echo "::error::Secret STORE_MACOS_CERTIFICATE not defined"
exit 1
fi
if [[ -z "${{ secrets.STORE_MACOS_CERTIFICATE_PWD }}" ]] ; then
echo "::error::Secret STORE_MACOS_CERTIFICATE_PWD not defined"
exit 1
fi
if [[ -z "${{ secrets.PKG_MACOS_CERTIFICATE }}" ]] ; then
echo "::error::Secret PKG_MACOS_CERTIFICATE not defined"
exit 1
fi
if [[ -z "${{ secrets.PKG_MACOS_CERTIFICATE_PWD }}" ]] ; then
echo "::error::Secret PKG_MACOS_CERTIFICATE_PWD not defined"
exit 1
fi
# Turn our base64-encoded certificates back to a regular .p12 files
signcertname="signcertificate.p12"
echo "- Base64-decode certificate to make \"$signcertname\""
echo "${{ secrets.STORE_MACOS_CERTIFICATE }}" | base64 --decode > "$signcertname"
pkgcertname="pkgcertificate.p12"
echo "- Base64-encode certificate to make \"$pkgcertname\""
echo "${{ secrets.PKG_MACOS_CERTIFICATE }}" | base64 --decode > "$pkgcertname"
# We need to create a new keychain, one that we can keep unlocked,
# otherwise using the certificate will prompt with a UI dialog asking for
# the certificate password, which won't work in a headless CI environment
echo "- Create unlocked keychain \"build.keychain\""
security create-keychain -p "${{ secrets.PROD_MACOS_CI_KEYCHAIN_PWD }}" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "${{ secrets.PROD_MACOS_CI_KEYCHAIN_PWD }}" build.keychain
echo "- Import \"$signcertname\" and \"$pkgcertname\" into \"build.keychain\""
security import "$signcertname" -k build.keychain \
-P "${{ secrets.STORE_MACOS_CERTIFICATE_PWD }}" -T /usr/bin/codesign
security import "$pkgcertname" -k build.keychain \
-P "${{ secrets.PKG_MACOS_CERTIFICATE_PWD }}" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign:,productbuild: \
-s -k "${{ secrets.PROD_MACOS_CI_KEYCHAIN_PWD }}" build.keychain
- name: Codesign App Bundle
run: |
:
if [[ -z "${{ secrets.STORE_MACOS_CERTIFICATE_NAME }}" ]] ; then
echo "::error::Secret STORE_MACOS_CERTIFICATE_NAME not defined"
exit 1
fi
# Codesign our app bundle, specifying the Hardened runtime option
echo "- Sign subcomponents..."
# this is thanks to https://stackoverflow.com/a/11284404/592739
# within this section change the Internal Field Separator (IFS) to
# iterate over newline-separated paths that contain spaces
savedIFS=$IFS
IFS=$(echo -en "\n\b")
subitems=""
addsubitems()
{
if [ -z "$subitems" ] ; then
subitems="$1"
else
subitems="$subitems"$'\n'"$1"
fi
}
frameworksdir="${{ steps.build.outputs.appbundle }}/Contents/Frameworks"
if [ -d "$frameworksdir" ] ; then
frameworksdirdylibs=$(find "$frameworksdir" -depth -name "*.dylib")
if [ -n "$frameworksdirdylibs" ] ; then
addsubitems "$frameworksdirdylibs"
fi
frameworksdirbundles=$(find "$frameworksdir" -depth -type d -name "*.bundle")
if [ -n "$frameworksdirbundles" ] ; then
addsubitems "$frameworksdirbundles"
fi
frameworksdirframeworks=$(find "$frameworksdir" -depth -type d -name "*.framework")
if [ -n "$frameworksdirframeworks" ] ; then
for framework in $frameworksdirframeworks; do
frameworksubapp=$(find "$framework" -depth -type d -name "*.app")
if [ -n "$frameworksubapp" ] ; then
addsubitems "$frameworksubapp"
fi
frameworksubapp=$(find "$framework" -depth -type d -name "*.xpc")
if [ -n "$frameworksubapp" ] ; then
addsubitems "$frameworksubapp"
fi
# search for executables with limited depth to avoid ones within an .app
frameworkname=$(basename -s ".framework" "$framework")
frameworksubexecutable=$(find "$framework" -maxdepth 4 -type f -perm +111 \
-not -name "$frameworkname")
if [ -n "$frameworksubexecutable" ] ; then
addsubitems "$frameworksubexecutable"
fi
done
addsubitems "$frameworksdirframeworks"
fi
fi
# potentially grab more subitems from other places within the .app here
# ie. resourcesdir="${{ steps.build.outputs.appbundle }}/Contents/Resources"
for subitem in $subitems; do
xcrun codesign --force --sign "${{ secrets.STORE_MACOS_CERTIFICATE_NAME }}" \
--options runtime -v "$subitem"
done
# would instead do this to if any subcomponents themselves included entitlements:
# for subitem in $subitems; do
# echo -n "" > subentitlements.xml # codesign doesn't erase prev contents but appends, avoid this problem
# xcrun codesign -d --entitlements subentitlements.xml --xml "$subitem"
# if [ -s subentitlements.xml ] ; then
# xcrun codesign --force --sign "${{ secrets.STORE_MACOS_CERTIFICATE_NAME }}" \
# --entitlements subentitlements.xml --options runtime -v "$subitem"
# else
# xcrun codesign --force --sign "${{ secrets.STORE_MACOS_CERTIFICATE_NAME }}" \
# --options runtime -v "$subitem"
# fi
# done
IFS=$savedIFS
echo "- Sign app"
xcrun codesign -d --entitlements entitlements.xml --xml "${{ steps.build.outputs.appbundle }}"
xcrun codesign --force --sign "${{ secrets.STORE_MACOS_CERTIFICATE_NAME }}" \
--entitlements entitlements.xml --options runtime -v "${{ steps.build.outputs.appbundle }}"
- name: Package App
id: package
run: |
:
if [[ -z "${{ secrets.PKG_MACOS_CERTIFICATE_NAME }}" ]] ; then
echo "::error::Secret PKG_MACOS_CERTIFICATE_NAME not defined"
exit 1
fi
# Build a pkg from the built app for uploading to App Store Connect
packagefilename="${{ steps.version.outputs.releaseArchivename }}.pkg"
echo "- Package app to make \"$packagefilename\""
xcrun productbuild --sign "${{ secrets.PKG_MACOS_CERTIFICATE_NAME }}" \
--component "${{ steps.build.outputs.appbundle }}" /Applications \
"${{ env.builddir }}/$packagefilename"
# timelimit -t20 xcrun productbuild --sign "${{ secrets.PKG_MACOS_CERTIFICATE_NAME }}" \
# --component "${{ steps.build.outputs.appbundle }}" /Applications \
# "${{ env.builddir }}/$packagefilename" || \
# (
# echo "::warning::Failed during productbuild, possible timeout"
# sudo log show --last 21s --debug --info > systemlog.txt # followup step can save as artifact
# exit 1
# )
echo "filename=$packagefilename" >> $GITHUB_OUTPUT
echo "file=${{ env.builddir }}/$packagefilename" >> $GITHUB_OUTPUT
- name: Verify Package and AppStore Connect Acceess
id: connect
run: |
:
if [[ -z "${{ secrets.APPSTORECONNECT_APIKEY }}" ]] ; then
echo "::error::Secret APPSTORECONNECT_APIKEY not defined"
exit 1
fi
if [[ -z "${{ secrets.APPSTORECONNECT_APIKEYID }}" ]] ; then
echo "::error::Secret APPSTORECONNECT_APIKEYID not defined"
exit 1
fi
if [[ -z "${{ secrets.APPSTORECONNECT_APIISSUERID }}" ]] ; then
echo "::error::Secret APPSTORECONNECT_APIISSUERID not defined"
exit 1
fi
# Turn our base64-encoded acess key back to a regular .p8 file
# in the expected subdirectory with the expected name containing the key id
packagefile="${{ steps.package.outputs.file }}"
keyfilename="AuthKey_${{ secrets.APPSTORECONNECT_APIKEYID }}.p8"
keydir="private_keys"
mkdir "$keydir"
echo "- Base64-decode key to make \"$keyfilename\""
echo "${{ secrets.APPSTORECONNECT_APIKEY }}" | base64 --decode > "./$keydir/$keyfilename"
# if deploying, this decoded key file will be used again by altool
echo "- Run verification"
xcrun altool --validate-app --file "$packagefile" --type macos \
--apiKey "${{ secrets.APPSTORECONNECT_APIKEYID }}" \
--apiIssuer "${{ secrets.APPSTORECONNECT_APIISSUERID }}"
echo "keyid=${{ secrets.APPSTORECONNECT_APIKEYID }}" >> $GITHUB_OUTPUT
echo "issuerid=${{ secrets.APPSTORECONNECT_APIISSUERID }}" >> $GITHUB_OUTPUT
- name: Release Notes
id: notes
run: |
:
echo "- Collect release notes"
changeLogFilename=CHANGELOG.md
tempNotesFilename="${{ steps.version.outputs.releaseName }}.temp.md"
currentNotesFilename="${{ steps.version.outputs.releaseName }}.md"
if [[ ! -f $changeLogFilename ]] ; then
echo "::warning::Change log file is missing"
numlines=0
else
echo -n "" > "${{ env.builddir }}/$tempNotesFilename"
thisversion=''
prevversion=''
while read line || [[ -n $line ]] ; do
if [[ -z $thisversion ]]; then
thisversion=$(echo $line | sed -n -E 's/^#+ version ([0-9.dabrc]+) .*$/\1/p')
if [[ -n $thisversion ]] ; then
if [[ $thisversion != "${{ steps.version.outputs.version }}" ]] ; then
echo "::warning::Version $thisversion at the top of the change log doesn't match build version ${{ steps.version.outputs.version }}"
break
fi
echo "- Found section for build version ${{ steps.version.outputs.version }} at the top of the change log"
fi
continue
fi
prevversion=$(echo $line | sed -n -E 's/^#+ version ([0-9.dabrc]+) .*$/\1/p')
if [[ -n $prevversion ]] ; then
break
fi
echo $line >> "${{ env.builddir }}/$tempNotesFilename"
done < "$changeLogFilename"
# sed command removes initial and trailing blank lines, don't ask me how it works
# from https://unix.stackexchange.com/a/552195
cat "${{ env.builddir }}/$tempNotesFilename" | sed -e '/./,$!d' -e :a -e '/^\n*$/{$d;N;ba' -e '}' \
> "${{ env.builddir }}/$currentNotesFilename"
numlines=$(wc -l "${{ env.builddir }}/$currentNotesFilename" | cut -w -f2)
fi
if [[ $numlines -gt 0 ]] ; then
echo "- Save $numlines lines of release notes to \"$currentNotesFilename\""
else
echo "- Save placeholder release notes to \"$currentNotesFilename\""
echo "Release notes unavailable at this time" > "${{ env.builddir }}/$currentNotesFilename"
fi
echo "filename=$currentNotesFilename" >> $GITHUB_OUTPUT
echo "file=${{ env.builddir }}/$currentNotesFilename" >> $GITHUB_OUTPUT
- name: Save Build Components as Artifact
uses: actions/upload-artifact@v4
with:
name: "${{ steps.version.outputs.releaseArchivename }} App and Pkg"
path: |
${{ steps.build.outputs.appbundle }}
${{ steps.package.outputs.file }}
- name: Save Release Notes as Artifact
uses: actions/upload-artifact@v4
with:
name: "${{ steps.version.outputs.releaseArchivename }} Release notes"
path: ${{ steps.notes.outputs.file }}
- name: Deploy
if: ${{ success() && steps.version.outputs.uploadToStore == 'true' }}
run: |
:
if [[ -z "${{ secrets.APPSTORECONNECT_APPLEID }}" ]] ; then
echo "::error::Secret APPSTORECONNECT_APPLEID not defined"
exit 1
fi
packagefile="${{ steps.package.outputs.file }}"
keyid="${{ steps.connect.outputs.keyid }}"
issuerid="${{ steps.connect.outputs.issuerid }}"
bundleid="${{ steps.build.outputs.bundleID }}"
bundleversion="${{ steps.build.outputs.version }}"
versionstr="${{ steps.version.outputs.version }}"
echo "- Deploy"
xcrun altool --upload-package --file "$packagefile" --type macos \
--apiKey "$keyid" --apiIssuer "$issuerid" --bundle-id "$bundleid" \
--bundle-version "$bundleversion" --bundle-short-version-string "$versionstr" \
--apple-id "${{ secrets.APPSTORECONNECT_APPLEID }}" \
- name: Fin
run: |
:
if [[ "${{ steps.version.outputs.uploadToStore }}" == "true" ]] ; then
echo "::notice::Deployed \"${{ env.bundlename }}\" to app store, saved it and \"${{ steps.notes.outputs.filename }}\" as artifacts"
else
echo "::notice::Saved \"${{ env.bundlename }}\" and \"${{ steps.notes.outputs.filename }}\" as artifacts"
fi