The monorepo from
A Swift Monorepo with an Xcode Workspace and Local Packages
builds two iOS apps from shared packages, but it cannot release them one at a
time. Both apps report version 1.0, build 1. Their version numbers are
typed into each project’s target settings, where a release would have to edit
them in Xcode. Shopping has no display name of its own, neither app has an
icon, and make archive keeps no record of what it shipped or when.
This article gives each app its own identity and version, and a release
process that can ship one app without touching the other. By the end, each
app’s bundle identifier, display name and version live in one .xcconfig
file of its own, on top of a base file every app shares. Each app shows its
own icon and name on the simulator’s home screen. make release APP=Shopping
refuses to run on uncommitted changes or a version that has already shipped;
otherwise it archives Shopping with a build number taken from Git and tags the
commit Shopping/v1.1.0. make changes APP=TodoList lists the commits since
TodoList’s last release, including commits to the packages it is built from,
so one change to DesignSystem shows up as due for release in both apps.
The archives stay unsigned, as in this article’s two predecessors, The Xcode Build Process from the Command Line, with make and the workspace article. Signing, exporting and uploading to App Store Connect need a paid Apple Developer Program membership and are left for a later article; everything here runs without one.
What you end up with
Apps/Config/Shared.xcconfig, the settings every app shares, andApps/TodoList/TodoList.xcconfigandApps/Shopping/Shopping.xcconfig, each holding one app’s bundle identifier, display name and version.- App targets that set none of those values themselves, so the files are the only place they come from.
- An icon for each app, drawn by a short Swift script.
- A
Makefilewithversion,set-version,changesandrelease, and anarchivethat stamps each build with the number of commits in the repository. - Release tags such as
TodoList/v1.0.1andShopping/v1.1.1, one series per app in one repository.
Prerequisites
- The finished
my-appsfolder from A Swift Monorepo with an Xcode Workspace and Local Packages, with the square check symbols from its last step and theTodoList-iPhonesimulator. .xcconfigfiles and build configurations. An.xcconfigfile holds build settings asNAME = valuelines, and Debug and Release are the two configurations each project builds in. Both were introduced in The Xcode Build Process from the Command Line, with make.- Xcode 27 or later on a Mac with Apple silicon. Validated on Xcode 27.1 (build 27A9275) with Swift 6.4, on macOS 27.0.1.
- Git, with your name and email set (
git config --global user.nameanduser.email). Validated with Git 2.56.0. The article assumes you knowgit addandgit commit; Pro Git covers both, and tags are explained where they are first used. - No Apple Developer account.
- Steps 3 and 4 are done in Xcode; a command after each one checks the result.
Run every command from inside my-apps.
Step 1: Put the monorepo under Git
A release in this article is an archive (the Release build for real devices,
with its debug symbols, that make archive writes to build/) plus a Git tag
on the commit it was built from, and the build number is counted from Git’s history, so the
repository needs a history to count. If my-apps is already a Git repository
(the first article suggested git init as an option), commit any outstanding
changes and go on to Step 2. Otherwise create one now. The .gitignore from
the first article already keeps build/, xcuserdata/ and the package build
folders out:
git init -q -b main
git add .
git commit -q -m "Monorepo with TodoList and Shopping"
git log --oneline
git ls-files | wc -l
e005ef6 Monorepo with TodoList and Shopping
35
The commit hash differs on your Mac. The 35 tracked files are the workspace,
both projects with their shared schemes, the three packages, the Config
folder, the Makefile and .gitignore. Nothing under build/ is among them.
Step 2: Write one .xcconfig file per app
The previous article’s Apps/Config folder holds three files that only
TodoList uses: Shared.xcconfig, and Debug.xcconfig and Release.xcconfig,
which set its display name. Shopping uses none of them. The new layout keeps
Shared.xcconfig for settings every app shares, deletes the other
two, and gives each app one file of its own beside its
.xcodeproj, holding everything that makes it a separate product:
- The bundle identifier, the reverse-DNS name iOS and the App Store tell apps apart by.
- The display name under the icon.
- The version,
MARKETING_VERSION, which becomesCFBundleShortVersionStringin the app’sInfo.plist: the1.1.0people see on the App Store. - The build number,
CURRENT_PROJECT_VERSION, which becomesCFBundleVersion. It identifies one build of a version. App Store Connect rejects an upload whose build number it has already seen for that version, and a number that rises with every upload is the simple way to avoid that.
Each app’s file sits outside its synchronized folder (Apps/TodoList/TodoList/
for TodoList), the folder whose contents Xcode adds to the app target, because
Xcode copies every file in that folder into the app.
Create the file
touch Apps/Config/Shared.xcconfig
The file exists already. Replace its contents with the listing below.
Add the code: Apps/Config/Shared.xcconfig
// Settings for every app in the monorepo, in every configuration.
SWIFT_TREAT_WARNINGS_AS_ERRORS = YES
// The reverse-DNS prefix of every app's bundle identifier.
BUNDLE_ID_PREFIX = com.example
Create the file
touch Apps/TodoList/TodoList.xcconfig Apps/Shopping/Shopping.xcconfig
Add the code: Apps/TodoList/TodoList.xcconfig
// TodoList's identity and version, for Debug and Release.
#include "../Config/Shared.xcconfig"
PRODUCT_BUNDLE_IDENTIFIER = $(BUNDLE_ID_PREFIX).TodoList
INFOPLIST_KEY_CFBundleDisplayName = To-Do
INFOPLIST_KEY_CFBundleDisplayName[config=Debug] = To-Do Dev
// The version people see. Raise it for each release.
MARKETING_VERSION = 1.0.0
// The build number. `make archive` replaces it with the commit count.
CURRENT_PROJECT_VERSION = 1
Add the code: Apps/Shopping/Shopping.xcconfig
// Shopping's identity and version, for Debug and Release.
#include "../Config/Shared.xcconfig"
PRODUCT_BUNDLE_IDENTIFIER = $(BUNDLE_ID_PREFIX).Shopping
INFOPLIST_KEY_CFBundleDisplayName = Shopping
INFOPLIST_KEY_CFBundleDisplayName[config=Debug] = Shopping Dev
// The version people see. Raise it for each release.
MARKETING_VERSION = 1.0.0
// The build number. `make archive` replaces it with the commit count.
CURRENT_PROJECT_VERSION = 1
Detailed breakdown
BUNDLE_ID_PREFIXis a setting of your own. Xcode does not know the name; it is defined inShared.xcconfigand read with$(BUNDLE_ID_PREFIX)in each app’s file, the same syntax Xcode’s own settings use to refer to each other. Moving every app to your company’s prefix later is a one-line change. Usecom.examplewhile you follow along.#includetakes a path relative to the including file, so../Config/Shared.xcconfigfromApps/TodoList/reachesApps/Config/Shared.xcconfig. A setting defined in both files takes the value that comes last, the app’s own.[config=Debug]makes one line apply to one configuration. Both Debug and Release read this file; Release getsTo-Do, and Debug gets the conditional line’sTo-Do Dev, the same pair the oldDebug.xcconfigandRelease.xcconfiggave. One file per app replaces two.- Shopping now treats warnings as errors too, because the setting moved into the file both apps include. It compiles without warnings, so nothing changes yet.
- The version starts at
1.0.0, three numbers, so each part can be raised on its own. The build number1is what Xcode,make buildandmake runuse; Step 6’smake archivereplaces it.
Step 3: Attach the new files in Xcode
Xcode reads an .xcconfig file only when a configuration is based on it, and
TodoList’s configurations are still based on the two files about to be
deleted. Open the workspace:
open MyApps.xcworkspace
Delete the two old files through Xcode, so the project drops its references too:
- In the Project navigator, expand the TodoList project, select Debug.xcconfig, and press Delete. In the sheet, click Move to Trash.
- Do the same for Release.xcconfig. Keep Shared.xcconfig.
Then add each app’s file to its project, without a target:
- Select the TodoList project, the blue icon, in the Project navigator. The File menu now reads Add Files to “TodoList”…; choose it.
- Select
Apps/TodoList/TodoList.xcconfigand click Add. - In the options sheet, clear the TodoList checkbox under Targets so no target is ticked, and click Finish. A ticked target would copy the file into the app.
- Repeat for the Shopping project with
Apps/Shopping/Shopping.xcconfig, clearing the Shopping checkbox.
Now base the configurations on the files, as the build-process article did:
- Click the TodoList project in the Project navigator. In the editor, select TodoList under PROJECT (not under TARGETS) and open the Info tab.
- Under Configurations, expand Debug and set the menu on the first TodoList row, the project row, to TodoList.xcconfig. Expand Release and do the same.
- Repeat for the Shopping project with Shopping.xcconfig.
If the editor opens on a target’s General tab when you click a project, select the PROJECT entry straight away. Step 4 explains why that tab matters.
grep -o 'baseConfigurationReference.*' Apps/*/*.xcodeproj/project.pbxproj
Apps/Shopping/Shopping.xcodeproj/project.pbxproj:baseConfigurationReference = 30D7A2AF308F6E5200751B2C /* Shopping.xcconfig */;
Apps/Shopping/Shopping.xcodeproj/project.pbxproj:baseConfigurationReference = 30D7A2AF308F6E5200751B2C /* Shopping.xcconfig */;
Apps/TodoList/TodoList.xcodeproj/project.pbxproj:baseConfigurationReference = 30D7A2AE308F6D9700751B2C /* TodoList.xcconfig */;
Apps/TodoList/TodoList.xcodeproj/project.pbxproj:baseConfigurationReference = 30D7A2AE308F6D9700751B2C /* TodoList.xcconfig */;
Two lines per project, one for each configuration. The identifiers differ in
your projects. Now ask xcodebuild what TodoList resolves to:
make settings
CONFIGURATION = Debug
INFOPLIST_KEY_CFBundleDisplayName = To-Do Dev
MARKETING_VERSION = 1.0
PRODUCT_BUNDLE_IDENTIFIER = com.example.TodoList
SWIFT_TREAT_WARNINGS_AS_ERRORS = YES
The display name came from the new file, but the version is still 1.0, not
the file’s 1.0.0. The bundle identifier matches either way, so it proves
nothing.
Step 4: Delete the values the app targets set themselves
The version is 1.0 because the TodoList target sets MARKETING_VERSION
itself, in project.pbxproj, and a target’s own setting beats any
.xcconfig file. The build-process article showed this with a version setting
that had no effect. Xcode’s App template sets the version, the build number and
the bundle identifier on each app target, so all three override the new files
until you delete them from both targets.
Use the Build Settings editor, and get there from the project, not from the
target’s General tab. Showing General can write the display name into
the target. The previous article found that in Xcode 27.1, and it happened
again while validating this one: selecting the Shopping target with General
open wrote INFOPLIST_KEY_CFBundleDisplayName = "Shopping Dev" into both its
configurations, so Release builds were named Shopping Dev. The steps below
include the display name in what to delete, in case it was written.
- Click the TodoList project in the Project navigator, select TodoList under PROJECT, and open the Build Settings tab.
- Now select the TodoList target under TARGETS. The editor stays on Build Settings.
- Click All and Levels in the bar above the settings list. Levels shows one column per layer: Resolved (the final value), the target, the project, the project’s Config.File, and iOS Default. The layer that supplies the value is outlined.
- Type
MARKETING_VERSIONinto the filter field at the top right. The row shows1.0in the target column and1.0.0under Config.File, with the target’s value outlined. Select the Marketing Version row and press Delete. The target column empties, and Resolved reads1.0.0. - Do the same for
CURRENT_PROJECT_VERSION(the Current Project Version row; the filter also shows Current Library Version, which has no value to delete),PRODUCT_BUNDLE_IDENTIFIER, andCFBundleDisplayName(the Bundle Display Name row, only if the target column has a value). - Repeat steps 1 to 5 for the Shopping project and target.
Leave the TodoListUITests target alone: it has its own bundle identifier,
com.example.TodoListUITests, and it is not an app you ship.
Check that the app targets no longer set any of the four:
grep -cE 'MARKETING_VERSION|CURRENT_PROJECT_VERSION|PRODUCT_BUNDLE_IDENTIFIER|CFBundleDisplayName' Apps/Shopping/Shopping.xcodeproj/project.pbxproj
grep -oE '(MARKETING_VERSION|PRODUCT_BUNDLE_IDENTIFIER) = .*' Apps/TodoList/TodoList.xcodeproj/project.pbxproj
0
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.TodoListUITests;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.TodoListUITests;
Shopping’s project sets none of them. The four lines left in TodoList’s project all belong to the UI-test target, Debug and Release. Both apps now resolve these values from their files:
make settings CONFIG=Release
make settings APP=Shopping
make settings APP=Shopping CONFIG=Release
CONFIGURATION = Release
INFOPLIST_KEY_CFBundleDisplayName = To-Do
MARKETING_VERSION = 1.0.0
PRODUCT_BUNDLE_IDENTIFIER = com.example.TodoList
SWIFT_TREAT_WARNINGS_AS_ERRORS = YES
CONFIGURATION = Debug
INFOPLIST_KEY_CFBundleDisplayName = Shopping Dev
MARKETING_VERSION = 1.0.0
PRODUCT_BUNDLE_IDENTIFIER = com.example.Shopping
SWIFT_TREAT_WARNINGS_AS_ERRORS = YES
CONFIGURATION = Release
INFOPLIST_KEY_CFBundleDisplayName = Shopping
MARKETING_VERSION = 1.0.0
PRODUCT_BUNDLE_IDENTIFIER = com.example.Shopping
SWIFT_TREAT_WARNINGS_AS_ERRORS = YES
Shopping’s Release name must read Shopping, not Shopping Dev; if it does
not, the target still sets Bundle Display Name. Run the tests, since
Shopping now treats warnings as errors, then commit:
make test 2>&1 | tail -2
git status --short
Test Case '-[TodoListUITests.TodoListUITests testTodoIsSavedAcrossLaunches]' passed (17.402 seconds).
** TEST SUCCEEDED **
D Apps/Config/Debug.xcconfig
D Apps/Config/Release.xcconfig
M Apps/Config/Shared.xcconfig
M Apps/Shopping/Shopping.xcodeproj/project.pbxproj
M Apps/TodoList/TodoList.xcodeproj/project.pbxproj
?? Apps/Shopping/Shopping.xcconfig
?? Apps/TodoList/TodoList.xcconfig
The D in the first column means Xcode already staged the two deletions
with Git when it moved the files to the Trash. Stage the rest and commit:
git add -A
git commit -q -m "Give each app its own xcconfig file"
Step 5: Give each app an icon
Both apps still show the blank icon from Xcode’s template, and the App Store
requires one: a 1024 by 1024 pixel PNG with no transparency. Xcode’s asset
catalog, the Assets.xcassets folder in each app, takes that single image and makes every smaller size an iPhone or
iPad needs. A short Swift script draws a letter on a colored square, which is
enough to tell the apps apart on the home screen; swap in real artwork at the
same size later.
Create the file
mkdir -p Tools
touch Tools/make-icon.swift
Add the code: Tools/make-icon.swift
// Draws a 1024x1024 app icon: one letter on a solid color.
// Usage: swift Tools/make-icon.swift <letter> <red> <green> <blue> <output.png>
import CoreGraphics
import CoreText
import Foundation
import ImageIO
import UniformTypeIdentifiers
let args = CommandLine.arguments
guard args.count == 6, let r = Double(args[2]), let g = Double(args[3]), let b = Double(args[4]) else {
print("usage: make-icon.swift <letter> <red> <green> <blue> <output.png>")
exit(1)
}
let size = 1024
let space = CGColorSpace(name: CGColorSpace.sRGB)!
// No alpha channel: App Store icons must be opaque.
let context = CGContext(data: nil, width: size, height: size, bitsPerComponent: 8, bytesPerRow: 0,
space: space, bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)!
context.setFillColor(CGColor(srgbRed: r, green: g, blue: b, alpha: 1))
context.fill(CGRect(x: 0, y: 0, width: size, height: size))
let font = CTFontCreateWithName("Helvetica-Bold" as CFString, 640, nil)
let text = NSAttributedString(string: args[1], attributes: [
NSAttributedString.Key(kCTFontAttributeName as String): font,
NSAttributedString.Key(kCTForegroundColorAttributeName as String): CGColor(srgbRed: 1, green: 1, blue: 1, alpha: 1),
])
let line = CTLineCreateWithAttributedString(text)
let bounds = CTLineGetImageBounds(line, context)
context.textPosition = CGPoint(x: (CGFloat(size) - bounds.width) / 2 - bounds.minX,
y: (CGFloat(size) - bounds.height) / 2 - bounds.minY)
CTLineDraw(line, context)
let url = URL(fileURLWithPath: args[5])
let destination = CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil)!
CGImageDestinationAddImage(destination, context.makeImage()!, nil)
guard CGImageDestinationFinalize(destination) else { print("could not write \(url.path)"); exit(1) }
Detailed breakdown
swift Tools/make-icon.swiftruns the file as a script. Swift compiles it and runs it in one go, so the tool needs no package or project of its own. It lives inTools/, outsideApps/andPackages/, because it is not part of any app.CGImageAlphaInfo.noneSkipLastgives the bitmap no alpha channel, so the PNG has none. App Store Connect rejects an app icon that has an alpha channel (error ITMS-90717), whatever its pixels hold.- The letter is centered on its drawn shape, not its font box.
CTLineGetImageBoundsmeasures the ink of the glyph, and subtractingbounds.minXandbounds.minYmoves that ink to the middle of the square. - The colors are red, green and blue from 0 to 1 in the sRGB color space.
Draw a blue T for TodoList and a green S for Shopping, straight into each
app’s AppIcon image set, the folder in the asset catalog that holds the
icon:
swift Tools/make-icon.swift T 0.20 0.45 0.90 Apps/TodoList/TodoList/Assets.xcassets/AppIcon.appiconset/AppIcon.png
swift Tools/make-icon.swift S 0.15 0.60 0.35 Apps/Shopping/Shopping/Assets.xcassets/AppIcon.appiconset/AppIcon.png
file Apps/*/*/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Apps/Shopping/Shopping/Assets.xcassets/AppIcon.appiconset/AppIcon.png: PNG image data, 1024 x 1024, 8-bit/color RGB, non-interlaced
Apps/TodoList/TodoList/Assets.xcassets/AppIcon.appiconset/AppIcon.png: PNG image data, 1024 x 1024, 8-bit/color RGB, non-interlaced
RGB, not RGBA: no alpha channel. The image set’s Contents.json lists
slots, one entry per icon variant, and says which file fills each. The
template’s version lists three empty 1024-pixel slots: the standard icon, a
dark one and a tinted one. Replace it
with one slot that names the new file. The dark and tinted variants are
optional, and this article leaves them out.
Create the file
touch Apps/TodoList/TodoList/Assets.xcassets/AppIcon.appiconset/Contents.json
The file exists already. Replace its contents with the listing below.
Add the code: Apps/TodoList/TodoList/Assets.xcassets/AppIcon.appiconset/Contents.json
{
"images" : [
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Detailed breakdown
filenameis the only new key: it points the slot atAppIcon.pngin the same folder.idiom: universalwithsize: 1024x1024is the single-size format. The build turns it into the sizes the device needs.- Shopping needs the same file, since its image is also named
AppIcon.png.
Copy Contents.json into Shopping’s image set:
cp Apps/TodoList/TodoList/Assets.xcassets/AppIcon.appiconset/Contents.json Apps/Shopping/Shopping/Assets.xcassets/AppIcon.appiconset/
Install both apps in Release, so the home screen shows their Release display
names (without Dev), then quit Shopping to go back to the home screen.
screenshots/ is ignored by Git, so create it in case it is missing:
make run CONFIG=Release
make run APP=Shopping CONFIG=Release
xcrun simctl terminate TodoList-iPhone com.example.Shopping
ls build/Build/Products/Release-iphonesimulator/Shopping.app | grep AppIcon
mkdir -p screenshots
xcrun simctl io TodoList-iPhone screenshot screenshots/home.png
[email protected]
AppIcon76x76@2x~ipad.png
Detected file type from extension: PNG
Note: No display specified. Defaulting to display: 8AFB279A-37BC-421B-856A-86EA4F1A54E9 (screenID: 1, name: LCD)
The make run output is left out; it ends with each app’s process ID, as
before. The two PNG files are sizes the build generated from the 1024-pixel
image: 120 pixels for the iPhone home screen and 152 for the iPad. The display
identifier differs on your Mac, and the screenshot command’s last line,
Wrote screenshot to: with the full path, is left out.
After an app quits, the simulator shows the home screen page that holds it.
screenshots/home.png shows the page with your apps: a blue T labeled
To-Do and a green S labeled Shopping, beside the
TodoListUITests-Runner icon from the UI tests. Commit the icons and the
script:
git add -A
git commit -q -m "Give each app an icon"
Step 6: Add release targets to the Makefile
Each app now carries its own version, and the Makefile is where releases
happen. The new make targets read an app’s version from its .xcconfig file, take
the build number from Git, and record each release as a tag. A tag is a
name Git attaches to one commit; unlike a branch, it does not move when you
commit again. An annotated tag, made with
git tag -a, also stores a message, which here records the version and the
build number. Tag names may contain /, so Shopping/v1.1.0 keeps each app’s
releases in a series of its own.
Create the file
touch Makefile
The file exists already. Replace its contents with the listing below.
Add the code: Makefile
WORKSPACE := MyApps.xcworkspace
APPS := TodoList Shopping
# Leaves first: each package comes after the packages it depends on.
PACKAGES := CoreKit DesignSystem TodoKit
# The apps with a UI-test target.
UI_TESTED := TodoList
# Each app's packages, direct and indirect, so `make changes` sees an edit
# to a package as a change to every app built from it.
DEPS_TodoList := TodoKit CoreKit DesignSystem
DEPS_Shopping := CoreKit DesignSystem
# The app that settings, run, screenshot, archive, and the release targets act on.
APP := TodoList
ifeq ($(filter $(APP),$(APPS)),)
$(error APP must be one of $(APPS), not "$(APP)")
endif
# Debug or Release; override with `make build CONFIG=Release`.
CONFIG := Debug
ifeq ($(filter $(CONFIG),Debug Release),)
$(error CONFIG must be Debug or Release, not "$(CONFIG)")
endif
APP_CONFIG := Apps/$(APP)/$(APP).xcconfig
# Everything that goes into APP: its project, the shared settings, its packages.
APP_PATHS := Apps/$(APP) Apps/Config $(addprefix Packages/,$(DEPS_$(APP)))
BUNDLE_ID := $(shell sed -n 's/^BUNDLE_ID_PREFIX = //p' Apps/Config/Shared.xcconfig).$(APP)
# `=`, not `:=`: worked out only when a recipe uses them, so `make help`
# runs no git command and a recipe sees the version file as it is now.
VERSION = $(shell sed -n 's/^MARKETING_VERSION = //p' $(APP_CONFIG))
BUILD_NUMBER = $(shell git rev-list --count HEAD)
TAG = $(APP)/v$(VERSION)
SIM_NAME := TodoList-iPhone
DEVICE_TYPE := iPhone 17
DERIVED := build
APP_PATH := $(DERIVED)/Build/Products/$(CONFIG)-iphonesimulator/$(APP).app
ARCHIVE := $(DERIVED)/$(APP).xcarchive
SIM := -destination 'platform=iOS Simulator,name=$(SIM_NAME)'
DEVICE := -destination 'generic/platform=iOS'
# Keep only result lines from the test logs: Swift Testing (✔/✘),
# XCTest ("Test Case ... passed"), errors, and xcodebuild's final verdict.
RESULTS := grep -E '(✔|✘) Test|Test Case .*(passed|failed)|error:|\*\* TEST'
# The build settings `make settings` prints, out of several hundred.
SETTINGS := CONFIGURATION|CURRENT_PROJECT_VERSION|INFOPLIST_KEY_CFBundleDisplayName|MARKETING_VERSION|PRODUCT_BUNDLE_IDENTIFIER|SWIFT_TREAT_WARNINGS_AS_ERRORS
XCODEBUILD := xcodebuild -workspace $(WORKSPACE) -derivedDataPath $(DERIVED)
TEST_FLAGS := -parallel-testing-enabled NO -collect-test-diagnostics never
# bash, so the recipes can use `set -o pipefail`.
SHELL := /bin/bash
.DEFAULT_GOAL := help
.PHONY: help simulator settings package-test build test run screenshot version set-version changes archive release clean delete-simulator
help: ## Show this help
@echo "Usage: make <target> [APP=$(subst $() ,|,$(APPS))] [CONFIG=Debug|Release] [TO=1.2.0]"
@echo
@grep -E '^[a-z-]+:.*## ' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*## "}; {printf " %-17s %s\n", $$1, $$2}'
simulator: ## Create the TodoList-iPhone simulator if it is missing
@xcrun simctl list devices available | grep -q "$(SIM_NAME) (" || \
xcrun simctl create "$(SIM_NAME)" "$(DEVICE_TYPE)"
settings: ## Show the key build settings for APP in CONFIG
@set -o pipefail; \
$(XCODEBUILD) -scheme $(APP) $(DEVICE) -configuration $(CONFIG) -showBuildSettings 2>/dev/null | \
grep -E '^ *($(SETTINGS)) =' | sed 's/^ *//'
# `|| exit 1` stops at the first package that fails, so the failure is
# reported against the lowest package that broke.
package-test: ## Test every package on the Mac, leaves first
@set -o pipefail; for pkg in $(PACKAGES); do \
echo "--- $$pkg"; \
SWT_SF_SYMBOLS_ENABLED=0 swift test --package-path Packages/$$pkg \
--disable-xctest 2>&1 | $(RESULTS) || exit 1; \
done
build: simulator ## Build every app for the simulator in CONFIG
@for app in $(APPS); do \
echo "--- $$app"; \
$(XCODEBUILD) -scheme $$app $(SIM) -configuration $(CONFIG) -quiet build || exit 1; \
done
test: package-test build ## Test the packages, build every app, run the UI tests
@set -o pipefail; for app in $(UI_TESTED); do \
echo "--- $$app UI tests"; \
$(XCODEBUILD) -scheme $$app $(SIM) $(TEST_FLAGS) test 2>&1 | $(RESULTS) || exit 1; \
done
run: simulator ## Build APP in CONFIG, then install and launch it
$(XCODEBUILD) -scheme $(APP) $(SIM) -configuration $(CONFIG) -quiet build
xcrun simctl bootstatus "$(SIM_NAME)" -b
open "$$(xcode-select -p)/../Applications/DeviceHub.app"
xcrun simctl install "$(SIM_NAME)" "$(APP_PATH)"
xcrun simctl launch "$(SIM_NAME)" $(BUNDLE_ID)
screenshot: ## Save a screenshot of the simulator to screenshots/APP.png
@mkdir -p screenshots
xcrun simctl io "$(SIM_NAME)" screenshot screenshots/$(APP).png
version: ## Show APP's version and the build number an archive gets now
@echo "$(APP) $(VERSION) (build $(BUILD_NUMBER))"
set-version: ## Set APP's version in its .xcconfig file to TO
@echo "$(TO)" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$$' || \
{ echo "TO must be a version such as 1.2.0, not \"$(TO)\""; exit 1; }
@sed -i '' 's/^MARKETING_VERSION = .*/MARKETING_VERSION = $(TO)/' $(APP_CONFIG)
@grep '^MARKETING_VERSION' $(APP_CONFIG)
changes: ## List the commits that changed APP since its last release
@last=$$(git describe --tags --abbrev=0 --match '$(APP)/v*' 2>/dev/null) || \
{ echo "$(APP) has not been released yet"; exit 0; }; \
echo "$(APP) since $$last:"; \
log=$$(git log --oneline $$last..HEAD -- $(APP_PATHS)); \
echo "$${log:-(no changes)}"
# An archive is a Release build for real devices. It is left unsigned,
# because signing needs a development team.
archive: ## Archive an unsigned Release build of APP into build/
$(XCODEBUILD) -scheme $(APP) $(DEVICE) -archivePath $(ARCHIVE) CURRENT_PROJECT_VERSION=$(BUILD_NUMBER) CODE_SIGNING_ALLOWED=NO -quiet archive
@plutil -extract ApplicationProperties json -o - $(ARCHIVE)/Info.plist; echo
# The checks run before the archive, so a refused release leaves no archive
# and no tag behind.
release: ## Archive APP and tag the commit APP/vVERSION
@test -n "$(VERSION)" || \
{ echo "No 'MARKETING_VERSION = ' line in $(APP_CONFIG)"; exit 1; }
@test -z "$$(git status --porcelain)" || \
{ echo "Commit your changes first, so the tag names the code that was archived"; exit 1; }
@! git rev-parse -q --verify "refs/tags/$(TAG)" >/dev/null || \
{ echo "$(TAG) already exists: raise the version with make set-version"; exit 1; }
@$(MAKE) --no-print-directory archive APP=$(APP)
git tag -a $(TAG) -m "$(APP) $(VERSION) (build $(BUILD_NUMBER))"
clean: ## Delete the build folders
rm -rf $(DERIVED) $(addsuffix /.build,$(addprefix Packages/,$(PACKAGES)))
delete-simulator: ## Delete the TodoList-iPhone simulator
xcrun simctl delete "$(SIM_NAME)"
Detailed breakdown
- The build number is the number of commits.
git rev-list --count HEADcounts the commits that lead to the current one. As long as you release from one line of history, such asmain, and never rewrite it, the count rises with every commit and never repeats, which is what App Store Connect asks of a build number. Two branches that leave the same commit reach the same count.archivepasses it on the command line asCURRENT_PROJECT_VERSION=.... A setting given toxcodebuildthat way beats every file and target, so the1in each.xcconfigfile only reaches builds that skipmake archive. Two apps archived from the same commit get the same build number, which is fine: each app’s numbers only have to rise within that app. VERSIONreads the app’s file, the same lineset-versionrewrites.TAGbuilds the tag name from it:TodoList/v1.0.0.releasechecks three things before it builds anything. It refuses when the app’s file has noMARKETING_VERSION =line it can read, since the tag would then beTodoList/v. It refuses on uncommitted changes (git status --porcelainprints a line per changed file), because the tag would name a commit that is not what was archived. And it refuses when the tag already exists (git rev-parse --verifyfinds it), because that version has shipped. Only then does it runarchivefor the same app and tag the commit.changesfinds the last release withgit describe.--match 'Shopping/v*'keeps only Shopping’s tags,--abbrev=0prints the tag name alone, and--tagsalso counts tags made without-a, in case one was made by hand.git logthen lists the commits since that tag that touchedAPP_PATHS: the app’s own folder,Apps/Config, and each package it is built from.DEPS_TodoListandDEPS_Shoppinglist packages by hand. TodoList linksTodoKitandDesignSystem, andTodoKitdepends onCoreKit, soCoreKitis on TodoList’s list too. Nothing checks these lists against the projects: when an app links another package, add it here, ormake changeswill miss the package’s commits.BUNDLE_IDreads the prefix fromShared.xcconfiginstead of repeatingcom.example, somake runkeeps launching the right app if the prefix changes.set-versionchecks the format before it edits anything, so a typo such asTO=1.1does not end up in the file.settingsaddsCURRENT_PROJECT_VERSIONto the values it prints.
Check the help screen and the new version target:
make
make version
make version APP=Shopping
Usage: make <target> [APP=TodoList|Shopping] [CONFIG=Debug|Release] [TO=1.2.0]
help Show this help
simulator Create the TodoList-iPhone simulator if it is missing
settings Show the key build settings for APP in CONFIG
package-test Test every package on the Mac, leaves first
build Build every app for the simulator in CONFIG
test Test the packages, build every app, run the UI tests
run Build APP in CONFIG, then install and launch it
screenshot Save a screenshot of the simulator to screenshots/APP.png
version Show APP's version and the build number an archive gets now
set-version Set APP's version in its .xcconfig file to TO
changes List the commits that changed APP since its last release
archive Archive an unsigned Release build of APP into build/
release Archive APP and tag the commit APP/vVERSION
clean Delete the build folders
delete-simulator Delete the TodoList-iPhone simulator
TodoList 1.0.0 (build 3)
Shopping 1.0.0 (build 3)
Build 3, because the repository has three commits so far. Your number may
differ if my-apps already had a history.
Step 7: Release both apps for the first time
The first release of each app sets the starting point that make changes
counts from. The Makefile itself is not committed yet, so release stops
before it builds anything:
make release
Commit your changes first, so the tag names the code that was archived
make: *** [release] Error 1
Commit it, then release each app:
git add Makefile
git commit -q -m "Add version, changes and release targets"
make release
make release APP=Shopping
xcodebuild -workspace MyApps.xcworkspace -derivedDataPath build -scheme TodoList -destination 'generic/platform=iOS' -archivePath build/TodoList.xcarchive CURRENT_PROJECT_VERSION=4 CODE_SIGNING_ALLOWED=NO -quiet archive
{"CFBundleShortVersionString":"1.0.0","CFBundleIdentifier":"com.example.TodoList","Team":"","ApplicationPath":"Applications\/TodoList.app","SigningIdentity":"","Architectures":["arm64"],"CFBundleVersion":"4"}
git tag -a TodoList/v1.0.0 -m "TodoList 1.0.0 (build 4)"
xcodebuild -workspace MyApps.xcworkspace -derivedDataPath build -scheme Shopping -destination 'generic/platform=iOS' -archivePath build/Shopping.xcarchive CURRENT_PROJECT_VERSION=4 CODE_SIGNING_ALLOWED=NO -quiet archive
{"CFBundleShortVersionString":"1.0.0","CFBundleIdentifier":"com.example.Shopping","Team":"","ApplicationPath":"Applications\/Shopping.app","SigningIdentity":"","Architectures":["arm64"],"CFBundleVersion":"4"}
git tag -a Shopping/v1.0.0 -m "Shopping 1.0.0 (build 4)"
Both archives are build 4, not the 3 that make version showed, because
committing the Makefile added a commit. Each archive’s Info.plist carries the version from the app’s file
(CFBundleShortVersionString) and the commit count (CFBundleVersion), and
each tag message repeats them. Team and SigningIdentity are empty because
the archives are unsigned. List the tags with their messages:
git tag -n
Shopping/v1.0.0 Shopping 1.0.0 (build 4)
TodoList/v1.0.0 TodoList 1.0.0 (build 4)
git push does not send tags. With a remote, push each one by name, such as
git push origin TodoList/v1.0.0.
Step 8: Change one app and release only that app
This is the case a shared repository makes awkward without per-app releases: a fix to Shopping should ship Shopping and leave TodoList’s version alone. Change the placeholder in Shopping’s text field and commit:
sed -i '' 's/TextField("New item"/TextField("Add an item"/' Apps/Shopping/Shopping/ContentView.swift
git commit -q -am "Shopping: clearer placeholder for new items"
make changes APP=Shopping
make changes
Shopping since Shopping/v1.0.0:
f2bcca6 Shopping: clearer placeholder for new items
TodoList since TodoList/v1.0.0:
(no changes)
Shopping has one change to release and TodoList has none. Releasing Shopping
at the same version is refused, since Shopping/v1.0.0 already names a
commit:
make release APP=Shopping
Shopping/v1.0.0 already exists: raise the version with make set-version
make: *** [release] Error 1
Raise the version, commit it, and release:
make set-version APP=Shopping TO=1.1.0
git commit -q -am "Shopping 1.1.0"
make release APP=Shopping
MARKETING_VERSION = 1.1.0
xcodebuild -workspace MyApps.xcworkspace -derivedDataPath build -scheme Shopping -destination 'generic/platform=iOS' -archivePath build/Shopping.xcarchive CURRENT_PROJECT_VERSION=6 CODE_SIGNING_ALLOWED=NO -quiet archive
{"CFBundleShortVersionString":"1.1.0","CFBundleIdentifier":"com.example.Shopping","Team":"","ApplicationPath":"Applications\/Shopping.app","SigningIdentity":"","Architectures":["arm64"],"CFBundleVersion":"6"}
git tag -a Shopping/v1.1.0 -m "Shopping 1.1.0 (build 6)"
Build 6: two commits since the first release. The version bump is a commit of
its own, so the tag names a commit whose Shopping.xcconfig says 1.1.0.
TodoList’s file still says 1.0.0, and its last tag is untouched.
Step 9: Change a shared package and release both apps
A change to a package changes every app built from it, even though neither
app’s own folder changes. make changes covers this because each app’s paths
include its packages. Go back from the square check symbols to round ones in
DesignSystem, check the names with the package tests, and commit:
sed -i '' 's/"checkmark.square.fill" : "square"/"checkmark.circle.fill" : "circle"/' Packages/DesignSystem/Sources/DesignSystem/CheckRow.swift
make package-test 2>&1 | grep symbolExists
git commit -q -am "DesignSystem: go back to round check symbols"
make changes
make changes APP=Shopping
✔ Test symbolExists(isDone:) with 2 test cases passed after 0.020 seconds.
TodoList since TodoList/v1.0.0:
d93a305 DesignSystem: go back to round check symbols
Shopping since Shopping/v1.1.0:
d93a305 DesignSystem: go back to round check symbols
The same commit is due in both apps, each measured from its own last release:
TodoList from 1.0.0, Shopping from 1.1.0. Shopping’s earlier placeholder
change is not listed again, because it shipped in 1.1.0. Raise both
versions, commit once, and release both:
make set-version TO=1.0.1
make set-version APP=Shopping TO=1.1.1
git commit -q -am "TodoList 1.0.1, Shopping 1.1.1"
make release
make release APP=Shopping
MARKETING_VERSION = 1.0.1
MARKETING_VERSION = 1.1.1
xcodebuild -workspace MyApps.xcworkspace -derivedDataPath build -scheme TodoList -destination 'generic/platform=iOS' -archivePath build/TodoList.xcarchive CURRENT_PROJECT_VERSION=8 CODE_SIGNING_ALLOWED=NO -quiet archive
{"CFBundleShortVersionString":"1.0.1","CFBundleIdentifier":"com.example.TodoList","Team":"","ApplicationPath":"Applications\/TodoList.app","SigningIdentity":"","Architectures":["arm64"],"CFBundleVersion":"8"}
git tag -a TodoList/v1.0.1 -m "TodoList 1.0.1 (build 8)"
xcodebuild -workspace MyApps.xcworkspace -derivedDataPath build -scheme Shopping -destination 'generic/platform=iOS' -archivePath build/Shopping.xcarchive CURRENT_PROJECT_VERSION=8 CODE_SIGNING_ALLOWED=NO -quiet archive
{"CFBundleShortVersionString":"1.1.1","CFBundleIdentifier":"com.example.Shopping","Team":"","ApplicationPath":"Applications\/Shopping.app","SigningIdentity":"","Architectures":["arm64"],"CFBundleVersion":"8"}
git tag -a Shopping/v1.1.1 -m "Shopping 1.1.1 (build 8)"
The repository now records every release of both apps:
git tag -n
make changes
Shopping/v1.0.0 Shopping 1.0.0 (build 4)
Shopping/v1.1.0 Shopping 1.1.0 (build 6)
Shopping/v1.1.1 Shopping 1.1.1 (build 8)
TodoList/v1.0.0 TodoList 1.0.0 (build 4)
TodoList/v1.0.1 TodoList 1.0.1 (build 8)
TodoList since TodoList/v1.0.1:
(no changes)
Nothing is due. Each app has its own series
of versions, and each build number is higher than the one before it for that
app. To see the released code running, install a Release build of the tagged
commit on the simulator. The sleep gives the app a moment to finish opening,
so the screenshot does not catch the launch animation:
make run APP=Shopping CONFIG=Release
sleep 3
make screenshot APP=Shopping
screenshots/Shopping.png shows the text field reading Add an item, and
Apples and Bread with round symbols again: the placeholder from
Shopping 1.1.0 and the symbols from 1.1.1. A small To-Do link may appear at the top left. It
is iOS offering a way back to the app that was in front before, not part of
Shopping.
Troubleshooting
- A value in an app’s
.xcconfigfile has no effect. The target still sets it. Step 4: open the target’s Build Settings with Levels shown; the outlined column is the one that supplies the value. Delete the target’s value. - Release builds are named To-Do Dev or Shopping Dev. Showing the
target’s General tab wrote the Debug display name into the target.
Delete Bundle Display Name from the target in Build Settings, then check
with
make settings CONFIG=Release. - The home screen shows a blank icon.
Contents.jsondoes not name the file, or the PNG is not in theAppIcon.appiconsetfolder. The built app should contain[email protected]; if it does and the icon is still blank, delete the app from the simulator withxcrun simctl uninstall TodoList-iPhone com.example.Shoppingand run it again. No 'MARKETING_VERSION = ' line. The version line in the app’s.xcconfigfile lost the spaces around=. Write it asmake set-versiondoes:MARKETING_VERSION = 1.2.0.TO must be a version such as 1.2.0.set-versiontakes three numbers separated by dots.already exists: raise the version. The app’s current version has a tag. Runmake set-versionwith a higher version and commit beforemake release.Commit your changes first.git status --shortlists what is not committed, including files Git is not tracking yet.- Build numbers are 1 in a CI job. A shallow clone, the default in many
CI services, holds only the latest commit, so
git rev-list --count HEADcounts 1. It usually has no tags either, somake changesreports no release andreleasecannot see a version that already shipped. Fetch the full history with its tags (for examplefetch-depth: 0in GitHub Actions' checkout step). make changesmisses a package’s commits. The app’sDEPS_list in theMakefiledoes not name the package. Add it.
Recap
Each app in my-apps now has one .xcconfig file that holds its bundle
identifier, display name and version, on top of Shared.xcconfig, and the app
targets set none of those values themselves, so the files are where they come
from. Both apps take their icon from a 1024-pixel image and show it on the
simulator’s home screen with their Release display names. make release APP=...
archives one app with a build number counted from Git and tags the commit
<app>/v<version>, refusing uncommitted work and versions that already
shipped. make changes APP=... lists what has changed in an app since its
last tag, counting commits to the packages it is built from, which is how one
change to DesignSystem became a release of both apps while a change to
Shopping alone released only Shopping.
Next improvements:
- Sign, export and upload each archive with
xcodebuild -exportArchiveonce you have an Apple Developer Program membership. - Keep a
CHANGELOG.mdin each app’s folder and havereleaserefuse to run when it has no section for the new version. - Run
make changesfor every app in CI, so a pull request shows which apps it will need to release.