We all love Xcode Cloud for just how simple it makes getting up and running with automated tests. Once your source code is connected to Xcode Cloud (yeah, I use GitHub here) you end up defining what gets run as far as tests and archive actions.
Apple provides documentation that covers best practices around defining workflows.
As with much documentation, it covers a very simple approach to getting up and going. There is a lot more you can do with a CI environment and that is where shell scripts fit in.
Scripts directory
Xcode Cloud will look for scripts to run in the ci_scripts directory and these will follow a specific naming convention.
ci_post_clone.shis run just after your code is cloned and useful for doing any changes that might be required before building your appci_pre_xcodebuild.shis run just prior to building your app. If you need to pre-compile any dependencies, this is a good location.ci_post_xcodebuild.shis run just after Xcode builds your app.
Environment variables
Environment variables are how you find out specific bits of data relating to the Xcode Cloud process. You can also use the workflow editor to add in custom variables that are useful for authenticating any API requests such as uploading build artifacts to third party services.
Apple maintains documentation about the environment variables which are available.
Building a script to generate release notes
Let's take a look at an example script that will generate a release on GitHub with details about the changes made.
This script will sit in the ci_scripts directory and form part of ci_post_xcodebuild.sh.
Running on merges to main
The first check for this script is to make sure that we only create a release when a merge is made to the main branch as a result of a PR being merged. We also check that the action performed by Xcode Cloud was archive.
# --- Guards: skip cleanly when this isn't a post-merge archive of main. ----
if [[ "${CI_XCODEBUILD_ACTION:-}" != "archive" ]]; then
echo "Skipping tag: CI_XCODEBUILD_ACTION='${CI_XCODEBUILD_ACTION:-unset}', not 'archive'."
exit 0
fi
if [[ "${CI_BRANCH:-}" != "main" ]]; then
echo "Skipping tag: CI_BRANCH='${CI_BRANCH:-unset}', not 'main'."
exit 0
fi
if [[ -n "${CI_PULL_REQUEST_NUMBER:-}" ]]; then
echo "Skipping tag: PR build (#${CI_PULL_REQUEST_NUMBER}), not post-merge."
exit 0
fi
Creating the tag details
The second part of the script is to make sure that all environment variables are set and that we then go and create the details about the tag. This looks at the current marketing version of the app as well as the build number from Xcode Cloud.
require_var() {
local name="$1"
if [[ -z "${!name:-}" ]]; then
echo "ERROR: required env var '$name' is not set." >&2
exit 1
fi
}
require_var ACCESS_TOKEN
require_var CI_BUILD_NUMBER
require_var CI_COMMIT
require_var CI_ARCHIVE_PATH
require_var CI_PRIMARY_REPOSITORY_PATH
ARCHIVE_INFO="$CI_ARCHIVE_PATH/Info.plist"
if [[ ! -f "$ARCHIVE_INFO" ]]; then
echo "ERROR: archive Info.plist not found at $ARCHIVE_INFO" >&2
exit 1
fi
MARKETING_VERSION=$(/usr/libexec/PlistBuddy \
-c "Print :ApplicationProperties:CFBundleShortVersionString" \
"$ARCHIVE_INFO")
TAG="${MARKETING_VERSION}-${CI_BUILD_NUMBER}"
note the variable ACCESS_TOKEN is set via the workflow editor in Xcode and is the GitHub personal access token that gets used to create the release.
Prepare to create the release
This is where we do the authentication setup to make sure we can talk to GitHub. It constructs the auth header and makes sure that the remote url is valid. There is also a check to see if the tag exists. If it does, the script cleanly exits.
# --- Resolve owner/repo from the checkout's origin remote. -----------------
REMOTE_URL=$(git -C "$CI_PRIMARY_REPOSITORY_PATH" config --get remote.origin.url)
REPO=$(printf '%s' "$REMOTE_URL" \
| sed -E -e 's#^git@github\.com:##' -e 's#^https://github\.com/##' -e 's#\.git$##')
if [[ -z "$REPO" || "$REPO" == "$REMOTE_URL" ]]; then
echo "ERROR: could not parse owner/repo from remote URL '$REMOTE_URL'." >&2
exit 1
fi
echo "Preparing GitHub release $TAG for $REPO at commit $CI_COMMIT."
# --- GitHub API helpers. ---------------------------------------------------
API="https://api.github.com/repos/$REPO"
AUTH_HEADERS=(
-H "Authorization: Bearer $ACCESS_TOKEN"
-H "Accept: application/vnd.github+json"
-H "X-GitHub-Api-Version: 2022-11-28"
)
# Idempotency: if the tag already exists, do nothing. Protects against
# workflow re-runs on the same commit.
existing_status=$(curl -sS -o /dev/null -w "%{http_code}" \
"${AUTH_HEADERS[@]}" "$API/git/ref/tags/$TAG")
if [[ "$existing_status" == "200" ]]; then
echo "Tag $TAG already exists on $REPO; nothing to do."
exit 0
fi
Create the release and tag.
This is where things get created by performing a few http requests.
# --- Create the release (also creates the underlying tag). -----------------
response_body=$(mktemp)
trap 'rm -f "$response_body"' EXIT
payload=$(cat <<JSON
{
"tag_name": "$TAG",
"target_commitish": "$CI_COMMIT",
"name": "$TAG",
"generate_release_notes": true
}
JSON
)
http_code=$(curl -sS -o "$response_body" -w "%{http_code}" \
-X POST "${AUTH_HEADERS[@]}" \
-d "$payload" \
"$API/releases")
if [[ "$http_code" -lt 200 || "$http_code" -ge 300 ]]; then
echo "ERROR: GitHub release creation failed (HTTP $http_code)." >&2
cat "$response_body" >&2
exit 1
fi
echo "Created GitHub release $TAG."
What else can be done
Shell scripts are super versatile and there's lots which can be done. In my case it was a desire to keep track of what goes into each release of the app. Automation is amazing for this and making use of Xcode Cloud is perfect.