Skip to content

asc: TestFlight groups, testers, users and builds management - #24

Merged
Interlap01 merged 28 commits into
mainfrom
feat/asc-manage
Sep 17, 2026
Merged

Interlap01 merged 28 commits into
mainfrom
feat/asc-manage

Conversation

@Interlap01

Copy link
Copy Markdown
Collaborator

Summary

Stacked on #20. Adds the App Store Connect management surface found missing while testing the release path on a real app, plus fixes for two behaviors Apple enforces that Builder did not know about.

  • builder asc apps, asc builds [--all-versions], asc builds expire, asc groups / create [--external --public-link --no-auto-builds] / delete / add-build, asc testers [--group] / add / remove / invite, asc users / invite. All non-interactive, all --json, app resolved like ios upload (--bundle-id, ios.bundleId, newest IPA).
  • ios submit --testflight --group X (and release) creates a missing group, internal by default, --external for external.
  • Internal groups with automatic distribution (hasAccessToAllBuilds) cannot have builds assigned; Apple answers 422. Builder now skips them with a note instead of failing, and asc groups shows "internal, all builds". Such a group only sees builds uploaded after it was created; the docs say so.
  • Internal testers added to a group stay NOT_INVITED and get no email until a build is available or an invite is sent. asc testers add on an internal group invites team members to the team when needed (userInvitations, minimal role, only this app visible), adds them, and sends the TestFlight invite; asc testers invite resends one; asc testers shows the state with a hint.
  • Destructive commands (groups delete, testers remove, builds expire) print what they will remove and require --yes; group name matches that differ only by case are refused with both candidates listed; emails are lowercased for Apple's filter.
  • httptest coverage for every helper, including the 422 avoidance, the 409 already-exists path, the invite paths and the case-collision refusal.

Verified against the live account: the 422 on an automatic group, publicLinkEnabled: null decoding, a NOT_INVITED team member, and betaTesterInvitations moving the state to INVITED.

Test plan

  • go build ./... && go vet ./... && go test ./..., gofmt -l . clean, golangci-lint v2.12.2 0 issues
  • builder asc groups and asc testers --group Testers on the test app show the group as "internal, all builds" and the tester state
  • builder asc testers invite <email> --group Testers reports invited
  • builder ios release --profile store --group Testers prints the automatic-distribution note and exits 0

ES256 JWT auth from the .p8 key (15 minute tokens, cached and refreshed
before expiry), generic JSON:API documents with pagination over links.next,
typed decoding of the errors[] array, and retries on 429 for every method
and on 5xx for idempotent ones only.

Typed helpers cover what upload and submit need: apps by bundle ID, builds
(list/filter, processing state, export compliance, beta group linkage), the
buildUploads/buildUploadFiles chunked delivery with state polling, beta
groups, beta build localizations, beta app review submissions, App Store
versions and review submissions. Everything runs from the developer's
machine; the runner is not involved.
builder auth apple saves the issuer ID, key ID and .p8 key as one secret
through the same keyring/file storage the CI tokens use, after checking the
key against the API. Flags left out are prompted for on a terminal; without
one the command asks for the flags or the ASC_ISSUER_ID, ASC_KEY_ID and
ASC_PRIVATE_KEY/ASC_KEY_PATH variables, which always take precedence so CI
jobs and agents need no keychain. auth status and auth logout apple cover
the new login.
builder ios upload reads the bundle ID, version, build number and
ITSAppUsesNonExemptEncryption from the newest IPA in dist/ (or --ipa),
resolves the app and runs the buildUploads flow: create the delivery,
reserve the file, PUT the chunks to the presigned URLs with their request
headers, commit. With --wait it polls the delivery until COMPLETE, then the
build until VALID, surfacing App Store Connect's error details on failure,
and answers the export compliance question when the plist declares no
non-exempt encryption or --no-encryption is given. --json prints the result
for agents.

Info.plist reading moves from internal/dev into internal/ipa so both the dev
session and the upload share it.
builder ios submit --testflight picks the newest VALID build (or
--build-number), sets the What to Test notes in the app's primary locale,
submits the build for beta review when a chosen group is external and adds
it to the named groups; without --group it reports the build and lists the
groups. --wait follows the beta review decision.

builder ios submit --app-store finds or creates the App Store version for
the marketing version, attaches the build, sets the release type, reuses an
open review submission or creates one, adds the version and submits it.
App Store Connect's 409/422 state errors, nearly always incomplete metadata,
are rewritten with a hint to finish it in App Store Connect or with asc-cli.
README gets a TestFlight and App Store section after Code Signing covering
the API key, upload, TestFlight and App Review steps, the build number and
export compliance rules, and credits asc-cli as the reference that proved the
Mac-free buildUploads path. CLAUDE.md documents the asc, distribute and ipa
packages and the client, credential, upload, compliance and submit-order
patterns.
Every retry and poll now sleeps through Client.sleep, so the 429 test
asserts the Retry-After it was handed instead of waiting a real second,
and status polls grow 1.5x per round up to 4x the base interval.

Remove ListApps, GetBuildUploadFile and Error.HasCode, which nothing
called, and parse the private key once instead of twice on NewClient.
The beta review wait moves into asc as WaitForBetaAppReview beside the
other waits. Options structs over 80 bytes are passed by pointer, and
the tests check their JSON type assertions, both of which golangci-lint
v2.12.2 flags in CI.
finish took the result as any, so a typed nil pointer on failure was
encoded as "null" on stdout in --json mode; a generic finish[T] sees
the nil. The missing-credentials error now names the environment
variables next to builder auth apple, and the key-rejected error is
lower-case for staticcheck.
Adds typed helpers for what the asc commands need: ListApps; CreateBetaGroup,
DeleteBetaGroup and the betaTesters linkage calls; ListBetaTesters,
FindBetaTester, CreateBetaTester, AddBetaTester (409 on a known address falls
back to finding the record and adding it to the groups) and DeleteBetaTester;
ListUsers, FindUser, FindUserInvitation and InviteUser; ExpireBuild and a
BuildFilter.Details mode that includes preReleaseVersion and betaGroups.

