I am shipping an iPhone app, Tidyroom. Its App Store Connect record is more state than I expected: a listing in two languages, keywords, promotional text, notes for the reviewer, the age rating questionnaire, two TestFlight groups with their own texts, and which build each group sees. All of it sits behind a web UI without history. My release checklist had become “click here, paste that, don’t forget the German one”.
My DNS is already in Pulumi, so I did the same for App Store Connect. It works. Read the caveats at the end before you copy it.
A Terraform provider for App Store Connect
Apple has no Terraform or Pulumi provider. The community providers I found mostly cover bundle IDs and provisioning profiles. One covers the whole record: AhmedOsman00/terraform-provider-apple, 38 resources, from bundle IDs to listing texts, screenshots, TestFlight and subscriptions.
Pulumi bridges it like any other Terraform provider:
pulumi package add terraform-provider registry.terraform.io/ahmedosman00/apple 0.7.0
Use the full registry.terraform.io/... address. The short name
ahmedosman00/apple fails, because Pulumi looks it up in the OpenTofu
registry, and the provider is not published there.
Auditing the provider before it gets the API key
The provider gets an App Store Connect API key that can change my listing, revoke certificates and add testers. It is version 0.7, has two stars and one maintainer. So I read the code first. 40,000 lines of Go, but the part that matters is small:
- The only hard-coded host is
api.appstoreconnect.apple.com. Uploads go to the presigned URLs Apple returns. The code refuses non-HTTPS targets and sends only the headers Apple handed back, no bearer token. - Pagination follows
nextlinks only if they point to the same host. There is a unit test for that, withattacker.exampleas the host. - The
.p8key is parsed, signs a 20-minute JWT, and is never written or logged. - No
os/execin the provider. The repository also has a CLI that callsxcrunandsecurity, but that is a separate binary. go mod verifyis clean, 495 unit tests pass.
Then I built the tag with the flags the release pipeline uses
(CGO_ENABLED=0 -trimpath -ldflags "-s -w") and compared it with the binary
from the registry:
00dbbca6…b947 my build
00dbbca6…b947 registry darwin_arm64
Byte-identical, so the published binary is the code I read. The version is pinned. A bump means reading the diff first.
The listing as a Pulumi program
Python, one file, the long texts in separate files:
provider = apple.Provider(
"apple",
issuer_id=config.require_secret("issuerId"),
api_key=config.require_secret("keyId"),
private_key=config.require_secret("privateKey"),
)
opts = pulumi.ResourceOptions(provider=provider)
version = apple.AppStoreVersion(
"version-1.0",
app_id=APP_ID,
platform="IOS",
version_string="1.0",
copyright="2026 Wladislaw Mitzel (tawadi)",
release_type="MANUAL",
opts=opts,
)
for locale, slug in (("en-US", "en"), ("de-DE", "de")):
apple.AppStoreVersionLocalization(
f"version-1.0-{slug}",
app_store_version_id=version.id,
locale=locale,
description=text(locale, "store-description"),
keywords=text(locale, "store-keywords").split(","),
promotional_text=text(locale, "store-promotionalText"),
opts=opts,
)
text() reads texts/<locale>/<name>.txt. A 3,000-character German
description does not belong in a Python string. As a file it diffs like any
other text.
TestFlight groups as code
A group is a resource, and so is the build it sees:
family = apple.BetaGroup("testflight-family", app_id=APP_ID, name="family",
is_internal_group=False, feedback_enabled=True, ...)
apple.BetaGroupBuild("testflight-family-build", group_id=family.id,
pre_release_version="1.0", build_number="133")
Next build for the family: change "133", pulumi up.
Importing the existing app record
The app record existed before the program. Every resource got an import_
ID, so the first pulumi up adopts the resources instead of creating them.
Pulumi refuses an import if the declared inputs differ from what Apple has.
That makes the preview a free check of the program: 17 resources, 17 =
lines, no diff, or nothing happens.
The import ID format differs per resource. The bundle ID imports by
de.tawadi.tidyroom, not by Apple’s short ID. App info and age rating import
by the app’s numeric ID. A group build imports by <group>/<version>/<build>.
The docs list them, but expect a few failed previews before all 17 resolve.
The import ran from a second machine and took two runs of pulumi up. Apple’s
API let one request hang past the provider’s 30-second timeout. The beta group
behind it and its build were skipped, and the next up imported those two.
Imports only read, so a half-finished run breaks nothing. Once all 17 were in,
I removed the import IDs from the program.
Encrypted credentials in the repo
The stack runs from more than one machine, so the API key is in the repo, encrypted:
pulumi stack init prod --secrets-provider passphrase
pulumi config set --secret privateKey < AuthKey_XXXXXXXXXX.p8
Pulumi.prod.yaml holds the key encrypted with AES-256-GCM, with a key
derived from the passphrase. The passphrase stays outside git. Any host with
the repo and PULUMI_CONFIG_PASSPHRASE (or PULUMI_CONFIG_PASSPHRASE_FILE)
can run the stack. Without it, nothing decrypts. This is Pulumi’s normal
secrets mechanism, no extra tool.
The alternative is Pulumi ESC. The issuer
ID, key ID and .p8 key go into an ESC environment in Pulumi Cloud as
secrets, the key read straight from the file:
pulumi env init tidyroom/asc
pulumi env set tidyroom/asc pulumiConfig.privateKey --secret --string --file AuthKey_XXXXXXXXXX.p8
ESC encrypts each value on save. The stored definition holds only ciphertext:
values:
pulumiConfig:
privateKey:
fn::secret:
ciphertext: ZXNjeAAAAAEAAAEA...
The stack imports the environment instead of carrying the secrets itself:
# Pulumi.prod.yaml
environment:
- tidyroom/asc
Then there is no passphrase to hand around. Any machine logged in to Pulumi Cloud with access to the environment can run the stack, access is managed per environment, and every read shows up in the audit log. The key can also come from 1Password or a cloud secret manager instead of being stored in ESC itself. The trade-off: the credentials are no longer in the repo, so the stack depends on Pulumi Cloud for them, not just for the state.
What I get out of it
- Both languages stay in sync. English and German are one loop. I can no longer forget the German promotional text.
- Review notes have a history. The notes for the reviewer are a text file in git. When a reviewer asks why the app needs full Photos access, I know what I told them last time.
- A release is a diff. Version, build number, what’s new, which group gets
the build: one commit, one preview, one
pulumi up. - Drift shows up. If I change something in the web UI in a hurry, the next preview shows it.
- The checklist is shorter. Everything the program covers left the checklist. What is left are the things Apple has no API for.
Caveats
- Apple’s API stalls, and the provider does not retry. Over one afternoon,
five of seven runs failed on
context deadline exceededor an HTTP 500 fromapi.appstoreconnect.apple.com. Each time on a different resource, each time fine on the next run. The provider uses a plain 30-secondhttp.Clientwithout retries, and a plan reads all resources in parallel. The bigger the stack, the likelier one stall per run. Re-running works. I opened an issue asking for retries. - Some things stay manual. App Privacy labels, the Paid Applications Agreement, the EU trader declaration and tax forms have no API, only the web UI. Submitting for review has an API, but it is an event, not state. I keep it as a script, not a resource.
- Metadata locks during review. Once a version is submitted, every update
fails. Run
pulumi upbetween submissions only, or it fails halfway. - One editable version per platform. A second
AppStoreVersionreturns 409. Import the version Apple created, then change itsversion_stringfor the next release. - Destroy means different things. Destroying a certificate resource
revokes the certificate at Apple and breaks every build signed with it.
Destroying price schedules or availability only removes them from the state.
Read each resource’s docs on destroy, and set
protect=Trueon everything that hurts. - No build upload. The provider does not upload binaries and does not wait
for processing. Upload, wait, then set
build_number. - A young provider. Five releases in October alone. The availability resource has a known bug in 0.7.0 (a fix is open upstream), so I don’t manage availability yet. Pin the version, read the changelog, bump on purpose.
- The state holds contact details. The review contact’s phone number and email are in the stack state. Fine in Pulumi Cloud with access control, but worth knowing.
- Two states, one Apple record. A file-backed stack is handy for previews.
Running
upfrom it once the cloud stack exists means two states fighting over the same resources. Preview anywhere, apply from one stack only.
The first change from code
The first real change went through the same day. My website moved to a new
URL structure, so the privacy, support and marketing links had to change in
both store languages and both TestFlight languages. In the web UI that is
four screens, two of them in the TestFlight tab, and the German ones are easy
to miss. In code it was one commit. pulumi preview showed six resources to
update, pulumi up applied them, including the privacy link at the end of
both store descriptions.
The listing, the TestFlight setup and the review notes are where I used to make mistakes, and the provider covers them well. What it can’t cover was manual before and is still manual, it’s just less.