asc: TestFlight groups, testers, users and builds management - #24
Merged
Merged
Conversation
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.
2 of 4 tasks
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 likeios upload(--bundle-id,ios.bundleId, newest IPA).ios submit --testflight --group X(andrelease) creates a missing group, internal by default,--externalfor external.hasAccessToAllBuilds) cannot have builds assigned; Apple answers 422. Builder now skips them with a note instead of failing, andasc groupsshows "internal, all builds". Such a group only sees builds uploaded after it was created; the docs say so.NOT_INVITEDand get no email until a build is available or an invite is sent.asc testers addon 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 inviteresends one;asc testersshows the state with a hint.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.Verified against the live account: the 422 on an automatic group,
publicLinkEnabled: nulldecoding, aNOT_INVITEDteam member, andbetaTesterInvitationsmoving the state toINVITED.Test plan
go build ./... && go vet ./... && go test ./...,gofmt -l .clean, golangci-lint v2.12.2 0 issuesbuilder asc groupsandasc testers --group Testerson the test app show the group as "internal, all builds" and the tester statebuilder asc testers invite <email> --group Testersreportsinvitedbuilder ios release --profile store --group Testersprints the automatic-distribution note and exits 0