◉ANTIGRAVITY LABJP
Articles/App Development
▣ App Development/2026-07-03Advanced

When CI Passes but App Review Rejects Your Screenshots — Field Notes on Measuring the Freshness of Your Validation Rules

Store asset validation can pass in CI and still get rejected in review, because the rules themselves go stale. Move store specs into a freshness-dated contract file, then add locale overflow checks and perceptual diffs.

CI/CD17GitHub Actions5store assetsApp Store7Google Play6Antigravity377quality validationscreenshots

✦ Premium Article

The night before submission, every store asset check in CI was green. The next morning, App Store Connect returned a metadata rejection caused by screenshots. I read the logs three times. The validation script kept insisting that every file had passed.

The generation pipeline wasn't the problem. The validation rules themselves had gone stale.

Screenshot size requirements change quietly a few times a year. Yet most validation scripts bake the store spec of the day they were written into the code as constants like 1170x2532. From that moment on, a green build no longer means "this meets the store's requirements." It means "this meets the requirements as they existed when someone wrote this script." The gap between the two is invisible until you actually submit.

As an indie developer shipping apps in multiple locales for years, I hit exactly this rejection right before a release deadline. I suspected the generation pipeline immediately — it never occurred to me to suspect the validator. Since then I structure store asset validation in two tiers: one layer that validates the assets, and one layer that validates the freshness of the validation rules themselves.

This article walks through that two-tier setup on GitHub Actions, with code you can run.

Why a Green Build Stops Being Trustworthy

It helps to see why this failure mode is so hard to catch.

LayerWho changes itHow oftenDetectable by plain CI?
Asset generation (capture and processing)Your own codePer commitYes — classic validation
Validation rules (sizes, formats, count limits)Apple / GoogleA few times a year, unannouncedNo, not as-is
Per-locale captionsTranslation updatesIrregularNo, not without rendering

The first layer is what conventional CI protects. The second and third change outside your repository, which fundamentally clashes with commit-triggered CI: when the outside world moves, no build runs — and when a build does run, it passes against outdated rules.

Closing the gap takes three small tools rather than a big platform:

  1. Pull store specs out of the code into a contract file that carries freshness metadata
  2. Have CI check that freshness on every run, and fail once the contract is past its shelf life
  3. Compare rendered per-locale output against golden images with a perceptual diff

Move Store Specs into a Freshness-Dated Contract File

Evict the rules from code constants into JSON, and record when and against what they were last verified.

{
  "specVersion": "2026-06-20",
  "verifiedAt": "2026-06-20",
  "verifiedAgainst": "App Store Connect Help, screenshot specifications page",
  "staleAfterDays": 45,
  "ios": {
    "screenshots": {
      "requiredSizes": [
        { "label": "6.9inch", "width": 1320, "height": 2868 },
        { "label": "6.5inch", "width": 1284, "height": 2778 }
      ],
      "maxCount": 10,
      "maxBytes": 5242880,
      "formats": ["png", "jpg"]
    }
  },
  "android": {
    "screenshots": {
      "minWidth": 1080,
      "minHeight": 1920,
      "maxCount": 8,
      "maxBytes": 8388608,
      "formats": ["png", "jpg"]
    }
  }
}

The key field is staleAfterDays. A machine cannot judge whether the spec contents are still correct — but it can absolutely judge how many days have passed since a human last reconciled them against the official page. So the validator checks the contract before it checks a single image.

// scripts/check-spec-freshness.js
const spec = require('../store-spec.json');
 
const verifiedAt = new Date(spec.verifiedAt);
const ageDays = Math.floor((Date.now() - verifiedAt.getTime()) / 86400000);
 
if (ageDays > spec.staleAfterDays) {
  console.error(
    `❌ store-spec.json was last verified ${ageDays} days ago (limit: ${spec.staleAfterDays}). ` +
    `Reconcile it against the official spec pages and update verifiedAt.`
  );
  process.exit(1);
}
console.log(`✅ Spec freshness OK (${ageDays} days since verification)`);

Why fail the build instead of warning? Because warnings get read for about a week, and then they become scenery. I use a 45-day limit: Apple's size requirement changes tend to land roughly on a quarterly cadence, so I set the shelf life slightly shorter than a quarter. The renewal itself is a ten-minute job — open the official pages, reconcile, bump verifiedAt. Ten minutes on a schedule is a cheap trade for structurally eliminating deadline-night rejections.

One caution: the size numbers in this article are examples. The entire point of a contract file is that you build the first version by reconciling against the official specs yourself, not by copying numbers from a blog post — including this one.

✦

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
✦A CI design that treats store specs as a freshness-dated contract file instead of hardcoded constants, with working validation code
✦How to wire three defense layers into GitHub Actions — locale text overflow, perceptual diffs against golden images, and spec staleness
✦A manifest that binds each asset set to a build number, so a rejection can be traced to a single commit in minutes
Secure payment via Stripe · Cancel anytime
✦

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $15 for lifetime access
View Membership →

Related Articles

▣ App Dev2026-09-29
Purging Cloudflare Didn't Bring Back the New Wallpapers: the Stale JPEGs Were Held by XPageSpeed on the Origin
An operations record from a wallpaper app: images overwritten at the same URL kept arriving stale even after a Cloudflare purge. Covers the three-way local/origin/edge probe that names the guilty layer, when prefix purge beats per-URL purge, and how to switch off Xserver's XPageSpeed in .htaccess without a 500.
▣ App Dev2026-09-19
I Asked for the Whole Quote PDF Tool at Once, Then Split It Into Four Requests
How I moved my client quote paperwork from copy-and-edit to a small PDF tool built with Antigravity, why the first single-shot request had to be thrown away, and the four smaller requests that replaced it, with working code.
▣ App Dev2026-09-03
A dependency without musllinux wheels doesn't fail on Alpine. It just gets slower
On Alpine, a dependency without musllinux wheels can quietly fall back to pure Python. Protobuf went from 0.043ms to 36.2ms, and pip reported no error.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links