Chris Lorenzo

homelab and infrastructure notes · stayz3ro

entry-50

Rotating the tokens behind 21 mirrors

Twenty-one Forgejo push mirrors needed a new GitHub token, swapped by creating the new mirror, syncing it, verifying the refs, and only then deleting the old one. The sync-all call returns 500 when an old mirror is already broken, so four repositories waited on the queue and were checked by hand. The pull mirrors on the relay VPS moved to a read-only token, and nothing old was revoked until both sides matched.

revisions rev. 1 first set 2026-10-23 last revised 2026-10-23 8 min read

On 12 October I rotated the credentials on both sides of the mirrors between Forgejo and GitHub. Twenty-one repositories. Each one has a push mirror from Forgejo out to GitHub, and the relay VPS keeps a pull mirror of each one coming back. The push side was still on the GitHub token from the migration. The pull side still had a Forgejo token embedded in the clone URLs. Three old tokens had also shown up in session transcripts that same day.

The rule was no gap. Create the new mirror, sync it, verify it, and only then delete the old one. Revoke last. While the old token is still valid, a mirror removed by mistake can be created again. After revocation, the only move is a new token and a repeat of that step.

The token shape

The push mirrors authenticate as a bot account that collaborates on the GitHub repositories. A fine-grained personal access token cannot reach repositories that account only collaborates on. I confirmed that on 12 October, so the replacement had to be a classic token.

The old classic token was missing the workflow scope. GitHub rejects a mirrored push that changes .github/workflows/ when that scope is absent. Four of the twenty-one mirrors had been failing for that reason.

The new token is a classic personal access token on that bot account, scopes repo and workflow, expiry 90 days. The master copy is in Proton Pass. Once Forgejo holds the token inside each push-mirror config, the workstation keeps no copy.

Forgejo needed two more tokens, created by hand and stored in Proton Pass with the GitHub one. An owner token with write:repository creates and deletes push mirrors. It is typed at a prompt and not left on disk. A second token, read:repository only, is what the relay VPS uses to pull. I checked those scope names against Forgejo 16.0.5 first. The mirror routes require the repo owner or a site admin. A collaborator token with only write access gets a 403.

Create, then prove it, then delete

Each new push mirror is HTTPS, aimed at the GitHub repository, interval 8h0m0s, sync_on_commit on, and no branch filter, so every branch goes across. List and create responses leave the password out. The script reads every token with a hidden prompt. The header and the JSON body sit in a mode 700 temporary directory that an exit trap removes. Tokens stay off the command line and out of any repository.

Create returns the new remote name, a successful sync returns 200, and delete returns 204. A failed create, a failed verify, or a response that does not parse stops before the delete. The old mirror stays, including when the process dies after the new mirror already exists.

A real run also stops, before any change, when a repository does not already have exactly one GitHub mirror. Finished and untouched repositories look the same to the script, one mirror either way, so a resume has to omit names that already finished. The dry run prints the plan, sends nothing, logs the addresses when the count is wrong, and exits non-zero at the end if any repository was off.

Forgejo 16 formats a unix time of zero in the server timezone, so a non-UTC host emits an offset timestamp and a check for 1970-01-01T00:00:00Z misses it. A sync counts only when last_error is empty and last_update is at or after the time taken before the create. Null, unparseable, zero, and older values stay pending, and the old mirror stays.

Before either script touched a live host I checked them against the Forgejo 16.0.5 source, rewrote what disagreed, and recorded them as safe to run. Both passed bash -n and shellcheck 0.11.0.

The call that returns 500

push_mirrors-sync is one call per repository. It syncs every push mirror on that repository, and the handler returns HTTP 500 when any one of them is rejected. The script logs that status and then reads the new mirror directly. Only the new mirror’s own check decides whether the old one is deleted.

The four repositories missing workflow on the old token hit this. The new mirror was created. The sync trigger returned 500 because the old mirror was rejected, so the new mirror was not pushed inside that call. Forgejo’s queue pushed each new mirror on its own, two to eight minutes later, with no error. Waiting 300 seconds did not cover that every time. The script stopped, and the old mirror was still there.

