Engineering Practices

Build an iOS Font Registration Gate on a Cloud Mac

Build an iOS Font Registration Gate on a Cloud Mac

The design uses a custom font for headings, and everything works perfectly during local development. Yet after the automated build is installed on a test device, the App silently falls back to the system font. Problems like this usually do not cause a compilation failure: the font may be missing from the target, Info.plist may contain an incorrect relative path, or the code may use the filename instead of the font’s internal PostScript name. The most reliable approach is not to keep inspecting the project directory, but to audit the final App bundle immediately after building it on a cloud Mac.

Define What the Font Gate Checks

A font gate should answer at least four questions: whether each declared file exists, whether Core Text can parse it, whether its internal name matches the name expected by the code, and whether multiple files produce duplicate names. The gate must inspect the .app directory rather than the source Resources folder, because target membership and the Copy Bundle Resources phase can both affect the final output.

Check What a failure means Recommended action
UIAppFonts entries The build configuration does not declare the font Correct the target’s Info configuration
Files in the App bundle The font was not copied into the build output Check target membership and the resources phase
PostScript name The name used by the code may be incorrect Read the actual name from the font descriptor
Name uniqueness Font families or versions conflict Remove duplicate files or define the replacement explicitly

A font that “looks close enough” in the interface is not an acceptable validation criterion. Fallback may be difficult to notice in short English text, but changes in character width can affect line wrapping, button dimensions, and screenshot comparisons.

Maintain a ci/expected-fonts.txt file in the repository, with one permitted PostScript name per line. This file acts as a stable contract between code references and resource files. It should not list every font installed on a developer’s computer.

Extract Facts from the Final App Bundle

First generate the App bundle with the existing build process, then pass its path to the validation script. Debug and Release builds may use different resource configurations, so the release pipeline must inspect the configuration that is actually being prepared for delivery. The script must not register fonts with the system before validating them, because an identically named font already installed on the machine could conceal a missing font in the App bundle.

The following Swift script reads UIAppFonts from Info.plist, verifies that every declared file exists, and extracts its internal names through Core Text. Save it as ci/check_fonts.swift:

import Foundation
import CoreText

let arguments = CommandLine.arguments
guard arguments.count == 2 else {
    FileHandle.standardError.write(Data("usage: check_fonts.swift /path/App.app
".utf8))
    exit(64)
}

let appURL = URL(fileURLWithPath: arguments[1], isDirectory: true)
let plistURL = appURL.appendingPathComponent("Info.plist")
guard
    let data = try? Data(contentsOf: plistURL),
    let plist = try? PropertyListSerialization.propertyList(from: data) as? [String: Any],
    let declared = plist["UIAppFonts"] as? [String],
    !declared.isEmpty
else {
    FileHandle.standardError.write(Data("UIAppFonts is missing or empty
".utf8))
    exit(1)
}

var failed = false
var seen = Set<String>()

for relativePath in declared {
    let url = appURL.appendingPathComponent(relativePath)
    guard FileManager.default.fileExists(atPath: url.path) else {
        FileHandle.standardError.write(Data("missing: \(relativePath)
".utf8))
        failed = true
        continue
    }

    let descriptors = CTFontManagerCreateFontDescriptorsFromURL(url as CFURL) as? [CTFontDescriptor] ?? []
    if descriptors.isEmpty {
        FileHandle.standardError.write(Data("unreadable: \(relativePath)
".utf8))
        failed = true
    }

    for descriptor in descriptors {
        let value = CTFontDescriptorCopyAttribute(descriptor, kCTFontNameAttribute)
        guard let name = value as? String, !name.isEmpty else {
            FileHandle.standardError.write(Data("unnamed: \(relativePath)
".utf8))
            failed = true
            continue
        }
        if !seen.insert(name).inserted {
            FileHandle.standardError.write(Data("duplicate: \(name)
".utf8))
            failed = true
        }
        print("\(name)	\(relativePath)")
    }
}

exit(failed ? 1 : 0)

Run it with the actual path to the build output:

xcrun swift ci/check_fonts.swift "$APP_PATH" | sort > build/actual-fonts.txt
diff -u ci/expected-fonts.txt build/actual-fonts.txt

If the allowlist stores names only, add cut -f1 after the output. The key requirement is for any difference to return a nonzero exit code so the pipeline stops immediately.

Pin PostScript Names, Not Filenames

Headline-Bold.otf is only a filename on disk; the font’s internal name may be HeadlinePro-Bold. SwiftUI’s Font.custom, UIKit font initializers, and test code must all use the internal name. Renaming the font file does not automatically change that name, while a font update from the vendor may change it.

Require Explicit Review for Name Changes

When actual-fonts.txt differs from the allowlist, do not automatically accept the new values in the pipeline. First verify that the change comes from a planned upgrade, then search the project for font references. Centralize font names in a source constants file so they are not scattered across view code and test fixtures.

A single font file can also contain multiple descriptors. The script should retain every name rather than taking only the first one. If the team genuinely needs variable fonts, add interface-level smoke tests for commonly used weights to confirm that axis settings match the design baseline.

Add Runtime and UI Validation

A static gate can prove that the resource structure is correct, but it cannot prove that every screen requests the correct weight. Add a font catalog screen used only by the test target. It should cover headings, body text, numbers, Chinese punctuation, and long English words, with screenshot validation on a simulator configured to a fixed size.

Runtime assertions should inspect the font instance’s fontName, not merely compare its point size. For dynamic fonts, tests should also cover at least two content size categories and check for clipping after scaling. If the interface is allowed to fall back when a font is unavailable, that fallback behavior must be explicitly documented in code. Missing core brand fonts should instead fail immediately in the test environment.

When tests run in parallel, give each job its own build and results directories so an allowlist generated by one branch cannot overwrite another branch’s copy. Font files are input resources and must not be modified in place by build scripts.

Integrate the Gate into a Reproducible Pipeline

The complete sequence should be: clean an isolated build directory, build the target configuration, locate the single App bundle, run the font structure check, compare the allowlist, and only then run the UI tests. Do not select an artifact with an ambiguous find | head -1; when a directory contains multiple Apps, it may inspect a test host or a stale build.

When the gate fails, retain actual-fonts.txt, the list of font files in the App bundle, and the relevant build log excerpts, but do not upload unrelated credentials. During investigation, check for missing files first, inspect UIAppFonts next, and verify internal names last. This order quickly distinguishes a packaging failure from an incorrect name used by the code.

When a font upgrade is approved, commit the font files, allowlist, centralized name constants, and screenshot baselines in the same change. Reverting any such change will then restore a complete, consistent state, and builds on a cloud Mac will not depend on fonts preinstalled on a particular developer machine.

Frequently asked questions

Why is checking font files in the repository not enough?

A file can exist in source control but still be omitted from the target. The reliable check inspects the built App bundle and resolves every UIAppFonts entry.

Should a SwiftUI custom font use the font filename?

Not by assumption. It normally needs the internal PostScript name, which should be read from the font descriptor and compared with an approved manifest.

Where should the font gate run in CI?

Run it after producing the installable App bundle and before any upload or distribution step.

Dedicated physical node

Run your Mac mini workflows continuously in the cloud

Choose RunAMac M4, a rental period, and a deployment region to run development, build, or experimental workloads on a dedicated physical machine.

Choose a rental plan