Skip to content

Managing App Store Connect with Pulumi

Published: at 08:00 PM

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:

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

Caveats

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.


Previous Post
Two Ahrefs Findings on My Astro Sites, Fixed
Next Post
Managing INWX DNS with Pulumi