Skip to content

Build Non-App Store Release #95

Build Non-App Store Release

Build Non-App Store Release #95

Workflow file for this run

name: Draft Cleepp Release
on:
create:
workflow_dispatch:
inputs:
releaseVersion:
description: "Version to title release with (like: 1.0rc3), blank for project's version"
type: string
required: false
isPrerelease:
description: "Prerelease"
type: boolean
required: true
default: true
updateAppcast:
description: "Update Sparkle Appcast"
type: boolean
required: true
default: true
env:
isPrereleaseDefault: true
updateAppcastDefault: true
projectfile: Maccy.xcodeproj
buildscheme: Cleepp
productname: "Batch Clipboard"
bundlename: "Batch Clipboard.app"
builddir: Build/Products/Release
branch: forkmain
jobs:
build:
name: Build Cleepp Non-AppStore Variant and Draft Release
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
- 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="${{ env.productname }}.$version"
if [[ -z "${{ inputs.isPrerelease }}" ]] ; then
echo "- Use default value for isPrerelease: ${{ env.isPrereleaseDefault }}"
isPrerelease=${{ env.isPrereleaseDefault }}
else
echo "- Use supplied value for isPrerelease: ${{ inputs.isPrerelease }}"
isPrerelease=${{ inputs.isPrerelease }}
fi
if [[ -z "${{ inputs.updateAppcast }}" ]] ; then
echo "- Use default value for updateAppcast: ${{ env.updateAppcastDefault }}"
updateAppcast=${{ env.updateAppcastDefault }}
else
echo "- Use supplied value for updateAppcast: ${{ inputs.updateAppcast }}"
updateAppcast=${{ inputs.updateAppcast }}
fi
echo "- Parse trigger"
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 git show-ref --tags --verify refs/tags/v$version --quiet ; then
tag="v$version"
echo "- Will build and draft release \"$releaseName\" with matching tag \"$tag\""
else
echo "- Will build and draft untagged release \"$releaseName\" because triggered manually and no tag \"v$version\" was found"
echo "::notice::Updating the sparkle appcast is always skipped for untagged builds"
fi
elif [[ "${{ github.ref }}" == refs/tags/* ]] ; then
# TODO: how do we verify the branch of this tag and ensure its ${{ env.branch }} ?
ref="${{ github.ref }}"
tag="${ref:10}" # magic number 10 being the length of "refs/tags/", stripping that to leave just the tag name
echo "- Will build and draft release \"$releaseName\" because triggered by pushed tag \"$tag\""
else
echo "::error::Not triggered manually or by a tag (github.event_name == ${{ github.event_name }}, gihub.ref == ${{ github.ref }})"
exit 1
fi
echo "version=$version" >> $GITHUB_OUTPUT
echo "bundleID=$bundleID" >> $GITHUB_OUTPUT
echo "releaseName=$releaseName" >> $GITHUB_OUTPUT
echo "releaseArchivename=$releaseNameNoSpaces" >> $GITHUB_OUTPUT
echo "isPrerelease=$isPrerelease" >> $GITHUB_OUTPUT
echo "updateAppcast=$updateAppcast" >> $GITHUB_OUTPUT
if [[ -n $tag ]] ; then
echo "tag=$tag" >> $GITHUB_OUTPUT
fi
- name: Build
id: build
run: |
:
if ! command -v xcodebuild >/dev/null 2>&1 || ! command -v xcbeautify >/dev/null 2>&1 \
|| ! command -v plutil >/dev/null 2>&1
then
echo "::error::Required executables not found: xcodebuild, xcbeautify, plutil"
exit 1
fi
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 . | \
xcbeautify --renderer github-actions --preserve-unbeautified
echo "- Extract bundle version from app"
plutil -extract CFBundleVersion raw \
"Build/Products/Release/${{ env.bundlename }}/Contents/Info.plist"
bundleVersion=$(plutil -extract CFBundleVersion raw \
"Build/Products/Release/${{ env.bundlename }}/Contents/Info.plist" 2> /dev/null)
if [[ -z $bundleVersion && "${{ steps.version.outputs.updateAppcast }}" == "true" ]] ; then
echo "::error::Unable to find app's bundle version"
exit 1
elif [[ -z $bundleVersion ]] ; then
echo "::warning::Unable to find app's bundle version, workflows updating Sparkle appcast require this, but this one can continue"
fi
echo "version=$bundleVersion" >> $GITHUB_OUTPUT
- name: "Codesign app bundle"
if: success()
run: |
:
test -d "${{ env.builddir }}/${{ env.bundlename }}" || exit 1
if ! command -v xcrun >/dev/null 2>&1 || ! command -v security >/dev/null 2>&1 ; then
echo "::error::Required executables not found: xcrun, security"
exit 1
fi
if [[ -z "${{ secrets.PROD_MACOS_CERTIFICATE }}" ]] ; then
echo "::error::Secret PROD_MACOS_CERTIFICATE not defined"
exit 1
fi
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.PROD_MACOS_CERTIFICATE_PWD }}" ]] ; then
echo "::error::Secret PROD_MACOS_CERTIFICATE_PWD not defined"
exit 1
fi
if [[ -z "${{ secrets.PROD_MACOS_CERTIFICATE_NAME }}" ]] ; then
echo "::error::Secret PROD_MACOS_CERTIFICATE_NAME not defined"
exit 1
fi
# Turn our base64-encoded certificate back to a regular .p12 file
echo "- Base64-encode certificate to make \"certificate.p12\""
echo "${{ secrets.PROD_MACOS_CERTIFICATE }}" | base64 --decode > certificate.p12
# We need to create a new keychain, otherwise using the certificate will prompt
# with a UI dialog asking for the certificate password, which we can't
# use 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 \"certificate.p12\" into \"build.keychain\""
security import certificate.p12 -k build.keychain \
-P "${{ secrets.PROD_MACOS_CERTIFICATE_PWD }}" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: \
-s -k "${{ secrets.PROD_MACOS_CI_KEYCHAIN_PWD }}" build.keychain
# We finally 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="${{ env.builddir }}/${{ env.bundlename }}/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="${{ env.builddir }}/${{ env.bundlename }}/Contents/Resources"
for subitem in $subitems; do
xcrun codesign --force -s "${{ secrets.PROD_MACOS_CERTIFICATE_NAME }}" \
--options runtime -v "$subitem"
done
IFS=$savedIFS
echo "- Sign app"
xcrun codesign --force -s "${{ secrets.PROD_MACOS_CERTIFICATE_NAME }}" \
--options runtime -v "${{ env.builddir }}/${{ env.bundlename }}"
- name: "Notarize app bundle"
if: success()
run: |
:
if [[ -z "${{ secrets.PROD_MACOS_NOTARIZATION_APPLE_ID }}" ]] ; then
echo "::error::Secret PROD_MACOS_NOTARIZATION_APPLE_ID not defined"
exit 1
fi
if [[ -z "${{ secrets.PROD_MACOS_NOTARIZATION_TEAM_ID }}" ]] ; then
echo "::error::Secret PROD_MACOS_NOTARIZATION_TEAM_ID not defined"
exit 1
fi
if [[ -z "${{ secrets.PROD_MACOS_NOTARIZATION_PWD }}" ]] ; then
echo "::error::Secret PROD_MACOS_NOTARIZATION_PWD not defined"
exit 1
fi
# Store the notarization credentials so that we can prevent a UI password dialog
# from blocking the CI
echo "- Create keychain profile"
xcrun notarytool store-credentials "notarytool-profile" \
--apple-id "${{ secrets.PROD_MACOS_NOTARIZATION_APPLE_ID }}" \
--team-id "${{ secrets.PROD_MACOS_NOTARIZATION_TEAM_ID }}" \
--password "${{ secrets.PROD_MACOS_NOTARIZATION_PWD }}"
# We can't notarize an app bundle directly, but we need to compress it as an archive.
# Therefore, we create a zip file containing our app bundle, so that we can send it to the
# notarization service
echo "- Create temp notarization archive"
ditto -c -k --keepParent "${{ env.builddir }}/${{ env.bundlename }}" "notarization.zip"
# Here we send the notarization request to the Apple's Notarization service, waiting for the result.
# This typically takes a few seconds inside a CI environment, but it might take more depending on the App
# characteristics. Visit the Notarization docs for more information and strategies on how to optimize it if
# you're curious
echo "- Notarize app"
xcrun notarytool submit "notarization.zip" --keychain-profile "notarytool-profile" --wait \
2>&1 | tee notarytool-out.txt
if [ ${PIPESTATUS[0]} -ne 0 ] || grep -q Invalid notarytool-out.txt ; then
if sed -nr '/^[[:space:]]*id: (.*)$/{s//\1/p;q;}' notarytool-out.txt > notarytool-id.txt ; then
echo "- Extract notarytool failure log"
xcrun notarytool log "$(<notarytool-id.txt)" --keychain-profile "notarytool-profile"
fi
exit 1
fi
# Finally, we need to "attach the staple" to our executable, which will allow our app to be
# validated by macOS even when an internet connection is not available.
echo "- Attach staple"
xcrun stapler staple "${{ env.builddir }}/${{ env.bundlename }}"
- name: Build Zip File
id: zip
run: |
:
test -d "${{ env.builddir }}/${{ env.bundlename }}" || exit 1
archiveDir="${{ env.builddir }}/Sparkle"
archiveFileName="${{ steps.version.outputs.releaseArchivename }}.zip"
echo "- Compress built app to \"$(basename $archiveDir)/$archiveFileName\""
mkdir "$archiveDir"
ditto -c -k --sequesterRsrc --keepParent "${{ env.builddir }}/${{ env.bundlename }}" \
"$archiveDir/$archiveFileName"
echo "file=$archiveDir/$archiveFileName" >> $GITHUB_OUTPUT
echo "directory=$archiveDir" >> $GITHUB_OUTPUT
- name: Disk Image
id: dmg
run: |
:
if ! command -v create-dmg >/dev/null 2>&1 ; then
echo "::warning::Required helper script not found: create-dmg. Skipping dmg creation"
# not sure if need to do `echo "file=whatever" >> $GITHUB_OUTPUT`
# to make release step work, or if empty/missing steps.dmg.output.file is ok
exit 0
fi
imageDir="${{ env.builddir }}/Image"
imageFileName="${{ steps.version.outputs.releaseArchivename }}.dmg"
readmeFilename="${{ env.productname }} version ${{ steps.version.outputs.version }} read me.rtf"
echo "- Copy built app to \"$(basename $imageDir)/${{ env.bundlename }}\""
mkdir "$imageDir"
ditto "${{ env.builddir }}/${{ env.bundlename }}" "$imageDir/${{ env.bundlename }}"
echo "- Copy readme file from source repo to \"$(basename $imageDir)/$readmeFilename\""
cp "Designs/Cleepp/Cleepp download read me.rtf" "$imageDir/$readmeFilename"
echo "- Build disk image \"${{ env.builddir }}/$imageFileName\""
create-dmg --hdiutil-quiet \
--volname "${{ steps.version.outputs.releaseName }}" \
--window-size 540 160 --icon-size 64 \
--icon "$readmeFilename" 40 60 \
--icon "${{ env.bundlename }}" 200 60 \
--app-drop-link 360 60 \
"${{ env.builddir }}/$imageFileName" "$imageDir"
# IMPORTANT: when notarize enabled above, enable this too
#echo "- Attach staple"
#xcrun stapler staple "${{ env.builddir }}/$imageFileName"
echo "file=${{ env.builddir }}/$imageFileName" >> $GITHUB_OUTPUT
- name: "Sign and notarize disk image"
if: success()
run: |
:
echo "- Notarize disk image"
xcrun notarytool submit "${{ steps.dmg.outputs.file }}" --keychain-profile "notarytool-profile" --wait \
2>&1 | tee notarytool-out.txt
if [ ${PIPESTATUS[0]} -ne 0 ] || grep -q Invalid notarytool-out.txt ; then
if sed -nr '/^[[:space:]]*id: (.*)$/{s//\1/p;q;}' notarytool-out.txt > notarytool-id.txt ; then
echo "- Extract notarytool failure log"
xcrun notarytool log "$(<notarytool-id.txt)" --keychain-profile "notarytool-profile"
fi
exit 1
fi
echo "- Attach staple"
xcrun stapler staple "${{ steps.dmg.outputs.file }}"
- 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 "file=${{ env.builddir }}/$currentNotesFilename" >> $GITHUB_OUTPUT
- name: Setup Sparkle
if: success() && steps.version.outputs.updateAppcast && steps.version.outputs.tag
uses: jozefizso/setup-sparkle@v1
with:
version: 2.6.0
- name: Generate Sparkle appcast.xml
id: sparkle
if: success() && steps.version.outputs.updateAppcast && steps.version.outputs.tag
run: |
:
echo "::add-mask::${{ secrets.SPARKLE_PRIVATE_KEY }}"
if [[ -z "${{ secrets.SPARKLE_PRIVATE_KEY }}" ]] ; then
echo "::warning::Secret SPARKLE_PRIVATE_KEY not defined. Skipping Sparkle step"
exit 0
fi
if ! command -v pandoc >/dev/null 2>&1 || ! command -v generate_appcast >/dev/null 2>&1 ; then
echo "::warning::Required executables not found: pandoc, generate_appcast. Skipping Sparkle step"
exit 0
fi
htmlNotesFilename="$(basename -s .zip "${{ steps.zip.outputs.file }}")".html
htmlTemplateFilename=htmlnotestemplate.html
releasesURL="https://github.com/${{ github.repository }}/releases"
downloadURLPrefix="https://github.com/${{ github.repository }}/releases/download/v${{ steps.version.outputs.version }}/"
echo "- Convert release notes to html"
echo '$body$' > "$htmlTemplateFilename"
if ! pandoc --standalone --template "$htmlTemplateFilename" --metadata title="Release Notes" \
"${{ steps.notes.outputs.file }}" \
> "${{ steps.zip.outputs.directory }}/$htmlNotesFilename"
then
echo "::warning::pandoc failed, no new appcast.xml file generated"
exit 0
fi
echo "- Update appcast"
cp ./appcast.xml "${{ steps.zip.outputs.directory }}/appcast.xml" # dir needs current xml file
if ! echo "${{ secrets.SPARKLE_PRIVATE_KEY }}" | generate_appcast --ed-key-file - \
--link "$releasesURL" --download-url-prefix "$downloadURLPrefix" \
--embed-release-notes -o ./appcast.xml "${{ steps.zip.outputs.directory }}"
then
echo "::warning::generate_appcast failed, no new appcast.xml file generated"
echo
echo "ls -alF \"${{ steps.zip.outputs.directory }}\""
ls -alF "${{ steps.zip.outputs.directory }}"
echo
echo "cat \"${{ steps.zip.outputs.directory }}/$htmlNotesFilename\""
cat "${{ steps.zip.outputs.directory }}/$htmlNotesFilename"
echo
exit 0
fi
echo "appcastGenerated=true" >> $GITHUB_OUTPUT
- name: Commit appcast.xml
if: success() && steps.version.outputs.updateAppcast && steps.version.outputs.tag && steps.sparkle.outputs.appcastGenerated
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: Automated Change to appcast.xml
file_pattern: "appcast.xml"
status_options: "--untracked-files=no"
- name: Draft Untagged Release
if: success() && steps.version.outputs.tag == ''
uses: softprops/action-gh-release@v2
with:
name: ${{ steps.version.outputs.releaseName }}
draft: true
prerelease: ${{ steps.version.outputs.isPrerelease }}
body_path: ${{ steps.notes.outputs.file }}
files: |
${{ steps.zip.outputs.file }}
${{ steps.dmg.outputs.file }}
fail_on_unmatched_files: false
- name: Draft Tagged Release
if: success() && steps.version.outputs.tag
uses: softprops/action-gh-release@v2
with:
name: ${{ steps.version.outputs.releaseName }}
tag_name: ${{ steps.version.outputs.tag }}
draft: true
prerelease: ${{ steps.version.outputs.isPrerelease }}
body_path: ${{ steps.notes.outputs.file }}
files: |
${{ steps.zip.outputs.file }}
${{ steps.dmg.outputs.file }}
fail_on_unmatched_files: false
- name: Fin
run: |
:
echo "::notice::Release \"${{ steps.version.outputs.releaseName }}\" draft created continaing \"${{ steps.zip.outputs.file }}\""