Apple App Delivery Skill
An Agent Skill that creates builds, distributes on TestFlight, submits for App Review, and releases on the App Store through the App Store Connect API and Apple's official CLIs. Mutations use a separate approval gate by default; stable internal-TestFlight delivery also supports a tightly bounded one-request automation mode, including brief requests such as "Upload this app to TestFlight" or "TestFlightにアップロードして。" It targets iOS/iPadOS, macOS, tvOS, and visionOS.
Current status: local syntax checks, dry runs, approval refusals, secret protection, and the stable internal-only archive/upload/processing/localization path have been exercised. The brief-request resolver is instruction-driven and is checked with ambiguity scenarios; external TestFlight, App Review, and public release still require dedicated testing.
README vs SKILL.md
- This
README.mdis the manual for the people who install and operate the skill. SKILL.mdis the instruction file that lets Codex or Claude discover the skill and execute it in a safe order.references/holds the details for setup, workflow, metadata, and failure recovery.agents/openai.yamlis optional UI metadata for OpenAI products. Claude does not need it, and no core functionality depends on it.
README.md is human-facing distribution documentation and is not used for skill
discovery. At runtime the authoritative sources are SKILL.md and the references/ it
points to.
Supported environments
| Environment | Supported | How to invoke, and caveats |
|---|---|---|
| Codex (local) | Yes | $apple-app-delivery |
| Claude Code (local macOS) | Yes | /apple-app-delivery |
| Claude Desktop Code, Local environment | Yes | Only where local Xcode and keys are reachable |
| Claude Code Remote Control | Yes | The UI is remote, but process and filesystem access run on the local Mac |
| Claude.ai, Claude Code on the web/cloud, Cowork, API-hosted skill | Reference only | No local Xcode, signing assets, or private keys, so production release is out of scope |
The skill itself is a standard SKILL.md plus Node.js, Bash, and Xcode CLIs, and calls no
Codex-specific API. Claude Code also supports Agent Skills and user-scope symlinks. See the
Claude Code Skills documentation,
Claude Code Remote Control, and the
OpenAI Skills documentation.
What it does
- Register a bundle ID and enable its capabilities, including Sign In with Apple
- Report whether an App Store Connect app record exists, whether the bundle ID can be signed yet, and walk you through the two things the API cannot create
- Create an archive from an Xcode project/workspace and, under a separate approval, upload it to App Store Connect
- Inspect, validate, and upload an existing signed IPA/PKG
- Wait for build processing to finish, create TestFlight groups, invite testers, and add builds to internal/external groups
- Configure Beta App Review text, contacts, notification policy, and export compliance
- Set the App Store version, per-locale metadata, screenshots, and build association
- Create a review submission, snapshot its contents, and submit for App Review
- Release manually after approval, or use automatic, scheduled, and phased release when explicitly requested
It does not decide anything that requires human judgment: contracts, tax and banking, privacy, age rating, content rights, and encryption legal determinations. Creating the app record is a separate case. It is not withheld on purpose, the App Store Connect API simply has no endpoint for it, so the skill guides you through that step instead. The same is true of the signing assets a brand new bundle ID needs: the skill reports exactly what is missing and hands you the shortest way to create it.
What using it looks like
You do not write the release manifest by hand. After the one-time setup below, invoke the skill and answer questions; the agent measures the machine, reads live state from App Store Connect, drafts the manifest, and shows it to you before anything runs.
One time, by you: install the skill (symlink), create the App Store Connect API key,
place the .p8, and set ASC_KEY_ID and ASC_ISSUER_ID. These are the only steps this
document asks you to perform manually.
Every release, in conversation:
- You invoke the skill. It checks prerequisites first (Node, Xcode, credentials, a JWT self-test) and stops with a pointer into this README if something is missing. It also checks that the bundle ID and app record exist, and walks you through creating the app record if it does not.
- It resolves a matching release manifest from the conversation or repository release setup before asking for a path. If one exists, it validates each phase to work out where you left off and re-checks that against live state.
- In the default workflow it asks only unresolved values among the bundle ID, distribution scope, and stop point. A brief TestFlight request instead defaults to the most restrictive stable internal-only scope when the current repository resolves uniquely.
- It derives the rest, typically with a single
asc-release.mjs init-manifestcall. App resource ID and beta groups come from the API. Workspace/project, scheme, bundle ID, team, version, and the next unused integer build number come from repository/Xcode state and live builds when the brief internal mode applies. Xcode and SDK versions come from measuring a stable installed Xcode and matching the toolchain policy. - It shows you the drafted manifest, says which values were derived and from where, and asks only for a value that is genuinely unresolved. External distribution, screenshot directories, review contact, and compliance confirmations are never inferred.
- Every mutation is a dry run first. By default, you approve with an exact phrase and the hash that dry run printed, one stage at a time. For a stable internal-only TestFlight delivery, a short imperative request may authorize the one repository-derived build; the agent then verifies and supplies each operation's phrase and hash itself.
Values such as expectedSdkBuild and policyEntryId are never asked of you. If the agent
asks for one, it is not following SKILL.md.
Nothing in this flow writes to Apple until you approve either a specific stage or one explicitly bounded stable internal-TestFlight sequence. Approval never crosses into external TestFlight, App Review, or release.
The shortest form is:
Upload this app to TestFlight.
TestFlightにアップロードして。
That means the current repository's next stable build, internal TestFlight only, and stop when it is available to the existing internal audience. The skill derives and reports the workspace/scheme, bundle ID, environment, version/build, stable Xcode, and group before it mutates anything. It asks only when more than one candidate exists, the working tree is dirty, or a required boundary cannot be determined safely.
You can still provide every coordinate when you want to override a derived value:
Deliver 1.2.0 (42) from App.xcworkspace / App to internal TestFlight only.
See
references/brief-testflight-request.md and
references/internal-testflight-automation.md
for its exact allowlist and stop conditions.
Provisioning, and the steps you have to do yourself
Almost all of the Apple-side setup is automated behind approval gates.
# Read-only: is the bundle ID registered, what capabilities does it carry,
# and does an App Store Connect app record exist?
node "$asc_skill_dir/scripts/asc-release.mjs" app-record-guide --bundle-id 'com.example.app'
# Register the bundle ID (dry run first, then approve with CREATE_BUNDLE_ID)
node "$asc_skill_dir/scripts/asc-release.mjs" provision-bundle-id \
--bundle-id 'com.example.app' --name 'Example App'
# Enable a capability (dry run first, then approve with ENABLE_CAPABILITY)
node "$asc_skill_dir/scripts/asc-release.mjs" provision-capability \
--bundle-id 'com.example.app' --capability APPLE_ID_AUTH
Sign In with Apple is APPLE_ID_AUTH in the API even though the portal calls it something
else, and the command accepts either spelling. Without a --settings-file it is enabled as
a primary App ID.
TestFlight groups and testers are automated the same way, with create-beta-group
(CREATE_BETA_GROUP) and add-beta-tester (ADD_BETA_TESTER). --internal true|false is
required rather than defaulted, an internal group is checked against the team's App Store
Connect users, and a tester address appears in the dry run only as a mask while the
approval hash still covers the exact address.
Signing assets are the other thing you may have to do once. A registered bundle ID is
not enough to build: a distribution certificate and an App Store provisioning profile must
exist, and the certificate's private key must be in your Keychain. app-record-guide
reports all of that under signing, including the local identities, which no API can see.
Archive creation deliberately never creates any of it — it runs with the App Store Connect
key blocked and without -allowProvisioningUpdates — so on a brand new bundle ID you
create the assets once through Xcode's automatic signing, or by running
xcodebuild -allowProvisioningUpdates yourself, and then the gated flow continues. During
upload, Xcode may still update provisioning assets under the separate
ALLOW_PROVISIONING_UPDATES approval.
The App Store Connect app record is the exception. The API has no endpoint that creates
one, so that step is always done by hand, in the web interface.
app-record-guide makes that step as short as possible: it checks whether the record
exists, and when it does not it prints the URL, the required role, the prerequisites, every
field you must fill with the values already resolved, and which fields can never be changed
afterwards. Create the record, re-run the command, and the rest continues automatically.
Requirements
- macOS
- Node.js 18 or later (no npm package dependencies)
- A stable Xcode and SDK meeting Apple's current requirements (the default). For TestFlight-only use, an Xcode beta matching the bundled exact allowlist is also usable
- Xcode command-line tools,
xcodebuild,xcrun altool - The macOS built-ins
plutil,sips,ditto,unzip/zipinfo, andshasum pkgutilandxmllintwhen working with PKGs- Apple Developer Program membership, an App Store Connect app record, and signing configuration
- An App Store Connect API team key (App Manager or equivalent is recommended for end-to-end use)
Apple's upload requirements change, so check Upcoming Requirements and the App Store Connect Release Notes for every release. This implementation targets OpenAPI 4.4.1 as of 2026-08-16.
Xcode beta acceptance changes especially often. Verify the release notes live on every
use, and stop unless the exact allowlist in
assets/toolchain-acceptance-2026-08-18.json
has been reviewed and selected as a new dated file. The 2026-08-18 beta entry has
validUntil=2026-08-25. Re-checking Apple's official sources is required even inside that
window, and the skill stops fail-closed once the entry expires or acceptance changes. See
references/prerelease-xcode.md for the current beta
policy and the TestFlight-only boundary.
Use it from every project
Keep a single copy of the skill source and symlink to that same directory from the Codex and Claude Code user scopes. There is no need to copy it into each app repository.
(
set -eu
asc_skill_source='/absolute/path/to/apple-app-delivery'
test -d "$asc_skill_source"
install -d -m 700 "$HOME/.agents/skills" "$HOME/.claude/skills"
test ! -e "$HOME/.agents/skills/apple-app-delivery"
test ! -L "$HOME/.agents/skills/apple-app-delivery"
ln -s "$asc_skill_source" "$HOME/.agents/skills/apple-app-delivery"
test ! -e "$HOME/.claude/skills/apple-app-delivery"
test ! -L "$HOME/.claude/skills/apple-app-delivery"
ln -s "$asc_skill_source" "$HOME/.claude/skills/apple-app-delivery"
)
If a path with the same name already exists, do not overwrite or delete it. Check where it
points with readlink. If you created ~/.claude/skills for the first time after starting
Claude Code, restart Claude Code once.
readlink "$HOME/.agents/skills/apple-app-delivery"
readlink "$HOME/.claude/skills/apple-app-delivery"
Prepare an App Store Connect API key
Create a team API key under "Users and Access" in App Store Connect and record the following.
| Value | Purpose |
|---|---|
| Key ID | Identifies the .p8, and is usually part of the file name |
| Issuer ID | The JWT issuer UUID for the team key |
| Team ID | Used for Xcode signing and archive verification. Distinct from the Issuer ID |
| Bundle ID | Matches the binary to the app record |
| App resource ID | The app identifier within the App Store Connect API |
The .p8 can be downloaded only once, so decide on a safe backup policy first. Confirm
current permissions and steps in
Apple's App Store Connect API guide.
An Apple team API key applies to every app in the account and cannot be scoped to a single app. Issue a dedicated key per person and choose the minimum role required for the end-to-end workflow. A leaked App Manager key can affect multiple apps, so never embed a shared key in a distributed artifact.
Place the .p8 at a safe standard path
Never paste .p8 contents into chat, issues, a release manifest, .env, logs, or Git. The
standard path below, outside the repository, is recommended. Keep the key out of the app
repository and out of anywhere under the --source-root passed to archive. A single
*.p8, *.pem, or *.p12 inside the source root causes archive to refuse.
~/.appstoreconnect/private_keys/AuthKey_<KEY_ID>.p8
The following uses placeholders. It stops without overwriting if the destination already exists.
(
set -eu
umask 077
asc_key_id='ABC123DEFG'
asc_key_source='/absolute/download/path/AuthKey_ABC123DEFG.p8'
asc_key_dir="$HOME/.appstoreconnect/private_keys"
asc_key_destination="$asc_key_dir/AuthKey_${asc_key_id}.p8"
test -f "$asc_key_source"
test ! -L "$asc_key_source"
test ! -e "$asc_key_destination"
test ! -L "$asc_key_destination"
install -d -m 700 "$HOME/.appstoreconnect" "$asc_key_dir"
mv "$asc_key_source" "$asc_key_destination"
chmod 600 "$asc_key_destination"
test -f "$asc_key_destination"
test ! -L "$asc_key_destination"
stat -f '%Lp %N' "$HOME/.appstoreconnect" "$asc_key_dir" "$asc_key_destination"
)
Expected permissions are 700 for the directories and 600 for the key. The key must be a
regular file, not a symlink. There is no need to display its contents with cat or
anything similar.
A key that may have previously been committed, cloud-synced, shared in chat, or included in a distributed ZIP is not made safe by moving it. Revoke it in App Store Connect and issue a new one.
Set environment variables
When using the standard path and file name, the key path is resolved automatically from
ASC_KEY_ID.
export ASC_KEY_ID='ABC123DEFG'
export ASC_ISSUER_ID='00000000-0000-0000-0000-000000000000'
Add an absolute path to a regular file only when the key lives outside the standard path.
export ASC_PRIVATE_KEY_PATH='/absolute/private/path/AuthKey_ABC123DEFG.p8'
The Key ID and Issuer ID must belong to the same team key. The Team ID is passed separately
through the manifest or the upload command's --team-id.
Launching Claude Desktop from the Dock or Finder normally does not inherit environment
variables exported in a terminal. Select the Local environment in Claude Desktop's Code tab
and set ASC_KEY_ID, ASC_ISSUER_ID, and, only when needed, ASC_PRIVATE_KEY_PATH in the
environment editor behind the gear icon. The env setting in Claude Code's
~/.claude/settings.json works as an alternative. Never paste .p8 contents into either.
See the Claude Desktop documentation for the
difference between Local and Remote environments.
Initial verification
asc_skill_dir='/absolute/path/to/apple-app-delivery'
# Offline test that exercises only the JWT implementation. It does not contact Apple.
node "$asc_skill_dir/scripts/asc-api.mjs" self-test
# Check help output and the manifest validator
node "$asc_skill_dir/scripts/validate-manifest.mjs" --help
node "$asc_skill_dir/scripts/asc-release.mjs" --help
"$asc_skill_dir/scripts/xcode-upload.sh" --help
"$asc_skill_dir/scripts/altool-upload.sh" --help
node "$asc_skill_dir/scripts/toolchain-policy.mjs" --help
After configuring credentials, the GET below verifies JWT signing and basic access. It writes nothing, but it does send a read-only request to Apple.
node "$asc_skill_dir/scripts/asc-api.mjs" request GET \
'/v1/apps?limit=1&fields%5Bapps%5D=name%2CbundleId'
Success does not prove code signing, App Manager permissions, contracts, or that the target app is ready for release.
Release manifest
The quickest path is to generate a draft from live data. This is read-only, writes with
mode 0600, and refuses to overwrite an existing file.
node "$asc_skill_dir/scripts/asc-release.mjs" init-manifest \
--bundle-id 'com.example.app' \
--platform IOS \
--out '/absolute/private/release-work/release.json' \
--developer-dir '/Applications/Xcode.app/Contents/Developer' \
--distribution-scope APP_STORE
It resolves the app ID, internal beta group IDs, and any existing per-locale metadata from App Store Connect, measures the selected Xcode and SDK and matches them against the bundled toolchain policy, and leaves every human-only field null. It then prints the exact list of fields that still need a person, so nothing is silently guessed. Contact email and phone are never derived: the API client redacts them as PII.
Alternatively, copy the example into a private working directory outside the skill and edit it without adding secrets.
cp "$asc_skill_dir/assets/release-manifest.example.json" \
'/absolute/private/release-work/release.json'
chmod 600 '/absolute/private/release-work/release.json'
node "$asc_skill_dir/scripts/validate-manifest.mjs" \
'/absolute/private/release-work/release.json' --phase plan
The manifest is schemaVersion: 2. The main required fields are
delivery.distributionScope, toolchain.channel,
toolchain.expectedXcodeProductVersion, toolchain.expectedXcodeBuild,
toolchain.expectedSdkVersion, toolchain.expectedSdkBuild,
toolchain.expectedPlatformBuild, and toolchain.policyEntryId. After upload, also set
build.appStoreConnectBuildId and build.provenancePath, and validate at each point you
reach.
node "$asc_skill_dir/scripts/validate-manifest.mjs" \
'/absolute/private/release-work/release.json' --phase upload
node "$asc_skill_dir/scripts/validate-manifest.mjs" \
'/absolute/private/release-work/release.json' --phase internal-beta
node "$asc_skill_dir/scripts/validate-manifest.mjs" \
'/absolute/private/release-work/release.json' --phase external-beta
node "$asc_skill_dir/scripts/validate-manifest.mjs" \
'/absolute/private/release-work/release.json' --phase app-review
node "$asc_skill_dir/scripts/validate-manifest.mjs" \
'/absolute/private/release-work/release.json' --phase release
You do not have to run all of them. Stop at internal-beta for internal-only, or at
external-beta for external TestFlight. Only an APP_STORE manifest may advance to
app-review and release. The starting point for beta work is
testflight-beta-manifest.example.json.
For internal-only, change the scope to TESTFLIGHT_INTERNAL_ONLY, the audience to
internal, and build.testFlightInternalTestingOnly to true, and set the internal group ID.
The manifest hash is a release record. It is not a substitute for approving each individual
change at Apple. Never put demo account passwords, .p8 contents, or JWTs in the manifest.
Always record distributionScope as one of APP_STORE, TESTFLIGHT_INTERNAL_ONLY, or
TESTFLIGHT_INTERNAL_EXTERNAL. The default and production value is APP_STORE. Write
upload provenance receipts to a private release directory outside the skill and the app
repository, with mode 0600.
Core workflow
archive/sign -> upload -> build VALID
+-- APP_STORE --------------------------> internal/external TestFlight -> optional stop
| -> separate App Review approval -> separate release approval
+-- TESTFLIGHT_INTERNAL_ONLY -----------> internal TestFlight -> stop
+-- TESTFLIGHT_INTERNAL_EXTERNAL -------> beta internal/external TestFlight -> stop
--distribution-scope is the maximum reach permitted for that build, not the group you
distribute to first. It defaults to APP_STORE.
| Scope | testFlightInternalTestingOnly on export |
Reachable range |
|---|---|---|
APP_STORE |
false |
Stable internal/external TestFlight, App Review after a separate approval, App Store |
TESTFLIGHT_INTERNAL_ONLY |
true |
Internal TestFlight only |
TESTFLIGHT_INTERNAL_EXTERNAL |
false |
Beta internal/external TestFlight only under current policy. App Store not allowed |
To distribute to external TestFlight from a stable Xcode, current policy uses the
APP_STORE scope. You can stop at external-beta, and neither this scope nor external
distribution is approval to submit for App Review or to release.
TESTFLIGHT_INTERNAL_EXTERNAL currently exists for the exact-allowlist beta entry.
External TestFlight requires false, but that never means a TestFlight-only build may be
used on the App Store. The scope and the upload provenance are the production boundary.
Beta-derived or TestFlight-only provenance is rejected at build attach, App Review
submission, and production release.
Pass the same receipt with --provenance-file to attach-build, add-review-item,
review-snapshot, submit-review-submission, release-snapshot, release-version, and
to set-release-policy when choosing AFTER_APPROVAL or SCHEDULED. Only a receipt from
a stable toolchain with distributionScope=APP_STORE, eligibility=STORE_ALLOWED, and a
matching build identity is accepted.
See references/workflow.md for the detailed commands and
ordering, references/metadata.md for metadata fields, and
references/failure-runbook.md for how to resume after a
failure.
Building from an Xcode project
Archive creation and upload are separate approvals by default. A qualifying bounded stable internal-TestFlight request authorizes both while retaining separate dry runs and hashes. Dry run first and review the source snapshot.
DEVELOPER_DIR='/Applications/Xcode.app/Contents/Developer' \
"$asc_skill_dir/scripts/xcode-upload.sh" archive \
--source-root '/absolute/path/to/project-repository' \
--workspace '/absolute/path/to/project-repository/App.xcworkspace' \
--scheme 'App' \
--archive-path '/absolute/private/release-work/App.xcarchive' \
--bundle-id 'com.example.app' \
--platform IOS \
--marketing-version '1.2.0' \
--build-number '42' \
--team-id 'ABCDE12345' \
--distribution-scope APP_STORE \
--expected-xcode-build '17F113' \
--expected-sdk-version '26.5'
Only after explicit approval, add the --execute --confirm CREATE_ARCHIVE --plan-sha256 HASH
that the same command printed. Verify the finished archive's digest, then dry run
xcode-upload.sh upload separately.
Archive hashes the whole --source-root except .git, including ignored files and
submodule working trees, and builds from a temporary copy. Symlinks pointing outside the
root are rejected, as are *.p8 / *.pem / *.p12 inside the source root and an ASC key
path pointing into it. The archive xcodebuild runs with ASC_KEY_ID, ASC_ISSUER_ID,
and ASC_PRIVATE_KEY_PATH removed from the environment, and sandbox-exec denies file
reads of the standard key directory and any explicitly given custom key path. This is a
narrow boundary that reduces accidental access to ASC keys; network, Keychain, other files,
and the rest of the caller environment remain allowed. Because no general guarantee can be
made about what an arbitrary build script reads from those, always approve the archive
digest and signing identity at the upload dry run.
For projects whose build phases use git describe or a commit ID, pre-generate those
values into source files before the dry run. For safety, .git itself is not included in
the temporary build copy. When stronger isolation is required, build on a dedicated OS user
or an isolated CI without ASC keys, and hand only a hash-verified immutable archive/artifact
to a separate upload/release identity.
Xcode's upload authentication requires -allowProvisioningUpdates, which may create or
update profiles, App IDs, and certificates. Include --allow-provisioning-updates in the
upload dry run, and at execution time add the following in addition to the normal upload
approval.
DEVELOPER_DIR='/Applications/Xcode.app/Contents/Developer' \
"$asc_skill_dir/scripts/xcode-upload.sh" upload \
--archive-path '/absolute/private/release-work/App.xcarchive' \
--bundle-id 'com.example.app' \
--platform IOS \
--marketing-version '1.2.0' \
--build-number '42' \
--team-id 'ABCDE12345' \
--distribution-scope APP_STORE \
--expected-xcode-build '17F113' \
--expected-sdk-version '26.5' \
--provenance-output '/absolute/private/release-work/upload-provenance.json' \
--allow-provisioning-updates
--confirm-provisioning-updates ALLOW_PROVISIONING_UPDATES
At execution time this is required in addition to the
--execute --confirm UPLOAD_ARCHIVE --plan-sha256 HASH that the dry run displayed.
The above is the stable example from the 2026-08-18 policy (Xcode 26.6 build 17F113,
SDK version 26.5). These are not fixed recommended versions; match them exactly
against the bundled policy and Apple's requirements at execution time.
--expected-sdk-version is the canonical SDKVersion, the value DTSDKName encodes as
iphoneos26.5. Read it with xcodebuild -version -sdk iphoneos SDKVersion. Do not use
ProductVersion, which reports 26.5.1 for the same SDK and matches nothing an archive
records. The exactness comes from ProductBuildVersion (23F81a), stored as sdkBuild.
The policy's values were measured from an Xcode 26.6 installation on 2026-08-18, and an
iOS archive confirmed DTSDKBuild and DTPlatformBuild both equal 23F81a. The other
platforms' platformBuild values are unconfirmed, and no Store upload has been exercised.
Confirm that the selected Xcode and SDK ProductBuildVersion,
the archive's DTXcodeBuild / DTSDKBuild / DTPlatformBuild, and the BuildBundle at
Apple match the manifest and the policy's Store tuple exactly. On any mismatch, stop
fail-closed rather than proceeding to a production operation.
Building a TestFlight-only build with an Xcode beta
As of 2026-08-18 the current beta entry is Xcode 27 beta 5 build 27A5237l with SDK
ProductVersion 27.0, usable only for internal/external TestFlight. Its validUntil is
2026-08-25. Even inside that window, verify Apple's release notes live immediately before
use, confirm that the corresponding dated policy has been selected, and then state the
maximum reach explicitly. For example, an archive that permits external TestFlight dry runs
as follows.
"$asc_skill_dir/scripts/xcode-upload.sh" archive \
--source-root '/absolute/path/to/project-repository' \
--workspace '/absolute/path/to/project-repository/App.xcworkspace' \
--scheme 'App' \
--archive-path '/absolute/private/release-work/App-beta.xcarchive' \
--bundle-id 'com.example.app' \
--platform IOS \
--marketing-version '1.2.0' \
--build-number '43' \
--team-id 'ABCDE12345' \
--developer-dir '/Applications/Xcode-beta.app/Contents/Developer' \
--distribution-scope TESTFLIGHT_INTERNAL_EXTERNAL \
--expected-xcode-build '27A5237l' \
--expected-sdk-version '27.0'
The approval phrase is CREATE_TESTFLIGHT_PRERELEASE_ARCHIVE. After creating the archive
and verifying its digest, the upload dry run is as follows.
"$asc_skill_dir/scripts/xcode-upload.sh" upload \
--archive-path '/absolute/private/release-work/App-beta.xcarchive' \
--bundle-id 'com.example.app' \
--platform IOS \
--marketing-version '1.2.0' \
--build-number '43' \
--team-id 'ABCDE12345' \
--developer-dir '/Applications/Xcode-beta.app/Contents/Developer' \
--distribution-scope TESTFLIGHT_INTERNAL_EXTERNAL \
--expected-xcode-build '27A5237l' \
--expected-sdk-version '27.0' \
--provenance-output '/absolute/private/release-work/beta-upload-provenance.json' \
--allow-provisioning-updates
This needs a separate UPLOAD_TESTFLIGHT_PRERELEASE_ARCHIVE approval plus the
ALLOW_PROVISIONING_UPDATES approval for provisioning. For internal-only, change the scope
to TESTFLIGHT_INTERNAL_ONLY and confirm testFlightInternalTestingOnly=true in the export
plist. An Xcode 27 beta 1 build such as 27A5194q is rejected by the current-only policy.
Prerelease Xcode never qualifies for the one-request automation mode.
Under current policy, a stable Xcode may select a TestFlight-only scope for internal-only
alone. In that case the approval phrases are CREATE_TESTFLIGHT_ARCHIVE /
UPLOAD_TESTFLIGHT_ARCHIVE, and neither the APP_STORE phrases nor their hashes can be
reused. Stable external TestFlight uses the APP_STORE scope with the normal
CREATE_ARCHIVE / UPLOAD_ARCHIVE, and any later App Review or release is always a
separate explicit approval. See
references/prerelease-xcode.md for the full procedure.
Using an existing IPA/PKG
"$asc_skill_dir/scripts/altool-upload.sh" \
--file '/absolute/path/App.ipa' \
--bundle-id 'com.example.app' \
--platform IOS \
--marketing-version '1.2.0' \
--build-number '42' \
--team-id 'ABCDE12345' \
--developer-dir '/Applications/Xcode.app/Contents/Developer' \
--distribution-scope APP_STORE \
--expected-artifact-xcode-build '17F113' \
--expected-uploader-xcode-build '17F113' \
--expected-sdk-version '26.5' \
--provenance-output '/absolute/private/release-work/upload-provenance.json'
This is a dry run that inspects identity, platform, version/build, toolchain, and file
digest. Only after approval, add --execute --confirm UPLOAD_BUILD --plan-sha256 HASH. The
Xcode build that produced the artifact and the Xcode build used to upload are verified
separately.
altool-upload.sh rejects TESTFLIGHT_INTERNAL_ONLY, because an internal-only export
cannot be retrofitted onto or proven from an existing package. Use the .xcarchive
workflow when that is required. For stable external TestFlight, use the APP_STORE scope
above with UPLOAD_BUILD, and you may stop at external-beta after distribution. There is
no external-only scope or separate IPA/PKG approval phrase for stable.
At upload time the receipt is reserved exclusively with uploadCompleted=false before
anything is sent to Apple, and the same file is completed to uploadCompleted=true only
after success. A prepared receipt is rejected by later operations. If the command stops
after reserving, do not blindly resend the same build. Follow the recovery procedure in
failure-runbook.md.
Receipt mode, hashes, and policy matching reduce mistakes and reuse of stale inputs. They
are not cryptographic attestations against a state where the same OS user can modify code,
policy, artifacts, and receipts. Validating or reserving a new receipt enforces createdAt
freshness and the current policy's validUntil. A completed receipt is verified against its
receipt-bound policy by creation time, so it remains readable as historical evidence after
expiry. Later App Store Connect distribution and release operations separately check the
bundled policy at that time. After re-verifying Apple's official sources, an existing
receipt may continue to be used only when the newly selected dated policy differs from the
receipt-bound entry solely in verifiedAt / validUntil, with exactly the same toolchain
identity and acceptance conditions.
Approval gates
Mutations are dry runs by default. Execution requires both the exact confirmation phrase
and the planSha256 that the dry run displayed. By default, the operator supplies both
after reviewing each stage. For a qualifying stable TESTFLIGHT_INTERNAL_ONLY request,
including a brief request whose repository inputs resolve uniquely, the agent verifies and
supplies the archive, upload/provisioning, and manifest-backed beta build-metadata values
without asking the operator to echo them. The helper checks are not bypassed. All other
rows remain separately approved. See
references/brief-testflight-request.md and
references/internal-testflight-automation.md.
| Stage | Command | Confirmation phrase |
|---|---|---|
| Register a bundle ID | provision-bundle-id |
CREATE_BUNDLE_ID |
| Enable a bundle ID capability | provision-capability |
ENABLE_CAPABILITY |
| Create archive for APP_STORE | xcode-upload.sh archive |
CREATE_ARCHIVE |
| Create stable internal-only TestFlight archive | xcode-upload.sh archive |
CREATE_TESTFLIGHT_ARCHIVE |
| Create beta TestFlight-only archive | xcode-upload.sh archive |
CREATE_TESTFLIGHT_PRERELEASE_ARCHIVE |
| Upload archive for APP_STORE | xcode-upload.sh upload |
UPLOAD_ARCHIVE |
| Upload stable internal-only TestFlight archive | xcode-upload.sh upload |
UPLOAD_TESTFLIGHT_ARCHIVE |
| Upload beta TestFlight-only archive | xcode-upload.sh upload |
UPLOAD_TESTFLIGHT_PRERELEASE_ARCHIVE |
| Update Xcode provisioning | xcode-upload.sh upload |
ALLOW_PROVISIONING_UPDATES |
| Upload IPA/PKG for APP_STORE | altool-upload.sh |
UPLOAD_BUILD |
| Upload beta external TestFlight-only IPA/PKG | altool-upload.sh |
UPLOAD_TESTFLIGHT_PRERELEASE_BUILD |
| Declare export compliance on a build | set-build-encryption |
SET_EXPORT_COMPLIANCE |
| Set TestFlight localizations | create/update-beta-build-localization, create/update-beta-app-localization |
SET_BETA_METADATA |
| Set the external Beta Review contact and notes | update-beta-review-detail |
SET_BETA_REVIEW_DETAILS |
| Change tester notification | set-beta-auto-notify |
SET_TESTER_NOTIFICATION |
| Create a TestFlight group | create-beta-group |
CREATE_BETA_GROUP |
| Invite a TestFlight tester | add-beta-tester |
ADD_BETA_TESTER |
| Add to a TestFlight group | add-beta-group |
ADD_TO_BETA_GROUP |
| Submit for external Beta Review | submit-beta-review |
SUBMIT_BETA_REVIEW |
| Create an App Store version | create-version |
CREATE_APP_STORE_VERSION |
| Set App Store metadata or copyright | create/update-app-store-localization, set-version-copyright |
SET_APP_STORE_METADATA |
| Upload screenshots | asc-screenshots.mjs upload |
UPLOAD_SCREENSHOTS |
| Attach a build to a version | attach-build |
ATTACH_BUILD |
| Set App Review contact, demo access, notes | create/update-app-review-detail |
SET_APP_REVIEW_DETAILS |
| Create a review submission draft | create-review-submission |
CREATE_REVIEW_DRAFT |
| Add a version to a review submission | add-review-item |
ADD_REVIEW_ITEM |
| Submit for App Review | submit-review-submission |
SUBMIT_APP_REVIEW |
| Set the release policy | set-release-policy |
SET_RELEASE_POLICY |
| Configure phased release | create/update-phased-release |
CONFIGURE_PHASED_RELEASE |
| Manual App Store release | release-version |
RELEASE_TO_APP_STORE |
The common form is as follows.
--execute --confirm EXACT_PHRASE --plan-sha256 EXACT_HASH
- Confirm the app, bundle ID, platform, version, build, and resource IDs before approving.
- If the inputs or the target state at Apple change, redo the dry run and re-approve.
- Never reuse a phrase or hash across operations. The bounded internal-only mode shares intent, not operation hashes, and never reaches external TestFlight, App Review, or production release.
- Specify
AFTER_APPROVALorSCHEDULEDonly when you understand that no separate manual release gate may remain after App Review approval. The default isMANUAL. - Some commands send read-only GETs to Apple even during a dry run, in order to verify the target.
Items that require human review
Do not let the skill infer these. A responsible person must confirm them.
- App Store Connect contracts, tax, banking, pricing, and territories
- App Privacy, age rating, and content rights
- Export compliance and encryption declarations
- App Review contact, demo access, and review notes
- Final wording of screenshots and descriptions
- External tester notifications, phased release, and scheduled dates
- Final approval to submit for review and to release to production
Security checklist before distributing
This skill ships a .gitignore, but ignore rules do not remove secrets that
were already committed or synced. Before publishing, name the directory you are
distributing and verify it.
# Point at the apple-app-delivery directory being distributed
asc_distribution_root='/absolute/path/to/apple-app-delivery'
cd "$asc_distribution_root"
# Nothing should be printed for the whole distribution
find . -type f \( -name '*.p8' -o -name '*.pem' -o -name '*.p12' \
-o -name '*.cer' -o -name '*.mobileprovision' \) -print
# No file should contain private key material (without printing the material itself)
rg -l --hidden --glob '!.git/**' 'BEGIN [A-Z ]*PRIVATE KEY' .
# Review what is being distributed
find . -path './.git' -prune -o -type f -print | sort
# Once under Git, review tracked files separately
git ls-files '*.p8' '*.pem' '*.p12' '*.cer' '*.mobileprovision'
Also run a secret scanner such as gitleaks over the published history. A clean
.gitignore and working tree do not remove secrets that entered an earlier commit.
Do not include in a distribution:
.p8files, JWTs, or configuration files with an embedded Key ID/Issuer ID.env*, real release manifests, reviewer PII, or demo passwords- IPAs, PKGs, xcarchives, screenshot upload
No comments yet
Be the first to share your take.