[vibe](../../../README.md) > [Guidebooks](../../README.md) > [Factory provisioning](../README.md) > [Ansible](README.md) > **07 · Mirrors** # 07 · Mirrors — Gitea → GitHub / GitLab > [!NOTE] > **Status:** ✅ active · **Last Updated:** 2026-07-27 > **Upstream:** [Ansible sub-hub](README.md) · [Factory provisioning hub](../README.md) > **Downstream:** [Roles reference](roles.md) — `gitea_repo`, `gitea_sync`, `gitea_token` > **Related:** [Inventory & variables](inventory.md) · [03 · CI/CD](03-cicd.md) Gitea is the **source of truth**; GitHub and GitLab hold a pushed copy. [`playbooks/07_mirrors.yml`](../../../../ansible/arcodange/factory/playbooks/07_mirrors.yml) walks the repos declared in `gitea_mirrored_repos` ([`inventory/group_vars/all/gitea.yml`](../../../../ansible/arcodange/factory/inventory/group_vars/all/gitea.yml)) and, for each, calls [`gitea_repo`](../../../../ansible/arcodange/factory/roles/gitea_repo): create the counterpart repo **private** if it is missing, then attach a push mirror refreshed every **8 h** *and* on **every commit**. Nothing is pulled back. A mirror only ever pushes Gitea → forge, so a change made on GitHub is overwritten at the next sync. ```sh uv run ansible-playbook -i ansible/arcodange/factory/inventory \ ansible/arcodange/factory/playbooks/07_mirrors.yml # GitHub only — while the personal GitLab namespace is still unset: … -e gitea_mirror_gitlab=false ``` The Gitea token is minted for the run by `gitea_token` and **revoked in `post_tasks`**. Everything is tagged `gitea_mirrors`. --- ## Two ways to pick repos, and when each fits | | [`gitea_sync`](../../../../ansible/arcodange/factory/roles/gitea_sync) | `gitea_mirrored_repos` + `07_mirrors.yml` | | --- | --- | --- | | Selection | Automatic: diffs the three forges for **one owner**, reconciles whatever is missing somewhere | Explicit list, reviewed in the inventory | | Fits | The organisation, where every repo is meant to exist everywhere | The personal account, where each repo leaving the homelab is a deliberate call | | Blind spot | `repos_incomplete = all − common` says nothing about *why* a repo is missing — a repo deleted on purpose from GitHub is recreated | Anything absent from the list is silently never mirrored | Both drive the same `gitea_repo` role, so the mirror they produce is identical. --- ## Owner mapping A Gitea repo owned by the **user** `arcodange` does not belong on the GitHub **organisation** — and GitHub does not even create it the same way (`POST /user/repos` instead of `POST /orgs//repos`). Hence three knobs, all defaulting to the previous org-only behaviour: | Var | Default | Meaning | | --- | --- | --- | | `gitea_repo_owner` | `gitea_organization` | Owner **on Gitea** | | `github_owner` / `gitlab_owner` | `github_organization` / `gitlab_root_group` | Owner **on the far forge** | | `github_owner_is_org` | `true` | `false` routes creation to `POST /user/repos` | | `gitea_mirror_github` / `gitea_mirror_gitlab` | `true` | Turn a forge off entirely | > [!IMPORTANT] > GitLab was **not optional** before. Its create call expected `201` with no `ignore_errors`, so a GitLab failure aborted the iteration — including the GitHub half that had nothing to do with it. `gitea_mirror_gitlab: false` is the way out. > [!WARNING] > A GitHub repo created **empty** adopts as its default branch the *first branch the mirror pushes*, which is routinely a work branch rather than `main`. The role realigns it against Gitea's default branch, but the mirror is asynchronous: the alignment fails on the run that creates the repo and succeeds on the next one. Run the playbook twice, or fix the branch by hand. --- ## Current state (2026-07-27) | Owner | Repos mirrored | Target | | --- | --- | --- | | `arcodange-org` | 10 (`factory`, `tools`, `erp`, `cms`, `webapp`, `url-shortener`, `docker.tofu`, `docker-build-workflow`, `super-linter-workflow`, `vault-action`) | `github.com/arcodange-org/*` + GitLab | | `arcodange` (user) | 5 (`kadans`, `kadans-api`, `kadans-dossier`, `kadans-jobs`, `video_analysis`) — all **private** | `github.com/arcodange/*` | Not mirrored, deliberately left out of `gitea_mirrored_repos`: `documents`, `studio`, `prospection`, `kissmetrics_contract_proposal` (org) and `.profile`, `DanceVideos`, `SecondBrain`, `dance-lessons-coach`, `frame-sdk`, `telegram-gateway` (user). > [!NOTE] > The personal repos have **no GitLab mirror yet**: `gitlab_personal_namespace_id` is still `~`. Fill it with the numeric namespace ID of the `arcodange` account on gitlab.com, otherwise creation would land the project in the `arcodange-org` group. --- ## Reading the truth from Gitea The push mirrors live in Gitea, not in this repo — the playbook is idempotent precisely because it asks first: ```sh curl -s -H "Authorization: token $GITEA_TOKEN" \ https://gitea.arcodange.lab/api/v1/repos/arcodange/kadans/push_mirrors ``` `last_update` tells you when the mirror last pushed. A repo with no entry has no mirror, whatever this page claims.