Build/Submit details page URL
Not applicable — this is about eas metadata:push, not a build or submit job.
Summary
eas metadata:push exits 0 even when stdout contains explicit per-asset failure markers such as:
Failed uploading screenshot <name> (<locale>)
Failed deleting screenshot <name> (<locale>)
Failed reordering screenshots (<locale>)
Failed creating screenshot set for <displayType> (<locale>)
The result is that any caller relying on $? to gate retries / CI promotion / "did the push succeed?" sees success while large parts of the screenshot phase silently failed. To work around this in a real release pipeline, callers have to write a stdout-aware retry wrapper that greps for the failure markers themselves and re-runs eas metadata:push --non-interactive until a clean run is observed.
The cause is in packages/eas-cli/src/metadata/apple/tasks/screenshots.ts. Each upload/delete/reorder is wrapped in logAsync(...) whose failure: text is rendered if the underlying promise rejects:
const newScreenshot = await logAsync(
() =>
AppScreenshot.uploadAsync(localization.context, {
id: screenshotSet!.id,
filePath: absolutePath,
waitForProcessing: true,
}),
{
pending: `Uploading screenshot ${chalk.bold(fileName)} (${locale})...`,
success: `Uploaded screenshot ${chalk.bold(fileName)} (${locale})`,
failure: `Failed uploading screenshot ${chalk.bold(fileName)} (${locale})`,
}
);
logAsync either rethrows (in which case the for (const absolutePath of pathsToUpload) loop in syncScreenshotSetAsync aborts that single (locale × screenshotDisplayType) pair, but the surrounding outer loop continues to the next pair) or — more relevantly here — the outer pushAsync driver does not treat per-pair throws as a non-zero exit signal. The end result is "many pairs failed loudly, exit code says everything is fine".
Either way: when stdout has any Failed (uploading|deleting|reordering|creating) … line, the only correct exit code is non-zero.
Managed or bare?
bare (a native Swift / SwiftUI iOS project that uses EAS only for metadata:*)
Environment
eas-cli: 18.11.0 darwin-arm64 node-v22.13.1
node: v22.13.1
macOS: 26.4.1 (arm64)
store.config.json has 17 localizations × 2 device types = up to 34 screenshot sets, ~10 screenshots each. Apple's screenshot upload / S3 presigned URL pipeline produces transient 503 / 403 / Unexpected response errors at this scale, so per-asset retries are common and unavoidable.
Reproducible demo or steps to reproduce
-
Configure store.config.json with a non-trivial number of (locale × displayType) pairs (e.g. 10+ locales × 2 device types × 8–10 screenshots per set).
-
Run eas metadata:push --profile production --non-interactive 2>&1 | tee /tmp/eas_push.log.
-
With a real Apple account at this scale, at least one of the upload / delete / reorder calls will fail transiently. Verify with:
grep -E 'Failed (uploading|deleting|reordering|creating)' /tmp/eas_push.log
-
Observe exit code:
-
The failures are real — repeating the push will re-attempt them, and a metadata:pull will reveal that the affected sets are inconsistent with store.config.json.
Concrete numbers from the project where I hit this
I had to wrap eas metadata:push in a retry loop that re-runs the command until stdout has zero Failed (uploading|deleting|reordering|creating) … markers, because exit 0 alone was meaningless. The loop ran 21 attempts before stdout was finally clean. Every single attempt exited 0.
Excerpts from the actual log (one of 21 attempts that exited 0 with failures in stdout):
✖ Failed uploading screenshot 07-events-2048x2732.png (es-ES)
…
✖ Failed deleting screenshot 02-add-2048x2732.png (it)
…
=== EXIT=0 ===
Suggested fix
In packages/eas-cli/src/metadata/apple/tasks/screenshots.ts (and any sibling task that uses the same logAsync failure pattern):
- Track per-pair failures inside
syncScreenshotSetAsync. Instead of letting logAsync(...) rethrow and abort the inner for loop, capture the error, increment a counter on the task context, and continue. After the outer for (const localeCode of locales) loop completes, throw an aggregate error if the counter is non-zero.
- Have
pushAsync (packages/eas-cli/src/metadata/apple/index.ts or wherever the task runner aggregates results) propagate that aggregate error to its caller so MetadataPush can throw new MetadataUploadError(...) and exit non-zero.
- Independently, add a final
if (failureCount > 0) summary line and have the process exit non-zero whenever it is non-zero. This is a small, behavior-preserving change for the success path and a strict improvement for the failure path.
A rough shape of the fix in syncScreenshotSetAsync:
const failures: Error[] = [];
…
for (const absolutePath of pathsToUpload) {
try {
const newScreenshot = await logAsync(/* … */);
screenshotIdsToKeep.push(newScreenshot.id);
} catch (error: any) {
failures.push(error);
}
}
…
if (failures.length > 0) {
throw new AggregateMetadataError(
`Encountered ${failures.length} screenshot ${failures.length === 1 ? 'error' : 'errors'} for ${displayType} (${locale})`,
failures
);
}
…with the corresponding adjustment in the outer driver to collect / rethrow these so the process exit code is non-zero.
Workaround we are using
A bash retry wrapper that runs eas metadata:push --non-interactive, captures stdout to a log, and only treats the run as successful when both:
- exit code is 0; and
- stdout contains zero
Failed (uploading|deleting|reordering|creating) … markers.
Otherwise it retries (the underlying push step is idempotent — completed assets match by filename + filesize and are skipped on the next attempt).
If it would help, we can share the wrapper as a reference, but the right fix is for eas metadata:push to make stdout-grep unnecessary.
Error output
Per-asset stdout entries like:
✖ Failed uploading screenshot 07-events-2048x2732.png (es-ES)
✖ Failed deleting screenshot 02-add-2048x2732.png (it)
…with echo $? reporting 0 on the parent process.
Build/Submit details page URL
Not applicable — this is about
eas metadata:push, not a build or submit job.Summary
eas metadata:pushexits 0 even when stdout contains explicit per-asset failure markers such as:Failed uploading screenshot <name> (<locale>)Failed deleting screenshot <name> (<locale>)Failed reordering screenshots (<locale>)Failed creating screenshot set for <displayType> (<locale>)The result is that any caller relying on
$?to gate retries / CI promotion / "did the push succeed?" sees success while large parts of the screenshot phase silently failed. To work around this in a real release pipeline, callers have to write a stdout-aware retry wrapper that greps for the failure markers themselves and re-runseas metadata:push --non-interactiveuntil a clean run is observed.The cause is in
packages/eas-cli/src/metadata/apple/tasks/screenshots.ts. Each upload/delete/reorder is wrapped inlogAsync(...)whosefailure:text is rendered if the underlying promise rejects:logAsynceither rethrows (in which case thefor (const absolutePath of pathsToUpload)loop insyncScreenshotSetAsyncaborts that single(locale × screenshotDisplayType)pair, but the surrounding outer loop continues to the next pair) or — more relevantly here — the outerpushAsyncdriver does not treat per-pair throws as a non-zero exit signal. The end result is "many pairs failed loudly, exit code says everything is fine".Either way: when stdout has any
Failed (uploading|deleting|reordering|creating) …line, the only correct exit code is non-zero.Managed or bare?
bare (a native Swift / SwiftUI iOS project that uses EAS only for
metadata:*)Environment
store.config.jsonhas 17 localizations × 2 device types = up to 34 screenshot sets, ~10 screenshots each. Apple's screenshot upload / S3 presigned URL pipeline produces transient503/403/Unexpected responseerrors at this scale, so per-asset retries are common and unavoidable.Reproducible demo or steps to reproduce
Configure
store.config.jsonwith a non-trivial number of(locale × displayType)pairs (e.g. 10+ locales × 2 device types × 8–10 screenshots per set).Run
eas metadata:push --profile production --non-interactive 2>&1 | tee /tmp/eas_push.log.With a real Apple account at this scale, at least one of the upload / delete / reorder calls will fail transiently. Verify with:
grep -E 'Failed (uploading|deleting|reordering|creating)' /tmp/eas_push.logObserve exit code:
The failures are real — repeating the push will re-attempt them, and a
metadata:pullwill reveal that the affected sets are inconsistent withstore.config.json.Concrete numbers from the project where I hit this
I had to wrap
eas metadata:pushin a retry loop that re-runs the command until stdout has zeroFailed (uploading|deleting|reordering|creating) …markers, because exit 0 alone was meaningless. The loop ran 21 attempts before stdout was finally clean. Every single attempt exited 0.Excerpts from the actual log (one of 21 attempts that exited 0 with failures in stdout):
Suggested fix
In
packages/eas-cli/src/metadata/apple/tasks/screenshots.ts(and any sibling task that uses the samelogAsyncfailure pattern):syncScreenshotSetAsync. Instead of lettinglogAsync(...)rethrow and abort the innerforloop, capture the error, increment a counter on the task context, and continue. After the outerfor (const localeCode of locales)loop completes, throw an aggregate error if the counter is non-zero.pushAsync(packages/eas-cli/src/metadata/apple/index.tsor wherever the task runner aggregates results) propagate that aggregate error to its caller soMetadataPushcanthrow new MetadataUploadError(...)and exit non-zero.if (failureCount > 0)summary line and have the process exit non-zero whenever it is non-zero. This is a small, behavior-preserving change for the success path and a strict improvement for the failure path.A rough shape of the fix in
syncScreenshotSetAsync:…with the corresponding adjustment in the outer driver to collect / rethrow these so the process exit code is non-zero.
Workaround we are using
A bash retry wrapper that runs
eas metadata:push --non-interactive, captures stdout to a log, and only treats the run as successful when both:Failed (uploading|deleting|reordering|creating) …markers.Otherwise it retries (the underlying push step is idempotent — completed assets match by filename + filesize and are skipped on the next attempt).
If it would help, we can share the wrapper as a reference, but the right fix is for
eas metadata:pushto make stdout-grep unnecessary.Error output
Per-asset stdout entries like:
…with
echo $?reporting 0 on the parent process.