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 withrepo
scopeGITLAB_TOKEN
– GitLab PAT withapi
andwrite_repository
scopesGITLAB_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.py
in 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_RUN
back 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}/repos
for 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 --mirror
for 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.json
to record last-seen timestamps
- Uses
-
Prune Logic
- Projects missing on GitHub are marked with a timestamp
- Only deleted after
GRACE_DAYS
have passed - Respects
PRUNE_EXCLUDE
to protect utility repos
-
Dry-Run vs. Delete
DRY_RUN=true
lists candidates onlyDRY_RUN=false
issuesDELETE /projects/:id
for each
- Cron schedules: Adjust
cron
entries in workflows. - Grace period: Change
GRACE_DAYS
in prune workflow env. - Exclusions: Update
PRUNE_EXCLUDE
to protect essential projects. - Visibility: Modify payload in
sync_repos.py
to 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.