Automate one-way mirroring of all GitHub repositories (private, owned, public, forks) to a GitLab group.
Keeps your full commit history and contribution graphs in sync across both platforms using only GitHub Actions.
- Fetches every repository visible to your GitHub PAT
- Creates or updates a private project in a specified GitLab group
- Configures GitLab pull mirrors for automatic upstream updates, with a push-mirror fallback to handle any edge cases
- Fallback push-mirror step to catch any repos not covered by the pull configuration
- Safe, auditable setup with minimal manual steps
- Prune job to automatically identify and remove projects deleted on GitHub after a configurable grace period, with dry-run support
-
GitHub Personal Access Token
- Scope:
repo - Stored in GitHub Actions as secret
GH_PAT
- Scope:
-
GitLab Personal Access Token
- Scopes:
api,write_repository - Stored as secret
GITLAB_TOKEN
- Scopes:
-
GitLab Group
- Numeric group ID, stored as secret
GITLAB_GROUP_ID - Namespace path (e.g.,
mao1910-group), hard-coded in workflows
- Numeric group ID, stored as secret
-
GitHub Usernames
- Comma-separated list (e.g.,
mao1910,le-fork), hard-coded in workflows
- Comma-separated list (e.g.,
-
(Optional) PRUNE_EXCLUDE
- Comma-separated project names to never delete (defaults to
mirror-scripts)
- Comma-separated project names to never delete (defaults to
.
├── .github/
│ └── workflows/
│ ├── mirror-to-gitlab.yml # Mirror setup workflow
│ └── prune-stale.yml # Prune stale projects workflow
├── sync_repos.py # Python script for mirror setup
├── cleanup_pruned_repos.py # Python script for pruning stale projects
└── README.md # This documentation
In Settings → Secrets and variables → Actions, add:
GH_PAT– GitHub PAT withreposcopeGITLAB_TOKEN– GitLab PAT withapiandwrite_repositoryscopesGITLAB_GROUP_ID– Numeric ID of your GitLab groupPRUNE_EXCLUDE– (Optional) Projects to always keep during prune
Hard-coded in the workflows:
GITHUB_USER– Comma-separated GitHub usernames (mirror source)GITLAB_GROUP_PATH– GitLab namespace path (mirror target)
- The mirror-to-gitlab.yml workflow runs on schedule (daily at 03:00 UTC by default) or manual dispatch.
- It installs dependencies, runs
sync_repos.py, and ensures each GitHub repo exists in GitLab with pull-mirror + fallback push-mirror.
- The prune-stale.yml workflow runs weekly (Sunday at 03:00 UTC by default) or manual dispatch.
- It restores
prune_state.json, runscleanup_pruned_repos.pyin dry-run mode by default, and updates the cache.
- In the workflow run, expand the Dry-run prune step.
- Look for lines like:
These are projects no longer on GitHub and past the grace period.
[DRY RUN] Would delete 'obsolete-repo' (project ID 123456)
- After verifying dry-run candidates, edit prune-stale.yml:
DRY_RUN: "false"
- Commit and rerun the workflow.
- The logs will show:
Deleting 'obsolete-repo' (project ID 123456)… Done. - Safety Tip: Revert
DRY_RUNback to"true"after pruning to prevent unintended deletions.
-
fetch_repos()
- Lists private & owned repos via
GET /user/repos - Lists public & forked repos via
GET /users/{owner}/reposfor eachGITHUB_USER - Combines and deduplicates
- Lists private & owned repos via
-
create_project(name, owner)
- Searches existing projects under your group
- Creates a new private project if missing
-
setup_mirror(project_id, name, owner)
- Configures a pull mirror in GitLab
- Falls back to
git clone --mirror+git push --mirrorfor edge cases
-
fetch_github_repos()
- Same dual-fetch logic as mirror script
-
list_gitlab_projects()
- Retrieves all projects in your GitLab group
-
State Tracking
- Uses
prune_state.jsonto record last-seen timestamps
- Uses
-
Prune Logic
- Projects missing on GitHub are marked with a timestamp
- Only deleted after
GRACE_DAYShave passed - Respects
PRUNE_EXCLUDEto protect utility repos
-
Dry-Run vs. Delete
DRY_RUN=truelists candidates onlyDRY_RUN=falseissuesDELETE /projects/:idfor each
- Cron schedules: Adjust
cronentries in workflows. - Grace period: Change
GRACE_DAYSin prune workflow env. - Exclusions: Update
PRUNE_EXCLUDEto protect essential projects. - Visibility: Modify payload in
sync_repos.pyto create public mirrors. - Rate limiting: Tweak
time.sleep(1)in mirror script as needed.
Released under the MIT License. Feel free to fork, modify, and extend to suit your needs.