Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ID> # App Store Connect checks (needs auth)
storeready release-checklist --app-type all
storeready publish --app-id <ID> --version <X.Y.Z> [--build <BUILD_ID>] [--confirm]
storeready guidelines search "privacy" # Search Apple guidelines
```
2 changes: 2 additions & 0 deletions codex-skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,7 @@ storeready codescan .
storeready privacy .
storeready ipa /path/to/build.ipa
storeready scan --app-id <ID>
storeready release-checklist --app-type all
storeready publish --app-id <ID> --version <X.Y.Z> [--build <BUILD_ID>] [--confirm]
storeready guidelines search "privacy"
```
83 changes: 52 additions & 31 deletions internal/asc/apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}

Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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"`
}

Expand All @@ -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.
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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"`
}

Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions internal/checks/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
124 changes: 106 additions & 18 deletions internal/checks/tier1_metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
}
Expand Down Expand Up @@ -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.",
})
}

Expand All @@ -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.",
})
}

Expand Down
Loading
Loading