DEV Community

孫昊
孫昊

Posted on • Originally published at jiejuefuyou.github.io

The Bundle(for:) Trap: Why Your iOS Test Bundle Lies About Resources

The fastlane Matchfile Bundle ID Trap That Killed My CI After Adding a Widget

TL;DR: If you add a widget / app extension / Action Extension to your iOS project, fastlane match will silently skip its provisioning profile unless you explicitly list the widget's bundle ID in Matchfile. CI will fail at build_app with "Provisioning profile doesn't support the App Group" — and the error message points you at the main app's profile, not the missing widget profile, so you spend a day chasing the wrong bug.


How It Killed My CI

DaysUntil v1.0.7 shipped fine. v1.0.8 added a widget extension. Pushed tag. CI failed at build_app:

::error file=...DaysUntil.xcodeproj::Provisioning profile 
"match AppStore com.jiejuefuyou.daysuntil 1778731087" doesn't 
support the group.com.jiejuefuyou.daysuntil App Group. 
(in target 'DaysUntilWidget' from project 'DaysUntil')
Enter fullscreen mode Exit fullscreen mode

The error names the main app's provisioning profile. My first interpretation: the App Group capability wasn't registered. So I logged into the Apple Developer Portal, added App Group, regenerated profiles. CI still failed.

Then I added the App Group capability via the ASC API:

curl -X POST https://api.appstoreconnect.apple.com/v1/bundleIdCapabilities \
  -H "Authorization: Bearer $JWT" \
  -d '{
    "data": {
      "type": "bundleIdCapabilities",
      "attributes": { "capabilityType": "APP_GROUPS" },
      "relationships": { "bundleId": { "data": { "type": "bundleIds", "id": "6J52R36XL5" } } }
    }
  }'
# 201 success
Enter fullscreen mode Exit fullscreen mode

Verified via API the capability was attached. CI still failed.

I ran gh workflow run init_signing.yml -f force=true to regenerate match profiles. CI still failed.

The fix turned out to be in fastlane's own config:

# fastlane/Matchfile (before)
app_identifier([ENV["APP_BUNDLE_ID"] || "com.jiejuefuyou.daysuntil"])

# fastlane/Matchfile (after)
app_identifier([
  ENV["APP_BUNDLE_ID"] || "com.jiejuefuyou.daysuntil",
  "com.jiejuefuyou.daysuntil.widget"
])
Enter fullscreen mode Exit fullscreen mode

Plus the Fastfile sync_code_signing and update_code_signing_settings calls, which only referenced the main bundle.


The Mental Model That Was Wrong

I had assumed fastlane match was project-aware. That is, when you run match, it parses your Xcode project, finds every target with a bundle ID, and fetches profiles for all of them.

It does not.

fastlane match only manages profiles for bundles you explicitly list in Matchfile / pass to sync_code_signing.

So when you add a widget target via xcodegen (or Xcode GUI), fastlane is blind to it. The widget target gets no profile in your match storage. At build time, Xcode looks for a profile matching the widget's bundle ID + capabilities — and finds nothing. The next-best match it finds is the main app's profile (similar bundle ID prefix), which it tries to use, then fails because the main app's profile doesn't include the widget's bundle ID.

The error message names the main app's profile because that's the one Xcode tried. The actual problem is the widget profile that doesn't exist. The error message is technically true but maximally misleading.


The Complete Fix

For DaysUntil (and any project with a widget / app extension):

1. fastlane/Matchfile

app_identifier([
  ENV["APP_BUNDLE_ID"] || "com.jiejuefuyou.daysuntil",
  "com.jiejuefuyou.daysuntil.widget"
])
Enter fullscreen mode Exit fullscreen mode

2. fastlane/Fastfile

Define a WIDGET_BUNDLE_ID constant:

BUNDLE_ID = ENV["APP_BUNDLE_ID"] || "com.jiejuefuyou.daysuntil"
WIDGET_BUNDLE_ID = "#{BUNDLE_ID}.widget"
Enter fullscreen mode Exit fullscreen mode

Update init_signing and beta lanes' sync_code_signing:

sync_code_signing(
  type:          "appstore",
  readonly:      is_ci,
  api_key:       api_key,
  app_identifier: [BUNDLE_ID, WIDGET_BUNDLE_ID]
)
Enter fullscreen mode Exit fullscreen mode

Update update_code_signing_settings (call twice — once per bundle):

real_profile_name = ENV.fetch("sigh_#{BUNDLE_ID}_appstore_profile-name") {
  "match AppStore #{BUNDLE_ID}"
}
widget_profile_name = ENV.fetch("sigh_#{WIDGET_BUNDLE_ID}_appstore_profile-name") {
  "match AppStore #{WIDGET_BUNDLE_ID}"
}

update_code_signing_settings(
  use_automatic_signing: false,
  path: PROJECT,
  team_id: ENV.fetch("TEAM_ID"),
  bundle_identifier: BUNDLE_ID,
  profile_name: real_profile_name,
  code_sign_identity: "Apple Distribution"
)
update_code_signing_settings(
  use_automatic_signing: false,
  path: PROJECT,
  team_id: ENV.fetch("TEAM_ID"),
  bundle_identifier: WIDGET_BUNDLE_ID,
  profile_name: widget_profile_name,
  code_sign_identity: "Apple Distribution"
)
Enter fullscreen mode Exit fullscreen mode

