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, and Apps/TodoList/TodoList.xcconfig and Apps/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 Makefile with version, set-version, changes and release, and an archive that stamps each build with the number of commits in the repository.
  • Release tags such as TodoList/v1.0.1 and Shopping/v1.1.1, one series per app in one repository.

Prerequisites

  • The finished my-apps folder from A Swift Monorepo with an Xcode Workspace and Local Packages, with the square check symbols from its last step and the TodoList-iPhone simulator.
  • .xcconfig files and build configurations. An .xcconfig file holds build settings as NAME = value lines, 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.name and user.email). Validated with Git 2.56.0. The article assumes you know git add and git 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 becomes CFBundleShortVersionString in the app’s Info.plist: the 1.1.0 people see on the App Store.
  • The build number, CURRENT_PROJECT_VERSION, which becomes CFBundleVersion. 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_PREFIX is a setting of your own. Xcode does not know the name; it is defined in Shared.xcconfig and 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. Use com.example while you follow along.
  • #include takes a path relative to the including file, so ../Config/Shared.xcconfig from Apps/TodoList/ reaches Apps/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 gets To-Do, and Debug gets the conditional line’s To-Do Dev, the same pair the old Debug.xcconfig and Release.xcconfig gave. 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 number 1 is what Xcode, make build and make run use; Step 6’s make archive replaces 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:

  1. In the Project navigator, expand the TodoList project, select Debug.xcconfig, and press Delete. In the sheet, click Move to Trash.
  2. Do the same for Release.xcconfig. Keep Shared.xcconfig.

Then add each app’s file to its project, without a target:

  1. Select the TodoList project, the blue icon, in the Project navigator. The File menu now reads Add Files to “TodoList”…; choose it.
  2. Select Apps/TodoList/TodoList.xcconfig and click Add.
  3. 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.
  4. 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:

  1. Click the TodoList project in the Project navigator. In the editor, select TodoList under PROJECT (not under TARGETS) and open the Info tab.
  2. 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.
  3. 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.

  1. Click the TodoList project in the Project navigator, select TodoList under PROJECT, and open the Build Settings tab.
  2. Now select the TodoList target under TARGETS. The editor stays on Build Settings.
  3. 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.
  4. Type MARKETING_VERSION into the filter field at the top right. The row shows 1.0 in the target column and 1.0.0 under Config.File, with the target’s value outlined. Select the Marketing Version row and press Delete. The target column empties, and Resolved reads 1.0.0.
  5. 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, and CFBundleDisplayName (the Bundle Display Name row, only if the target column has a value).
  6. 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.swift runs 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 in Tools/, outside Apps/ and Packages/, because it is not part of any app.
  • CGImageAlphaInfo.noneSkipLast gives 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. CTLineGetImageBounds measures the ink of the glyph, and subtracting bounds.minX and bounds.minY moves 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

  • filename is the only new key: it points the slot at AppIcon.png in the same folder.
  • idiom: universal with size: 1024x1024 is 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 HEAD counts the commits that lead to the current one. As long as you release from one line of history, such as main, 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. archive passes it on the command line as CURRENT_PROJECT_VERSION=.... A setting given to xcodebuild that way beats every file and target, so the 1 in each .xcconfig file only reaches builds that skip make 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.
  • VERSION reads the app’s file, the same line set-version rewrites. TAG builds the tag name from it: TodoList/v1.0.0.
  • release checks three things before it builds anything. It refuses when the app’s file has no MARKETING_VERSION = line it can read, since the tag would then be TodoList/v. It refuses on uncommitted changes (git status --porcelain prints 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 --verify finds it), because that version has shipped. Only then does it run archive for the same app and tag the commit.
  • changes finds the last release with git describe. --match 'Shopping/v*' keeps only Shopping’s tags, --abbrev=0 prints the tag name alone, and --tags also counts tags made without -a, in case one was made by hand. git log then lists the commits since that tag that touched APP_PATHS: the app’s own folder, Apps/Config, and each package it is built from.
  • DEPS_TodoList and DEPS_Shopping list packages by hand. TodoList links TodoKit and DesignSystem, and TodoKit depends on CoreKit, so CoreKit is on TodoList’s list too. Nothing checks these lists against the projects: when an app links another package, add it here, or make changes will miss the package’s commits.
  • BUNDLE_ID reads the prefix from Shared.xcconfig instead of repeating com.example, so make run keeps launching the right app if the prefix changes.
  • set-version checks the format before it edits anything, so a typo such as TO=1.1 does not end up in the file.
  • settings adds CURRENT_PROJECT_VERSION to 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 .xcconfig file 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.json does not name the file, or the PNG is not in the AppIcon.appiconset folder. The built app should contain [email protected]; if it does and the icon is still blank, delete the app from the simulator with xcrun simctl uninstall TodoList-iPhone com.example.Shopping and run it again.
  • No 'MARKETING_VERSION = ' line. The version line in the app’s .xcconfig file lost the spaces around =. Write it as make set-version does: MARKETING_VERSION = 1.2.0.
  • TO must be a version such as 1.2.0. set-version takes three numbers separated by dots.
  • already exists: raise the version. The app’s current version has a tag. Run make set-version with a higher version and commit before make release.
  • Commit your changes first. git status --short lists 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 HEAD counts 1. It usually has no tags either, so make changes reports no release and release cannot see a version that already shipped. Fetch the full history with its tags (for example fetch-depth: 0 in GitHub Actions' checkout step).
  • make changes misses a package’s commits. The app’s DEPS_ list in the Makefile does 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 -exportArchive once you have an Apple Developer Program membership.
  • Keep a CHANGELOG.md in each app’s folder and have release refuse to run when it has no section for the new version.
  • Run make changes for every app in CI, so a pull request shows which apps it will need to release.