Skip to content

Build Non-App Store Release #78

Build Non-App Store Release

Build Non-App Store Release #78

Workflow file for this run

name: Draft Cleepp Release
on:
create:
workflow_dispatch:
branches:
- forkmain
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
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
- 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 from project"
version=$(xcodebuild -scheme "${{ env.buildscheme }}" -configuration Release \
-project "${{ env.projectfile }}" -showBuildSettings \
| sed -nr 's/^.*MARKETING_VERSION = (.*)$/\1/p') 2>/dev/null
if [[ -z $version ]] ; then
echo "::error::Unable to determine a version number for the current state of 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 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::Will skip notarizing and updating sparkle appcast"
fi
elif [[ "${{ github.ref }}" == refs/tags/* ]] ; then
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 "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
subitems=""
frameworksdir="${{ env.builddir }}/${{ env.bundlename }}/Contents/Frameworks"
if [ -d "$frameworksdir" ] ; then
frameworksdirframeworks=$(find "$frameworksdir" -depth -type d \
-name "*.framework" -print0 | xargs -0 -I % grealpath %/Versions/Current)
frameworksdirother=$(find "$frameworksdir" -depth -type d \
-name "*.dylib" -or -name "*.bundle")
subitems="$frameworksdirframeworks"'\n'"$frameworksdirother"
fi
# potentially grab more subitems from other places within the .app here
#resourcesdir="${{ env.builddir }}/${{ env.bundlename }}/Contents/Resources"
#..use find as above..
#subitems="$subitems"'\n'"$resourcesdiritems"
# changing the Internal Field Separator (IFS) makes spaces in paths not cause problems below
savedIFS=$IFS
IFS=$(echo -en "\n\b")
for subitem in $subitems; do
xcrun codesign --force -s "${{ secrets.PROD_MACOS_CERTIFICATE_NAME }}" \
--options runtime "$subitem" -v
done
IFS=$savedIFS
echo "- Sign app"
xcrun codesign --force -s "${{ secrets.PROD_MACOS_CERTIFICATE_NAME }}" \
--options runtime "${{ env.builddir }}/${{ env.bundlename }}" -v
- name: "Notarize app bundle"
if: success()
run: |
:
test -d "${{ env.builddir }}/${{ env.bundlename }}" || exit 1
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"
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: |
:
test -d "${{ env.builddir }}/${{ env.bundlename }}" || exit 1
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: Release Notes
id: notes
run: |
:
echo "- Collect release notes"
changeLogFilename=CHANGELOG.md
currentNotesFilename="${{ steps.version.outputs.releaseName }}.md"
if [[ ! -f $changeLogFilename ]] ; then
echo "::warning::Release notes file is missing"
numlines=0
else
echo -n "" > "${{ env.builddir }}/$currentNotesFilename"
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 && $thisversion != "${{ steps.version.outputs.version }}" ]] ; then
echo "::warning::Version $thisversion at the top of the release notes doesn't match build version ${{ steps.version.outputs.version }}"
break
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 }}/$currentNotesFilename"
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 }}/$currentNotesFilename" | sed -e '/./,$!d' -e :a -e '/^\n*$/{$d;N;ba' -e '}' \
| tee "${{ env.builddir }}/$currentNotesFilename" >/dev/null
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 }}\""