Keeping GitHub Repository Mirrors in Sync with GitHub Actions
Mirroring a Git repository sounds simple until you need it to stay current without thinking about it. A one-time copy is easy. The useful version is a mirror that keeps following upstream branches, tags, and rewritten history without becoming another chore on your list.
I use this pattern for repositories I want to preserve, test against, or keep available under my own GitHub namespace. A recent r/github threasd asked about mirroring a public repository into a private one; this is the workflow I use for that and similar cases.
The twist is that I don't put the sync workflow in each destination repository. I keep the automation in a standalone private repository that acts as a mirror controller.
Before getting to GitHub Actions, it helps to understand the manual Git operation the workflow is automating.
Why Mirror a Repository?¶
A mirror is useful when you want a repository to follow another repository as closely as possible. That is different from a normal fork, where you expect to create your own branches, open pull requests, and maintain local work.
I usually think about mirrors as infrastructure, not collaboration space. They are useful for:
- Keeping a personal copy of an upstream project
- Preserving access to a dependency you rely on
- Testing automation against a repository under your own namespace
- Keeping several upstream repositories available from one GitHub account or organization
- Avoiding a manual Sync fork habit for repositories that should simply track upstream
That last point is the practical one. If the destination is supposed to reflect upstream, humans should not be the scheduler.
Treat Mirrors as Destructive Targets
A mirror destination should not contain independent work. Mirroring usually involves forced updates to branches and tags, which means local-only changes in the destination can be overwritten. Use dedicated mirror repositories, not repositories where people are actively developing.
What Git Means by a Mirror¶
A normal clone gives you a working tree and enough remote-tracking information to collaborate:
A mirror-style sync is different. The thing you care about is not the checked-out files. You care about refs: branches, tags, and the Git object database behind them. That is why mirror workflows usually start with a bare clone:
A bare repository has no working tree. It stores the Git database and refs, which is exactly what a sync job needs. There is no build step, no dependency install, and no source checkout to modify. This is repository plumbing.
Mirroring Manually¶
The simplest manual mirror uses git push --mirror. First, create an empty destination repository, then run:
git clone --bare https://github.com/example-organization/example-repository.git repo
cd repo
git push --mirror https://github.com/tenthirtyam/example-repository.git
That works, and for a one-time administrative copy it may be exactly enough.
git push --mirror pushes all refs under refs/, which can include more than branches and tags. For a public GitHub mirror, I usually prefer being explicit about the refs I want to synchronize:
git clone --bare https://github.com/example-organization/example-repository.git repo
cd repo
git push https://github.com/tenthirtyam/example-repository.git \
'refs/heads/*:refs/heads/*' \
'refs/tags/*:refs/tags/*' \
--force
That refspec says: take every local branch ref and write it to the same branch ref on the destination, then do the same for tags. The --force matters because mirrors should track the upstream state, including rewritten branches or moved tags. Without it, the mirror can drift the first time upstream rewrites history.
If you want the destination to delete branches and tags that were deleted upstream, refresh the bare clone from the source, then push with --prune:
git fetch origin --prune --prune-tags
git push --prune https://github.com/tenthirtyam/example-repository.git \
'refs/heads/*:refs/heads/*' \
'refs/tags/*:refs/tags/*' \
--force
Use --prune on the push intentionally. It removes destination refs that are not present locally. It doesn't compare the mirror to upstream on its own.
That distinction matters for branches and tags. git push --prune will not delete a ref on the mirror unless your local bare repository no longer has it. git fetch --prune cleans stale branch refs; it doesn't remove local tags by default. Add --prune-tags on the fetch so tags deleted upstream disappear from the local clone before you push.
Why Manual Sync Doesn't Scale¶
Manual mirroring teaches the shape of the operation, but it is a poor operating model. You have to remember to run it, you need credentials on the machine where you run it, and each additional repository becomes another little maintenance task.
That is why I prefer a standalone private GitHub repository as a mirror controller. It doesn't contain the mirrored source code. It contains:
- A GitHub Actions workflow
- A secret with a personal access token
- A matrix of source and destination repositories
- A schedule and manual trigger
That gives me a clean separation between the upstream repositories I don't own, the destination mirrors I do own, and the automation that keeps them synchronized.
Automating Mirrors with GitHub Actions¶
For a mirror like example-organization/example-repository into tenthirtyam/example-repository, the controller workflow looks like this:
---
name: Sync Mirrors
on:
schedule:
- cron: '0 * * * *'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: sync-mirrors
cancel-in-progress: false
jobs:
sync:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
mirror:
- name: example-repository
source: "https://github.com/example-organization/example-repository.git"
destination: "tenthirtyam/example-repository"
steps:
- name: Sync ${{ matrix.mirror.name }}
run: |
git clone --bare "${{ matrix.mirror.source }}" repo
cd repo
git remote set-url --push origin "https://x-access-token:${{ secrets.MIRROR_PAT }}@github.com/${{ matrix.mirror.destination }}.git"
git fetch --prune --prune-tags origin
git push --prune origin 'refs/heads/*:refs/heads/*' 'refs/tags/*:refs/tags/*' --force
The workflow runs hourly and can also be started manually with workflow_dispatch. GitHub Actions schedules are not guaranteed to run at the exact minute, so treat this as eventual synchronization, not real-time replication.
The permissions block limits the default GITHUB_TOKEN for the controller repository. The actual push uses MIRROR_PAT, but it is still good practice to keep the workflow's ambient token boring. The concurrency block prevents overlapping scheduled runs. If one sync is slow and the next hourly run starts before it finishes, the second run waits instead of colliding with the first.
What the Workflow Is Doing¶
The workflow starts with a bare clone of the upstream repository:
Then it changes only the push URL for origin:
git remote set-url --push origin "https://x-access-token:${{ secrets.MIRROR_PAT }}@github.com/${{ matrix.mirror.destination }}.git"
The fetch URL still points at the source repository, while the push URL points at the destination repository. The job can keep reading from upstream and writing to the mirror through the same remote name. Then it refreshes the local bare clone, prunes stale branch refs, and prunes local tags that no longer exist on the source:
git fetch --prune alone doesn't remove local tags. Without --prune-tags, a tag deleted upstream can remain in a persistent bare clone and get pushed back to the mirror on the next sync.
Finally, it pushes branches and tags into the destination and prunes refs that are gone locally:
--force updates rewritten branches and moved tags. --prune on the push removes destination branches and tags that are not present in the local bare repository. Branch and tag deletions only propagate when both sides do their part: --prune on the fetch drops deleted upstream branches locally, --prune-tags drops deleted tags, and --prune on the push drops the matching refs on the mirror.
Each workflow run starts from a fresh bare clone, which already limits how much stale tag state can accumulate. The fetch and push pruning flags still matter because the job fetches again after the clone, and because the same commands are what you want in a persistent bare mirror outside Actions.
Why Use a Standalone Private Repository?¶
A standalone controller repository gives you one place to manage the schedule, token, and list of mirrors. Adding another mirror becomes a matrix entry instead of another copied workflow:
strategy:
fail-fast: false
matrix:
mirror:
- name: example-one
source: "https://github.com/source/example-one.git"
destination: "destination/example-one"
- name: example-two
source: "https://github.com/source/example-two.git"
destination: "destination/example-two"
- name: example-three
source: "https://github.com/source/example-three.git"
destination: "destination/example-three"
Each matrix item runs as a separate job variation. With fail-fast: false, one broken mirror does not cancel the others. A source repository might disappear, a destination token permission might be wrong, or a network hiccup might affect one sync. The rest should still run.
The private repository also keeps the operational details out of the mirror itself. That matters when the destination repository is public but the automation token, mirror inventory, or sync strategy is not something you want to advertise.
Token and Permission Model¶
The workflow example uses a secret named MIRROR_PAT. That token needs permission to push to every destination repository listed in the matrix.
Secret Masking in Workflow Logs
GitHub Actions masks secrets such as MIRROR_PAT in job logs when they appear in output. That helps when the push URL embeds the token, but it's not a substitute for least-privilege token scoping.
For a fine-grained personal access token, scope it as narrowly as possible:
- Repository access: only the destination mirror repositories
- Contents: read and write
- Metadata: read
If the destination repositories are private, the token needs access to those private repositories. If the source repositories are private, the source URL also needs authentication. In that case, I would avoid placing credentials directly in the matrix and instead construct authenticated URLs from secrets inside the step.
When This Pattern Fits¶
This approach is a good fit when you want a GitHub-hosted mirror that follows upstream automatically and you're comfortable treating the destination as disposable replication output. It's not a replacement for a fork-based contribution workflow. If you intend to make changes and open pull requests upstream, use a normal fork and keep your feature branches separate.
That distinction is the whole point: a fork is for collaboration, while a mirror is for fidelity. You put the control plane in one small private repository, give it a narrowly scoped token, and let GitHub Actions do the repetitive part.