BetaGroup now carries hasAccessToAllBuilds and publicLink. The JSON:API
plumbing parses the included block (collect/includedAttr) and to-many
linkages (Relationships.Many); ListBuilds with a limit above the page size
follows pages and cuts. Apple's email/username filters are substring matches,
so the Find helpers compare exactly.
…ibution groups

A --group name the app has no group for is created (internal by default,
external with --external); existing groups keep their type. The result marks
created groups and the log says "Created TestFlight group X (internal)".
Without --group, an app with no groups prints "Available groups: (none)".

An internal group with hasAccessToAllBuilds already receives every build and
App Store Connect answers 422 when one is added by hand, so such groups are
skipped with a note (GroupRef.AutoBuilds) and the command exits 0.
…users

builder asc apps | builds [expire] | groups [create|delete|add-build] |
testers [add|remove] | users [invite], all non-interactive, all with --json.
The app comes from --bundle-id, else ios.bundleId in builder.json, else the
newest IPA in ./dist. Human output is aligned columns; --json prints arrays
of snake_case objects.

distribute.AddTester routes by group type: external groups create the tester
record in the group or add the existing one; internal groups take team
members only, so a member's record joins the group and a stranger is invited
to the team (POST userInvitations, CUSTOMER_SUPPORT with only this app
visible, --role to override) with a note that the invitation must be accepted
first. groups create sends hasAccessToAllBuilds for internal groups unless
--no-auto-builds; groups lists such groups as "internal, all builds".

getASCClient is a package variable so command tests can point it at an
httptest server.
…s group names

POST betaTesterInvitations (relationships app + betaTester) sends or resends
the TestFlight email; GetBetaTester reads the state back, since the
invitation resource carries none. filter[email] goes out lowercased because
App Store Connect stores addresses that way. MatchBetaGroup is the one
case-insensitive name lookup for the command layer and distribute, and it
errors, listing the candidates, when several groups fold to the same name.
…roup matcher

A team member put into an internal group keeps a NOT_INVITED tester record
and never gets an email, so AddTester reads the state back after the add and
sends the TestFlight invitation when it is still NOT_INVITED; the result now
carries the state. InviteTester is the reusable half for the command layer.
findOrCreateGroup goes through asc.MatchBetaGroup, so a name that matches
two groups case-insensitively fails instead of picking the first.
asc testers invite <email>... sends or resends the TestFlight email to
NOT_INVITED and INVITED testers and reports the new state; asc testers marks
NOT_INVITED rows with a hint pointing at it. groups delete always needs
--yes and says what it will delete first; testers remove resolves every
address before the first deletion and previews team-wide removals. Group
names go through asc.MatchBetaGroup, so Team/team duplicates are refused.

resolveApp (--bundle-id, --ipa, ios.bundleId, newest dist IPA) serves ios
submit and every asc command; runTestFlight is the shared submit-and-print
step of ios submit --testflight and asc groups add-build, writing to the
command's stdout. Command tests reset cobra flags between runs, since values
otherwise carry over on the shared command tree.
A small IPA printed "100% (0/0 MB)" because the sizes were shifted to
whole megabytes. progressSize prints one decimal ("0.2/0.2 MB") and
falls back to kilobytes while the whole upload is under a megabyte.
Inviting a tester whose groups hold no build makes App Store Connect
answer 409 STATE_ERROR.TESTER_INVITE.NO_INSTALLABLE_BUILDS, which asc
testers invite printed raw. InviteTester now returns a noBuildError that
says to add a build to the group first (asc groups add-build) and that
external groups also need Beta App Review; the ASC error stays wrapped.
AddTester, which sends the invitation itself after a group add, treats
the same refusal as "Added <email> to <group> (invite goes out once the
group has a build)" with status added and the NOT_INVITED state, instead
of claiming an invitation went out. asc.HasCode matches ASC error codes.
On Linux and WSL the login is written to a 0600 file in the config dir,
not a keychain, so the auth apple confirmation was wrong there. Word it
like the other provider logins instead.
Drop comments that only restated the function below them (getOne, post,
patch, sleep, utiFor, logf, pollInterval, resolveIPA, getASCClient), cut
the package doc for asc to what is not already in CLAUDE.md, and compress
the six CLAUDE.md bullets this branch added to three lines each.
SubmitTestFlight logged strings.Join(opts.Groups), so a run mixing an
automatic-distribution group with a manual one claimed the build had been
added to the group it had just skipped.
Cut the comment blocks this branch added down to what they explain: the
CLAUDE.md bullets on internal testers, invitations and the command layer
split into one fact each, the README's TestFlight section tightened, and
the longest Go and test comments shortened.
# Conflicts:
#	CLAUDE.md
#	cmd/builder/upload.go
@Interlap01
Interlap01 changed the base branch from feat/asc-client to main September 17, 2026 18:24
@Interlap01
Interlap01 merged commit 6f6420a into main Sep 17, 2026
8 checks passed
@Interlap01
Interlap01 deleted the feat/asc-manage branch September 17, 2026 18:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant