Skip to content

Commit 3562f0a

Browse files
ci: handle large repo mirroring via GitLab import
Add size-aware mirroring logic that routes large empty projects through GitLab’s server-side import API before pushing, to avoid GitLab.com’s 5 GiB push limit. The workflow now tracks project emptiness, starts authenticated imports for private sources, waits for import completion with clear status/permission/time-out handling, and then proceeds with mirror push. It also removes GitHub-only `refs/pull/*` refs from the local mirror before push.
1 parent a6fca60 commit 3562f0a

1 file changed

Lines changed: 169 additions & 0 deletions

File tree

.github/workflows/__mirror-github-to-gitlab.yml

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ jobs:
5656
const repositoryData = repos.map((repo) => ({
5757
cloneUrl: repo.clone_url,
5858
name: repo.name,
59+
sizeKiB: repo.size,
5960
targetVisibility: repo.visibility === 'public' ? 'public' : 'private',
6061
...gitlabTarget(repo),
6162
}));
@@ -149,6 +150,57 @@ jobs:
149150
curl "${curl_args[@]}" "${url}"
150151
}
151152
153+
wait_for_gitlab_import() {
154+
local project_id="$1"
155+
local source_name="$2"
156+
local deadline="$((SECONDS + 10800))"
157+
local import_error
158+
local import_status
159+
local status
160+
161+
while ((SECONDS < deadline)); do
162+
if ! status="$(gitlab_request GET "${GITLAB_API_URL}/projects/${project_id}/import")"; then
163+
echo "::error title=Mirror failed: ${source_name}::Unable to query the GitLab import."
164+
return 1
165+
fi
166+
if [[ "${status}" != "200" ]]; then
167+
if [[ "${status}" == "403" ]]; then
168+
echo "::error title=Mirror failed: ${source_name}::GITLAB_TOKEN needs Import API Read access."
169+
return 1
170+
fi
171+
echo "::error title=Mirror failed: ${source_name}::Unable to query the GitLab import (HTTP ${status})."
172+
return 1
173+
fi
174+
175+
import_status="$(jq -er '.import_status' "${response_file}")"
176+
case "${import_status}" in
177+
finished)
178+
echo "GitLab server-side import finished."
179+
return 0
180+
;;
181+
scheduled | started)
182+
echo "GitLab server-side import status: ${import_status}."
183+
sleep 30
184+
;;
185+
failed)
186+
import_error="$(jq -r '.import_error // "No import error detail was provided."' "${response_file}")"
187+
import_error="${import_error//$'\r'/ }"
188+
import_error="${import_error//$'\n'/ }"
189+
echo "GitLab import failed: ${import_error}"
190+
echo "::error title=Mirror failed: ${source_name}::GitLab server-side import failed."
191+
return 1
192+
;;
193+
*)
194+
echo "::error title=Mirror failed: ${source_name}::Unexpected GitLab import status: ${import_status}."
195+
return 1
196+
;;
197+
esac
198+
done
199+
200+
echo "::error title=Mirror failed: ${source_name}::GitLab server-side import timed out after three hours."
201+
return 1
202+
}
203+
152204
encoded_group="$(jq -rn --arg value "${GITLAB_GROUP}" '$value | @uri')"
153205
if ! status="$(gitlab_request GET "${GITLAB_API_URL}/groups/${encoded_group}")"; then
154206
echo "::error::Unable to query the GitLab group."
@@ -163,12 +215,17 @@ jobs:
163215
github_auth="$(printf 'x-access-token:%s' "${GITHUB_TOKEN}" | base64 --wrap=0)"
164216
echo "::add-mask::${github_auth}"
165217
218+
# GitLab.com limits each Git push to 5 GiB. Seed large empty projects through GitLab's import API,
219+
# using a lower threshold because GitHub's reported repository size is not the resulting pack size.
220+
gitlab_import_threshold_kib="$((4 * 1024 * 1024))"
221+
166222
repository_count="$(jq -er 'length' "${repository_file}")"
167223
for ((index = 0; index < repository_count; index++)); do
168224
repository="$(jq -ec ".[${index}]" "${repository_file}")"
169225
repository_number="$((index + 1))"
170226
source_name="$(jq -er '.name' <<< "${repository}")"
171227
source_clone_url="$(jq -er '.cloneUrl' <<< "${repository}")"
228+
source_size_kib="$(jq -er '.sizeKiB' <<< "${repository}")"
172229
target_name="$(jq -er '.targetName' <<< "${repository}")"
173230
target_path="$(jq -er '.targetPath' <<< "${repository}")"
174231
target_visibility="$(jq -er '.targetVisibility' <<< "${repository}")"
@@ -180,6 +237,8 @@ jobs:
180237
echo "Visibility: ${target_visibility}"
181238
echo "Checking for the GitLab project..."
182239
240+
project_created=false
241+
183242
if ! status="$(gitlab_request GET "${GITLAB_API_URL}/projects/${encoded_project_path}")"; then
184243
echo "::error title=Mirror failed: ${source_name}::Unable to query the GitLab project."
185244
exit 1
@@ -210,6 +269,7 @@ jobs:
210269
echo "::error title=Mirror failed: ${source_name}::${error_message}"
211270
exit 1
212271
fi
272+
project_created=true
213273
elif [[ "${status}" != "200" ]]; then
214274
echo "::error title=Mirror failed: ${source_name}::Unable to query the GitLab project (HTTP ${status})."
215275
exit 1
@@ -218,6 +278,13 @@ jobs:
218278
fi
219279
220280
project_id="$(jq -er '.id' "${response_file}")"
281+
if [[ "${project_created}" == "true" ]]; then
282+
project_empty=true
283+
else
284+
project_empty="$(
285+
jq -r '(.empty_repo == true) or (.default_branch == null)' "${response_file}"
286+
)"
287+
fi
221288
222289
# Make a private source private before changing metadata or pushing any Git data.
223290
if [[ "${target_visibility}" == "private" ]]; then
@@ -267,6 +334,75 @@ jobs:
267334
268335
target_clone_url="$(jq -er '.ssh_url_to_repo' "${response_file}")"
269336
337+
gitlab_import_started=false
338+
gitlab_incremental_seed=false
339+
if [[ "${project_empty}" == "true" ]] && \
340+
((source_size_kib >= gitlab_import_threshold_kib)); then
341+
echo "Empty large project detected (${source_size_kib} KiB reported by GitHub)."
342+
echo "Scheduling a GitLab server-side Git import to avoid the 5 GiB push limit..."
343+
if [[ "${target_visibility}" == "private" ]]; then
344+
import_payload="$(
345+
jq -nc \
346+
--arg import_url "${source_clone_url}" \
347+
--arg import_url_user "x-access-token" \
348+
--arg import_url_password "${GITHUB_TOKEN}" \
349+
'{
350+
import_url: $import_url,
351+
import_url_user: $import_url_user,
352+
import_url_password: $import_url_password
353+
}'
354+
)"
355+
else
356+
import_payload="$(
357+
jq -nc \
358+
--arg import_url "${source_clone_url}" \
359+
'{import_url: $import_url}'
360+
)"
361+
fi
362+
363+
if ! status="$(
364+
gitlab_request POST "${GITLAB_API_URL}/projects/${project_id}/import/git" "${import_payload}"
365+
)"; then
366+
echo "::error title=Mirror failed: ${source_name}::Unable to schedule the GitLab import."
367+
exit 1
368+
fi
369+
if [[ "${status}" == "403" ]]; then
370+
echo "::error title=Mirror failed: ${source_name}::GITLAB_TOKEN needs Import API Create access."
371+
exit 1
372+
fi
373+
374+
import_response_message="$(jq -r '.message // "No response message was provided."' "${response_file}")"
375+
import_response_message="${import_response_message//$'\r'/ }"
376+
import_response_message="${import_response_message//$'\n'/ }"
377+
if [[ "${status}" == "409" ]]; then
378+
case "${import_response_message}" in
379+
"Import already in progress")
380+
echo "A GitLab import is already in progress; continuing to monitor it."
381+
gitlab_import_started=true
382+
;;
383+
"Project already has a repository")
384+
echo "GitLab has repository storage from an earlier push; using incremental seeding."
385+
gitlab_incremental_seed=true
386+
;;
387+
*)
388+
echo "GitLab import conflict: ${import_response_message}"
389+
echo "::error title=Mirror failed: ${source_name}::Unable to schedule the GitLab import."
390+
exit 1
391+
;;
392+
esac
393+
elif [[ "${status}" != "201" ]] && [[ "${status}" != "202" ]]; then
394+
echo "GitLab import response: ${import_response_message}"
395+
echo "::error title=Mirror failed: ${source_name}::Unable to schedule the GitLab import" \
396+
"(HTTP ${status})."
397+
exit 1
398+
else
399+
gitlab_import_started=true
400+
fi
401+
elif [[ "${project_empty}" != "true" ]] && \
402+
((source_size_kib >= gitlab_import_threshold_kib)); then
403+
echo "Large project is already populated; using an incremental Git push."
404+
fi
405+
270406
mirror_dir="${temp_root}/repository.git"
271407
echo "Cloning ${source_clone_url} as a mirror..."
272408
if ! git \
@@ -275,6 +411,39 @@ jobs:
275411
echo "::error title=Mirror failed: ${source_name}::Unable to clone the GitHub repository."
276412
exit 1
277413
fi
414+
415+
mapfile -t pull_refs < <(
416+
git -C "${mirror_dir}" for-each-ref --format='%(refname)' refs/pull/
417+
)
418+
if ((${#pull_refs[@]} > 0)); then
419+
echo "Removing ${#pull_refs[@]} GitHub-only pull request refs from the mirror..."
420+
printf 'delete %s\n' "${pull_refs[@]}" | git -C "${mirror_dir}" update-ref --stdin
421+
fi
422+
423+
if [[ "${gitlab_import_started}" == "true" ]]; then
424+
echo "Waiting for the GitLab server-side import to finish..."
425+
if ! wait_for_gitlab_import "${project_id}" "${source_name}"; then
426+
exit 1
427+
fi
428+
fi
429+
430+
if [[ "${gitlab_incremental_seed}" == "true" ]]; then
431+
mapfile -t seed_refs < <(
432+
git -C "${mirror_dir}" for-each-ref --format='%(refname)' refs/heads/ refs/tags/
433+
)
434+
echo "Seeding ${#seed_refs[@]} branches and tags in separate pushes..."
435+
for seed_ref in "${seed_refs[@]}"; do
436+
echo "Seeding ${seed_ref}..."
437+
if ! git \
438+
-C "${mirror_dir}" \
439+
-c core.sshCommand="${gitlab_ssh_command}" \
440+
push --progress "${target_clone_url}" "+${seed_ref}:${seed_ref}"; then
441+
echo "::error title=Mirror failed: ${source_name}::Unable to seed ${seed_ref} on GitLab."
442+
exit 1
443+
fi
444+
done
445+
fi
446+
278447
echo "Pushing the mirror to ${target_clone_url}..."
279448
if ! git \
280449
-C "${mirror_dir}" \

0 commit comments

Comments
 (0)