These moved every non-fork JMR-dev repository from GitHub into this Gitea
instance and made GitHub a push mirror. They are kept for re-runs and
for rotating the mirror PAT, which expires and fails silently.
- migrate.py full one-time migration (code, issues, PRs, releases,
wiki, LFS); skips anything already on Gitea
- verify.py compares branch/tag shas, issue/PR/release counts, and
the archived and visibility flags; read-only
- repoint.py re-points local clones' JMR-dev remotes at Gitea,
following GitHub renames; dry run unless --apply
- pushmirror.py sync-on-commit push mirrors to GitHub, created only when
every GitHub branch and tag already matches Gitea, so
the first force-push/prune cannot delete anything
Tokens come from the environment, sourced from Secret Manager. None
appear in these files.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GitHub → Gitea migration
These scripts moved every non-fork repository owned by JMR-dev from GitHub into this
Gitea instance and made Gitea the primary. GitHub is now a push mirror: Gitea pushes
every commit to it. They are kept here for re-runs and for rotating the mirror token.
All of them are standard-library Python. They read tokens from the environment, never
from arguments or files. verify.py, pushmirror.py and repoint.py also read GitHub
through the local gh CLI.
| Script | What it does | Writes to |
|---|---|---|
migrate.py |
Full one-time migration: code, issues, PRs, releases, labels, milestones, wiki, LFS. Archives on Gitea whatever is archived on GitHub. | Gitea only |
verify.py |
Compares every branch and tag sha, the issue/PR/release counts, and the archived flag and visibility. | nothing |
repoint.py |
Re-points local clones' JMR-dev GitHub remotes at Gitea, following renames. Dry run unless --apply. |
local .git/config |
pushmirror.py |
Creates a sync-on-commit push mirror to GitHub for each active repository. | Gitea, then GitHub through the mirror |
The input is a snapshot of the repository list:
gh repo list JMR-dev --limit 1000 \
--json name,visibility,isFork,isArchived,diskUsage,description,defaultBranchRef > repos.json
Tokens
Every token lives in Secret Manager in the Gitea project and is passed in by environment:
export GITEA_TOKEN=$(gcloud secrets versions access latest --secret=gitea-migration-token --project=<project>)
export GITHUB_TOKEN=$(gcloud secrets versions access latest --secret=github-migration-pat --project=<project>) # migrate.py
export MIRROR_PAT=$(gcloud secrets versions access latest --secret=github-mirror-pat --project=<project>) # pushmirror.py
GITEA_TOKENneedswrite:repositoryandread:issue.GITHUB_TOKENfor migrating should be a read-only fine-grained PAT (Contents, Metadata, Issues, Pull requests). Read-only cannot see draft releases; copy those by hand.MIRROR_PATneeds Contents and Workflows read & write. Without Workflows, GitHub rejects any push that touches.github/workflows/.
Why they are safe to re-run
migrate.pyskips any repository that already exists on Gitea. It never deletes one in order to retry.pushmirror.pyskips repositories that already have a GitHub push mirror. It also refuses to create one unless every GitHub branch and tag already matches Gitea. A push mirror force-pushes and prunes, so this check is what guarantees the first sync cannot overwrite or delete anything on GitHub.- Archived repositories never get a push mirror, because GitHub rejects pushes to them.
Rotating the mirror PAT
Each mirror stores its own copy of the PAT, and an expired PAT fails silently: the only sign is the error on the repository's Settings → Mirror page. To rotate:
- Store the new token as a new version of
github-mirror-pat. - Delete each repository's GitHub mirror with
DELETE /api/v1/repos/JMR-dev/<repo>/push_mirrors/<remote_name>. - Re-run
pushmirror.py.
The refs check runs again in step 3. Anything pushed to GitHub directly in the meantime
shows up as blocked rather than being overwritten.
Behaviour worth knowing
- New commits and branches reach GitHub within seconds.
- A bare branch delete does not trigger a sync. It reaches GitHub at the next push that carries commits, or at the 8-hour interval.
- Branches created on GitHub, such as Dependabot's, are pruned by the next sync.
- GitHub Actions
on: pushworkflows run for mirrored pushes.