What worked was to leave it stopped, wait until the new mirror showed a last_update after its created time and an empty last_error, compare refs, and delete the old mirror by hand. The comparison is git ls-remote on Forgejo against git ls-remote on GitHub, pull-request refs excluded, both sides sorted. Identical output is the pass. A known-broken repository can be run alone with a 10 second wait, so the stop does not hold the others up. That wait is POLL_SECS, and it is overridable for this reason.

Every repository ended with exactly one GitHub mirror, no last_error set, and a ref diff that matched on all 21. The four that had been failing mirror again.

The line I keep is a count and a flag: how many mirrors, and whether each last_error is set. One mirror and a false flag is the pass. The error text stays off the terminal, because a mirror error that echoes the token is how one of these values landed in a transcript. A true flag gets read on the Forgejo mirror page in a browser.

The read-only token on the relay VPS

Each of the twenty-one clones on the relay VPS had the migration token embedded in its URL. The replacement is the read-only token, typed at a prompt on that host and stored at ~/.config/forgejo-mirror/credentials, mode 600, in a mode 700 directory. The clone URL has no token in it. Git’s store helper attaches the credential when a fetch runs.

The new line is written in the temporary directory and tested with ls-remote against the first repository before the store is installed. A bad token changes nothing. Each clone is then tested with ls-remote on its token-free URL before its helper or origin changes. A failed test leaves that clone alone. A later update failure keeps the token-free URL and the store entry that already passed, and the row says failed. Restoring the embedded URL would write the token into git config again. Any failed row exits non-zero, and a second run retests every row. The leftover count matches any URL that still carries userinfo, including a bare token with no username. Matching only user:secret@ would report zero while a bare token was still in a config, a log, or FETCH_HEAD.

I copied the script to the relay VPS by hand, ran it, and removed it from /tmp. It reported 21 clones ok and zero files still holding an embedded credential. SSH there uses the host alias, and the /etc/hosts entry for the Forgejo host has to be present first, because MagicDNS from that machine is unreliable. The pull timers fire at 04, 10, 16, and 22 hours, two of them on a separate schedule from the migration record. After a run the sync log has to be clear of fatal, error, and Authentication failed.

Revoke last

Nothing old was revoked until both checks were in. Each push mirror was the only GitHub mirror on its repository, with no error set, and the refs matched on all 21. The pull side was 21 clones ok and a leftover count of 0.

On 12 October I revoked the old classic GitHub token, the two unused fine-grained GitHub tokens on the same bot account, and the Forgejo token that had been created to make repositories during the migration. That last one was the token embedded in the outpost clone URLs until the pull-mirror script ran. It could write repositories and the user. Leftover token files were checked and cleared. After the revocation, one repository’s push-mirror sync still succeeded, and the outpost pull timers still succeeded. An old URL in sync.log or FETCH_HEAD is cleared after those counts are written down.

Three credentials remain. The GitHub token behind the push mirrors expires on 10 January 2027. The read-only pull-mirror token stays on the relay VPS, and the workstation token for ordinary Forgejo work stays too. The owner token from this run was deleted when the run finished. The next rotation creates a fresh one at the start, before that January expiry.

What I would do differently

I would run the four known-broken mirrors on their own before the rest. The sync-all call fails for the whole repository while GitHub is still rejecting the old mirror, and a short wait on those four keeps that timeout off the healthy ones. The runbook says that now.

I would also judge a new mirror by last_update against created, with last_error empty. The queue took up to eight minutes, and a five-minute poll was short. And I would keep error text out of the terminal from the first check. A flag is enough. The token belongs in Proton Pass and in the mirror config Forgejo stores.

Create, sync, verify, then delete is what held. On the four broken mirrors the trigger failed, the old mirror stayed, and the refs caught up before anything was deleted.

Related posts: Forgejo as primary, GitHub as a mirror, Protect the branch where the pushes actually land, A long session of parallel coding agents, and the one guardrail I broke.