提出前夜、CI のストアアセット検証は全て緑でした。それなのに翌朝、App Store Connect から返ってきたのはスクリーンショット起因のメタデータ差し戻し。ログを何度見返しても、検証スクリプトは「全ファイル合格」と言い続けています。
原因はアセットの生成側ではありませんでした。検証ルールそのものが古くなっていたのです。
スクリーンショットの必須サイズ要件は年に何度か静かに変わります。ところが多くの検証スクリプトは、書いた日のストア仕様を 1170x2532 のような定数としてコードに焼き込んでいます。この瞬間から、CI の緑は「ストアの要件を満たしている」ではなく「書いた当時の要件を満たしている」という意味に変質します。両者のずれは、審査に出すまで誰にも見えません。
私自身、個人開発のアプリを日英含む複数ロケールで長く運用してきて、この差し戻しをリリース締め切りの直前に踏んだことがあります。生成パイプラインは疑ったのに、検証スクリプトを疑うという発想がなかった。以来、ストアアセットの検証は「アセットを検証する層」と「検証ルール自体の鮮度を検証する層」の二段構えで組むようになりました。
ここからは、その二段構えを GitHub Actions に載せる実装を、動くコードと運用上の判断基準ごと整理していきます。
CI の緑が信用できなくなる構造
最初に、なぜこの問題が検出しづらいのかを整理しておきます。
| 層 | 変化の主体 | 変化の頻度 | CIで検出できるか |
|---|
| アセット生成(スクリーンショット撮影・加工) | 自分のコード | コミット単位 | できる(従来の検証) |
| 検証ルール(サイズ・形式・枚数の上限) | Apple / Google | 年数回・不定期 | そのままではできない |
| ロケール別キャプション | 翻訳の追加・修正 | 不定期 | レンダリングまで見ないとできない |
一段目は普通の CI で守れます。問題は二段目と三段目で、どちらも「自分のリポジトリの外」で変化が起きるため、コミットをトリガーとする CI の設計思想と根本的に噛み合いません。変化が起きてもビルドは走らず、走っても古いルールで合格してしまいます。
ここを塞ぐ道具立ては大掛かりなものではなく、次の三つで足ります。
- ストア仕様をコードから分離した「契約ファイル」にし、鮮度メタデータを持たせる
- 契約ファイルの鮮度を CI が毎回チェックし、賞味期限切れなら警告で落とす
- ロケール別のレンダリング結果を知覚差分でゴールデン画像と照合する
ストア仕様を「鮮度つき契約ファイル」に分離する
検証ルールをコードの定数から JSON に追い出し、いつ・どこを見て確認したかを一緒に記録します。
{
"specVersion": "2026-06-20",
"verifiedAt": "2026-06-20",
"verifiedAgainst": "App Store Connect ヘルプのスクリーンショット仕様ページ",
"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"]
}
}
}
ポイントは staleAfterDays です。仕様の中身が正しいかどうかを機械は判定できませんが、「最後に人間が公式ページと突き合わせてから何日経ったか」なら機械で判定できます。検証スクリプトの冒頭で、まず契約ファイル自体を検証します。
// 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 は ${ageDays} 日前の確認が最後です(許容 ${spec.staleAfterDays} 日)。` +
`公式仕様ページと突き合わせて verifiedAt を更新してください。`
);
process.exit(1);
}
console.log(`✅ 仕様の鮮度 OK(確認から ${ageDays} 日)`);
なぜ失敗扱いにするのか。警告どまりにすると、通知は最初の一週間だけ読まれて、その後は景色になるからです。私の運用では 45 日を期限にしていますが、これは Apple のサイズ要件変更がおおむね四半期単位で来ることを踏まえ、四半期より一回り短くした値です。更新作業自体は公式ページを開いて突き合わせ、verifiedAt を書き換えるだけなので 10 分かかりません。10 分の定期作業で、締め切り前夜の差し戻しを構造的に消せるなら安い買い物だと考えています。
なお、ここで挙げているサイズの数値はあくまで例です。契約ファイルという仕組みの性質上、記事の数値をコピーするのではなく、必ずご自身で公式仕様と突き合わせて初版を作ってください。それがこの仕組みの出発点になります。
ロケール別のテキストあふれをレンダリング後に検出する
二つ目の盲点がキャプションのあふれです。日本語で収まっていた文言が、ドイツ語で 1.4 倍に伸びてセーフエリアからはみ出す。生成は成功し、サイズ検証も通り、審査も通ってしまうことすらあります。壊れた見た目のままストアに並ぶので、ある意味では差し戻しより性質が悪い失敗です。
キャプション合成を自前でやっているなら、合成時に文字列の描画幅を実測して防げます。
// scripts/render-captions.js(抜粋)
const { createCanvas, registerFont } = require('canvas');
registerFont('./fonts/NotoSansJP-Bold.otf', { family: 'Caption' });
function measureCaption(text, fontSizePx, maxWidthPx) {
const canvas = createCanvas(10, 10);
const ctx = canvas.getContext('2d');
ctx.font = `${fontSizePx}px Caption`;
const width = ctx.measureText(text).width;
return { width, overflow: width > maxWidthPx, ratio: width / maxWidthPx };
}
const captions = require('../captions.json'); // { locale: { screen01: "文言", ... } }
const SAFE_WIDTH = 1320 - 120 * 2; // 6.9inch幅からマージンを引いたセーフ幅
let failed = false;
for (const [locale, screens] of Object.entries(captions)) {
for (const [screen, text] of Object.entries(screens)) {
const m = measureCaption(text, 64, SAFE_WIDTH);
if (m.overflow) {
console.error(`❌ ${locale}/${screen}: あふれ率 ${(m.ratio * 100).toFixed(0)}% — "${text}"`);
failed = true;
}
}
}
process.exit(failed ? 1 : 0);
実測に基づく閾値がひとつ効きます。私の場合、日本語を基準に組んだレイアウトに対して、英語のキャプションは平均でおよそ 1.2 倍、長い文言では 1.5 倍近くまで伸びました。そこでセーフ幅は「日本語の想定最大幅 × 1.5」を目安に確保し、それでも収まらない文言は翻訳側を短くする運用にしています。フォントを縮めて詰め込む選択肢もありますが、スクリーンショット内の文字サイズがロケールごとにばらつくと売り場の印象が崩れるため、私は文言を削る方を選んでいます。
ゴールデン画像との知覚差分で「静かな劣化」を捕まえる
三つ目の層が知覚差分です。寸法もファイルサイズも正しいのに、中身が壊れているケースを捕まえます。実際に遭遇したのは、CI ランナーの環境更新で CJK フォントの解決が変わり、キャプションが別フォントで描画されていたという事故でした。サイズ検証は当然通ります。人間が見れば一瞬で気づくのに、機械は何も言いません。
承認済みのアセットをゴールデンとしてリポジトリに置き、生成のたびにピクセル差分を取ります。
// scripts/perceptual-diff.js
const fs = require('fs');
const { PNG } = require('pngjs');
const pixelmatch = require('pixelmatch');
function diffRatio(goldenPath, candidatePath) {
const golden = PNG.sync.read(fs.readFileSync(goldenPath));
const cand = PNG.sync.read(fs.readFileSync(candidatePath));
if (golden.width !== cand.width || golden.height !== cand.height) return 1;
const diff = new PNG({ width: golden.width, height: golden.height });
const diffPixels = pixelmatch(
golden.data, cand.data, diff.data,
golden.width, golden.height,
{ threshold: 0.12 } // アンチエイリアスの揺れは無視する
);
return diffPixels / (golden.width * golden.height);
}
const RATIO_LIMIT = 0.02; // 2%を超える画素差は「意図しない変化」とみなす
閾値 2% は経験値です。JPEG 再圧縮やアンチエイリアスの揺れは 0.5% 前後に収まる一方、フォント差し替えやレイアウト崩れは 5% を超えて出ます。その間に線を引いた形です。意図してデザインを変えたときは、差分検証の失敗を確認したうえでゴールデンを更新する。この「ゴールデン更新をレビューで通す」手順が、そのまま変更履歴になります。
アセットセットをビルド番号に束ねる
最後に、差し戻しが起きたときの調査時間を縮める仕掛けです。生成のたびにマニフェストを吐き、どのコミット・どの契約ファイル・どのビルド番号の組で作られたアセットかを一枚に束ねます。
// scripts/write-asset-manifest.js
const manifest = {
buildNumber: process.env.BUILD_NUMBER,
commit: process.env.GITHUB_SHA,
specVersion: require('../store-spec.json').specVersion,
generatedAt: new Date().toISOString(),
files: collectSha256('./screenshots/optimized') // ファイル毎のハッシュ一覧
};
fs.writeFileSync('./screenshots/optimized/manifest.json', JSON.stringify(manifest, null, 2));
差し戻しのメールには「どのスクリーンショットが問題か」までしか書かれていないことが多く、原因の特定は手元の記録にかかっています。マニフェストがあれば、審査に出したアセットの manifest.json を開き、specVersion が古ければ契約ファイルの陳腐化、コミットが直近ならレイアウト変更起因、と切り分けが一直線になります。以前は差し戻しのたびに生成ログを遡って 1 時間近く溶かしていた調査が、いまは数分で終わります。
GitHub Actions への載せ方は素直で、鮮度チェック → 生成 → キャプション実測 → 寸法検証 → 知覚差分 → マニフェスト、の順に直列で並べるだけです。生成より前に鮮度チェックを置くのが唯一のこだわりで、ルールが腐っている状態で生成を走らせても、その後の全工程が無意味になるためです。
次にやること
この仕組みを一度に全部入れる必要はありません。効果がいちばん大きいのは契約ファイルの分離と鮮度ゲートで、既存の検証スクリプトがあるなら定数を JSON に追い出して check-spec-freshness.js を先頭に足すだけです。まずそこから始めて、次のリリースで差し戻しゼロを確認してから、テキスト実測と知覚差分を足していくのが現実的な順序だと思います。
同じように締め切り前夜の差し戻しに苦い記憶のある方の参考になれば幸いです。