From 9fa1890aead15eb7c843096d427fc7fa21f81e6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 16 Sep 2026 16:09:18 +0800 Subject: [PATCH] Add App Store review submission command --- Makefile | 10 +- cmd/internal/app_store_connect/main.go | 628 ++++++++++++++++--------- cmd/internal/build_shared/xcodeproj.go | 47 ++ go.mod | 2 +- go.sum | 4 +- 5 files changed, 459 insertions(+), 232 deletions(-) create mode 100644 cmd/internal/build_shared/xcodeproj.go diff --git a/Makefile b/Makefile index eaeb37ed..949248a4 100644 --- a/Makefile +++ b/Makefile @@ -224,7 +224,7 @@ update_apple_version: go run ./cmd/internal/update_apple_version update_macos_version: - MACOS_PROJECT_VERSION=$(shell go run -v ./cmd/internal/app_store_connect next_macos_project_version) go run ./cmd/internal/update_apple_version + MACOS_PROJECT_VERSION=$(shell go run ./cmd/internal/app_store_connect next_project_version macos) go run ./cmd/internal/update_apple_version release_apple: lib_apple update_apple_version release_ios release_macos release_tvos release_macos_standalone @@ -233,11 +233,13 @@ release_apple_beta: update_apple_version release_ios release_macos release_tvos publish_testflight: go run -v ./cmd/internal/app_store_connect publish_testflight $(filter-out $@,$(MAKECMDGOALS)) -prepare_app_store: - go run -v ./cmd/internal/app_store_connect prepare_app_store +submit_app_store: + go run -v ./cmd/internal/app_store_connect submit_app_store $(filter-out $@,$(MAKECMDGOALS)) + +release_app_store: release_ios release_tvos submit_app_store publish_app_store: - go run -v ./cmd/internal/app_store_connect publish_app_store + go run -v ./cmd/internal/app_store_connect publish_app_store $(filter-out $@,$(MAKECMDGOALS)) test: @go test -v ./... && \ diff --git a/cmd/internal/app_store_connect/main.go b/cmd/internal/app_store_connect/main.go index 6803c054..59b88281 100644 --- a/cmd/internal/app_store_connect/main.go +++ b/cmd/internal/app_store_connect/main.go @@ -4,12 +4,14 @@ import ( "context" "net/http" "os" + "slices" "strconv" "strings" "time" "github.com/sagernet/asc-go/asc" "github.com/sagernet/sing-box/cmd/internal/build_shared" + "github.com/sagernet/sing-box/common/badversion" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" @@ -32,18 +34,37 @@ func main() { if err != nil { log.Fatal(err) } + case "list_builds": + if len(os.Args) < 3 { + log.Fatal("platform required: ios, macos, or tvos") + } + err := listBuilds(ctx, os.Args[2]) + if err != nil { + log.Fatal(err) + } + case "remove_testflight": + if len(os.Args) < 5 { + log.Fatal("usage: remove_testflight ") + } + err := removeTestflight(ctx, os.Args[2], os.Args[3], os.Args[4]) + if err != nil { + log.Fatal(err) + } case "cancel_app_store": + if len(os.Args) < 3 { + log.Fatal("platform required: ios, macos, or tvos") + } err := cancelAppStore(ctx, os.Args[2]) if err != nil { log.Fatal(err) } - case "prepare_app_store": - err := prepareAppStore(ctx) + case "submit_app_store": + err := submitAppStore(ctx, os.Args[2:]) if err != nil { log.Fatal(err) } case "publish_app_store": - err := publishAppStore(ctx) + err := publishAppStore(ctx, os.Args[2:]) if err != nil { log.Fatal(err) } @@ -55,8 +76,35 @@ func main() { const ( appID = "6785326793" groupID = "39f9ebdc-05d4-421f-9595-dae71df227c4" + + applePath = "../sing-box-for-apple" + appStorePromotionalText = "The universal proxy platform." + appStoreWhatsNew = "Fixes and improvements" + buildPollInterval = 15 * time.Second ) +var ( + appStorePlatforms = []string{"ios", "tvos"} + platformDirectories = map[asc.Platform]string{ + asc.PlatformIOS: "SFI", + asc.PlatformMACOS: "SFM", + asc.PlatformTVOS: "SFT", + } +) + +func parsePlatform(name string) (asc.Platform, error) { + switch name { + case "ios": + return asc.PlatformIOS, nil + case "macos": + return asc.PlatformMACOS, nil + case "tvos": + return asc.PlatformTVOS, nil + default: + return "", E.New("unknown platform: ", name) + } +} + func createClient(expireDuration time.Duration) *asc.Client { privateKey, err := os.ReadFile(os.Getenv("ASC_KEY_PATH")) if err != nil { @@ -70,16 +118,9 @@ func createClient(expireDuration time.Duration) *asc.Client { } func fetchNextProjectVersion(ctx context.Context, platformName string) error { - var platform asc.Platform - switch platformName { - case "ios": - platform = asc.PlatformIOS - case "macos": - platform = asc.PlatformMACOS - case "tvos": - platform = asc.PlatformTVOS - default: - return E.New("unknown platform: ", platformName) + platform, err := parsePlatform(platformName) + if err != nil { + return err } query := &asc.ListBuildsQuery{ @@ -115,20 +156,55 @@ func fetchNextProjectVersion(ctx context.Context, platformName string) error { return nil } +func listBuilds(ctx context.Context, platformName string) error { + platform, err := parsePlatform(platformName) + if err != nil { + return err + } + client := createClient(time.Minute) + buildIDsResponse, _, err := client.TestFlight.ListBuildIDsForBetaGroup(ctx, groupID, nil) + if err != nil { + return err + } + betaBuildIDs := common.Map(buildIDsResponse.Data, func(it asc.RelationshipData) string { + return it.ID + }) + builds, _, err := client.Builds.ListBuilds(ctx, &asc.ListBuildsQuery{ + FilterApp: []string{appID}, + FilterPreReleaseVersionPlatform: []string{string(platform)}, + Include: []string{"preReleaseVersion"}, + Sort: []string{"-uploadedDate"}, + Limit: 10, + }) + if err != nil { + return err + } + for _, build := range builds.Data { + var betaGroup string + if slices.Contains(betaBuildIDs, build.ID) { + betaGroup = " testflight" + } + var versionString string + if build.Relationships != nil && build.Relationships.PreReleaseVersion != nil && build.Relationships.PreReleaseVersion.Data != nil { + for _, included := range builds.Included { + preReleaseVersion := included.PrereleaseVersion() + if preReleaseVersion != nil && preReleaseVersion.ID == build.Relationships.PreReleaseVersion.Data.ID { + versionString = *preReleaseVersion.Attributes.Version + } + } + } + os.Stdout.WriteString(F.ToString(versionString, " (", *build.Attributes.Version, ") ", *build.Attributes.ProcessingState, " ", build.Attributes.UploadedDate.Time.Format(time.RFC3339), betaGroup, "\n")) + } + return nil +} + func publishTestflight(ctx context.Context) error { if len(os.Args) < 3 { return E.New("platform required: ios, macos, or tvos") } - var platform asc.Platform - switch os.Args[2] { - case "ios": - platform = asc.PlatformIOS - case "macos": - platform = asc.PlatformMACOS - case "tvos": - platform = asc.PlatformTVOS - default: - return E.New("unknown platform: ", os.Args[2]) + platform, err := parsePlatform(os.Args[2]) + if err != nil { + return err } tagVersion, err := build_shared.ReadTagVersion() @@ -173,19 +249,19 @@ func publishTestflight(ctx context.Context) error { } if len(builds.Data) == 0 { log.Info(string(platform), " ", testFlightVersion, " waiting for build upload") - time.Sleep(15 * time.Second) + time.Sleep(buildPollInterval) continue } build := builds.Data[0] log.Info(string(platform), " ", testFlightVersion, " found build: ", build.ID, " (", *build.Attributes.Version, ")") if projectVersion == "" && common.Contains(buildIDs, build.ID) { log.Info(string(platform), " ", testFlightVersion, " build ", *build.Attributes.Version, " already published, waiting for new upload") - time.Sleep(15 * time.Second) + time.Sleep(buildPollInterval) continue } if *build.Attributes.ProcessingState != "VALID" { log.Info(string(platform), " ", testFlightVersion, " waiting for process: ", *build.Attributes.ProcessingState) - time.Sleep(15 * time.Second) + time.Sleep(buildPollInterval) continue } log.Info(string(platform), " ", tag, " list localizations") @@ -210,7 +286,7 @@ func publishTestflight(ctx context.Context) error { response, err := client.TestFlight.AddBuildsToBetaGroup(ctx, groupID, []string{build.ID}) if response != nil && (response.StatusCode == http.StatusUnprocessableEntity || response.StatusCode == http.StatusNotFound) { log.Info("waiting for process") - time.Sleep(15 * time.Second) + time.Sleep(buildPollInterval) continue } else if err != nil { return err @@ -238,227 +314,341 @@ func publishTestflight(ctx context.Context) error { return nil } -func cancelAppStore(ctx context.Context, platform string) error { - switch platform { - case "ios": - platform = string(asc.PlatformIOS) - case "macos": - platform = string(asc.PlatformMACOS) - case "tvos": - platform = string(asc.PlatformTVOS) - } - tag, err := build_shared.ReadTag() +func removeTestflight(ctx context.Context, platformName string, versionString string, buildNumber string) error { + platform, err := parsePlatform(platformName) if err != nil { return err } client := createClient(time.Minute) - for { - log.Info(platform, " list versions") - versions, response, err := client.Apps.ListAppStoreVersionsForApp(ctx, appID, &asc.ListAppStoreVersionsQuery{ - FilterPlatform: []string{string(platform)}, - }) - if isRetryable(response) { - continue - } else if err != nil { - return err - } - version := common.Find(versions.Data, func(it asc.AppStoreVersion) bool { - return *it.Attributes.VersionString == tag - }) - if version.ID == "" { - return nil - } - log.Info(platform, " ", tag, " get submission") - submission, response, err := client.Submission.GetAppStoreVersionSubmissionForAppStoreVersion(ctx, version.ID, nil) - if response != nil && response.StatusCode == http.StatusNotFound { - return nil - } - if isRetryable(response) { - continue - } else if err != nil { - return err - } - log.Info(platform, " ", tag, " delete submission") - _, err = client.Submission.DeleteSubmission(ctx, submission.Data.ID) - if err != nil { - return err - } - return nil + builds, _, err := client.Builds.ListBuilds(ctx, &asc.ListBuildsQuery{ + FilterApp: []string{appID}, + FilterPreReleaseVersionPlatform: []string{string(platform)}, + FilterPreReleaseVersionVersion: []string{versionString}, + FilterVersion: []string{buildNumber}, + }) + if err != nil { + return err } + if len(builds.Data) != 1 { + return E.New(string(platform), " ", versionString, " (", buildNumber, ") matched ", len(builds.Data), " builds") + } + build := builds.Data[0] + log.Info(string(platform), " ", versionString, " (", buildNumber, ") remove from beta group") + _, err = client.Builds.RemoveAccessForBetaGroupsFromBuild(ctx, build.ID, []string{groupID}) + if err != nil { + return err + } + return nil } -func prepareAppStore(ctx context.Context) error { +func cancelAppStore(ctx context.Context, platformName string) error { + platform, err := parsePlatform(platformName) + if err != nil { + return err + } tag, err := build_shared.ReadTag() if err != nil { return err } client := createClient(time.Minute) - for _, platform := range []asc.Platform{ - asc.PlatformIOS, - asc.PlatformMACOS, - asc.PlatformTVOS, - } { - log.Info(string(platform), " list versions") - versions, _, err := client.Apps.ListAppStoreVersionsForApp(ctx, appID, &asc.ListAppStoreVersionsQuery{ - FilterPlatform: []string{string(platform)}, - }) + log.Info(string(platform), " list versions") + versions, _, err := client.Apps.ListAppStoreVersionsForApp(ctx, appID, &asc.ListAppStoreVersionsQuery{ + FilterPlatform: []string{string(platform)}, + FilterVersionString: []string{tag}, + }) + if err != nil { + return err + } + if len(versions.Data) == 0 { + return nil + } + versionID := versions.Data[0].ID + log.Info(string(platform), " ", tag, " list review submissions") + submissions, _, err := client.Submission.ListReviewSubmissions(ctx, &asc.ListReviewSubmissionsQuery{ + FilterApp: []string{appID}, + FilterPlatform: []string{string(platform)}, + FilterState: []string{ + string(asc.ReviewSubmissionStateReadyForReview), + string(asc.ReviewSubmissionStateWaitingForReview), + string(asc.ReviewSubmissionStateInReview), + }, + }) + if err != nil { + return err + } + for _, submission := range submissions.Data { + containsVersion, err := reviewSubmissionContainsVersion(ctx, client, submission.ID, versionID) if err != nil { return err } - version := common.Find(versions.Data, func(it asc.AppStoreVersion) bool { - return *it.Attributes.VersionString == tag - }) - log.Info(string(platform), " ", tag, " list builds") - builds, _, err := client.Builds.ListBuilds(ctx, &asc.ListBuildsQuery{ - FilterApp: []string{appID}, - FilterPreReleaseVersionPlatform: []string{string(platform)}, - }) + if !containsVersion { + continue + } + log.Info(string(platform), " ", tag, " cancel review submission ", submission.ID) + _, _, err = client.Submission.UpdateReviewSubmission(ctx, submission.ID, nil, common.Ptr(true)) if err != nil { return err } - if len(builds.Data) == 0 { - log.Fatal(string(platform), " ", tag, " no build found") - } - buildID := common.Ptr(builds.Data[0].ID) - if version.ID == "" { - log.Info(string(platform), " ", tag, " create version") - newVersion, _, err := client.Apps.CreateAppStoreVersion(ctx, asc.AppStoreVersionCreateRequestAttributes{ - Platform: platform, - VersionString: tag, - }, appID, buildID) - if err != nil { - return err - } - version = newVersion.Data - - } else { - log.Info(string(platform), " ", tag, " check build") - currentBuild, response, err := client.Apps.GetBuildIDForAppStoreVersion(ctx, version.ID) - if err != nil { - return err - } - if response.StatusCode != http.StatusOK || currentBuild.Data.ID != *buildID { - switch *version.Attributes.AppStoreState { - case asc.AppStoreVersionStatePrepareForSubmission, - asc.AppStoreVersionStateRejected, - asc.AppStoreVersionStateDeveloperRejected: - case asc.AppStoreVersionStateWaitingForReview, - asc.AppStoreVersionStateInReview, - asc.AppStoreVersionStatePendingDeveloperRelease: - submission, _, err := client.Submission.GetAppStoreVersionSubmissionForAppStoreVersion(ctx, version.ID, nil) - if err != nil { - return err - } - if submission != nil { - log.Info(string(platform), " ", tag, " delete submission") - _, err = client.Submission.DeleteSubmission(ctx, submission.Data.ID) - if err != nil { - return err - } - time.Sleep(5 * time.Second) - } - default: - log.Fatal(string(platform), " ", tag, " unknown state ", string(*version.Attributes.AppStoreState)) - } - log.Info(string(platform), " ", tag, " update build") - response, err = client.Apps.UpdateBuildForAppStoreVersion(ctx, version.ID, buildID) - if err != nil { - return err - } - if response.StatusCode != http.StatusNoContent { - response.Write(os.Stderr) - log.Fatal(string(platform), " ", tag, " unexpected response: ", response.Status) - } - } else { - switch *version.Attributes.AppStoreState { - case asc.AppStoreVersionStatePrepareForSubmission, - asc.AppStoreVersionStateRejected, - asc.AppStoreVersionStateDeveloperRejected: - case asc.AppStoreVersionStateWaitingForReview, - asc.AppStoreVersionStateInReview, - asc.AppStoreVersionStatePendingDeveloperRelease: - continue - default: - log.Fatal(string(platform), " ", tag, " unknown state ", string(*version.Attributes.AppStoreState)) - } - } - } - log.Info(string(platform), " ", tag, " list localization") - localizations, _, err := client.Apps.ListLocalizationsForAppStoreVersion(ctx, version.ID, nil) - if err != nil { - return err - } - localization := common.Find(localizations.Data, func(it asc.AppStoreVersionLocalization) bool { - return *it.Attributes.Locale == "en-US" - }) - if localization.ID == "" { - log.Info(string(platform), " ", tag, " no en-US localization found") - } - if localization.Attributes == nil || localization.Attributes.WhatsNew == nil || *localization.Attributes.WhatsNew == "" { - log.Info(string(platform), " ", tag, " update localization") - _, _, err = client.Apps.UpdateAppStoreVersionLocalization(ctx, localization.ID, &asc.AppStoreVersionLocalizationUpdateRequestAttributes{ - PromotionalText: common.Ptr("Yet another distribution for sing-box, the universal proxy platform."), - WhatsNew: common.Ptr(F.ToString("sing-box ", tag, ": Fixes and improvements.")), - }) - if err != nil { - return err - } - } - log.Info(string(platform), " ", tag, " create submission") - fixSubmit: - for { - _, response, err := client.Submission.CreateSubmission(ctx, version.ID) - if err != nil { - switch response.StatusCode { - case http.StatusInternalServerError: - continue - default: - return err - } - } - switch response.StatusCode { - case http.StatusCreated: - break fixSubmit - default: - return err - } - } } return nil } -func publishAppStore(ctx context.Context) error { - tag, err := build_shared.ReadTag() +func reviewSubmissionContainsVersion(ctx context.Context, client *asc.Client, submissionID string, versionID string) (bool, error) { + items, _, err := client.Submission.ListItemsForReviewSubmission(ctx, submissionID, nil) + if err != nil { + return false, err + } + return slices.ContainsFunc(items.Data, func(it asc.ReviewSubmissionItem) bool { + return it.Relationships != nil && it.Relationships.AppStoreVersion != nil && + it.Relationships.AppStoreVersion.Data != nil && it.Relationships.AppStoreVersion.Data.ID == versionID + }), nil +} + +func submitAppStore(ctx context.Context, platformNames []string) error { + if len(platformNames) == 0 { + platformNames = appStorePlatforms + } + client := createClient(20 * time.Minute) + for _, platformName := range platformNames { + platform, err := parsePlatform(platformName) + if err != nil { + return err + } + versionString, err := build_shared.ReadAppleMarketingVersion(applePath, platformDirectories[platform]) + if err != nil { + return err + } + err = submitAppStoreVersion(ctx, client, platform, versionString) + if err != nil { + return err + } + } + return nil +} + +func submitAppStoreVersion(ctx context.Context, client *asc.Client, platform asc.Platform, versionString string) error { + log.Info(string(platform), " ", versionString, " list versions") + versions, _, err := client.Apps.ListAppStoreVersionsForApp(ctx, appID, &asc.ListAppStoreVersionsQuery{ + FilterPlatform: []string{string(platform)}, + }) if err != nil { return err } - client := createClient(time.Minute) - for _, platform := range []asc.Platform{ - asc.PlatformIOS, - asc.PlatformMACOS, - asc.PlatformTVOS, - } { - log.Info(string(platform), " list versions") - versions, _, err := client.Apps.ListAppStoreVersionsForApp(ctx, appID, &asc.ListAppStoreVersionsQuery{ - FilterPlatform: []string{string(platform)}, + localVersion := badversion.Parse(versionString) + var version asc.AppStoreVersion + for _, remoteVersion := range versions.Data { + remoteVersionString := *remoteVersion.Attributes.VersionString + if remoteVersionString == versionString { + version = remoteVersion + continue + } + if !badversion.Parse(remoteVersionString).LessThan(localVersion) { + return E.New(string(platform), " ", versionString, " is not newer than App Store version ", remoteVersionString) + } + } + if version.ID != "" { + state := *version.Attributes.AppStoreState + switch state { + case asc.AppStoreVersionStatePrepareForSubmission, + asc.AppStoreVersionStateRejected, + asc.AppStoreVersionStateDeveloperRejected, + asc.AppStoreVersionStateMetadataRejected, + asc.AppStoreVersionStateInvalidBinary: + log.Info(string(platform), " ", versionString, " found version in state ", string(state)) + case asc.AppStoreVersionStateWaitingForReview, asc.AppStoreVersionStateInReview: + log.Info(string(platform), " ", versionString, " already submitted: ", string(state)) + return nil + case asc.AppStoreVersionStatePendingDeveloperRelease: + log.Info(string(platform), " ", versionString, " approved, run publish_app_store to release") + return nil + default: + return E.New(string(platform), " ", versionString, " already released: ", string(state)) + } + } else { + log.Info(string(platform), " ", versionString, " create version") + created, _, err := client.Apps.CreateAppStoreVersion(ctx, asc.AppStoreVersionCreateRequestAttributes{ + Platform: platform, + ReleaseType: common.Ptr("MANUAL"), + VersionString: versionString, + }, appID, nil) + if err != nil { + return err + } + version = created.Data + } + + log.Info(string(platform), " ", versionString, " list localizations") + localizations, _, err := client.Apps.ListLocalizationsForAppStoreVersion(ctx, version.ID, nil) + if err != nil { + return err + } + localization := common.Find(localizations.Data, func(it asc.AppStoreVersionLocalization) bool { + return *it.Attributes.Locale == "en-US" + }) + if localization.ID == "" { + log.Info(string(platform), " ", versionString, " create en-US localization") + _, _, err = client.Apps.CreateAppStoreVersionLocalization(ctx, asc.AppStoreVersionLocalizationCreateRequestAttributes{ + Locale: "en-US", + PromotionalText: common.Ptr(appStorePromotionalText), + WhatsNew: common.Ptr(appStoreWhatsNew), + }, version.ID) + if err != nil { + return err + } + } else if localization.Attributes.PromotionalText == nil || *localization.Attributes.PromotionalText != appStorePromotionalText || + localization.Attributes.WhatsNew == nil || *localization.Attributes.WhatsNew != appStoreWhatsNew { + log.Info(string(platform), " ", versionString, " update en-US localization") + _, _, err = client.Apps.UpdateAppStoreVersionLocalization(ctx, localization.ID, &asc.AppStoreVersionLocalizationUpdateRequestAttributes{ + PromotionalText: common.Ptr(appStorePromotionalText), + WhatsNew: common.Ptr(appStoreWhatsNew), }) if err != nil { return err } - version := common.Find(versions.Data, func(it asc.AppStoreVersion) bool { - return *it.Attributes.VersionString == tag + } + + var build asc.Build + for { + builds, _, err := client.Builds.ListBuilds(ctx, &asc.ListBuildsQuery{ + FilterApp: []string{appID}, + FilterPreReleaseVersionPlatform: []string{string(platform)}, + FilterPreReleaseVersionVersion: []string{versionString}, + Sort: []string{"-uploadedDate"}, + Limit: 1, }) - switch *version.Attributes.AppStoreState { + if err != nil { + return err + } + if len(builds.Data) == 0 { + log.Info(string(platform), " ", versionString, " waiting for build upload") + time.Sleep(buildPollInterval) + continue + } + build = builds.Data[0] + processingState := *build.Attributes.ProcessingState + if processingState == "VALID" { + break + } + if processingState == "PROCESSING" { + log.Info(string(platform), " ", versionString, " waiting for build ", *build.Attributes.Version, ": ", processingState) + time.Sleep(buildPollInterval) + continue + } + return E.New(string(platform), " ", versionString, " build ", *build.Attributes.Version, " ", processingState) + } + log.Info(string(platform), " ", versionString, " found build ", *build.Attributes.Version) + + currentBuild, response, err := client.Apps.GetBuildIDForAppStoreVersion(ctx, version.ID) + if err != nil { + return err + } + if response.StatusCode != http.StatusOK || currentBuild.Data.ID != build.ID { + log.Info(string(platform), " ", versionString, " attach build ", *build.Attributes.Version) + _, err = client.Apps.UpdateBuildForAppStoreVersion(ctx, version.ID, common.Ptr(build.ID)) + if err != nil { + return err + } + } + + log.Info(string(platform), " ", versionString, " list review submissions") + submissions, _, err := client.Submission.ListReviewSubmissions(ctx, &asc.ListReviewSubmissionsQuery{ + FilterApp: []string{appID}, + FilterPlatform: []string{string(platform)}, + FilterState: []string{ + string(asc.ReviewSubmissionStateReadyForReview), + string(asc.ReviewSubmissionStateWaitingForReview), + string(asc.ReviewSubmissionStateInReview), + string(asc.ReviewSubmissionStateUnresolvedIssues), + }, + }) + if err != nil { + return err + } + var ( + submission asc.ReviewSubmission + submissionHasItem bool + ) + for _, remoteSubmission := range submissions.Data { + containsVersion, err := reviewSubmissionContainsVersion(ctx, client, remoteSubmission.ID, version.ID) + if err != nil { + return err + } + state := *remoteSubmission.Attributes.State + switch state { + case asc.ReviewSubmissionStateReadyForReview: + submission = remoteSubmission + submissionHasItem = containsVersion + case asc.ReviewSubmissionStateWaitingForReview, asc.ReviewSubmissionStateInReview: + if containsVersion { + log.Info(string(platform), " ", versionString, " already submitted: ", string(state)) + return nil + } + return E.New(string(platform), " review submission ", remoteSubmission.ID, " with another version is ", string(state)) + default: + return E.New(string(platform), " review submission ", remoteSubmission.ID, " is ", string(state)) + } + } + if submission.ID == "" { + log.Info(string(platform), " ", versionString, " create review submission") + created, _, err := client.Submission.CreateReviewSubmission(ctx, appID, common.Ptr(platform)) + if err != nil { + return err + } + submission = created.Data + } + if !submissionHasItem { + log.Info(string(platform), " ", versionString, " add version to review submission ", submission.ID) + _, _, err = client.Submission.CreateReviewSubmissionItemForAppStoreVersion(ctx, submission.ID, version.ID) + if err != nil { + return err + } + } + log.Info(string(platform), " ", versionString, " submit review submission ", submission.ID) + _, _, err = client.Submission.UpdateReviewSubmission(ctx, submission.ID, common.Ptr(true), nil) + if err != nil { + return err + } + return nil +} + +func publishAppStore(ctx context.Context, platformNames []string) error { + if len(platformNames) == 0 { + platformNames = appStorePlatforms + } + client := createClient(time.Minute) + for _, platformName := range platformNames { + platform, err := parsePlatform(platformName) + if err != nil { + return err + } + versionString, err := build_shared.ReadAppleMarketingVersion(applePath, platformDirectories[platform]) + if err != nil { + return err + } + log.Info(string(platform), " ", versionString, " list versions") + versions, _, err := client.Apps.ListAppStoreVersionsForApp(ctx, appID, &asc.ListAppStoreVersionsQuery{ + FilterPlatform: []string{string(platform)}, + FilterVersionString: []string{versionString}, + }) + if err != nil { + return err + } + if len(versions.Data) == 0 { + return E.New(string(platform), " ", versionString, " not found") + } + version := versions.Data[0] + state := *version.Attributes.AppStoreState + switch state { case asc.AppStoreVersionStatePrepareForSubmission, asc.AppStoreVersionStateDeveloperRejected: - log.Fatal(string(platform), " ", tag, " not submitted") - case asc.AppStoreVersionStateWaitingForReview, - asc.AppStoreVersionStateInReview: - log.Warn(string(platform), " ", tag, " waiting for review") + return E.New(string(platform), " ", versionString, " not submitted") + case asc.AppStoreVersionStateWaitingForReview, asc.AppStoreVersionStateInReview: + log.Warn(string(platform), " ", versionString, " waiting for review") continue case asc.AppStoreVersionStatePendingDeveloperRelease: default: - log.Fatal(string(platform), " ", tag, " unknown state ", string(*version.Attributes.AppStoreState)) + return E.New(string(platform), " ", versionString, " unknown state ", string(state)) } + log.Info(string(platform), " ", versionString, " release") _, _, err = client.Publishing.CreatePhasedRelease(ctx, common.Ptr(asc.PhasedReleaseStateComplete), version.ID) if err != nil { return err @@ -466,15 +656,3 @@ func publishAppStore(ctx context.Context) error { } return nil } - -func isRetryable(response *asc.Response) bool { - if response == nil { - return false - } - switch response.StatusCode { - case http.StatusInternalServerError, http.StatusUnprocessableEntity: - return true - default: - return false - } -} diff --git a/cmd/internal/build_shared/xcodeproj.go b/cmd/internal/build_shared/xcodeproj.go new file mode 100644 index 00000000..a7117a35 --- /dev/null +++ b/cmd/internal/build_shared/xcodeproj.go @@ -0,0 +1,47 @@ +package build_shared + +import ( + "os" + "path/filepath" + "strings" + + E "github.com/sagernet/sing/common/exceptions" + + "howett.net/plist" +) + +func ReadAppleMarketingVersion(applePath string, directory string) (string, error) { + projectFile, err := os.Open(filepath.Join(applePath, "sing-box.xcodeproj", "project.pbxproj")) + if err != nil { + return "", err + } + defer projectFile.Close() + var project map[string]any + err = plist.NewDecoder(projectFile).Decode(&project) + if err != nil { + return "", err + } + var marketingVersion string + for _, object := range project["objects"].(map[string]any) { + buildSettings, isMap := object.(map[string]any)["buildSettings"].(map[string]any) + if !isMap { + continue + } + infoPlistFile, isString := buildSettings["INFOPLIST_FILE"].(string) + if !isString || !strings.HasPrefix(infoPlistFile, directory+"/") { + continue + } + version, isString := buildSettings["MARKETING_VERSION"].(string) + if !isString { + continue + } + if marketingVersion != "" && marketingVersion != version { + return "", E.New("inconsistent marketing versions in ", directory, ": ", marketingVersion, ", ", version) + } + marketingVersion = version + } + if marketingVersion == "" { + return "", E.New("marketing version not found in ", directory) + } + return marketingVersion, nil +} diff --git a/go.mod b/go.mod index a49a36e3..318d7c49 100644 --- a/go.mod +++ b/go.mod @@ -33,7 +33,7 @@ require ( github.com/openai/openai-go/v3 v3.26.0 github.com/oschwald/maxminddb-golang v1.13.1 github.com/pkg/sftp v1.13.10 - github.com/sagernet/asc-go v0.0.0-20260827085112-8f8226245b0d + github.com/sagernet/asc-go v0.0.0-20260914163356-9e3d45a797c1 github.com/sagernet/bbolt v0.0.0-20260915102804-500ee1e84832 github.com/sagernet/cors v1.2.1 github.com/sagernet/cronet-go v0.0.0-20260912104727-0d28acc44093 diff --git a/go.sum b/go.sum index 7c6d6b9b..48412202 100644 --- a/go.sum +++ b/go.sum @@ -236,8 +236,8 @@ github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9q github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= -github.com/sagernet/asc-go v0.0.0-20260827085112-8f8226245b0d h1:r3JBMjxwI8BPLprJpklmcgoPT3r7rJfUg4uuGWky48A= -github.com/sagernet/asc-go v0.0.0-20260827085112-8f8226245b0d/go.mod h1:JULDuzTMn2gyZFcjpTVZP4/UuwAdbHJ0bum2RdjXojU= +github.com/sagernet/asc-go v0.0.0-20260914163356-9e3d45a797c1 h1:fOZrud4hyHSHPpgTV0qicYziKru209IApFWamdRab50= +github.com/sagernet/asc-go v0.0.0-20260914163356-9e3d45a797c1/go.mod h1:JULDuzTMn2gyZFcjpTVZP4/UuwAdbHJ0bum2RdjXojU= github.com/sagernet/bbolt v0.0.0-20260915102804-500ee1e84832 h1:KDXMGbzqLmDdKcld0T3wuMuqf1iezZq+DemgtZ/vhtQ= github.com/sagernet/bbolt v0.0.0-20260915102804-500ee1e84832/go.mod h1:7/qFPFH5fqK5XQeY8EA1Qpaly08Av77p38Zt50ieQww= github.com/sagernet/cors v1.2.1 h1:Cv5Z8y9YSD6Gm+qSpNrL3LO4lD3eQVvbFYJSG7JCMHQ=