Compare commits
17 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 73e4614b34 | |||
| 108b362cd9 | |||
| 5cc3da486d | |||
| 7e362ef962 | |||
| c1bed9440e | |||
| 793c322f99 | |||
| ab97d6d72f | |||
| c5173e4cae | |||
| f637c5e4e7 | |||
| 3a22d7f455 | |||
| 96458bba5c | |||
| b361e22631 | |||
| b18d480d65 | |||
| f6f9fc3c41 | |||
| 8573c9f6ae | |||
| 1ce4f026e9 | |||
| 73a962fbdc |
30 changed files with 348 additions and 119 deletions
|
|
@ -9,7 +9,7 @@
|
||||||
"name": "storebound",
|
"name": "storebound",
|
||||||
"source": ".",
|
"source": ".",
|
||||||
"description": "Take a mobile app or game from nothing to Submit for review on both stores: skills for every store step and questionnaire plus the storebound CLI and MCP server.",
|
"description": "Take a mobile app or game from nothing to Submit for review on both stores: skills for every store step and questionnaire plus the storebound CLI and MCP server.",
|
||||||
"version": "0.8.71",
|
"version": "0.8.77",
|
||||||
"category": "development",
|
"category": "development",
|
||||||
"tags": [
|
"tags": [
|
||||||
"mobile",
|
"mobile",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "storebound",
|
"name": "storebound",
|
||||||
"version": "0.8.71",
|
"version": "0.8.77",
|
||||||
"description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.",
|
"description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "CRTkafa"
|
"name": "CRTkafa"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "storebound",
|
"name": "storebound",
|
||||||
"version": "0.8.71",
|
"version": "0.8.77",
|
||||||
"description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.",
|
"description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "CRTkafa"
|
"name": "CRTkafa"
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ runs:
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.bun/install/cache
|
~/.bun/install/cache
|
||||||
|
~/.cache/tauri
|
||||||
~/.cargo/registry/index
|
~/.cargo/registry/index
|
||||||
~/.cargo/registry/cache
|
~/.cargo/registry/cache
|
||||||
~/.cargo/git/db
|
~/.cargo/git/db
|
||||||
|
|
@ -35,7 +36,7 @@ runs:
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
P="${FORGEJO_PATH:-$GITHUB_PATH}"
|
P="${FORGEJO_PATH:-$GITHUB_PATH}"
|
||||||
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null
|
bash tools/ci-install-bun.sh
|
||||||
echo "$HOME/.bun/bin" >> "$P"
|
echo "$HOME/.bun/bin" >> "$P"
|
||||||
command -v rustup || curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
|
command -v rustup || curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||||
echo "$HOME/.cargo/bin" >> "$P"
|
echo "$HOME/.cargo/bin" >> "$P"
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ jobs:
|
||||||
if: ${{ matrix.platform == 'Linux' }}
|
if: ${{ matrix.platform == 'Linux' }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null
|
bash tools/ci-install-bun.sh
|
||||||
echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}"
|
echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}"
|
||||||
- name: Acceptance matrix (Linux)
|
- name: Acceptance matrix (Linux)
|
||||||
if: ${{ matrix.platform == 'Linux' }}
|
if: ${{ matrix.platform == 'Linux' }}
|
||||||
|
|
@ -97,7 +97,7 @@ jobs:
|
||||||
path: acceptance-runs/Windows-bun
|
path: acceptance-runs/Windows-bun
|
||||||
- name: Matrix (markdown), red cells fail the job
|
- name: Matrix (markdown), red cells fail the job
|
||||||
run: |
|
run: |
|
||||||
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null
|
bash tools/ci-install-bun.sh
|
||||||
export PATH="$HOME/.bun/bin:$PATH"
|
export PATH="$HOME/.bun/bin:$PATH"
|
||||||
V=$(bun tools/desktop-version.ts --print)
|
V=$(bun tools/desktop-version.ts --print)
|
||||||
mkdir -p out
|
mkdir -p out
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ jobs:
|
||||||
if: ${{ matrix.platform == 'Linux' }}
|
if: ${{ matrix.platform == 'Linux' }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null
|
bash tools/ci-install-bun.sh
|
||||||
echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}"
|
echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}"
|
||||||
- name: Acceptance matrix (Linux)
|
- name: Acceptance matrix (Linux)
|
||||||
if: ${{ matrix.platform == 'Linux' }}
|
if: ${{ matrix.platform == 'Linux' }}
|
||||||
|
|
@ -173,7 +173,9 @@ jobs:
|
||||||
working-directory: apps/desktop
|
working-directory: apps/desktop
|
||||||
run: |
|
run: |
|
||||||
rm -rf "$CARGO_TARGET_DIR/release/bundle"
|
rm -rf "$CARGO_TARGET_DIR/release/bundle"
|
||||||
bun ../../tools/desktop-release.ts exec -- bunx tauri build --bundles appimage,deb --ci
|
# the AppImage tools come from GitHub releases, which answer 504 now and then (cached in ~/.cache/tauri)
|
||||||
|
bash ../../tools/ci-tauri-tools.sh
|
||||||
|
for i in 1 2 3; do bun ../../tools/desktop-release.ts exec -- bunx tauri build --bundles appimage,deb --ci && break; [ $i = 3 ] && exit 1; sleep 30; done
|
||||||
- name: Self-test the exact artifacts
|
- name: Self-test the exact artifacts
|
||||||
run: |
|
run: |
|
||||||
V=$(bun tools/desktop-version.ts --print)
|
V=$(bun tools/desktop-version.ts --print)
|
||||||
|
|
@ -237,7 +239,8 @@ jobs:
|
||||||
bun tools/build-sidecars.ts --target bun-linux-x64
|
bun tools/build-sidecars.ts --target bun-linux-x64
|
||||||
patchelf --set-rpath '$ORIGIN/../lib' apps/desktop/src-tauri/binaries/storebound-x86_64-unknown-linux-gnu
|
patchelf --set-rpath '$ORIGIN/../lib' apps/desktop/src-tauri/binaries/storebound-x86_64-unknown-linux-gnu
|
||||||
B="$CARGO_TARGET_DIR/release/bundle"; rm -rf "$B"
|
B="$CARGO_TARGET_DIR/release/bundle"; rm -rf "$B"
|
||||||
(cd apps/desktop && bun ../../tools/desktop-release.ts exec -- bunx tauri build --bundles appimage,deb --ci)
|
bash tools/ci-tauri-tools.sh
|
||||||
|
for i in 1 2 3; do (cd apps/desktop && bun ../../tools/desktop-release.ts exec -- bunx tauri build --bundles appimage,deb --ci) && break; [ $i = 3 ] && exit 1; sleep 30; done
|
||||||
mkdir -p dev && cp "$B/appimage/storebound_${V}_amd64.AppImage" "$B/deb/storebound_${V}_amd64.deb" dev/
|
mkdir -p dev && cp "$B/appimage/storebound_${V}_amd64.AppImage" "$B/deb/storebound_${V}_amd64.deb" dev/
|
||||||
chmod +x dev/*.AppImage
|
chmod +x dev/*.AppImage
|
||||||
(cd /tmp && rm -rf squashfs-root && "$GITHUB_WORKSPACE/dev/storebound_${V}_amd64.AppImage" --appimage-extract >/dev/null && squashfs-root/usr/bin/storebound-desktop --self-test)
|
(cd /tmp && rm -rf squashfs-root && "$GITHUB_WORKSPACE/dev/storebound_${V}_amd64.AppImage" --appimage-extract >/dev/null && squashfs-root/usr/bin/storebound-desktop --self-test)
|
||||||
|
|
@ -371,7 +374,7 @@ jobs:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Tools
|
- name: Tools
|
||||||
run: |
|
run: |
|
||||||
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null
|
bash tools/ci-install-bun.sh
|
||||||
echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}"
|
echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}"
|
||||||
- uses: actions/download-artifact@v3
|
- uses: actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
|
|
@ -406,7 +409,7 @@ jobs:
|
||||||
# no apt: the installers were scanned with the private terms in their own jobs; this job only records
|
# no apt: the installers were scanned with the private terms in their own jobs; this job only records
|
||||||
- name: Tools
|
- name: Tools
|
||||||
run: |
|
run: |
|
||||||
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null
|
bash tools/ci-install-bun.sh
|
||||||
echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}"
|
echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}"
|
||||||
[ -n "$FORGEJO_TOKEN" ] || { echo "secret RELEASE_TOKEN is missing (repo Settings > Actions > Secrets)" >&2; exit 1; }
|
[ -n "$FORGEJO_TOKEN" ] || { echo "secret RELEASE_TOKEN is missing (repo Settings > Actions > Secrets)" >&2; exit 1; }
|
||||||
- uses: actions/download-artifact@v3
|
- uses: actions/download-artifact@v3
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ jobs:
|
||||||
- name: Tag, package.json version, every version copy and the CHANGELOG agree
|
- name: Tag, package.json version, every version copy and the CHANGELOG agree
|
||||||
id: check
|
id: check
|
||||||
run: |
|
run: |
|
||||||
curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null
|
bash tools/ci-install-bun.sh
|
||||||
echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}"
|
echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}"
|
||||||
export PATH="$HOME/.bun/bin:$PATH"
|
export PATH="$HOME/.bun/bin:$PATH"
|
||||||
bun tools/desktop-version.ts
|
bun tools/desktop-version.ts
|
||||||
|
|
|
||||||
25
CHANGELOG.md
25
CHANGELOG.md
|
|
@ -3,6 +3,31 @@
|
||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
## 0.8.77 · 2026-11-08
|
||||||
|
|
||||||
|
- **iPhone Duo:** outer and inner files are no longer cut to the first 10: the limit comes from Apple's reference data, and over it nothing changes and nothing is dropped. `apple screenshots --duo-only` writes only the Duo set. `apple cpp` no longer lists creative-assets flags.
|
||||||
|
|
||||||
|
## 0.8.76 · 2026-11-08
|
||||||
|
|
||||||
|
- 0.8.75 was tagged but not published (GitHub 502/504 in the Linux AppImage bundling). CI now takes the AppImage tools from a checked mirror instead of GitHub.
|
||||||
|
|
||||||
|
## 0.8.75 · 2026-11-08
|
||||||
|
|
||||||
|
- 0.8.74 was tagged but not published (GitHub release downloads answered 504 during the Linux AppImage bundling); 0.8.75 ships its section.
|
||||||
|
|
||||||
|
## 0.8.74 · 2026-11-08
|
||||||
|
|
||||||
|
- 0.8.73 was tagged but not published (a GitHub 504 in the Linux AppImage bundling); 0.8.74 ships its section below. CI caches the AppImage tools and retries the Linux bundle.
|
||||||
|
|
||||||
|
## 0.8.73 · 2026-11-08
|
||||||
|
|
||||||
|
- **iPhone Duo:** the live API names the platform IPHONE_APP_STORE (not IOS); accepted. A Duo problem now warns and the other screenshot sets still upload.
|
||||||
|
|
||||||
|
## 0.8.72 · 2026-11-08
|
||||||
|
|
||||||
|
- **iPhone Duo screenshots upload.** `apple screenshots` now sends them through the App Asset Library (display class
|
||||||
|
IPHONE_DUO, placement APP_SCREENSHOT) instead of skipping them as "not in the API" (pitfall 130).
|
||||||
|
|
||||||
## 0.8.71 · 2026-11-07
|
## 0.8.71 · 2026-11-07
|
||||||
|
|
||||||
- 0.8.69 and 0.8.70 were tagged but not published (the desktop release check stopped them); 0.8.71 ships both sections below. Desktop Portfolio: the Play state of an app uses the same words as Releases ("Not live yet: in review or blocked before review").
|
- 0.8.69 and 0.8.70 were tagged but not published (the desktop release check stopped them); 0.8.71 ships both sections below. Desktop Portfolio: the Play state of an app uses the same words as Releases ("Not live yet: in review or blocked before review").
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@storebound/desktop",
|
"name": "@storebound/desktop",
|
||||||
"version": "0.8.71",
|
"version": "0.8.77",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
2
apps/desktop/src-tauri/Cargo.lock
generated
2
apps/desktop/src-tauri/Cargo.lock
generated
|
|
@ -4096,7 +4096,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "storebound-desktop"
|
name = "storebound-desktop"
|
||||||
version = "0.8.71"
|
version = "0.8.77"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "storebound-desktop"
|
name = "storebound-desktop"
|
||||||
version = "0.8.71"
|
version = "0.8.77"
|
||||||
description = "storebound desktop: every game's releases on both stores in one window"
|
description = "storebound desktop: every game's releases on both stores in one window"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "storebound",
|
"productName": "storebound",
|
||||||
"mainBinaryName": "storebound-desktop",
|
"mainBinaryName": "storebound-desktop",
|
||||||
"version": "0.8.71",
|
"version": "0.8.77",
|
||||||
"identifier": "dev.storebound.desktop",
|
"identifier": "dev.storebound.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,11 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
|
||||||
'analyticsReportRequests_createInstance': 'analytics',
|
'analyticsReportRequests_createInstance': 'analytics',
|
||||||
'analyticsReportRequests_reports_getToManyRelated': 'analytics',
|
'analyticsReportRequests_reports_getToManyRelated': 'analytics',
|
||||||
'analyticsReports_instances_getToManyRelated': 'analytics',
|
'analyticsReports_instances_getToManyRelated': 'analytics',
|
||||||
'appAssetLibraries_images_getToManyRelated': 'apple/creative-assets',
|
'appAssetLibraries_images_getToManyRelated': 'apple/creative-assets apple/duo-library',
|
||||||
'appAssetLibraries_videos_getToManyRelated': 'apple/creative-assets',
|
'appAssetLibraries_videos_getToManyRelated': 'apple/creative-assets',
|
||||||
'appAssetLibraryPlacements_createInstance': 'apple/creative-assets',
|
'appAssetLibraryPlacements_createInstance': 'apple/creative-assets apple/duo-library',
|
||||||
'appAssetLibraryPlacements_deleteInstance': 'apple/creative-assets',
|
'appAssetLibraryPlacements_deleteInstance': 'apple/creative-assets apple/duo-library',
|
||||||
|
'appAssetLibraryRefData_getCollection': 'apple/duo-library',
|
||||||
'appAvailabilitiesV2_createInstance': 'apple/appinfo',
|
'appAvailabilitiesV2_createInstance': 'apple/appinfo',
|
||||||
'appAvailabilitiesV2_territoryAvailabilities_getToManyRelated': 'apple/appinfo apple/preorder',
|
'appAvailabilitiesV2_territoryAvailabilities_getToManyRelated': 'apple/appinfo apple/preorder',
|
||||||
'appCustomProductPageLocalizations_appScreenshotSets_getToManyRelated': 'apple/cpp',
|
'appCustomProductPageLocalizations_appScreenshotSets_getToManyRelated': 'apple/cpp',
|
||||||
|
|
@ -64,7 +65,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
|
||||||
'appStoreVersionLocalizations_appPreviewSets_getToManyRelated': 'apple/previews art',
|
'appStoreVersionLocalizations_appPreviewSets_getToManyRelated': 'apple/previews art',
|
||||||
'appStoreVersionLocalizations_appScreenshotSets_getToManyRelated': 'apple/device-families apple/screenshot-health apple/screenshots art validate',
|
'appStoreVersionLocalizations_appScreenshotSets_getToManyRelated': 'apple/device-families apple/screenshot-health apple/screenshots art validate',
|
||||||
'appStoreVersionLocalizations_createInstance': 'listing-store',
|
'appStoreVersionLocalizations_createInstance': 'listing-store',
|
||||||
'appStoreVersionLocalizations_placements_getToManyRelated': 'apple/creative-assets',
|
'appStoreVersionLocalizations_placements_getToManyRelated': 'apple/creative-assets apple/duo-library',
|
||||||
'appStoreVersionLocalizations_updateInstance': 'listing-store',
|
'appStoreVersionLocalizations_updateInstance': 'listing-store',
|
||||||
'appStoreVersionPhasedReleases_createInstance': 'apple/release',
|
'appStoreVersionPhasedReleases_createInstance': 'apple/release',
|
||||||
'appStoreVersionPhasedReleases_updateInstance': 'apple/release',
|
'appStoreVersionPhasedReleases_updateInstance': 'apple/release',
|
||||||
|
|
@ -87,7 +88,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
|
||||||
'apps_appPriceSchedule_getToOneRelated': 'apple/appinfo validate',
|
'apps_appPriceSchedule_getToOneRelated': 'apple/appinfo validate',
|
||||||
'apps_appStoreVersionExperimentsV2_getToManyRelated': 'apple/ppo',
|
'apps_appStoreVersionExperimentsV2_getToManyRelated': 'apple/ppo',
|
||||||
'apps_appStoreVersions_getToManyRelated': 'apple/asc apple/creative-assets apple/iap-submit apple/platform apple/review-details apple/status art lib/store-apps listing-store onboard',
|
'apps_appStoreVersions_getToManyRelated': 'apple/asc apple/creative-assets apple/iap-submit apple/platform apple/review-details apple/status art lib/store-apps listing-store onboard',
|
||||||
'apps_assetLibrary_getToOneRelated': 'apple/creative-assets',
|
'apps_assetLibrary_getToOneRelated': 'apple/creative-assets apple/duo-library',
|
||||||
'apps_betaAppLocalizations_getToManyRelated': 'apple/testflight',
|
'apps_betaAppLocalizations_getToManyRelated': 'apple/testflight',
|
||||||
'apps_betaAppReviewDetail_getToOneRelated': 'apple/testflight',
|
'apps_betaAppReviewDetail_getToOneRelated': 'apple/testflight',
|
||||||
'apps_betaFeedbackCrashSubmissions_getToManyRelated': 'health',
|
'apps_betaFeedbackCrashSubmissions_getToManyRelated': 'health',
|
||||||
|
|
|
||||||
57
cli/apple/asset-library.ts
Normal file
57
cli/apple/asset-library.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
// App Asset Library states and upsert rules shared by creative assets and iPhone Duo screenshots. No command flags here.
|
||||||
|
import * as rt from '../lib/rt';
|
||||||
|
import type { J } from './asc';
|
||||||
|
|
||||||
|
export const REF = (sum: string): string => `storebound ${sum}`;
|
||||||
|
export const sumOf = (a: J): string | undefined => /^storebound ([0-9a-f]{32})$/.exec(String(a?.attributes?.referenceName ?? ''))?.[1];
|
||||||
|
|
||||||
|
/** Asset states (AppAssetLibraryAssetState, API 4.5.1) by what storebound may do with them. */
|
||||||
|
export const ASSET_STATES = {
|
||||||
|
/** still uploading / processing: wait (bounded), then select */
|
||||||
|
processing: ['AWAITING_UPLOAD', 'UPLOAD_COMPLETE'],
|
||||||
|
/** processed and usable in a slot; not reviewed yet: goes into a review submission */
|
||||||
|
unreviewed: ['COMPLETE', 'PREPARE_FOR_SUBMISSION'],
|
||||||
|
/** in a submission or reviewed: usable in a slot, nothing to add */
|
||||||
|
reviewing: ['READY_FOR_REVIEW', 'WAITING_FOR_REVIEW', 'IN_REVIEW', 'ACCEPTED', 'APPROVED'],
|
||||||
|
} as const;
|
||||||
|
export type AssetClass = 'processing' | 'unreviewed' | 'reviewing' | 'failed' | 'rejected' | 'archived' | 'unknown';
|
||||||
|
|
||||||
|
/** What a library asset's state means for storebound. Pure. */
|
||||||
|
export function classify(state: string | undefined): AssetClass {
|
||||||
|
const s = String(state ?? '');
|
||||||
|
for (const [k, v] of Object.entries(ASSET_STATES)) if ((v as readonly string[]).includes(s)) return k as AssetClass;
|
||||||
|
if (s === 'FAILED') return 'failed';
|
||||||
|
if (s === 'REJECTED') return 'rejected';
|
||||||
|
if (s === 'ARCHIVED') return 'archived';
|
||||||
|
return s ? 'unknown' : 'processing';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Selectable for a slot: processed and not failed / rejected / archived (Prepare for Submission is enough). */
|
||||||
|
export const selectable = (c: AssetClass): boolean => c === 'unreviewed' || c === 'reviewing';
|
||||||
|
|
||||||
|
/** Stop re-uploading a file whose earlier uploads failed processing this many times. */
|
||||||
|
export const MAX_FAILED = 2;
|
||||||
|
|
||||||
|
/** The library asset to use for one MD5: a live one first (newest state wins: reviewing > unreviewed > processing),
|
||||||
|
* else an archived one (unarchive it), else none; plus why not to upload (a rejected file, repeated failures). Pure. */
|
||||||
|
export function pickAsset(assets: J[], sum: string): { asset?: J; unarchive?: boolean; refuse?: string } {
|
||||||
|
const mine = assets.filter(x => sumOf(x) === sum);
|
||||||
|
const rank: Record<AssetClass, number> = { reviewing: 0, unreviewed: 1, processing: 2, unknown: 3, archived: 4, rejected: 5, failed: 6 };
|
||||||
|
const by = (c: AssetClass) => mine.filter(x => classify(x.attributes?.state) === c);
|
||||||
|
const live = mine.filter(x => rank[classify(x.attributes?.state)] <= 3).sort((a, b) => rank[classify(a.attributes?.state)] - rank[classify(b.attributes?.state)]);
|
||||||
|
if (live[0]) return { asset: live[0] };
|
||||||
|
if (by('archived')[0]) return { asset: by('archived')[0], unarchive: true };
|
||||||
|
const rej = by('rejected')[0];
|
||||||
|
if (rej) return { refuse: `rejected by App Review${reason(rej)}: uploading the same file again changes nothing; change the file` };
|
||||||
|
if (by('failed').length >= MAX_FAILED) return { refuse: `${by('failed').length} earlier uploads of this file failed processing${reason(by('failed')[0]!)}: check the file (format, size) before trying again` };
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reason = (a: J): string => {
|
||||||
|
const d = ((a.attributes?.stateDetails ?? []) as J[]).map(x => x?.code ?? x?.message ?? x?.description).filter(Boolean);
|
||||||
|
return d.length ? ` (${d.join(', ')})` : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Bounded wait for processing (every wait has a timeout and a heartbeat); tests set waitMs 0 / timeoutMs 0. */
|
||||||
|
export const processingTiming = { pollMs: 3000, timeoutMs: 120_000, heartbeatMs: 20_000, sleep: (ms: number): Promise<void> => (ms > 0 ? rt.sleep(ms) : Promise.resolve()) };
|
||||||
|
|
||||||
|
|
@ -29,61 +29,17 @@ import { need } from '../lib/ctx';
|
||||||
import { consoleLink } from '../lib/console-links';
|
import { consoleLink } from '../lib/console-links';
|
||||||
import { done, log, warn } from '../lib/log';
|
import { done, log, warn } from '../lib/log';
|
||||||
import { elapsed } from '../lib/progress';
|
import { elapsed } from '../lib/progress';
|
||||||
import * as rt from '../lib/rt';
|
|
||||||
import { ffprobe } from '../shots/report';
|
import { ffprobe } from '../shots/report';
|
||||||
import { imageInfo } from '../shots/spec';
|
import { imageInfo } from '../shots/spec';
|
||||||
import { checkCreativeImage, checkCreativeVideo, type CreativeFile, creativeFiles, PLACEMENT_TYPE, type Placement } from '../shots/creative';
|
import { checkCreativeImage, checkCreativeVideo, type CreativeFile, creativeFiles, PLACEMENT_TYPE, type Placement } from '../shots/creative';
|
||||||
import { type Asc, ascClient, md5, type J } from './asc';
|
import { type Asc, ascClient, md5, type J } from './asc';
|
||||||
|
import { type AssetClass, ASSET_STATES, classify, MAX_FAILED, pickAsset, processingTiming, reason, REF, selectable, sumOf } from './asset-library';
|
||||||
|
export { type AssetClass, ASSET_STATES, classify, MAX_FAILED, pickAsset, processingTiming, REF, selectable };
|
||||||
import { partHeaders } from './screenshot-health';
|
import { partHeaders } from './screenshot-health';
|
||||||
import { AmbiguousWriteError } from '../lib/upsert';
|
import { AmbiguousWriteError } from '../lib/upsert';
|
||||||
import { addItem, ensureDraft, OtherSubmissionError } from './submission';
|
import { addItem, ensureDraft, OtherSubmissionError } from './submission';
|
||||||
|
|
||||||
const TAG = 'apple';
|
const TAG = 'apple';
|
||||||
export const REF = (sum: string): string => `storebound ${sum}`;
|
|
||||||
const sumOf = (a: J): string | undefined => /^storebound ([0-9a-f]{32})$/.exec(String(a?.attributes?.referenceName ?? ''))?.[1];
|
|
||||||
|
|
||||||
/** Asset states (AppAssetLibraryAssetState, API 4.5.1) by what storebound may do with them. */
|
|
||||||
export const ASSET_STATES = {
|
|
||||||
/** still uploading / processing: wait (bounded), then select */
|
|
||||||
processing: ['AWAITING_UPLOAD', 'UPLOAD_COMPLETE'],
|
|
||||||
/** processed and usable in a slot; not reviewed yet: goes into a review submission */
|
|
||||||
unreviewed: ['COMPLETE', 'PREPARE_FOR_SUBMISSION'],
|
|
||||||
/** in a submission or reviewed: usable in a slot, nothing to add */
|
|
||||||
reviewing: ['READY_FOR_REVIEW', 'WAITING_FOR_REVIEW', 'IN_REVIEW', 'ACCEPTED', 'APPROVED'],
|
|
||||||
} as const;
|
|
||||||
export type AssetClass = 'processing' | 'unreviewed' | 'reviewing' | 'failed' | 'rejected' | 'archived' | 'unknown';
|
|
||||||
|
|
||||||
/** What a library asset's state means for storebound. Pure. */
|
|
||||||
export function classify(state: string | undefined): AssetClass {
|
|
||||||
const s = String(state ?? '');
|
|
||||||
for (const [k, v] of Object.entries(ASSET_STATES)) if ((v as readonly string[]).includes(s)) return k as AssetClass;
|
|
||||||
if (s === 'FAILED') return 'failed';
|
|
||||||
if (s === 'REJECTED') return 'rejected';
|
|
||||||
if (s === 'ARCHIVED') return 'archived';
|
|
||||||
return s ? 'unknown' : 'processing';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Selectable for a slot: processed and not failed / rejected / archived (Prepare for Submission is enough). */
|
|
||||||
export const selectable = (c: AssetClass): boolean => c === 'unreviewed' || c === 'reviewing';
|
|
||||||
|
|
||||||
/** Stop re-uploading a file whose earlier uploads failed processing this many times. */
|
|
||||||
export const MAX_FAILED = 2;
|
|
||||||
|
|
||||||
/** The library asset to use for one MD5: a live one first (newest state wins: reviewing > unreviewed > processing),
|
|
||||||
* else an archived one (unarchive it), else none; plus why not to upload (a rejected file, repeated failures). Pure. */
|
|
||||||
export function pickAsset(assets: J[], sum: string): { asset?: J; unarchive?: boolean; refuse?: string } {
|
|
||||||
const mine = assets.filter(x => sumOf(x) === sum);
|
|
||||||
const rank: Record<AssetClass, number> = { reviewing: 0, unreviewed: 1, processing: 2, unknown: 3, archived: 4, rejected: 5, failed: 6 };
|
|
||||||
const by = (c: AssetClass) => mine.filter(x => classify(x.attributes?.state) === c);
|
|
||||||
const live = mine.filter(x => rank[classify(x.attributes?.state)] <= 3).sort((a, b) => rank[classify(a.attributes?.state)] - rank[classify(b.attributes?.state)]);
|
|
||||||
if (live[0]) return { asset: live[0] };
|
|
||||||
if (by('archived')[0]) return { asset: by('archived')[0], unarchive: true };
|
|
||||||
const rej = by('rejected')[0];
|
|
||||||
if (rej) return { refuse: `rejected by App Review${reason(rej)}: uploading the same file again changes nothing; change the file` };
|
|
||||||
if (by('failed').length >= MAX_FAILED) return { refuse: `${by('failed').length} earlier uploads of this file failed processing${reason(by('failed')[0]!)}: check the file (format, size) before trying again` };
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "uploaded 2026-10-19, waiting for Apple's approval (IN_REVIEW); run `storebound apple creative-assets` again later".
|
/** "uploaded 2026-10-19, waiting for Apple's approval (IN_REVIEW); run `storebound apple creative-assets` again later".
|
||||||
* The version page's header / search slots offer only approved assets ("Browse approved assets"), so an asset that
|
* The version page's header / search slots offer only approved assets ("Browse approved assets"), so an asset that
|
||||||
* is not APPROVED waits; a later run places it. Pure. */
|
* is not APPROVED waits; a later run places it. Pure. */
|
||||||
|
|
@ -107,14 +63,6 @@ export async function creativeWaiting(c: Asc, appId: string, local: Map<string,
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reason = (a: J): string => {
|
|
||||||
const d = ((a.attributes?.stateDetails ?? []) as J[]).map(x => x?.code ?? x?.message ?? x?.description).filter(Boolean);
|
|
||||||
return d.length ? ` (${d.join(', ')})` : '';
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Bounded wait for processing (every wait has a timeout and a heartbeat); tests set waitMs 0 / timeoutMs 0. */
|
|
||||||
export const processingTiming = { pollMs: 3000, timeoutMs: 120_000, heartbeatMs: 20_000, sleep: (ms: number): Promise<void> => (ms > 0 ? rt.sleep(ms) : Promise.resolve()) };
|
|
||||||
|
|
||||||
export interface Row { locale: string; placement: Placement; file: string; action: 'keep' | 'upload' | 'reuse' | 'wait' | 'select' | 'replace' | 'approve' | 'refuse'; state?: string; asset?: J }
|
export interface Row { locale: string; placement: Placement; file: string; action: 'keep' | 'upload' | 'reuse' | 'wait' | 'select' | 'replace' | 'approve' | 'refuse'; state?: string; asset?: J }
|
||||||
|
|
||||||
/** Local files per App Store locale, checked; throws on any error before a network call. */
|
/** Local files per App Store locale, checked; throws on any error before a network call. */
|
||||||
|
|
|
||||||
131
cli/apple/duo-library.ts
Normal file
131
cli/apple/duo-library.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
// iPhone Duo screenshots through the App Asset Library (App Store Connect API 4.5.1, pitfall 130). ScreenshotDisplayType
|
||||||
|
// has no Duo value and never will: Apple added the display class IPHONE_DUO only to AppAssetLibraryDisplayClass and
|
||||||
|
// deprecated appScreenshotSets / appScreenshots in the same release. Flow:
|
||||||
|
// 1. GET /v1/appAssetLibraryRefData?filter[placementTypes]=APP_SCREENSHOT -> the iOS placementProfileGroup whose
|
||||||
|
// displayClassId is IPHONE_DUO (its id is the placementGroup of every Duo screenshot);
|
||||||
|
// 2. each file is uploaded once to the app's library (POST appAssetLibraryImages, category
|
||||||
|
// APP_SCREENSHOTS_AND_PREVIEWS, referenceName "storebound <md5>", the same MD5 reuse as creative assets);
|
||||||
|
// 3. POST appAssetLibraryPlacements (placementType APP_SCREENSHOT, placementGroup, the version localization).
|
||||||
|
// Screenshots are reviewed with the version, so a processed asset is placed at once (no approval wait).
|
||||||
|
// Upsert: a group whose placements already show the local files in order is left alone; otherwise its placements are
|
||||||
|
// removed and the files placed in order (outer display files first, then inner). Never truncated: more files than
|
||||||
|
// the limit Apple publishes for the group (refData groupLimits) changes nothing and says so. Read back at the end.
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { basename } from 'node:path';
|
||||||
|
import { log, warn } from '../lib/log';
|
||||||
|
import { elapsed } from '../lib/progress';
|
||||||
|
import { classify, pickAsset, processingTiming, REF, selectable } from './asset-library';
|
||||||
|
import { type Asc, md5, type J } from './asc';
|
||||||
|
import { partHeaders } from './screenshot-health';
|
||||||
|
|
||||||
|
const TAG = 'apple';
|
||||||
|
export const DUO_CATEGORY = 'APP_SCREENSHOTS_AND_PREVIEWS';
|
||||||
|
const sumOf = (a: J | undefined): string | undefined => /^storebound ([0-9a-f]{32})$/.exec(String(a?.attributes?.referenceName ?? ''))?.[1];
|
||||||
|
|
||||||
|
/** iPhone placement profile group ids (platform IOS or IPHONE_APP_STORE, as the live API answers) of the IPHONE_DUO display class in an appAssetLibraryRefData response. Pure. */
|
||||||
|
export function duoGroups(refData: J[]): string[] {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const r of refData) for (const g of (r?.attributes?.placementProfileGroups ?? []) as J[])
|
||||||
|
if (g?.displayClassId === 'IPHONE_DUO' && (!g.platform || /IOS|IPHONE/.test(String(g.platform))) && g.placementProfileGroupId) ids.add(String(g.placementProfileGroupId));
|
||||||
|
return [...ids];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apple's screenshot limit for a placement group on a version page, or null when refData publishes none. Pure. */
|
||||||
|
export function duoLimit(refData: J[], group: string): number | null {
|
||||||
|
for (const r of refData) for (const f of (r?.attributes?.features ?? []) as J[]) {
|
||||||
|
if (f?.featureId !== 'APP_STORE_VERSIONS') continue;
|
||||||
|
for (const p of (f.placementPolicies ?? []) as J[]) {
|
||||||
|
if (p?.placementType !== 'APP_SCREENSHOT') continue;
|
||||||
|
for (const l of (p.groupLimits ?? []) as J[]) if ((l?.groupIds ?? []).includes(group) && typeof l.maxCount === 'number') return l.maxCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a group's current placements (asset MD5s in order) already show `want`. Pure. */
|
||||||
|
export const duoUpToDate = (shown: (string | undefined)[], want: string[]): boolean => shown.length === want.length && shown.every((s, i) => s === want[i]);
|
||||||
|
|
||||||
|
export interface DuoResult { locale: string; action: 'keep' | 'place' | 'wait' | 'refuse' | 'plan'; files: number }
|
||||||
|
|
||||||
|
/** Resolves the Duo placement group once per run; throws with the guardrail text when the API lists none. */
|
||||||
|
export async function duoGroup(c: Asc): Promise<{ id: string; limit: number | null }> {
|
||||||
|
const ref = await c.all('/v1/appAssetLibraryRefData?filter[placementTypes]=APP_SCREENSHOT');
|
||||||
|
const g = duoGroups(ref);
|
||||||
|
if (!g.length) throw new Error('appAssetLibraryRefData lists no iOS IPHONE_DUO placement group for APP_SCREENSHOT: report it (pitfall 130); the console version page > App Previews and Screenshots > iPhone Duo takes them meanwhile');
|
||||||
|
if (g.length > 1) log(TAG, `iPhone Duo: ${g.length} placement groups (${g.join(', ')}), using ${g[0]}`);
|
||||||
|
const limit = duoLimit(ref, g[0]!);
|
||||||
|
log(TAG, `iPhone Duo: placement group ${g[0]}, limit ${limit ?? 'not published'}`);
|
||||||
|
return { id: g[0]!, limit };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Makes the iPhone Duo screenshots of one version localization show `files` in order. */
|
||||||
|
export async function syncDuo(c: Asc, o: { appId: string; group: string; limit: number | null; loc: J; files: string[]; dry: boolean }): Promise<DuoResult> {
|
||||||
|
const locale: string = o.loc.attributes.locale;
|
||||||
|
const tag = `${locale} IPHONE_DUO`;
|
||||||
|
const files = o.files;
|
||||||
|
if (o.limit !== null && files.length > o.limit) {
|
||||||
|
warn(TAG, `${tag}: ${files.length} files (outer + inner) but Apple allows ${o.limit} in this group: nothing changed, none dropped. Keep at most ${o.limit} across iphone-duo-outer/ and iphone-duo-inner/`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return { locale, action: 'refuse', files: files.length };
|
||||||
|
}
|
||||||
|
const lib = (await c.asc('GET', `/v1/apps/${o.appId}/assetLibrary`)).data as J;
|
||||||
|
const library = () => c.all(`/v1/appAssetLibraries/${lib.id}/images?filter[category]=${DUO_CATEGORY}&limit=200`);
|
||||||
|
let assets = await library();
|
||||||
|
const want = files.map(f => md5(readFileSync(f)));
|
||||||
|
const placedAll = await c.all(`/v1/appStoreVersionLocalizations/${o.loc.id}/placements?filter[placementType]=APP_SCREENSHOT&include=image`);
|
||||||
|
const placed = placedAll.filter(p => p.attributes?.placementGroup === o.group);
|
||||||
|
const shown = placed.map(p => sumOf(assets.find(a => a.id === p.relationships?.image?.data?.id)));
|
||||||
|
if (duoUpToDate(shown, want)) { log(TAG, `${tag}: ${files.length} up to date`); return { locale, action: 'keep', files: files.length }; }
|
||||||
|
if (o.dry) {
|
||||||
|
const up = want.filter(s => !pickAsset(assets, s).asset).length;
|
||||||
|
log(TAG, `${tag}: would ${placed.length ? `replace ${placed.length} placed` : 'place'} ${files.length} screenshot(s) through the Asset Library (${up} to upload)`);
|
||||||
|
return { locale, action: 'plan', files: files.length };
|
||||||
|
}
|
||||||
|
const chosen: J[] = [];
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const f = files[i]!, sum = want[i]!;
|
||||||
|
const pick = pickAsset(assets, sum);
|
||||||
|
if (pick.refuse) { warn(TAG, `${tag} ${basename(f)}: ${pick.refuse}`); return { locale, action: 'refuse', files: files.length }; }
|
||||||
|
let a = pick.asset;
|
||||||
|
if (!a) { log(TAG, `${tag}: uploading ${basename(f)} to the Asset Library`); a = await uploadImage(c, lib.id, f, sum); assets = await library(); a = pickAsset(assets, sum).asset ?? a; }
|
||||||
|
else if (pick.unarchive) a = (await c.asc('PATCH', `/v1/${a.type}/${a.id}`, { data: { type: a.type, id: a.id, attributes: { archived: false } } })).data as J;
|
||||||
|
a = await processed(c, a, `${tag} ${basename(f)}`);
|
||||||
|
const cls = classify(a.attributes?.state);
|
||||||
|
if (cls === 'processing') return { locale, action: 'wait', files: files.length };
|
||||||
|
if (!selectable(cls)) { warn(TAG, `${tag} ${basename(f)}: ${a.attributes?.state}; nothing changed`); return { locale, action: 'refuse', files: files.length }; }
|
||||||
|
chosen.push(a);
|
||||||
|
}
|
||||||
|
// every file is ready before the old placements go, so a failed upload leaves the page as it was
|
||||||
|
for (const p of placed) await c.asc('DELETE', `/v1/appAssetLibraryPlacements/${p.id}`);
|
||||||
|
for (const a of chosen) await c.asc('POST', '/v1/appAssetLibraryPlacements', { data: { type: 'appAssetLibraryPlacements', attributes: { placementType: 'APP_SCREENSHOT', placementGroup: o.group },
|
||||||
|
relationships: { image: { data: { type: 'appAssetLibraryImages', id: a.id } }, appStoreVersionLocalization: { data: { type: 'appStoreVersionLocalizations', id: o.loc.id } } } } });
|
||||||
|
const now = (await c.all(`/v1/appStoreVersionLocalizations/${o.loc.id}/placements?filter[placementType]=APP_SCREENSHOT&include=image`)).filter(p => p.attributes?.placementGroup === o.group);
|
||||||
|
const ids = now.map(p => p.relationships?.image?.data?.id);
|
||||||
|
if (ids.length !== chosen.length || chosen.some(a => !ids.includes(a.id))) { warn(TAG, `${tag}: read back ${ids.length} placements, wanted ${chosen.length}`); process.exitCode = 1; }
|
||||||
|
else log(TAG, `${tag}: placed ${chosen.length} (removed ${placed.length})`);
|
||||||
|
return { locale, action: 'place', files: chosen.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadImage(c: Asc, libId: string, file: string, sum: string): Promise<J> {
|
||||||
|
const buf = readFileSync(file);
|
||||||
|
const type = 'appAssetLibraryImages';
|
||||||
|
const res = (await c.asc('POST', `/v1/${type}`, { data: { type, attributes: { fileName: basename(file), fileSize: buf.length, category: DUO_CATEGORY, referenceName: REF(sum) },
|
||||||
|
relationships: { assetLibrary: { data: { type: 'appAssetLibraries', id: libId } } } } })).data as J;
|
||||||
|
for (const op of (res.attributes.uploadOperations ?? []) as J[]) {
|
||||||
|
const r = await fetch(op.url, { method: op.method, headers: partHeaders(op), body: buf.subarray(op.offset, op.offset + op.length), signal: AbortSignal.timeout(300_000) });
|
||||||
|
if (r.status < 200 || r.status > 299) { await c.asc('DELETE', `/v1/${type}/${res.id}`).catch(() => {}); throw new Error(`upload ${basename(file)} part ${op.offset} -> ${r.status} (reservation deleted, nothing committed)`); }
|
||||||
|
}
|
||||||
|
return (await c.asc('PATCH', `/v1/${type}/${res.id}`, { data: { type, id: res.id, attributes: { uploaded: true } } })).data as J;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processed(c: Asc, a: J, label: string): Promise<J> {
|
||||||
|
const t0 = Date.now();
|
||||||
|
let beat = t0, cur = a;
|
||||||
|
while (classify(cur.attributes?.state) === 'processing') {
|
||||||
|
if (Date.now() - t0 >= processingTiming.timeoutMs) { warn(TAG, `${label}: still ${cur.attributes?.state ?? 'processing'} after ${elapsed(Date.now() - t0)}; re-run later to place it`); return cur; }
|
||||||
|
await processingTiming.sleep(processingTiming.pollMs);
|
||||||
|
cur = ((await c.maybe(`/v1/${cur.type}/${cur.id}`))?.data as J) ?? cur;
|
||||||
|
if (Date.now() - beat >= processingTiming.heartbeatMs) { beat = Date.now(); log(TAG, `${label}: processing (${cur.attributes?.state}, ${elapsed(Date.now() - t0)})`); }
|
||||||
|
}
|
||||||
|
return cur;
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
// Store screenshots of every locale on the editable App Store version (idempotent: a set whose files already match
|
// Store screenshots of every locale on the editable App Store version (idempotent: a set whose files already match
|
||||||
// by MD5 and order is left alone, otherwise it is emptied and re-uploaded).
|
// by MD5 and order is left alone, otherwise it is emptied and re-uploaded).
|
||||||
// storebound apple screenshots [--src store/renders] [--only en-US,tr] [--jobs 4] [--variant b] [--dry]
|
// storebound apple screenshots [--src store/renders] [--only en-US,tr] [--jobs 4] [--variant b] [--duo-only] [--dry]
|
||||||
// storebound apple screenshots --repair-stuck [--dry] (only the screenshots stuck outside COMPLETE, in place)
|
// storebound apple screenshots --repair-stuck [--dry] (only the screenshots stuck outside COMPLETE, in place)
|
||||||
// Source per language: <renders>/<language id>/ios69/NN.png (6.9", 1320 x 2868 -> APP_IPHONE_67),
|
// Source per language: <renders>/<language id>/ios69/NN.png (6.9", 1320 x 2868 -> APP_IPHONE_67),
|
||||||
// ios65/NN.png (6.5", 1284 x 2778 -> APP_IPHONE_65) and, only with apple.ipad, ipad13/NN.png (13" iPad, 2064 x 2752 ->
|
// ios65/NN.png (6.5", 1284 x 2778 -> APP_IPHONE_65) and, only with apple.ipad, ipad13/NN.png (13" iPad, 2064 x 2752 ->
|
||||||
// APP_IPAD_PRO_3GEN_129: the API has no "13 inch" value, the 13" display set keeps the 3rd gen 12.9" name). With
|
// APP_IPAD_PRO_3GEN_129: the API has no "13 inch" value, the 13" display set keeps the 3rd gen 12.9" name). With
|
||||||
// --platform macos (or when only the macOS version is editable) and apple.mac: mac/NN.png (2880 x 1800 -> APP_DESKTOP)
|
// --platform macos (or when only the macOS version is editable) and apple.mac: mac/NN.png (2880 x 1800 -> APP_DESKTOP)
|
||||||
// to the macOS version. iPhone Duo sets are skipped with the console deep link (no API display type yet). Up to 10
|
// to the macOS version. iPhone Duo (apple.iphoneDuo: iphone-duo-outer/ then iphone-duo-inner/) goes through the App Asset Library. Up to 10
|
||||||
// per set. --variant <id> uploads <renders>/<id>/<lang>/... instead (an A/B variant from shots.variants, e.g. for a
|
// per set. --variant <id> uploads <renders>/<id>/<lang>/... instead (an A/B variant from shots.variants, e.g. for a
|
||||||
// Product Page Optimization treatment you then set up in App Store Connect). Apple processes uploads asynchronously; failed
|
// Product Page Optimization treatment you then set up in App Store Connect). Apple processes uploads asynchronously; failed
|
||||||
// ones are reported at the end. Long: run in the background with a log file.
|
// ones are reported at the end. Long: run in the background with a log file.
|
||||||
|
|
@ -20,7 +20,8 @@ import { variantRoot } from '../shots/lib';
|
||||||
import { findOrCreate } from '../lib/upsert';
|
import { findOrCreate } from '../lib/upsert';
|
||||||
import { pulledChecksum } from '../lib/art-pull';
|
import { pulledChecksum } from '../lib/art-pull';
|
||||||
import { consoleLink } from '../lib/console-links';
|
import { consoleLink } from '../lib/console-links';
|
||||||
import { IPHONE_DUO_DISPLAY, IPHONE_DUO_NOT_IN_API } from '../shots/spec';
|
import { IPHONE_DUO_DISPLAY } from '../shots/spec';
|
||||||
|
import { duoGroup, syncDuo } from './duo-library';
|
||||||
import { type Asc, ascClient, EDITABLE, keptPrefix, md5, setUpToDate, type J } from './asc';
|
import { type Asc, ascClient, EDITABLE, keptPrefix, md5, setUpToDate, type J } from './asc';
|
||||||
import { pendingScreenshots, repairPlan, repairSet, setRows, updateLedger } from './screenshot-health';
|
import { pendingScreenshots, repairPlan, repairSet, setRows, updateLedger } from './screenshot-health';
|
||||||
import { allVersions, parsePlatform, pickPlatform, platformLabel, stateOf, versionLabel } from './platform';
|
import { allVersions, parsePlatform, pickPlatform, platformLabel, stateOf, versionLabel } from './platform';
|
||||||
|
|
@ -42,12 +43,9 @@ export const displaySets = (apple: { ipad?: boolean; iphoneMedium?: boolean; iph
|
||||||
platform === 'MAC_OS' ? (apple?.mac ? MAC_DISPLAY : {}) : ({ ...DISPLAY, ...(apple?.iphoneMedium ? MEDIUM_DISPLAY : {}), ...(apple?.ipad ? IPAD_DISPLAY : {}),
|
platform === 'MAC_OS' ? (apple?.mac ? MAC_DISPLAY : {}) : ({ ...DISPLAY, ...(apple?.iphoneMedium ? MEDIUM_DISPLAY : {}), ...(apple?.ipad ? IPAD_DISPLAY : {}),
|
||||||
...(apple?.iphoneDuo ? Object.fromEntries(Object.entries(IPHONE_DUO_DISPLAY).filter((e): e is [string, string] => !!e[1])) : {}) });
|
...(apple?.iphoneDuo ? Object.fromEntries(Object.entries(IPHONE_DUO_DISPLAY).filter((e): e is [string, string] => !!e[1])) : {}) });
|
||||||
|
|
||||||
/** iPhone Duo size keys `apple screenshots` skips (apple.iphoneDuo on, no API display type yet), with the message. */
|
/** iPhone Duo size keys (render folders) `apple screenshots` uploads through the App Asset Library (no
|
||||||
export function skippedDuoSets(apple: { iphoneDuo?: boolean; appId?: string } | undefined): { dirs: string[]; msg: string } | null {
|
* ScreenshotDisplayType exists for Duo: cli/apple/duo-library.ts, pitfall 130), outer display first. */
|
||||||
const dirs = apple?.iphoneDuo ? Object.entries(IPHONE_DUO_DISPLAY).filter(([, t]) => !t).map(([k]) => k) : [];
|
export const duoDirs = (apple: { iphoneDuo?: boolean } | undefined): string[] => (apple?.iphoneDuo ? Object.keys(IPHONE_DUO_DISPLAY) : []);
|
||||||
// the console takes them (its own "iPhone Duo" tab): send the owner straight to the version page
|
|
||||||
return dirs.length ? { dirs, msg: `${dirs.join(', ')}: ${IPHONE_DUO_NOT_IN_API}. Add them in ${consoleLink('asc.version', '> App Previews and Screenshots > iPhone Duo', { appleAppId: apple?.appId })}` } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Display types `validate` requires on every locale: apple.screenshotTypes, else the classes App Store Connect's
|
/** Display types `validate` requires on every locale: apple.screenshotTypes, else the classes App Store Connect's
|
||||||
* "App Previews and Screenshots" tab lists (October 2026): iPhone with Dynamic Island (medium display), filled by
|
* "App Previews and Screenshots" tab lists (October 2026): iPhone with Dynamic Island (medium display), filled by
|
||||||
|
|
@ -98,8 +96,10 @@ export async function run(ctx: Ctx): Promise<void> {
|
||||||
const version = await c.editableVersion(platform);
|
const version = await c.editableVersion(platform);
|
||||||
if (!version) throw new Error(`no editable ${platformLabel(platform)} App Store version (run apple listing --version x.y.z first)`);
|
if (!version) throw new Error(`no editable ${platformLabel(platform)} App Store version (run apple listing --version x.y.z first)`);
|
||||||
log('apple', `screenshots for version ${versionLabel(version)}`);
|
log('apple', `screenshots for version ${versionLabel(version)}`);
|
||||||
const duoSkip = platform === 'MAC_OS' ? null : skippedDuoSets(config.apple);
|
let duo = platform === 'MAC_OS' ? [] : duoDirs(config.apple);
|
||||||
if (duoSkip) warn('apple', duoSkip.msg);
|
// a Duo problem never stops the other sets
|
||||||
|
const duoGroupId = duo.length ? await duoGroup(c).catch((e: Error) => { warn('apple', `iPhone Duo skipped: ${e.message}`); duo = []; return null; }) : null;
|
||||||
|
const appId = duo.length ? await c.app() : '';
|
||||||
if (platform === 'MAC_OS' && !config.apple?.mac) throw new Error('macOS version: set apple.mac: true and render the mac size (storebound shots render --size mac) first');
|
if (platform === 'MAC_OS' && !config.apple?.mac) throw new Error('macOS version: set apple.mac: true and render the mac size (storebound shots render --size mac) first');
|
||||||
if (platform !== 'MAC_OS' && config.apple?.mac) log('apple', 'Mac screenshots (mac/, APP_DESKTOP) go to the macOS version: run apple screenshots --platform macos');
|
if (platform !== 'MAC_OS' && config.apple?.mac) log('apple', 'Mac screenshots (mac/, APP_DESKTOP) go to the macOS version: run apple screenshots --platform macos');
|
||||||
const langOf = byAppleLocale(config.languages);
|
const langOf = byAppleLocale(config.languages);
|
||||||
|
|
@ -121,12 +121,17 @@ export async function run(ctx: Ctx): Promise<void> {
|
||||||
if (!lang) { warn('apple', `${locale}: no language with apple: "${locale}", skipped`); continue; }
|
if (!lang) { warn('apple', `${locale}: no language with apple: "${locale}", skipped`); continue; }
|
||||||
tasks.push(async () => {
|
tasks.push(async () => {
|
||||||
const sets = await c.all(`/v1/appStoreVersionLocalizations/${loc.id}/appScreenshotSets`);
|
const sets = await c.all(`/v1/appStoreVersionLocalizations/${loc.id}/appScreenshotSets`);
|
||||||
for (const [dir, type] of Object.entries(displaySets(config.apple, platform))) {
|
for (const [dir, type] of Object.entries(ctx.args.has('duo-only') ? {} : displaySets(config.apple, platform))) {
|
||||||
const d = join(src, lang.id, dir);
|
const d = join(src, lang.id, dir);
|
||||||
const files = numberedImages(d).filter(f => f.endsWith('.png')).slice(0, 10);
|
const files = numberedImages(d).filter(f => f.endsWith('.png')).slice(0, 10);
|
||||||
if (!existsSync(d) || !files.length) { warn('apple', `${locale}: nothing in ${d}, skipped`); continue; }
|
if (!existsSync(d) || !files.length) { warn('apple', `${locale}: nothing in ${d}, skipped`); continue; }
|
||||||
await syncSet(loc.id, sets, type, files, `${locale} ${type}`);
|
await syncSet(loc.id, sets, type, files, `${locale} ${type}`);
|
||||||
}
|
}
|
||||||
|
if (duo.length && duoGroupId) {
|
||||||
|
const files = duo.flatMap(dir => numberedImages(join(src, lang.id, dir)).filter(f => f.endsWith('.png')));
|
||||||
|
if (!files.length) warn('apple', `${locale}: nothing in ${duo.map(d => join(src, lang.id, d)).join(' or ')}, iPhone Duo skipped`);
|
||||||
|
else await syncDuo(c, { appId, group: duoGroupId.id, limit: duoGroupId.limit, loc, files, dry: ctx.args.has('dry') }).catch((e: Error) => { warn('apple', `${locale} iPhone Duo: ${e.message}`); process.exitCode = 1; });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
log('apple', `${tasks.length} locales, ${jobs} in parallel`);
|
log('apple', `${tasks.length} locales, ${jobs} in parallel`);
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,7 @@
|
||||||
"name"
|
"name"
|
||||||
],
|
],
|
||||||
"apple cpp": [
|
"apple cpp": [
|
||||||
|
"duo-only",
|
||||||
"include-drafts",
|
"include-drafts",
|
||||||
"jobs",
|
"jobs",
|
||||||
"name",
|
"name",
|
||||||
|
|
@ -204,6 +205,7 @@
|
||||||
"write"
|
"write"
|
||||||
],
|
],
|
||||||
"apple ppo": [
|
"apple ppo": [
|
||||||
|
"duo-only",
|
||||||
"experiment",
|
"experiment",
|
||||||
"include-drafts",
|
"include-drafts",
|
||||||
"jobs",
|
"jobs",
|
||||||
|
|
@ -270,6 +272,7 @@
|
||||||
"platform"
|
"platform"
|
||||||
],
|
],
|
||||||
"apple screenshots": [
|
"apple screenshots": [
|
||||||
|
"duo-only",
|
||||||
"include-drafts",
|
"include-drafts",
|
||||||
"jobs",
|
"jobs",
|
||||||
"not-sent-for-review",
|
"not-sent-for-review",
|
||||||
|
|
@ -1113,6 +1116,7 @@
|
||||||
"cordova-android",
|
"cordova-android",
|
||||||
"cordova-ios",
|
"cordova-ios",
|
||||||
"days",
|
"days",
|
||||||
|
"duo-only",
|
||||||
"expo",
|
"expo",
|
||||||
"flutter",
|
"flutter",
|
||||||
"godot",
|
"godot",
|
||||||
|
|
@ -1244,6 +1248,7 @@
|
||||||
"device",
|
"device",
|
||||||
"dir",
|
"dir",
|
||||||
"download",
|
"download",
|
||||||
|
"duo-only",
|
||||||
"enable",
|
"enable",
|
||||||
"end",
|
"end",
|
||||||
"entitlements",
|
"entitlements",
|
||||||
|
|
@ -1588,7 +1593,7 @@
|
||||||
"storebound apple review-details [--platform ios|macos|tvos|visionos] [--contact-from <Apple ID of another app>] [--dry]"
|
"storebound apple review-details [--platform ios|macos|tvos|visionos] [--contact-from <Apple ID of another app>] [--dry]"
|
||||||
],
|
],
|
||||||
"apple screenshots": [
|
"apple screenshots": [
|
||||||
"storebound apple screenshots [--src store/renders] [--only en-US,tr] [--jobs 4] [--variant b] [--dry]",
|
"storebound apple screenshots [--src store/renders] [--only en-US,tr] [--jobs 4] [--variant b] [--duo-only] [--dry]",
|
||||||
"storebound apple screenshots --repair-stuck [--dry] (only the screenshots stuck outside COMPLETE, in place)"
|
"storebound apple screenshots --repair-stuck [--dry] (only the screenshots stuck outside COMPLETE, in place)"
|
||||||
],
|
],
|
||||||
"apple signing": [
|
"apple signing": [
|
||||||
|
|
|
||||||
|
|
@ -700,8 +700,8 @@ export interface ShipConfig {
|
||||||
*/
|
*/
|
||||||
iphoneMedium?: boolean;
|
iphoneMedium?: boolean;
|
||||||
/** Also render and check iPhone Duo (foldable) screenshots: outer 1398 x 2034 and inner 2007 x 2853 (landscape too).
|
/** Also render and check iPhone Duo (foldable) screenshots: outer 1398 x 2034 and inner 2007 x 2853 (landscape too).
|
||||||
* Optional now, reportedly required from April 2027 for iOS 27.1 SDK builds. Not uploaded while the API has no
|
* Optional now, reportedly required from April 2027 for iOS 27.1 SDK builds. `apple screenshots` uploads them through
|
||||||
* display type for them (cli/shots/spec.ts IPHONE_DUO_DISPLAY). */
|
* the App Asset Library (display class IPHONE_DUO; there is no Duo ScreenshotDisplayType). */
|
||||||
iphoneDuo?: boolean;
|
iphoneDuo?: boolean;
|
||||||
/** Also render, check and upload Mac App Store screenshots (size key `mac`, 2880 x 1800, 16:10; Apple also takes
|
/** Also render, check and upload Mac App Store screenshots (size key `mac`, 2880 x 1800, 16:10; Apple also takes
|
||||||
* 2560 x 1600, 1440 x 900, 1280 x 800). Landscape layout from <frames>/<lang>/landscape/ (shots.landscape). Uploaded
|
* 2560 x 1600, 1440 x 900, 1280 x 800). Landscape layout from <frames>/<lang>/landscape/ (shots.landscape). Uploaded
|
||||||
|
|
|
||||||
|
|
@ -61,11 +61,10 @@ export const IMAGE_SPECS: Record<string, ImageSpec> = {
|
||||||
feature: { store: 'play', type: 'featureGraphic', label: 'Play feature graphic', sizes: [[1024, 500]], min: 1, max: 1, maxBytes: 15 * MB, alpha: false },
|
feature: { store: 'play', type: 'featureGraphic', label: 'Play feature graphic', sizes: [[1024, 500]], min: 1, max: 1, maxBytes: 15 * MB, alpha: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** ASC screenshotDisplayType per iPhone Duo size key. null = NOT IN THE API YET: ScreenshotDisplayType in App Store
|
/** iPhone Duo size keys. Values stay null: ScreenshotDisplayType has no Duo value and never will (API 4.5.1 put the
|
||||||
* Connect API 4.5.1 (spec re-checked 2026-10-20: the published zip is still the 2026-10-06 one, same sha256) has no Duo value (only AppAssetLibraryDisplayClass lists IPHONE_DUO).
|
* display class IPHONE_DUO into the App Asset Library only and deprecated screenshot sets); apple screenshots
|
||||||
* When Apple adds it: regenerate (bun tools/gen-api.ts) and put the exact enum names here; uploads then include them. */
|
* uploads Duo through the library (cli/apple/duo-library.ts, pitfall 130). */
|
||||||
export const IPHONE_DUO_DISPLAY: Record<string, string | null> = { 'iphone-duo-outer': null, 'iphone-duo-inner': null };
|
export const IPHONE_DUO_DISPLAY: Record<string, string | null> = { 'iphone-duo-outer': null, 'iphone-duo-inner': null };
|
||||||
export const IPHONE_DUO_NOT_IN_API = 'not uploaded: the App Store Connect API has no iPhone Duo display type yet (the console takes them); the renders stay in store/renders';
|
|
||||||
/** Reportedly required from this date for apps built with the iOS 27.1 SDK or later. */
|
/** Reportedly required from this date for apps built with the iOS 27.1 SDK or later. */
|
||||||
export const IPHONE_DUO_REQUIRED_FROM = '2027-04-01';
|
export const IPHONE_DUO_REQUIRED_FROM = '2027-04-01';
|
||||||
const PLAY_FOLDABLE = 'Google Play has no foldable slot: foldables show the 7" / 10" tablet screenshots (tab7, tab10, play.tablets)';
|
const PLAY_FOLDABLE = 'Google Play has no foldable slot: foldables show the 7" / 10" tablet screenshots (tab7, tab10, play.tablets)';
|
||||||
|
|
@ -75,7 +74,7 @@ const PLAY_FOLDABLE = 'Google Play has no foldable slot: foldables show the 7" /
|
||||||
export function iphoneDuoNote(o: { apple: boolean; duo: boolean; play?: boolean; today?: Date }): { level: 'info' | 'warn'; msg: string } | null {
|
export function iphoneDuoNote(o: { apple: boolean; duo: boolean; play?: boolean; today?: Date }): { level: 'info' | 'warn'; msg: string } | null {
|
||||||
if (!o.apple || o.duo) return null;
|
if (!o.apple || o.duo) return null;
|
||||||
const late = (o.today ?? new Date()).toISOString().slice(0, 10) >= IPHONE_DUO_REQUIRED_FROM;
|
const late = (o.today ?? new Date()).toISOString().slice(0, 10) >= IPHONE_DUO_REQUIRED_FROM;
|
||||||
return { level: late ? 'warn' : 'info', msg: `no iPhone Duo screenshots (outer 1398x2034, inner 2007x2853; apple.iphoneDuo): optional now, required from April 2027 for iOS 27.1 SDK builds; the App Store Connect console takes Duo screenshots and previews, the API does not yet (apple screenshots skips them with the console link)${o.play ? `. ${PLAY_FOLDABLE}` : ''}` };
|
return { level: late ? 'warn' : 'info', msg: `no iPhone Duo screenshots (outer 1398x2034, inner 2007x2853; apple.iphoneDuo): optional now, required from April 2027 for iOS 27.1 SDK builds; apple screenshots uploads them through the App Asset Library (display class IPHONE_DUO)${o.play ? `. ${PLAY_FOLDABLE}` : ''}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** validate / check note for an app with a macOS version but no Mac screenshots (apple.mac off or no mac renders). */
|
/** validate / check note for an app with a macOS version but no Mac screenshots (apple.mac off or no mac renders). */
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,7 @@ State of storebound for whoever picks it up next. Public-safe: no project, app o
|
||||||
- The Play Games Services console page paths.
|
- The Play Games Services console page paths.
|
||||||
- The Games Configuration API bodies (no pinned spec).
|
- The Games Configuration API bodies (no pinned spec).
|
||||||
- Placing a Header and Search Results asset on a live App Store version.
|
- Placing a Header and Search Results asset on a live App Store version.
|
||||||
- **iPhone Duo:** App Store Connect's API has no Duo screenshot type yet. Uploads skip with a console link; wire it
|
- **iPhone Duo:** uploaded through the App Asset Library (`cli/apple/duo-library.ts`); previews still use the deprecated sets.
|
||||||
when the spec gains the type (`IPHONE_DUO_DISPLAY` in `cli/shots/spec.ts`).
|
|
||||||
|
|
||||||
## Rules that matter here
|
## Rules that matter here
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
|
||||||
|
|
||||||
| API | operations | GET | other | wrapped by a command | owner only |
|
| API | operations | GET | other | wrapped by a command | owner only |
|
||||||
|---|---:|---:|---:|---:|---:|
|
|---|---:|---:|---:|---:|---:|
|
||||||
| App Store Connect API 4.5.1 | 1303 | 826 | 477 | 235 | 166 |
|
| App Store Connect API 4.5.1 | 1303 | 826 | 477 | 236 | 166 |
|
||||||
| Google Play Android Developer API v3 (revision 20261008) | 145 | 45 | 100 | 35 | 49 |
|
| Google Play Android Developer API v3 (revision 20261008) | 145 | 45 | 100 | 35 | 49 |
|
||||||
| Google Play Developer Reporting API v1beta1 (revision 20261008) | 25 | 15 | 10 | 6 | 0 |
|
| Google Play Developer Reporting API v1beta1 (revision 20261008) | 25 | 15 | 10 | 6 | 0 |
|
||||||
|
|
||||||
|
|
@ -80,7 +80,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
|
||||||
| `androidToIosAppMappingDetails_updateInstance` | PATCH | `/v1/androidToIosAppMappingDetails/{id}` | write (dry by default) | |
|
| `androidToIosAppMappingDetails_updateInstance` | PATCH | `/v1/androidToIosAppMappingDetails/{id}` | write (dry by default) | |
|
||||||
| **appAssetLibraries** (5) | | | | |
|
| **appAssetLibraries** (5) | | | | |
|
||||||
| `appAssetLibraries_getInstance` | GET | `/v1/appAssetLibraries/{id}` | read | |
|
| `appAssetLibraries_getInstance` | GET | `/v1/appAssetLibraries/{id}` | read | |
|
||||||
| `appAssetLibraries_images_getToManyRelated` | GET | `/v1/appAssetLibraries/{id}/images` | read | apple/creative-assets |
|
| `appAssetLibraries_images_getToManyRelated` | GET | `/v1/appAssetLibraries/{id}/images` | read | apple/creative-assets, apple/duo-library |
|
||||||
| `appAssetLibraries_images_getToManyRelationship` | GET | `/v1/appAssetLibraries/{id}/relationships/images` | read | |
|
| `appAssetLibraries_images_getToManyRelationship` | GET | `/v1/appAssetLibraries/{id}/relationships/images` | read | |
|
||||||
| `appAssetLibraries_videos_getToManyRelated` | GET | `/v1/appAssetLibraries/{id}/videos` | read | apple/creative-assets |
|
| `appAssetLibraries_videos_getToManyRelated` | GET | `/v1/appAssetLibraries/{id}/videos` | read | apple/creative-assets |
|
||||||
| `appAssetLibraries_videos_getToManyRelationship` | GET | `/v1/appAssetLibraries/{id}/relationships/videos` | read | |
|
| `appAssetLibraries_videos_getToManyRelationship` | GET | `/v1/appAssetLibraries/{id}/relationships/videos` | read | |
|
||||||
|
|
@ -94,11 +94,11 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
|
||||||
| **appAssetLibraryPlacementOrderingRequests** (1) | | | | |
|
| **appAssetLibraryPlacementOrderingRequests** (1) | | | | |
|
||||||
| `appAssetLibraryPlacementOrderingRequests_createInstance` | POST | `/v1/appAssetLibraryPlacementOrderingRequests` | write (dry by default) | |
|
| `appAssetLibraryPlacementOrderingRequests_createInstance` | POST | `/v1/appAssetLibraryPlacementOrderingRequests` | write (dry by default) | |
|
||||||
| **appAssetLibraryPlacements** (3) | | | | |
|
| **appAssetLibraryPlacements** (3) | | | | |
|
||||||
| `appAssetLibraryPlacements_createInstance` | POST | `/v1/appAssetLibraryPlacements` | write (dry by default) | apple/creative-assets |
|
| `appAssetLibraryPlacements_createInstance` | POST | `/v1/appAssetLibraryPlacements` | write (dry by default) | apple/creative-assets, apple/duo-library |
|
||||||
| `appAssetLibraryPlacements_deleteInstance` | DELETE | `/v1/appAssetLibraryPlacements/{id}` | owner only (typed confirm) | apple/creative-assets |
|
| `appAssetLibraryPlacements_deleteInstance` | DELETE | `/v1/appAssetLibraryPlacements/{id}` | owner only (typed confirm) | apple/creative-assets, apple/duo-library |
|
||||||
| `appAssetLibraryPlacements_getInstance` | GET | `/v1/appAssetLibraryPlacements/{id}` | read | |
|
| `appAssetLibraryPlacements_getInstance` | GET | `/v1/appAssetLibraryPlacements/{id}` | read | |
|
||||||
| **appAssetLibraryRefData** (2) | | | | |
|
| **appAssetLibraryRefData** (2) | | | | |
|
||||||
| `appAssetLibraryRefData_getCollection` | GET | `/v1/appAssetLibraryRefData` | read | |
|
| `appAssetLibraryRefData_getCollection` | GET | `/v1/appAssetLibraryRefData` | read | apple/duo-library |
|
||||||
| `appAssetLibraryRefData_getInstance` | GET | `/v1/appAssetLibraryRefData/{id}` | read | |
|
| `appAssetLibraryRefData_getInstance` | GET | `/v1/appAssetLibraryRefData/{id}` | read | |
|
||||||
| **appAssetLibraryVideos** (6) | | | | |
|
| **appAssetLibraryVideos** (6) | | | | |
|
||||||
| `appAssetLibraryVideos_createInstance` | POST | `/v1/appAssetLibraryVideos` | write (dry by default) | |
|
| `appAssetLibraryVideos_createInstance` | POST | `/v1/appAssetLibraryVideos` | write (dry by default) | |
|
||||||
|
|
@ -342,7 +342,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
|
||||||
| `appStoreVersionLocalizations_createInstance` | POST | `/v1/appStoreVersionLocalizations` | write (dry by default) | listing-store |
|
| `appStoreVersionLocalizations_createInstance` | POST | `/v1/appStoreVersionLocalizations` | write (dry by default) | listing-store |
|
||||||
| `appStoreVersionLocalizations_deleteInstance` | DELETE | `/v1/appStoreVersionLocalizations/{id}` | owner only (typed confirm) | |
|
| `appStoreVersionLocalizations_deleteInstance` | DELETE | `/v1/appStoreVersionLocalizations/{id}` | owner only (typed confirm) | |
|
||||||
| `appStoreVersionLocalizations_getInstance` | GET | `/v1/appStoreVersionLocalizations/{id}` | read | |
|
| `appStoreVersionLocalizations_getInstance` | GET | `/v1/appStoreVersionLocalizations/{id}` | read | |
|
||||||
| `appStoreVersionLocalizations_placements_getToManyRelated` | GET | `/v1/appStoreVersionLocalizations/{id}/placements` | read | apple/creative-assets |
|
| `appStoreVersionLocalizations_placements_getToManyRelated` | GET | `/v1/appStoreVersionLocalizations/{id}/placements` | read | apple/creative-assets, apple/duo-library |
|
||||||
| `appStoreVersionLocalizations_placements_getToManyRelationship` | GET | `/v1/appStoreVersionLocalizations/{id}/relationships/placements` | read | |
|
| `appStoreVersionLocalizations_placements_getToManyRelationship` | GET | `/v1/appStoreVersionLocalizations/{id}/relationships/placements` | read | |
|
||||||
| `appStoreVersionLocalizations_searchKeywords_createToManyRelationship` | POST | `/v1/appStoreVersionLocalizations/{id}/relationships/searchKeywords` | write (dry by default) | |
|
| `appStoreVersionLocalizations_searchKeywords_createToManyRelationship` | POST | `/v1/appStoreVersionLocalizations/{id}/relationships/searchKeywords` | write (dry by default) | |
|
||||||
| `appStoreVersionLocalizations_searchKeywords_deleteToManyRelationship` | DELETE | `/v1/appStoreVersionLocalizations/{id}/relationships/searchKeywords` | owner only (typed confirm) | |
|
| `appStoreVersionLocalizations_searchKeywords_deleteToManyRelationship` | DELETE | `/v1/appStoreVersionLocalizations/{id}/relationships/searchKeywords` | owner only (typed confirm) | |
|
||||||
|
|
@ -425,7 +425,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
|
||||||
| `apps_appStoreVersions_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/appStoreVersions` | read | |
|
| `apps_appStoreVersions_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/appStoreVersions` | read | |
|
||||||
| `apps_appTags_getToManyRelated` | GET | `/v1/apps/{id}/appTags` | read | |
|
| `apps_appTags_getToManyRelated` | GET | `/v1/apps/{id}/appTags` | read | |
|
||||||
| `apps_appTags_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/appTags` | read | |
|
| `apps_appTags_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/appTags` | read | |
|
||||||
| `apps_assetLibrary_getToOneRelated` | GET | `/v1/apps/{id}/assetLibrary` | read | apple/creative-assets |
|
| `apps_assetLibrary_getToOneRelated` | GET | `/v1/apps/{id}/assetLibrary` | read | apple/creative-assets, apple/duo-library |
|
||||||
| `apps_assetLibrary_getToOneRelationship` | GET | `/v1/apps/{id}/relationships/assetLibrary` | read | |
|
| `apps_assetLibrary_getToOneRelationship` | GET | `/v1/apps/{id}/relationships/assetLibrary` | read | |
|
||||||
| `apps_backgroundAssets_getToManyRelated` | GET | `/v1/apps/{id}/backgroundAssets` | read | |
|
| `apps_backgroundAssets_getToManyRelated` | GET | `/v1/apps/{id}/backgroundAssets` | read | |
|
||||||
| `apps_backgroundAssets_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/backgroundAssets` | read | |
|
| `apps_backgroundAssets_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/backgroundAssets` | read | |
|
||||||
|
|
|
||||||
|
|
@ -82,8 +82,7 @@ languages, every store size, a review gallery and an app preview video. No store
|
||||||
Play takes a YouTube URL in `play.video`.
|
Play takes a YouTube URL in `play.video`.
|
||||||
7. **iPhone Duo (foldable).** `apple.iphoneDuo: true` renders and checks the outer (1398 x 2034) and inner
|
7. **iPhone Duo (foldable).** `apple.iphoneDuo: true` renders and checks the outer (1398 x 2034) and inner
|
||||||
(2007 x 2853) sets. Optional now, reportedly required from April 2027 for iOS 27.1 SDK builds (`check` / `validate`
|
(2007 x 2853) sets. Optional now, reportedly required from April 2027 for iOS 27.1 SDK builds (`check` / `validate`
|
||||||
note it). The API has no Duo display type yet, so `apple screenshots` skips them with a link to the console's
|
note it). `apple screenshots` uploads them through the App Asset Library (display class IPHONE_DUO). Play has no foldable slot: foldables use the 7" / 10" tablet sets.
|
||||||
iPhone Duo tab. Play has no foldable slot: foldables use the 7" / 10" tablet sets.
|
|
||||||
|
|
||||||
Details: the [store-screenshots](../skills/store-screenshots/SKILL.md) and [app-previews](../skills/app-previews/SKILL.md)
|
Details: the [store-screenshots](../skills/store-screenshots/SKILL.md) and [app-previews](../skills/app-previews/SKILL.md)
|
||||||
skills.
|
skills.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "storebound",
|
"name": "storebound",
|
||||||
"version": "0.8.71",
|
"version": "0.8.77",
|
||||||
"description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.",
|
"description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.",
|
||||||
"contextFileName": "GEMINI.md",
|
"contextFileName": "GEMINI.md",
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "storebound",
|
"name": "storebound",
|
||||||
"version": "0.8.71",
|
"version": "0.8.77",
|
||||||
"description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every store questionnaire (incl. account deletion and App Review demo accounts), privacy, icons, screenshots and previews, TestFlight. After launch: crash vitals, review replies, price sheets, analytics and Product Page Optimization. One config, a CLI (Node 20+ or Bun), an MCP server and Agent Skills for your AI coding agent.",
|
"description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every store questionnaire (incl. account deletion and App Review demo accounts), privacy, icons, screenshots and previews, TestFlight. After launch: crash vitals, review replies, price sheets, analytics and Product Page Optimization. One config, a CLI (Node 20+ or Bun), an MCP server and Agent Skills for your AI coding agent.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "CRTkafa",
|
"author": "CRTkafa",
|
||||||
|
|
|
||||||
|
|
@ -1610,3 +1610,19 @@ that fails without the fix (see CONTRIBUTING.md).
|
||||||
- **Guard:** `cli/play/old-builds.ts` (play status, validate `old-builds`), `PLAY_NOT_LIVE` in `cli/lib/portfolio.ts`,
|
- **Guard:** `cli/play/old-builds.ts` (play status, validate `old-builds`), `PLAY_NOT_LIVE` in `cli/lib/portfolio.ts`,
|
||||||
`isAutoSendOnly` in `cli/play/review-pending.ts` (the commit is redone without changesNotSentForReview),
|
`isAutoSendOnly` in `cli/play/review-pending.ts` (the commit is redone without changesNotSentForReview),
|
||||||
`tests/play-old-builds.test.ts`.
|
`tests/play-old-builds.test.ts`.
|
||||||
|
|
||||||
|
## 130. iPhone Duo screenshots were skipped as "not in the API"
|
||||||
|
|
||||||
|
- **Symptom:** `apple screenshots` skipped the iPhone Duo sets with "the API has no iPhone Duo display type yet".
|
||||||
|
Wrong: ScreenshotDisplayType never gets a Duo value. API 4.5.1 put the display class `IPHONE_DUO` into the App Asset
|
||||||
|
Library (AppAssetLibraryDisplayClass) and deprecated appScreenshotSets / appScreenshots / appPreviewSets /
|
||||||
|
appPreviews in the same release.
|
||||||
|
- **Do:** `storebound apple screenshots` uploads Duo (apple.iphoneDuo, renders iphone-duo-outer/ then
|
||||||
|
iphone-duo-inner/) through the library: `appAssetLibraryRefData` (filter placementTypes APP_SCREENSHOT) gives the
|
||||||
|
iOS placementProfileGroup of IPHONE_DUO, files go up as appAssetLibraryImages (category
|
||||||
|
APP_SCREENSHOTS_AND_PREVIEWS), then appAssetLibraryPlacements (APP_SCREENSHOT, that placementGroup, the version
|
||||||
|
localization). Same MD5 reuse as creative assets; read back.
|
||||||
|
- **Never:** say an API lacks a store feature after checking one enum: search the whole spec (every schema with the
|
||||||
|
feature's name) first.
|
||||||
|
- **Guard:** `cli/apple/duo-library.ts` (duoGroups, syncDuo), `duoDirs` in `cli/apple/screenshots.ts`,
|
||||||
|
`tests/ppo.test.ts`. Previews and the other display classes still use the deprecated sets until they move too.
|
||||||
|
|
|
||||||
|
|
@ -71,10 +71,9 @@ in a plain Android tablet body instead of the iPad one.
|
||||||
outer (1398 x 2034) and an inner (2007 x 2853) set, portrait or landscape, PNG / JPEG without alpha, 1-10 each. It is
|
outer (1398 x 2034) and an inner (2007 x 2853) set, portrait or landscape, PNG / JPEG without alpha, 1-10 each. It is
|
||||||
optional now and reportedly required from April 2027 for builds with the iOS 27.1 SDK or later; `check` / `validate`
|
optional now and reportedly required from April 2027 for builds with the iOS 27.1 SDK or later; `check` / `validate`
|
||||||
print an info note until then (a warning after, never a blocker). `apple.iphoneDuo: true` adds both sizes to
|
print an info note until then (a warning after, never a blocker). `apple.iphoneDuo: true` adds both sizes to
|
||||||
`shots render` / `shots check` / the gallery. The API (4.5.1) has no Duo `ScreenshotDisplayType` yet, so
|
`shots render` / `shots check` / the gallery. ScreenshotDisplayType has no Duo value (and won't get one), so
|
||||||
`apple screenshots` skips the Duo folders with a message and the deep link to the version page; add them in the
|
`apple screenshots` uploads the Duo folders through the App Asset Library (display class IPHONE_DUO, placement
|
||||||
console's iPhone Duo tab until then (one constant, `IPHONE_DUO_DISPLAY` in `cli/shots/spec.ts`, takes the names once
|
APP_SCREENSHOT; pitfall 130). No Duo app preview sizes are published. Google Play has no foldable slot: foldables show the
|
||||||
the enum has them). No Duo app preview sizes are published. Google Play has no foldable slot: foldables show the
|
|
||||||
7" / 10" tablet sets below.
|
7" / 10" tablet sets below.
|
||||||
|
|
||||||
**Mac App Store.** Apple's Screenshot specifications (checked 2026-10-20) list four 16:10 Mac sizes: 1280 x 800,
|
**Mac App Store.** Apple's Screenshot specifications (checked 2026-10-20) list four 16:10 Mac sizes: 1280 x 800,
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
import { displaySets, skippedDuoSets } from '../cli/apple/screenshots';
|
import { displaySets, duoDirs } from '../cli/apple/screenshots';
|
||||||
|
import { duoGroups, duoLimit, duoUpToDate } from '../cli/apple/duo-library';
|
||||||
import { appleRules } from '../cli/lib/validate-rules';
|
import { appleRules } from '../cli/lib/validate-rules';
|
||||||
import { checkCreate, createExperimentReq, defaultName, experimentFromApi, pickExperiment, planTreatmentLocales, planTreatments, startStopReq } from '../cli/lib/ppo';
|
import { checkCreate, createExperimentReq, defaultName, experimentFromApi, pickExperiment, planTreatmentLocales, planTreatments, startStopReq } from '../cli/lib/ppo';
|
||||||
import { validateConfig } from '../cli/lib/config';
|
import { validateConfig } from '../cli/lib/config';
|
||||||
import { FORBIDDEN_ARGS, TOOLS, toArgv, validateInput } from '../cli/mcp-tools';
|
import { FORBIDDEN_ARGS, TOOLS, toArgv, validateInput } from '../cli/mcp-tools';
|
||||||
import { requiredSets } from '../cli/shots/report';
|
import { requiredSets } from '../cli/shots/report';
|
||||||
import { defaultSizes, SIZES } from '../cli/shots/lib';
|
import { defaultSizes, SIZES } from '../cli/shots/lib';
|
||||||
import { checkImage, IMAGE_SPECS, imageInfo, IPHONE_DUO_DISPLAY, IPHONE_DUO_NOT_IN_API, iphoneDuoNote } from '../cli/shots/spec';
|
import { checkImage, IMAGE_SPECS, imageInfo, IPHONE_DUO_DISPLAY, iphoneDuoNote } from '../cli/shots/spec';
|
||||||
import { png } from './fixtures';
|
import { png } from './fixtures';
|
||||||
|
|
||||||
describe('product page optimization', () => {
|
describe('product page optimization', () => {
|
||||||
|
|
@ -91,12 +92,25 @@ describe('iPhone Duo (foldable) sizes', () => {
|
||||||
expect(checkImage(imageInfo(png(2007, 2853)), 'iphone-duo-outer').map(i => i.code)).toEqual(['size']);
|
expect(checkImage(imageInfo(png(2007, 2853)), 'iphone-duo-outer').map(i => i.code)).toEqual(['size']);
|
||||||
expect(checkImage(imageInfo(png(2007, 2853, { colorType: 6 })), 'iphone-duo-inner').map(i => i.code)).toEqual(['alpha']);
|
expect(checkImage(imageInfo(png(2007, 2853, { colorType: 6 })), 'iphone-duo-inner').map(i => i.code)).toEqual(['alpha']);
|
||||||
});
|
});
|
||||||
test('upload: no API display type yet, so the sets are skipped with the message', () => {
|
test('upload: no ScreenshotDisplayType; Duo goes through the App Asset Library (IPHONE_DUO group)', () => {
|
||||||
expect(Object.values(IPHONE_DUO_DISPLAY).every(t => t === null)).toBe(true);
|
expect(Object.values(IPHONE_DUO_DISPLAY).every(t => t === null)).toBe(true);
|
||||||
expect(Object.keys(displaySets({ iphoneDuo: true }))).not.toContain('iphone-duo-outer');
|
expect(Object.keys(displaySets({ iphoneDuo: true }))).not.toContain('iphone-duo-outer');
|
||||||
expect(skippedDuoSets({})).toBeNull();
|
expect(duoDirs({})).toEqual([]);
|
||||||
expect(skippedDuoSets({ iphoneDuo: true })).toEqual({ dirs: ['iphone-duo-outer', 'iphone-duo-inner'], msg: expect.stringContaining(`iphone-duo-outer, iphone-duo-inner: ${IPHONE_DUO_NOT_IN_API}. Add them in App Store Connect > the iOS version page > App Previews and Screenshots > iPhone Duo: https://`) });
|
expect(duoDirs({ iphoneDuo: true })).toEqual(['iphone-duo-outer', 'iphone-duo-inner']);
|
||||||
expect(skippedDuoSets({ iphoneDuo: true, appId: '1234567890' })!.msg).toContain('/apps/1234567890/distribution/ios/version/inflight');
|
const ref = [{ attributes: { placementProfileGroups: [
|
||||||
|
{ placementProfileGroupId: 'g-67', platform: 'IOS', displayClassId: 'IPHONE_DYNAMIC_ISLAND_LARGE_DISPLAY' },
|
||||||
|
{ placementProfileGroupId: 'g-duo', platform: 'IOS', displayClassId: 'IPHONE_DUO' },
|
||||||
|
{ placementProfileGroupId: 'g-duo', platform: 'IOS', displayClassId: 'IPHONE_DUO' },
|
||||||
|
{ placementProfileGroupId: 'g-mac', platform: 'MAC_OS', displayClassId: 'IPHONE_DUO' }] } }];
|
||||||
|
expect(duoGroups(ref)).toEqual(['g-duo']);
|
||||||
|
expect(duoGroups([])).toEqual([]);
|
||||||
|
expect(duoGroups([{ attributes: { placementProfileGroups: [{ placementProfileGroupId: 'IPHONE_DUO_PROFILE', platform: 'IPHONE_APP_STORE', displayClassId: 'IPHONE_DUO' }] } }])).toEqual(['IPHONE_DUO_PROFILE']);
|
||||||
|
const lim = [{ attributes: { features: [{ featureId: 'IN_APP_EVENTS', placementPolicies: [{ placementType: 'APP_SCREENSHOT', groupLimits: [{ groupIds: ['g'], maxCount: 1 }] }] }, { featureId: 'APP_STORE_VERSIONS', placementPolicies: [{ placementType: 'APP_SCREENSHOT', groupLimits: [{ groupIds: ['g'], maxCount: 20 }] }] }] } }];
|
||||||
|
expect(duoLimit(lim, 'g')).toBe(20);
|
||||||
|
expect(duoLimit(lim, 'other')).toBeNull();
|
||||||
|
expect(duoUpToDate(['a', 'b'], ['a', 'b'])).toBe(true);
|
||||||
|
expect(duoUpToDate(['b', 'a'], ['a', 'b'])).toBe(false);
|
||||||
|
expect(duoUpToDate(['a', undefined], ['a', 'b'])).toBe(false);
|
||||||
});
|
});
|
||||||
test('check / validate note: info before April 2027, never a blocker; points Play to the tablet sets', () => {
|
test('check / validate note: info before April 2027, never a blocker; points Play to the tablet sets', () => {
|
||||||
const before = new Date('2026-10-18'), after = new Date('2027-04-02');
|
const before = new Date('2026-10-18'), after = new Date('2027-04-02');
|
||||||
|
|
@ -106,7 +120,7 @@ describe('iPhone Duo (foldable) sizes', () => {
|
||||||
expect(n.level).toBe('info');
|
expect(n.level).toBe('info');
|
||||||
expect(n.msg).toContain('optional now, required from April 2027 for iOS 27.1 SDK builds');
|
expect(n.msg).toContain('optional now, required from April 2027 for iOS 27.1 SDK builds');
|
||||||
expect(n.msg).toContain('tablet screenshots');
|
expect(n.msg).toContain('tablet screenshots');
|
||||||
expect(n.msg).toContain('the App Store Connect console takes Duo screenshots and previews, the API does not yet');
|
expect(n.msg).toContain('apple screenshots uploads them through the App Asset Library');
|
||||||
expect(iphoneDuoNote({ apple: true, duo: false, today: after })!.level).toBe('warn');
|
expect(iphoneDuoNote({ apple: true, duo: false, today: after })!.level).toBe('warn');
|
||||||
const base = { version: null, build: null, locales: [], expectedLocales: [], requiredDisplayTypes: [], reviewDetail: null, ageRating: null, priceSchedule: true, availability: true, iaps: [], expectedProducts: [], privacyUrl: { url: 'https://example.com/privacy', status: 200 } };
|
const base = { version: null, build: null, locales: [], expectedLocales: [], requiredDisplayTypes: [], reviewDetail: null, ageRating: null, priceSchedule: true, availability: true, iaps: [], expectedProducts: [], privacyUrl: { url: 'https://example.com/privacy', status: 200 } };
|
||||||
const f = appleRules({ ...base, today: before } as unknown as Parameters<typeof appleRules>[0]).find(x => x.code === 'screenshots-duo')!;
|
const f = appleRules({ ...base, today: before } as unknown as Parameters<typeof appleRules>[0]).find(x => x.code === 'screenshots-duo')!;
|
||||||
|
|
|
||||||
14
tools/ci-install-bun.sh
Normal file
14
tools/ci-install-bun.sh
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Installs bun $1 (or $BUN_VERSION) into ~/.bun/bin on a Linux CI runner from the npm registry (@oven/bun-linux-<arch>),
|
||||||
|
# because GitHub's oven-sh/bun release assets answer 504 now and then; bun.sh/install with retries is the fallback.
|
||||||
|
set -u
|
||||||
|
V="${1:-${BUN_VERSION:?BUN_VERSION}}"
|
||||||
|
case "$(uname -m)" in aarch64|arm64) A=aarch64 ;; *) A=x64 ;; esac
|
||||||
|
D="$HOME/.bun/bin"; mkdir -p "$D"; T=$(mktemp -d)
|
||||||
|
for i in 1 2 3; do
|
||||||
|
if curl -fsSL --max-time 120 "https://registry.npmjs.org/@oven/bun-linux-$A/-/bun-linux-$A-$V.tgz" | tar -xz -C "$T" \
|
||||||
|
&& install -m 755 "$T/package/bin/bun" "$D/bun"; then ln -sf bun "$D/bunx"; "$D/bun" --version; exit 0; fi
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
for i in 1 2 3; do curl -fsSL https://bun.sh/install | bash -s "bun-v$V" >/dev/null && { "$D/bun" --version; exit 0; }; sleep 20; done
|
||||||
|
echo "bun $V: npm registry and GitHub both failed" >&2; exit 1
|
||||||
13
tools/ci-tauri-tools.sh
Executable file
13
tools/ci-tauri-tools.sh
Executable file
|
|
@ -0,0 +1,13 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Puts the AppImage tools Tauri's bundler would download from GitHub releases (which answer 502/504 now and then) into
|
||||||
|
# ~/.cache/tauri from our Forgejo package mirror, checked against its SHA256SUMS. Tauri skips the download when they exist.
|
||||||
|
set -eu
|
||||||
|
B="${TAURI_TOOLS_MIRROR:-https://git.crt.fyi/api/packages/CRT/generic/tauri-linux-tools/2026.11.08}"
|
||||||
|
D="$HOME/.cache/tauri"; mkdir -p "$D"; cd "$D"
|
||||||
|
curl -fsSL --retry 3 --max-time 60 -o SHA256SUMS "$B/SHA256SUMS"
|
||||||
|
while read -r sum f; do
|
||||||
|
f="${f#\*}"
|
||||||
|
if ! echo "$sum $f" | sha256sum -c --status 2>/dev/null; then curl -fsSL --retry 3 --max-time 300 -o "$f" "$B/$f"; echo "$sum $f" | sha256sum -c --status; fi
|
||||||
|
chmod +x "$f"
|
||||||
|
done < SHA256SUMS
|
||||||
|
rm -f SHA256SUMS; ls "$D"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue