Skip to content

Commit 1ef41e6

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 1ef41e6

1 file changed

Lines changed: 258 additions & 8 deletions

File tree

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

Lines changed: 258 additions & 8 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."
@@ -162,54 +214,99 @@ jobs:
162214
163215
github_auth="$(printf 'x-access-token:%s' "${GITHUB_TOKEN}" | base64 --wrap=0)"
164216
echo "::add-mask::${github_auth}"
217+
github_token_uri="$(jq -rn --arg value "${GITHUB_TOKEN}" '$value | @uri')"
218+
echo "::add-mask::${github_token_uri}"
219+
220+
# GitLab.com limits each Git push to 5 GiB. Seed large empty projects through GitLab's import API,
221+
# using a lower threshold because GitHub's reported repository size is not the resulting pack size.
222+
gitlab_import_threshold_kib="$((4 * 1024 * 1024))"
165223
166224
repository_count="$(jq -er 'length' "${repository_file}")"
225+
successful_repository_count=0
226+
failed_repository_names=()
227+
failed_repository_reasons=()
167228
for ((index = 0; index < repository_count; index++)); do
168229
repository="$(jq -ec ".[${index}]" "${repository_file}")"
169230
repository_number="$((index + 1))"
170231
source_name="$(jq -er '.name' <<< "${repository}")"
171232
source_clone_url="$(jq -er '.cloneUrl' <<< "${repository}")"
233+
source_size_kib="$(jq -er '.sizeKiB' <<< "${repository}")"
172234
target_name="$(jq -er '.targetName' <<< "${repository}")"
173235
target_path="$(jq -er '.targetPath' <<< "${repository}")"
174236
target_visibility="$(jq -er '.targetVisibility' <<< "${repository}")"
175237
description="Mirror of ${source_clone_url}"
176238
project_path="${GITLAB_GROUP}/${target_path}"
177239
encoded_project_path="$(jq -rn --arg value "${project_path}" '$value | @uri')"
240+
repository_log="${temp_root}/repository-${index}.log"
241+
242+
set +e
243+
(
244+
set -euo pipefail
245+
trap 'echo "::endgroup::"' EXIT
246+
247+
echo "::group::[${repository_number}/${repository_count}] ${source_name} -> ${project_path}"
248+
echo "Visibility: ${target_visibility}"
249+
echo "Checking for the GitLab project..."
178250
179-
echo "::group::[${repository_number}/${repository_count}] ${source_name} -> ${project_path}"
180-
echo "Visibility: ${target_visibility}"
181-
echo "Checking for the GitLab project..."
251+
project_created=false
252+
gitlab_import_started=false
182253
183254
if ! status="$(gitlab_request GET "${GITLAB_API_URL}/projects/${encoded_project_path}")"; then
184255
echo "::error title=Mirror failed: ${source_name}::Unable to query the GitLab project."
185256
exit 1
186257
fi
187258
188-
if [[ "${status}" == "404" ]]; then
189-
echo "Project does not exist; creating ${project_path}..."
259+
if [[ "${status}" == "404" ]] || [[ "${status}" == "301" ]]; then
260+
if [[ "${status}" == "301" ]]; then
261+
echo "The exact GitLab path redirects elsewhere; reclaiming ${project_path}."
262+
else
263+
echo "Project does not exist; creating ${project_path}..."
264+
fi
265+
266+
create_import_url=""
267+
if ((source_size_kib >= gitlab_import_threshold_kib)); then
268+
create_import_url="${source_clone_url}"
269+
if [[ "${target_visibility}" == "private" ]]; then
270+
create_import_url="https://x-access-token:${github_token_uri}@${source_clone_url#https://}"
271+
echo "::add-mask::${create_import_url}"
272+
fi
273+
echo "The large repository will be imported as part of project creation."
274+
fi
275+
190276
payload="$(
191277
jq -nc \
278+
--arg description "${description}" \
279+
--arg import_url "${create_import_url}" \
192280
--arg name "${target_name}" \
193281
--arg path "${target_path}" \
194282
--argjson namespace_id "${group_id}" \
195283
--arg visibility "${target_visibility}" \
196284
'{
285+
description: $description,
197286
name: $name,
198287
path: $path,
199288
namespace_id: $namespace_id,
200289
visibility: $visibility,
201290
initialize_with_readme: false
202-
}'
291+
} + if $import_url == "" then {} else {import_url: $import_url} end'
203292
)"
204293
if ! status="$(gitlab_request POST "${GITLAB_API_URL}/projects" "${payload}")"; then
205294
echo "::error title=Mirror failed: ${source_name}::Unable to create the GitLab project."
206295
exit 1
207296
fi
208297
if [[ "${status}" != "201" ]]; then
298+
create_error="$(jq -r '.message // "No response message was provided."' "${response_file}")"
299+
create_error="${create_error//$'\r'/ }"
300+
create_error="${create_error//$'\n'/ }"
301+
echo "GitLab project creation response: ${create_error}"
209302
error_message="Unable to create the GitLab project (HTTP ${status})."
210303
echo "::error title=Mirror failed: ${source_name}::${error_message}"
211304
exit 1
212305
fi
306+
project_created=true
307+
if [[ -n "${create_import_url}" ]]; then
308+
gitlab_import_started=true
309+
fi
213310
elif [[ "${status}" != "200" ]]; then
214311
echo "::error title=Mirror failed: ${source_name}::Unable to query the GitLab project (HTTP ${status})."
215312
exit 1
@@ -218,6 +315,13 @@ jobs:
218315
fi
219316
220317
project_id="$(jq -er '.id' "${response_file}")"
318+
if [[ "${project_created}" == "true" ]]; then
319+
project_empty=true
320+
else
321+
project_empty="$(
322+
jq -r '(.empty_repo == true) or (.default_branch == null)' "${response_file}"
323+
)"
324+
fi
221325
222326
# Make a private source private before changing metadata or pushing any Git data.
223327
if [[ "${target_visibility}" == "private" ]]; then
@@ -267,6 +371,75 @@ jobs:
267371
268372
target_clone_url="$(jq -er '.ssh_url_to_repo' "${response_file}")"
269373
374+
gitlab_incremental_seed=false
375+
if [[ "${project_empty}" == "true" ]] && \
376+
((source_size_kib >= gitlab_import_threshold_kib)) && \
377+
[[ "${gitlab_import_started}" != "true" ]]; then
378+
echo "Empty large project detected (${source_size_kib} KiB reported by GitHub)."
379+
echo "Scheduling a GitLab server-side Git import to avoid the 5 GiB push limit..."
380+
if [[ "${target_visibility}" == "private" ]]; then
381+
import_payload="$(
382+
jq -nc \
383+
--arg import_url "${source_clone_url}" \
384+
--arg import_url_user "x-access-token" \
385+
--arg import_url_password "${GITHUB_TOKEN}" \
386+
'{
387+
import_url: $import_url,
388+
import_url_user: $import_url_user,
389+
import_url_password: $import_url_password
390+
}'
391+
)"
392+
else
393+
import_payload="$(
394+
jq -nc \
395+
--arg import_url "${source_clone_url}" \
396+
'{import_url: $import_url}'
397+
)"
398+
fi
399+
400+
if ! status="$(
401+
gitlab_request POST "${GITLAB_API_URL}/projects/${project_id}/import/git" "${import_payload}"
402+
)"; then
403+
echo "::error title=Mirror failed: ${source_name}::Unable to schedule the GitLab import."
404+
exit 1
405+
fi
406+
if [[ "${status}" == "403" ]]; then
407+
echo "::error title=Mirror failed: ${source_name}::GITLAB_TOKEN needs Import API Create access."
408+
exit 1
409+
fi
410+
411+
import_response_message="$(jq -r '.message // "No response message was provided."' "${response_file}")"
412+
import_response_message="${import_response_message//$'\r'/ }"
413+
import_response_message="${import_response_message//$'\n'/ }"
414+
if [[ "${status}" == "409" ]]; then
415+
case "${import_response_message}" in
416+
"Import already in progress")
417+
echo "A GitLab import is already in progress; continuing to monitor it."
418+
gitlab_import_started=true
419+
;;
420+
"Project already has a repository")
421+
echo "GitLab has repository storage from an earlier push; using incremental seeding."
422+
gitlab_incremental_seed=true
423+
;;
424+
*)
425+
echo "GitLab import conflict: ${import_response_message}"
426+
echo "::error title=Mirror failed: ${source_name}::Unable to schedule the GitLab import."
427+
exit 1
428+
;;
429+
esac
430+
elif [[ "${status}" != "201" ]] && [[ "${status}" != "202" ]]; then
431+
echo "GitLab import response: ${import_response_message}"
432+
echo "::error title=Mirror failed: ${source_name}::Unable to schedule the GitLab import" \
433+
"(HTTP ${status})."
434+
exit 1
435+
else
436+
gitlab_import_started=true
437+
fi
438+
elif [[ "${project_empty}" != "true" ]] && \
439+
((source_size_kib >= gitlab_import_threshold_kib)); then
440+
echo "Large project is already populated; using an incremental Git push."
441+
fi
442+
270443
mirror_dir="${temp_root}/repository.git"
271444
echo "Cloning ${source_clone_url} as a mirror..."
272445
if ! git \
@@ -275,6 +448,39 @@ jobs:
275448
echo "::error title=Mirror failed: ${source_name}::Unable to clone the GitHub repository."
276449
exit 1
277450
fi
451+
452+
mapfile -t pull_refs < <(
453+
git -C "${mirror_dir}" for-each-ref --format='%(refname)' refs/pull/
454+
)
455+
if ((${#pull_refs[@]} > 0)); then
456+
echo "Removing ${#pull_refs[@]} GitHub-only pull request refs from the mirror..."
457+
printf 'delete %s\n' "${pull_refs[@]}" | git -C "${mirror_dir}" update-ref --stdin
458+
fi
459+
460+
if [[ "${gitlab_import_started}" == "true" ]]; then
461+
echo "Waiting for the GitLab server-side import to finish..."
462+
if ! wait_for_gitlab_import "${project_id}" "${source_name}"; then
463+
exit 1
464+
fi
465+
fi
466+
467+
if [[ "${gitlab_incremental_seed}" == "true" ]]; then
468+
mapfile -t seed_refs < <(
469+
git -C "${mirror_dir}" for-each-ref --format='%(refname)' refs/heads/ refs/tags/
470+
)
471+
echo "Seeding ${#seed_refs[@]} branches and tags in separate pushes..."
472+
for seed_ref in "${seed_refs[@]}"; do
473+
echo "Seeding ${seed_ref}..."
474+
if ! git \
475+
-C "${mirror_dir}" \
476+
-c core.sshCommand="${gitlab_ssh_command}" \
477+
push --progress "${target_clone_url}" "+${seed_ref}:${seed_ref}"; then
478+
echo "::error title=Mirror failed: ${source_name}::Unable to seed ${seed_ref} on GitLab."
479+
exit 1
480+
fi
481+
done
482+
fi
483+
278484
echo "Pushing the mirror to ${target_clone_url}..."
279485
if ! git \
280486
-C "${mirror_dir}" \
@@ -285,7 +491,51 @@ jobs:
285491
fi
286492
rm -rf "${mirror_dir}"
287493
echo "Completed mirror for ${source_name}."
288-
echo "::endgroup::"
494+
) 2>&1 | tee "${repository_log}"
495+
repository_status="${PIPESTATUS[0]}"
496+
set -e
497+
498+
rm -rf "${temp_root}/repository.git"
499+
if ((repository_status == 0)); then
500+
successful_repository_count="$((successful_repository_count + 1))"
501+
else
502+
error_line="$(grep -E '^::error(::| )' "${repository_log}" | tail -n 1 || true)"
503+
if [[ -n "${error_line}" ]]; then
504+
failure_reason="${error_line##*::}"
505+
else
506+
failure_reason="Repository mirroring failed; inspect its log group."
507+
fi
508+
failure_reason="${failure_reason//$'\r'/ }"
509+
failure_reason="${failure_reason//$'\n'/ }"
510+
failed_repository_names+=("${source_name}")
511+
failed_repository_reasons+=("${failure_reason}")
512+
echo "Recorded mirror failure for ${source_name}; continuing with the next repository."
513+
fi
514+
rm -f "${repository_log}"
289515
done
290516
291-
echo "Mirrored ${repository_count} repositories to GitLab."
517+
failed_repository_count="${#failed_repository_names[@]}"
518+
{
519+
echo "## GitHub to GitLab mirror summary"
520+
echo
521+
echo "- Successful: ${successful_repository_count}"
522+
echo "- Failed: ${failed_repository_count}"
523+
if ((failed_repository_count > 0)); then
524+
echo
525+
echo "| Repository | Failure |"
526+
echo "| --- | --- |"
527+
for ((failure_index = 0; failure_index < failed_repository_count; failure_index++)); do
528+
summary_name="${failed_repository_names[$failure_index]//|/\\|}"
529+
summary_reason="${failed_repository_reasons[$failure_index]//|/\\|}"
530+
printf '| %s | %s |\n' "\`${summary_name}\`" "${summary_reason}"
531+
done
532+
fi
533+
} >> "${GITHUB_STEP_SUMMARY}"
534+
535+
if ((failed_repository_count > 0)); then
536+
echo "::error::Failed to mirror ${failed_repository_count} of ${repository_count} repositories." \
537+
"See the step summary."
538+
exit 1
539+
fi
540+
541+
echo "Mirrored all ${repository_count} repositories to GitLab."

0 commit comments

Comments
 (0)