From 7f2b52a1c9d204c1c2bb915826aea06bc933807c Mon Sep 17 00:00:00 2001 From: Yossi Elkrief Date: Tue, 24 Mar 2026 09:04:54 +0200 Subject: [PATCH] Add ASC publish lane and release checklist gates --- SKILL.md | 2 + codex-skill/SKILL.md | 2 + internal/asc/apps.go | 83 +++++--- internal/checks/runner.go | 3 + internal/checks/tier1_metadata.go | 124 +++++++++-- internal/cli/publish.go | 147 +++++++++++++ internal/cli/release_checklist.go | 334 ++++++++++++++++++++++++++++++ internal/cli/root.go | 3 + 8 files changed, 649 insertions(+), 49 deletions(-) create mode 100644 internal/cli/publish.go create mode 100644 internal/cli/release_checklist.go diff --git a/SKILL.md b/SKILL.md index 072fa89..3d3143b 100644 --- a/SKILL.md +++ b/SKILL.md @@ -89,5 +89,7 @@ storeready codescan . # Code-only scan storeready privacy . # Privacy manifest scan storeready ipa /path/to/build.ipa # Binary inspection storeready scan --app-id # App Store Connect checks (needs auth) +storeready release-checklist --app-type all +storeready publish --app-id --version [--build ] [--confirm] storeready guidelines search "privacy" # Search Apple guidelines ``` diff --git a/codex-skill/SKILL.md b/codex-skill/SKILL.md index 0745a1e..4c951e1 100644 --- a/codex-skill/SKILL.md +++ b/codex-skill/SKILL.md @@ -82,5 +82,7 @@ storeready codescan . storeready privacy . storeready ipa /path/to/build.ipa storeready scan --app-id +storeready release-checklist --app-type all +storeready publish --app-id --version [--build ] [--confirm] storeready guidelines search "privacy" ``` diff --git a/internal/asc/apps.go b/internal/asc/apps.go index b36079c..55024ff 100644 --- a/internal/asc/apps.go +++ b/internal/asc/apps.go @@ -9,10 +9,10 @@ type App struct { } type AppAttributes struct { - Name string `json:"name"` - BundleID string `json:"bundleId"` - SKU string `json:"sku"` - PrimaryLocale string `json:"primaryLocale"` + Name string `json:"name"` + BundleID string `json:"bundleId"` + SKU string `json:"sku"` + PrimaryLocale string `json:"primaryLocale"` ContentRightsDeclaration string `json:"contentRightsDeclaration"` } @@ -23,10 +23,21 @@ type AppInfo struct { } type AppInfoAttributes struct { - AppStoreState string `json:"appStoreState"` + AppStoreState string `json:"appStoreState"` AppStoreAgeRating string `json:"appStoreAgeRating"` - BrazilAgeRating string `json:"brazilAgeRating"` - KidsAgeBand string `json:"kidsAgeBand"` + BrazilAgeRating string `json:"brazilAgeRating"` + KidsAgeBand string `json:"kidsAgeBand"` +} + +// AppInfoLocalization contains app-info level localization data. +type AppInfoLocalization struct { + ID string `json:"id"` + Attributes AppInfoLocalizationAttributes `json:"attributes"` +} + +type AppInfoLocalizationAttributes struct { + Locale string `json:"locale"` + PrivacyPolicyURL string `json:"privacyPolicyUrl"` } // AppStoreVersion represents a version of an app. @@ -41,6 +52,7 @@ type AppStoreVersionAttributes struct { Platform string `json:"platform"` ReleaseType string `json:"releaseType"` CreatedDate string `json:"createdDate"` + Copyright string `json:"copyright"` } // VersionLocalization contains localized version info. @@ -50,12 +62,12 @@ type VersionLocalization struct { } type VersionLocalizationAttributes struct { - Locale string `json:"locale"` - Description string `json:"description"` - Keywords string `json:"keywords"` - WhatsNew string `json:"whatsNew"` - SupportURL string `json:"supportUrl"` - MarketingURL string `json:"marketingUrl"` + Locale string `json:"locale"` + Description string `json:"description"` + Keywords string `json:"keywords"` + WhatsNew string `json:"whatsNew"` + SupportURL string `json:"supportUrl"` + MarketingURL string `json:"marketingUrl"` PromotionalText string `json:"promotionalText"` } @@ -66,11 +78,11 @@ type Build struct { } type BuildAttributes struct { - Version string `json:"version"` - UploadedDate string `json:"uploadedDate"` - ProcessingState string `json:"processingState"` - MinOsVersion string `json:"minOsVersion"` - UsesNonExemptEncryption *bool `json:"usesNonExemptEncryption"` + Version string `json:"version"` + UploadedDate string `json:"uploadedDate"` + ProcessingState string `json:"processingState"` + MinOsVersion string `json:"minOsVersion"` + UsesNonExemptEncryption *bool `json:"usesNonExemptEncryption"` } // ScreenshotSet represents a set of screenshots for a device type. @@ -110,6 +122,15 @@ func (c *Client) GetAppInfos(appID string) ([]AppInfo, error) { return resp.Data, nil } +// GetAppInfoLocalizations fetches app-info localizations (privacy policy URL and locale fields). +func (c *Client) GetAppInfoLocalizations(appInfoID string) ([]AppInfoLocalization, error) { + var resp ListResponse[AppInfoLocalization] + if err := c.get(fmt.Sprintf("/appInfos/%s/appInfoLocalizations", appInfoID), &resp); err != nil { + return nil, err + } + return resp.Data, nil +} + // GetAppStoreVersions fetches all versions for an app. func (c *Client) GetAppStoreVersions(appID string) ([]AppStoreVersion, error) { var resp ListResponse[AppStoreVersion] @@ -155,10 +176,10 @@ type Screenshot struct { } type ScreenshotAttributes struct { - FileSize int `json:"fileSize"` - FileName string `json:"fileName"` - ImageAsset *ImageAsset `json:"imageAsset"` - AssetToken string `json:"assetToken"` + FileSize int `json:"fileSize"` + FileName string `json:"fileName"` + ImageAsset *ImageAsset `json:"imageAsset"` + AssetToken string `json:"assetToken"` UploadOperations interface{} `json:"uploadOperations"` } @@ -183,11 +204,11 @@ type BetaGroup struct { } type BetaGroupAttributes struct { - Name string `json:"name"` - IsInternalGroup bool `json:"isInternalGroup"` - PublicLinkEnabled *bool `json:"publicLinkEnabled"` - PublicLinkLimitEnabled *bool `json:"publicLinkLimitEnabled"` - HasAccessToAllBuilds *bool `json:"hasAccessToAllBuilds"` + Name string `json:"name"` + IsInternalGroup bool `json:"isInternalGroup"` + PublicLinkEnabled *bool `json:"publicLinkEnabled"` + PublicLinkLimitEnabled *bool `json:"publicLinkLimitEnabled"` + HasAccessToAllBuilds *bool `json:"hasAccessToAllBuilds"` } // GetBetaGroups fetches TestFlight beta groups for an app. @@ -206,8 +227,8 @@ type AppPrice struct { } type AppPriceAttributes struct { - Manual bool `json:"manual"` - StartDate string `json:"startDate"` + Manual bool `json:"manual"` + StartDate string `json:"startDate"` } // Territory represents an App Store territory. @@ -231,8 +252,8 @@ func (c *Client) GetAppAvailability(appID string) ([]Territory, error) { // AppPricePoint represents a price tier. type AppPricePoint struct { - ID string `json:"id"` - Attributes AppPricePointAttributes `json:"attributes"` + ID string `json:"id"` + Attributes AppPricePointAttributes `json:"attributes"` } type AppPricePointAttributes struct { diff --git a/internal/checks/runner.go b/internal/checks/runner.go index 3d1ac83..c2878be 100644 --- a/internal/checks/runner.go +++ b/internal/checks/runner.go @@ -43,6 +43,9 @@ func (r *Runner) registerChecks() { r.register(TierMetadata, "Build processed", checkBuildProcessed) r.register(TierMetadata, "Age rating declared", checkAgeRating) r.register(TierMetadata, "Encryption compliance", checkEncryption) + r.register(TierMetadata, "Content rights declaration", checkContentRightsDeclaration) + r.register(TierMetadata, "Privacy policy URL", checkAppInfoPrivacyPolicyURL) + r.register(TierMetadata, "Copyright metadata", checkCopyright) r.register(TierMetadata, "Territory availability", checkTerritoryAvailability) r.register(TierMetadata, "Pricing consistency", checkPricingConsistency) diff --git a/internal/checks/tier1_metadata.go b/internal/checks/tier1_metadata.go index 9cf1df0..552ecf3 100644 --- a/internal/checks/tier1_metadata.go +++ b/internal/checks/tier1_metadata.go @@ -338,15 +338,103 @@ func checkEncryption(ctx context.Context, client *asc.Client, appID string, find return nil } +// checkContentRightsDeclaration verifies the app has a content rights declaration set. +func checkContentRightsDeclaration(ctx context.Context, client *asc.Client, appID string, findings *[]Finding) error { + app, err := client.GetApp(appID) + if err != nil { + return err + } + + if strings.TrimSpace(app.Attributes.ContentRightsDeclaration) == "" { + *findings = append(*findings, Finding{ + Tier: TierMetadata, + Severity: SeverityBlock, + Guideline: "2.3", + Title: "Content rights declaration is missing", + Detail: "App Store submission requires a content rights declaration before review can proceed.", + Fix: "Set content rights in App Store Connect (DOES_NOT_USE_THIRD_PARTY_CONTENT or USES_THIRD_PARTY_CONTENT).", + }) + } + + return nil +} + +// checkAppInfoPrivacyPolicyURL verifies privacy policy URLs are set on app-info localizations. +func checkAppInfoPrivacyPolicyURL(ctx context.Context, client *asc.Client, appID string, findings *[]Finding) error { + infos, err := client.GetAppInfos(appID) + if err != nil { + return err + } + if len(infos) == 0 { + return nil + } + + for _, info := range infos { + localizations, err := client.GetAppInfoLocalizations(info.ID) + if err != nil { + return err + } + if len(localizations) == 0 { + *findings = append(*findings, Finding{ + Tier: TierMetadata, + Severity: SeverityBlock, + Guideline: "5.1.1", + Title: "No app-info localizations found", + Detail: "Privacy policy URL is validated on app-info localizations, but no app-info localizations were returned.", + Fix: "Create app-info localizations and set a privacy policy URL per locale in App Store Connect.", + }) + continue + } + + for _, loc := range localizations { + if strings.TrimSpace(loc.Attributes.PrivacyPolicyURL) != "" { + continue + } + *findings = append(*findings, Finding{ + Tier: TierMetadata, + Severity: SeverityBlock, + Guideline: "5.1.1", + Title: fmt.Sprintf("[%s] Privacy policy URL is missing", loc.Attributes.Locale), + Detail: "Privacy policy URL is required in app-info localization settings for submission readiness.", + Fix: "Set Privacy Policy URL under App Store Connect → App Information → Localizations.", + }) + } + } + + return nil +} + +// checkCopyright verifies the current version has copyright metadata. +func checkCopyright(ctx context.Context, client *asc.Client, appID string, findings *[]Finding) error { + versions, err := client.GetAppStoreVersions(appID) + if err != nil || len(versions) == 0 { + return err + } + + latest := versions[0] + if strings.TrimSpace(latest.Attributes.Copyright) == "" { + *findings = append(*findings, Finding{ + Tier: TierMetadata, + Severity: SeverityWarn, + Guideline: "2.3", + Title: fmt.Sprintf("Version %s copyright is empty", latest.Attributes.VersionString), + Detail: "Copyright metadata is commonly required during final submission checks.", + Fix: "Set copyright on the current App Store version in App Store Connect.", + }) + } + + return nil +} + // Required screenshot dimensions for each display type. var requiredScreenshotDimensions = map[string]struct { name string width int height int }{ - "APP_IPHONE_67": {"iPhone 6.7\"", 1290, 2796}, - "APP_IPHONE_65": {"iPhone 6.5\"", 1284, 2778}, - "APP_IPHONE_55": {"iPhone 5.5\"", 1242, 2208}, + "APP_IPHONE_67": {"iPhone 6.7\"", 1290, 2796}, + "APP_IPHONE_65": {"iPhone 6.5\"", 1284, 2778}, + "APP_IPHONE_55": {"iPhone 5.5\"", 1242, 2208}, "APP_IPAD_PRO_3GEN_129": {"iPad Pro 12.9\"", 2048, 2732}, "APP_IPAD_PRO_129": {"iPad Pro 12.9\" (2nd gen)", 2048, 2732}, } @@ -447,19 +535,19 @@ func checkTerritoryAvailability(ctx context.Context, client *asc.Client, appID s if len(territories) == 0 { *findings = append(*findings, Finding{ - Tier: TierMetadata, - Severity: SeverityBlock, - Title: "App not available in any territory", - Detail: "The app has no territory availability configured.", - Fix: "Set territory availability in App Store Connect → Pricing and Availability.", + Tier: TierMetadata, + Severity: SeverityBlock, + Title: "App not available in any territory", + Detail: "The app has no territory availability configured.", + Fix: "Set territory availability in App Store Connect → Pricing and Availability.", }) } else if len(territories) < 5 { *findings = append(*findings, Finding{ - Tier: TierMetadata, - Severity: SeverityInfo, - Title: fmt.Sprintf("App available in only %d territories", len(territories)), - Detail: "Your app is available in very few territories. Consider expanding for wider reach.", - Fix: "Review territory availability in App Store Connect → Pricing and Availability.", + Tier: TierMetadata, + Severity: SeverityInfo, + Title: fmt.Sprintf("App available in only %d territories", len(territories)), + Detail: "Your app is available in very few territories. Consider expanding for wider reach.", + Fix: "Review territory availability in App Store Connect → Pricing and Availability.", }) } @@ -477,11 +565,11 @@ func checkPricingConsistency(ctx context.Context, client *asc.Client, appID stri if len(prices) == 0 { *findings = append(*findings, Finding{ - Tier: TierMetadata, - Severity: SeverityWarn, - Title: "No price schedule configured", - Detail: "No pricing information found. Ensure your app's price (including Free) is explicitly set.", - Fix: "Set pricing in App Store Connect → Pricing and Availability.", + Tier: TierMetadata, + Severity: SeverityWarn, + Title: "No price schedule configured", + Detail: "No pricing information found. Ensure your app's price (including Free) is explicitly set.", + Fix: "Set pricing in App Store Connect → Pricing and Availability.", }) } diff --git a/internal/cli/publish.go b/internal/cli/publish.go new file mode 100644 index 0000000..3fb055b --- /dev/null +++ b/internal/cli/publish.go @@ -0,0 +1,147 @@ +package cli + +import ( + "fmt" + "os" + "os/exec" + "strings" + "time" + + ascapi "github.com/MaTriXy/StoreReady/internal/asc" + "github.com/MaTriXy/StoreReady/internal/checks" + "github.com/MaTriXy/StoreReady/internal/config" + "github.com/MaTriXy/StoreReady/internal/preflight" + "github.com/spf13/cobra" +) + +var ( + publishAppID string + publishVersion string + publishBuild string + publishMetadataDir string + publishPath string + publishIPA string + publishScanTier int + publishSkipLocal bool + publishSkipASCScan bool + publishConfirm bool +) + +var publishCmd = &cobra.Command{ + Use: "publish", + Short: "Run StoreReady gates, then publish with ASC CLI", + Long: `End-to-end lane for App Store release: + 1) Run local StoreReady preflight gates + 2) Run ASC metadata gates via StoreReady scan + 3) Execute 'asc release run' to continue submit flow + +By default this command runs ASC in dry-run mode. +Use --confirm to execute the real submission flow.`, + RunE: runPublish, +} + +func init() { + publishCmd.Flags().StringVar(&publishAppID, "app-id", "", "App Store Connect app ID (required)") + publishCmd.Flags().StringVar(&publishVersion, "version", "", "App Store version (required, e.g. 1.2.3)") + publishCmd.Flags().StringVar(&publishBuild, "build", "", "build ID for release attach/submit") + publishCmd.Flags().StringVar(&publishMetadataDir, "metadata-dir", "", "path to ASC metadata version directory") + publishCmd.Flags().StringVar(&publishPath, "path", ".", "project path for local preflight checks") + publishCmd.Flags().StringVar(&publishIPA, "ipa", "", "optional .ipa path for binary preflight checks") + publishCmd.Flags().IntVar(&publishScanTier, "scan-tier", 2, "max StoreReady ASC scan tier to run (1-4)") + publishCmd.Flags().BoolVar(&publishSkipLocal, "skip-local-checks", false, "skip local StoreReady preflight checks") + publishCmd.Flags().BoolVar(&publishSkipASCScan, "skip-asc-scan", false, "skip StoreReady ASC API checks before asc publish") + publishCmd.Flags().BoolVar(&publishConfirm, "confirm", false, "run asc release for real (default is dry-run)") + _ = publishCmd.MarkFlagRequired("app-id") + _ = publishCmd.MarkFlagRequired("version") + + rootCmd.AddCommand(publishCmd) +} + +func runPublish(cmd *cobra.Command, args []string) error { + if publishScanTier < 1 || publishScanTier > 4 { + return fmt.Errorf("--scan-tier must be between 1 and 4") + } + + if _, err := exec.LookPath("asc"); err != nil { + return fmt.Errorf("asc CLI not found in PATH. Install via: brew install asc") + } + + purple.Println("\n storeready publish — gate + release lane") + fmt.Printf(" App ID: %s\n", publishAppID) + fmt.Printf(" Version: %s\n", publishVersion) + if publishBuild != "" { + fmt.Printf(" Build: %s\n", publishBuild) + } + if publishConfirm { + fmt.Println(" Mode: submit") + } else { + fmt.Println(" Mode: dry-run") + } + fmt.Println() + + if !publishSkipLocal { + dim.Println(" Running local StoreReady preflight gates...") + start := time.Now() + localResult, err := preflight.Run(publishPath, publishIPA, verbose) + if err != nil { + return fmt.Errorf("local preflight failed: %w", err) + } + if localResult.Summary.Critical > 0 { + return fmt.Errorf("local preflight blocked release: %d critical finding(s). Fix them first or use --skip-local-checks", localResult.Summary.Critical) + } + dim.Printf(" ✓ Local preflight passed (%d findings, %s)\n", localResult.Summary.Total, time.Since(start).Round(time.Millisecond)) + } + + if !publishSkipASCScan { + dim.Println(" Running StoreReady ASC metadata gates...") + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("ASC API credentials missing for scan gate: %w\nrun 'storeready auth setup' or pass --skip-asc-scan", err) + } + + client, err := ascapi.NewClient(cfg.KeyID, cfg.IssuerID, cfg.PrivateKeyPath) + if err != nil { + return fmt.Errorf("failed to create ASC API client: %w", err) + } + + start := time.Now() + runner := checks.NewRunner(client, verbose) + results, err := runner.Run(cmd.Context(), publishAppID, publishBuild, publishScanTier) + if err != nil { + return fmt.Errorf("ASC metadata gate failed: %w", err) + } + if results.Summary.Blocks > 0 { + return fmt.Errorf("ASC scan blocked release: %d blocking finding(s). Fix them first or use --skip-asc-scan", results.Summary.Blocks) + } + dim.Printf(" ✓ ASC scan passed (%d findings, %s)\n", results.Summary.Total, time.Since(start).Round(time.Millisecond)) + } + + ascArgs := []string{"release", "run", "--app", publishAppID, "--version", publishVersion} + if publishBuild != "" { + ascArgs = append(ascArgs, "--build", publishBuild) + } + if publishMetadataDir != "" { + ascArgs = append(ascArgs, "--metadata-dir", publishMetadataDir) + } + if publishConfirm { + ascArgs = append(ascArgs, "--confirm") + } else { + ascArgs = append(ascArgs, "--dry-run") + } + + dim.Printf(" Executing: asc %s\n\n", strings.Join(ascArgs, " ")) + + ascCmd := exec.CommandContext(cmd.Context(), "asc", ascArgs...) + ascCmd.Stdout = os.Stdout + ascCmd.Stderr = os.Stderr + ascCmd.Stdin = os.Stdin + + if err := ascCmd.Run(); err != nil { + return fmt.Errorf("asc release failed: %w", err) + } + + fmt.Println() + purple.Println(" ✓ publish lane completed") + fmt.Println() + return nil +} diff --git a/internal/cli/release_checklist.go b/internal/cli/release_checklist.go new file mode 100644 index 0000000..e74d723 --- /dev/null +++ b/internal/cli/release_checklist.go @@ -0,0 +1,334 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "time" + + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +type releaseChecklistItem struct { + ID string `json:"id"` + Area string `json:"area"` + Title string `json:"title"` + Verification string `json:"verification"` // MANUAL | HYBRID + Why string `json:"why"` + Verify string `json:"verify"` + Source string `json:"source,omitempty"` +} + +type releaseChecklistSummary struct { + Total int `json:"total"` + Manual int `json:"manual"` + Hybrid int `json:"hybrid"` +} + +type releaseChecklistOutput struct { + GeneratedAt string `json:"generated_at"` + AppType string `json:"app_type"` + Items []releaseChecklistItem `json:"items"` + Summary releaseChecklistSummary `json:"summary"` +} + +var ( + releaseChecklistFormat string + releaseChecklistOutputPath string + releaseChecklistAppType string +) + +var releaseChecklistCmd = &cobra.Command{ + Use: "release-checklist", + Short: "Structured manual gate list before App Store submit", + Long: `Print a structured release checklist for items that are not fully automatable. +Run this before 'storeready publish --confirm' to avoid common App Review misses.`, + RunE: runReleaseChecklist, +} + +func init() { + releaseChecklistCmd.Flags().StringVar(&releaseChecklistFormat, "format", "terminal", "output format: terminal, json") + releaseChecklistCmd.Flags().StringVar(&releaseChecklistOutputPath, "output", "", "write checklist to file (stdout if omitted)") + releaseChecklistCmd.Flags().StringVar(&releaseChecklistAppType, "app-type", "all", "app profile: all, subscription, social, kids, health, games, macos, ai, crypto, vpn") + rootCmd.AddCommand(releaseChecklistCmd) +} + +func runReleaseChecklist(cmd *cobra.Command, args []string) error { + appType := strings.ToLower(strings.TrimSpace(releaseChecklistAppType)) + if appType == "" { + appType = "all" + } + + validAppTypes := map[string]bool{ + "all": true, "subscription": true, "social": true, "kids": true, "health": true, + "games": true, "macos": true, "ai": true, "crypto": true, "vpn": true, + } + if !validAppTypes[appType] { + return fmt.Errorf("invalid --app-type '%s' (use: all, subscription, social, kids, health, games, macos, ai, crypto, vpn)", appType) + } + + items := append([]releaseChecklistItem{}, baseReleaseChecklistItems()...) + items = append(items, appTypeSpecificChecklistItems(appType)...) + + out := releaseChecklistOutput{ + GeneratedAt: time.Now().Format(time.RFC3339), + AppType: appType, + Items: items, + Summary: buildReleaseChecklistSummary(items), + } + + var output *os.File + var err error + if releaseChecklistOutputPath != "" { + output, err = os.Create(releaseChecklistOutputPath) + if err != nil { + return fmt.Errorf("failed to create output file: %w", err) + } + defer output.Close() + } else { + output = os.Stdout + } + + switch strings.ToLower(releaseChecklistFormat) { + case "json": + enc := json.NewEncoder(output) + enc.SetIndent("", " ") + return enc.Encode(out) + default: + return writeReleaseChecklistTerminal(output, out) + } +} + +func buildReleaseChecklistSummary(items []releaseChecklistItem) releaseChecklistSummary { + s := releaseChecklistSummary{Total: len(items)} + for _, item := range items { + switch item.Verification { + case "MANUAL": + s.Manual++ + case "HYBRID": + s.Hybrid++ + } + } + return s +} + +func writeReleaseChecklistTerminal(w *os.File, out releaseChecklistOutput) error { + bold := color.New(color.Bold) + yellow := color.New(color.FgYellow) + blue := color.New(color.FgHiBlue) + + purple.Println("\n storeready release-checklist — manual gate review") + fmt.Fprintf(w, " App Type: %s\n", out.AppType) + fmt.Fprintf(w, " Items: %d (%d manual, %d hybrid)\n\n", out.Summary.Total, out.Summary.Manual, out.Summary.Hybrid) + + for _, item := range out.Items { + switch item.Verification { + case "HYBRID": + blue.Fprintf(w, " [HYBRID] ") + default: + yellow.Fprintf(w, " [MANUAL] ") + } + bold.Fprintf(w, "%s ", item.ID) + fmt.Fprintf(w, "%s\n", item.Title) + dim.Fprintf(w, " Area: %s\n", item.Area) + fmt.Fprintf(w, " Why: %s\n", item.Why) + fmt.Fprintf(w, " Verify: %s\n", item.Verify) + if item.Source != "" { + dim.Fprintf(w, " Source: %s\n", item.Source) + } + fmt.Fprintln(w) + } + + dim.Println(" Run after this:") + fmt.Fprintln(w, " storeready publish --app-id --version --build --confirm") + fmt.Fprintln(w) + return nil +} + +func baseReleaseChecklistItems() []releaseChecklistItem { + return []releaseChecklistItem{ + { + ID: "RC-001", Area: "Submission State", Title: "Version state is submittable", + Verification: "HYBRID", + Why: "Review submission fails when version/build state is invalid.", + Verify: "In ASC, confirm version is PREPARE_FOR_SUBMISSION (or equivalent) and build is VALID.", + Source: "Apple ASC submission workflow", + }, + { + ID: "RC-002", Area: "Export Compliance", Title: "Encryption declaration is complete", + Verification: "MANUAL", + Why: "Missing export-compliance answers blocks review.", + Verify: "Confirm ITSAppUsesNonExemptEncryption and export compliance declaration match app behavior.", + Source: "Guideline 5 / ASC export compliance", + }, + { + ID: "RC-003", Area: "Content Rights", Title: "Content rights declaration is set", + Verification: "MANUAL", + Why: "Required before submit; missing declaration blocks processing.", + Verify: "Set DOES_NOT_USE_THIRD_PARTY_CONTENT or USES_THIRD_PARTY_CONTENT in ASC.", + Source: "ASC app information requirements", + }, + { + ID: "RC-004", Area: "Privacy", Title: "Privacy policy URL is valid for every locale", + Verification: "HYBRID", + Why: "Missing/invalid privacy policy metadata commonly causes metadata rejection.", + Verify: "Check app-info localization URLs resolve publicly and match in-app privacy claims.", + Source: "Guideline 5.1.1", + }, + { + ID: "RC-005", Area: "Metadata", Title: "Store listing claims match in-app behavior", + Verification: "MANUAL", + Why: "Overpromising or misleading metadata causes 2.3 rejections.", + Verify: "Review description, subtitle, keywords, and screenshots for truthful feature representation.", + Source: "Guideline 2.3", + }, + { + ID: "RC-006", Area: "Screenshots", Title: "All required screenshot sets are uploaded per locale", + Verification: "HYBRID", + Why: "Missing storefront/device screenshot sets can block submission.", + Verify: "Confirm required device families and localizations are complete in ASC media manager.", + Source: "ASC media requirements", + }, + { + ID: "RC-007", Area: "Review Notes", Title: "Review notes include account/test credentials", + Verification: "MANUAL", + Why: "Lack of reviewer access is a frequent avoidable rejection.", + Verify: "Provide test account, OTP bypass instructions, and any hardware/backend prerequisites.", + Source: "App Review operational best practice", + }, + { + ID: "RC-008", Area: "Network/Backend", Title: "Production backend endpoints are live", + Verification: "MANUAL", + Why: "Broken endpoints during review lead to 2.1 completeness rejection.", + Verify: "Smoke-test sign-in, core flow, paywall and support URLs from a clean device/session.", + Source: "Guideline 2.1", + }, + { + ID: "RC-009", Area: "Account Management", Title: "Account deletion flow is accessible (if account creation exists)", + Verification: "MANUAL", + Why: "Account apps must support deletion and communicate data deletion path.", + Verify: "Confirm in-app delete option, data handling behavior, and help documentation.", + Source: "Guideline 5.1.1", + }, + { + ID: "RC-010", Area: "Payments", Title: "Digital goods use IAP and restore flow exists", + Verification: "MANUAL", + Why: "Using external payments for digital goods is a common rejection trigger.", + Verify: "Validate purchase/restore flows and remove external checkout references for digital content.", + Source: "Guideline 3.1.1", + }, + { + ID: "RC-011", Area: "Login", Title: "Sign in with Apple parity requirement is satisfied", + Verification: "MANUAL", + Why: "If third-party social login exists, SIWA parity may be required.", + Verify: "Ensure SIWA is available where policy requires and does not request prohibited extra fields.", + Source: "Guideline 4.8", + }, + { + ID: "RC-012", Area: "Legal", Title: "Terms and policy links are present where required", + Verification: "MANUAL", + Why: "Subscription/product pages often fail review due to missing legal links.", + Verify: "Ensure ToS/EULA/Privacy links exist in listing and in-app purchase context.", + Source: "Guideline 3.1.2 / 5.1.1", + }, + } +} + +func appTypeSpecificChecklistItems(appType string) []releaseChecklistItem { + switch appType { + case "subscription": + return []releaseChecklistItem{ + { + ID: "RC-SUB-001", Area: "Subscriptions", Title: "Pricing copy is not misleading", + Verification: "MANUAL", + Why: "Misleading monthly-equivalent emphasis can trigger subscription metadata rejection.", + Verify: "Ensure billing period, trial terms, renewal terms, and effective price are clear and balanced.", + Source: "Guideline 3.1.2", + }, + } + case "social": + return []releaseChecklistItem{ + { + ID: "RC-SOC-001", Area: "UGC", Title: "User-generated content moderation controls are active", + Verification: "MANUAL", + Why: "UGC apps require reporting/blocking/moderation to reduce abuse risk.", + Verify: "Verify report/block flows, moderation escalation, and abuse contact path.", + Source: "Guideline 1.2", + }, + } + case "kids": + return []releaseChecklistItem{ + { + ID: "RC-KID-001", Area: "Kids", Title: "Kids category privacy/ad requirements are met", + Verification: "MANUAL", + Why: "Kids apps have stricter data collection and external-linking limits.", + Verify: "Confirm age-gating, no behavioral ads, and compliant third-party SDK usage.", + Source: "Guideline 1.3", + }, + } + case "health": + return []releaseChecklistItem{ + { + ID: "RC-HLT-001", Area: "Health", Title: "Medical/health claims are properly qualified", + Verification: "MANUAL", + Why: "Unsubstantiated medical claims are high-risk during review.", + Verify: "Validate disclaimers, evidence language, and escalation path for critical conditions.", + Source: "Guideline 1.4", + }, + } + case "games": + return []releaseChecklistItem{ + { + ID: "RC-GME-001", Area: "Games", Title: "Loot box and randomization disclosures are present", + Verification: "MANUAL", + Why: "Missing random-item odds disclosure may fail policy expectations.", + Verify: "Expose odds/conditions where required by platform and regional policy.", + Source: "Guideline 3.1.1 and regional policy", + }, + } + case "macos": + return []releaseChecklistItem{ + { + ID: "RC-MAC-001", Area: "macOS", Title: "Temporary exception entitlements are justified or removed", + Verification: "MANUAL", + Why: "Unused/overbroad entitlements trigger rejection questions.", + Verify: "Audit entitlements and provide reviewer justification for each exception.", + Source: "Guideline 2.4.5(i)", + }, + } + case "ai": + return []releaseChecklistItem{ + { + ID: "RC-AI-001", Area: "AI", Title: "AI output safety and abuse handling are documented", + Verification: "MANUAL", + Why: "Generative output without safeguards raises review and trust issues.", + Verify: "Confirm moderation, safety boundaries, and abuse reporting mechanisms are visible.", + Source: "Guideline 1.1 / 1.2", + }, + } + case "crypto": + return []releaseChecklistItem{ + { + ID: "RC-CRY-001", Area: "Crypto/Finance", Title: "Regulated functionality and regions are compliant", + Verification: "MANUAL", + Why: "Finance/crypto features are subject to jurisdiction and licensing scrutiny.", + Verify: "Validate supported regions, disclosures, and licensing references for offered features.", + Source: "Guideline 3.1.5 and local law", + }, + } + case "vpn": + return []releaseChecklistItem{ + { + ID: "RC-VPN-001", Area: "VPN", Title: "VPN purpose and data handling are explicit", + Verification: "MANUAL", + Why: "Networking/VPN apps receive heightened privacy and utility review.", + Verify: "Ensure logging policy, data retention, and core user benefit are clearly disclosed.", + Source: "Guideline 5.1.1 / Network Extension expectations", + }, + } + default: + return nil + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 58d7636..fcc38c2 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -29,6 +29,9 @@ Get started: storeready play-guidelines list Browse Google Play policy matrix storeready preflight . --ipa X Apple preflight with IPA binary analysis storeready scan --app-id ID Apple App Store Connect metadata (needs API key) + storeready release-checklist Manual ASC/App Review gate checklist + storeready publish --app-id ID --version X.Y.Z + End-to-end gate + ASC release lane storeready guidelines search Browse Apple's review guidelines`, purple.Sprint("storeready — know before you submit.")), }