Update build_app's provisioningProfiles:

build_app(
  # ... other options ...
  export_options: {
    method: "app-store",
    provisioningProfiles: {
      BUNDLE_ID => real_profile_name,
      WIDGET_BUNDLE_ID => widget_profile_name
    },
    signingStyle: "manual",
    teamID: ENV.fetch("TEAM_ID")
  }
)
Enter fullscreen mode Exit fullscreen mode

3. Regenerate match profiles

# Force-regen to pick up new bundle ID + current capabilities
gh workflow run init_signing.yml -f type=appstore -f force=true

# Wait for green, then push the next tag
git tag v1.0.9
git push origin v1.0.9
Enter fullscreen mode Exit fullscreen mode

How to Audit Your Own Repos

I wrote a Python script that cross-checks fastlane config against xcodegen's project.yml. It flags any bundle in project.yml that's missing from Matchfile or sync_code_signing.

python orchestrator/lib/match_audit.py --all repos/
Enter fullscreen mode Exit fullscreen mode

Output for my 5 repos before fixing:

=== autoapp-days-until ===
  project.yml targets: 4 (['com.jiejuefuyou.daysuntil', 'com.jiejuefuyou.daysuntil.widget', ...])
  Matchfile bundles:   ['com.jiejuefuyou.daysuntil']
  - error: Bundle(s) in project.yml but NOT in Matchfile: ['com.jiejuefuyou.daysuntil.widget']

=== autoapp-prompt-vault ===
  project.yml targets: 4 (['com.jiejuefuyou.promptvault', 'com.jiejuefuyou.promptvault.ActionExtension', ...])
  Matchfile bundles:   ['com.jiejuefuyou.promptvault']
  - error: Bundle(s) in project.yml but NOT in Matchfile: ['com.jiejuefuyou.promptvault.ActionExtension']
Enter fullscreen mode Exit fullscreen mode

It correctly identified two repos with the same trap. The script is MIT under my autoapp repo.


Why This Bites Indie Devs Specifically

Big teams with full-time DevOps notice this on day 1 because they have a runbook for adding new targets. Indie devs adding a widget for the first time hit this on day N (the first time the widget needs a signed build, which is when you push the next TestFlight tag).

The error message points away from the real bug. Fastlane docs mention app_identifier accepts an array, but don't say "you MUST list every signed target."

I lost ~3 days of CI failures to this. The fix was 15 lines across 2 files.

If you're about to add your first widget / Live Activity / Today Extension / Watch app:

  1. Add the target via xcodegen.
  2. Open fastlane/Matchfile. Add the new bundle ID.
  3. Open fastlane/Fastfile. Update sync_code_signing + update_code_signing_settings + build_app for the new bundle.
  4. Run init_signing.yml -f force=true to regen profiles.
  5. Then push your tag.

Or: drop my match_audit.py into your CI as a pre-commit / pre-push check.


Reusable Audit Script (excerpt)

import re, yaml
from pathlib import Path

def project_yml_bundles(repo: Path) -> set[str]:
    data = yaml.safe_load((repo / "project.yml").read_text())
    bundles = set()
    for tgt, defn in (data.get("targets") or {}).items():
        bid = (defn.get("settings") or {}).get("base", {}).get("PRODUCT_BUNDLE_IDENTIFIER")
        if bid and not bid.endswith((".tests", ".uitests")):
            bundles.add(bid)
    return bundles

def matchfile_bundles(repo: Path) -> set[str]:
    text = (repo / "fastlane" / "Matchfile").read_text()
    m = re.search(r'app_identifier\s*\(\s*\[(.*?)\]\s*\)', text, re.DOTALL)
    if not m: return set()
    return set(re.findall(r'"(com\.[\w\.-]+)"', m.group(1)))

def audit(repo: Path):
    missing = project_yml_bundles(repo) - matchfile_bundles(repo)
    if missing:
        print(f"{repo.name}: missing from Matchfile: {sorted(missing)}")
Enter fullscreen mode Exit fullscreen mode

50 lines of Python catches a category of bugs that fastlane itself won't tell you about.


Cover Image Prompt (1000×420)

"A Xcode project navigator with three target icons: main app, widget extension, and Apple Watch app. A red 'missing' badge floats over the widget icon, pointing to a small Matchfile thumbnail labeled 'app_identifier: [...main...]' — a thread connecting the missing badge to the Matchfile, with a magnifying glass over the [...] showing the absent bundle. Editorial illustration style, muted colors."

Tags: fastlane, ios, ci, widget, gotcha

Internal cross-links:

  • Previous article: dev.to 96 "Swift truncatingRemainder Trap"
  • Series: "Indie iOS Lessons from 4 Apps in 75 Days"
  • Repo: github.com/jiejuefuyou/autoapp

Top comments (0)