Summary
When several files are added to a media library in one go (multi-select in a file dialog, or dragging a selection onto the drop zone), every file is uploaded concurrently rather than one after another. Each upload is its own commit on the branch, so the concurrent requests race on the branch head and GitHub rejects all but one per round with:
... is at <sha> but expected <sha> - https://docs.github.com/rest/repos/contents#create-or-update-file-contents
The three SHAs in the errors are commit SHAs (the branch head moving), not blob SHAs. Some files in a batch land, the rest fail, and it gets worse the more files are selected. Uploading one file at a time works.
Cause
components/media/media-upload.tsx, in handleFiles:
for (const file of files) {
const uploadPromise = (async () => { /* fetch POST /files/... */ })();
await toast.promise(uploadPromise, { ... });
}
The loop is written to be sequential, but toast.promise() from sonner (^2.0.7 in package.json) does not return the promise. It returns the toast id synchronously, with an unwrap helper attached:
// sonner src/state.ts
return Object.assign(id, { unwrap }); // or { unwrap } when id is undefined
So await toast.promise(...) resolves immediately and the loop starts the next upload straight away. The uploadPromise IIFE has already begun its fetch by then, so all files in the batch are in flight together.
The rich-text image insert path (components/ui/editor/index.tsx, insertLocalImageFile) awaits onUploadImage directly, so it is not affected. components/folder-create.tsx:89 has the same await toast.promise(...) pattern but is a single call, not a loop, so it is harmless.
Fix
Await the upload itself, and keep going with the remaining files if one fails (the toast already reports the failure):
toast.promise(uploadPromise, { ... });
try {
await uploadPromise;
} catch {
// Already surfaced by the toast; continue with the next file.
}
PR to follow against development.
Environment
- app.pagescms.org, observed 2026-09-08
- GitHub backend, repository with a single
main branch
- Reproduces with as few as 2 files selected together; seen most often with 5 to 30
Summary
When several files are added to a media library in one go (multi-select in a file dialog, or dragging a selection onto the drop zone), every file is uploaded concurrently rather than one after another. Each upload is its own commit on the branch, so the concurrent requests race on the branch head and GitHub rejects all but one per round with:
The three SHAs in the errors are commit SHAs (the branch head moving), not blob SHAs. Some files in a batch land, the rest fail, and it gets worse the more files are selected. Uploading one file at a time works.
Cause
components/media/media-upload.tsx, inhandleFiles:The loop is written to be sequential, but
toast.promise()from sonner (^2.0.7in package.json) does not return the promise. It returns the toast id synchronously, with anunwraphelper attached:So
await toast.promise(...)resolves immediately and the loop starts the next upload straight away. TheuploadPromiseIIFE has already begun itsfetchby then, so all files in the batch are in flight together.The rich-text image insert path (
components/ui/editor/index.tsx,insertLocalImageFile) awaitsonUploadImagedirectly, so it is not affected.components/folder-create.tsx:89has the sameawait toast.promise(...)pattern but is a single call, not a loop, so it is harmless.Fix
Await the upload itself, and keep going with the remaining files if one fails (the toast already reports the failure):
PR to follow against
development.Environment
mainbranch