Add authenticated GitHub PAT for Forgejo mirror sync #269
7 changed files with 234 additions and 2 deletions
|
|
@ -52,6 +52,10 @@ NO_REPLY_ADDRESS = noreply.indri
|
|||
ENABLE_OPENID_SIGNIN = false
|
||||
ENABLE_OPENID_SIGNUP = false
|
||||
|
||||
[mirror]
|
||||
DEFAULT_INTERVAL = 8h
|
||||
MIN_INTERVAL = 10m
|
||||
|
||||
[cron.update_checker]
|
||||
ENABLED = false
|
||||
|
||||
|
|
|
|||
1
docs/changelog.d/feature-mirror-github-pat.feature.md
Normal file
1
docs/changelog.d/feature-mirror-github-pat.feature.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
Add authenticated GitHub mirror sync with PAT rotation tooling (`mirror-update-pats`, `mirror-create` auth support, how-to doc).
|
||||
147
docs/how-to/configuration/manage-forgejo-mirrors.md
Normal file
147
docs/how-to/configuration/manage-forgejo-mirrors.md
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
---
|
||||
title: Manage Forgejo Mirrors
|
||||
modified: 2026-02-25
|
||||
tags:
|
||||
- how-to
|
||||
- forgejo
|
||||
- git
|
||||
---
|
||||
|
||||
# Manage Forgejo Mirrors
|
||||
|
||||
How Forgejo upstream mirrors work, how to create new mirrors, and how to rotate the GitHub PAT used for authenticated sync.
|
||||
|
||||
## Overview
|
||||
|
||||
BlumeOps mirrors upstream repositories (mostly from GitHub) into the `mirrors/` organization on forge. These are **pull mirrors** — Forgejo periodically fetches from the upstream URL and updates the local copy. ArgoCD and other consumers then read from forge instead of hitting upstream directly.
|
||||
|
||||
### Why Authenticate
|
||||
|
||||
GitHub rate-limits unauthenticated git fetch/clone over HTTPS. As of May 2025, these limits were tightened significantly. All mirrors should use an authenticated `clone_addr` (via a GitHub fine-grained PAT) to avoid throttling.
|
||||
|
||||
The GitHub PAT is stored in 1Password:
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Vault** | blumeops (`vg6xf6vvfmoh5hqjjhlhbeoaie`) |
|
||||
| **Item** | Forgejo Secrets (`w3663ffnvkewbftncqxtcpeavy`) |
|
||||
| **Field** | `github-mirror-pat` |
|
||||
| **op ref** | `op://blumeops/w3663ffnvkewbftncqxtcpeavy/github-mirror-pat` |
|
||||
|
||||
### Sync Interval
|
||||
|
||||
Mirror sync frequency is controlled by two settings in `app.ini`:
|
||||
|
||||
| Setting | Section | Default | Purpose |
|
||||
|---------|---------|---------|---------|
|
||||
| `DEFAULT_INTERVAL` | `[mirror]` | `8h` | How often each mirror checks for upstream changes |
|
||||
| `MIN_INTERVAL` | `[mirror]` | `10m` | Floor for per-repo interval overrides |
|
||||
| `SCHEDULE` | `[cron.update_mirrors]` | `@every 10m` | How often the cron scans for due mirrors |
|
||||
|
||||
With 10–30 mirrors at 8h intervals, expect ~1–4 fetches/hour — well within any rate limit when authenticated.
|
||||
|
||||
The explicit configuration lives in `ansible/roles/forgejo/templates/app.ini.j2`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Access to 1Password blumeops vault
|
||||
- Forgejo admin account on forge.ops.eblu.me
|
||||
- `op` CLI authenticated
|
||||
- For new mirrors: `mise run mirror-create`
|
||||
|
||||
## Create a New Mirror
|
||||
|
||||
```fish
|
||||
mise run mirror-create https://github.com/org/repo.git
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--name <name>` — override the repo name on forge (default: derived from URL)
|
||||
- `--description <text>` — set the repo description
|
||||
- `--dry-run` — preview without creating
|
||||
|
||||
For GitHub upstreams, the script automatically includes the GitHub PAT from 1Password so the mirror authenticates from the start. Non-GitHub upstreams (Codeberg, etc.) are created without upstream auth.
|
||||
|
||||
## Update All Mirror PATs
|
||||
|
||||
To update the GitHub PAT on all existing mirrors at once:
|
||||
|
||||
```fish
|
||||
mise run mirror-update-pats
|
||||
```
|
||||
|
||||
This SSHs into indri and rewrites the git remote URL in each mirror's bare repository to embed `eblume:<PAT>@` in the upstream URL. It reads the PAT from 1Password and skips mirrors that already have the current PAT.
|
||||
|
||||
Use `--dry-run` to preview:
|
||||
|
||||
```fish
|
||||
mise run mirror-update-pats --dry-run
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
Forgejo stores mirror credentials directly in the bare repo's git config on disk (not in the database). The `remote_address` in SQLite stays as the clean URL; the actual fetch URL in `<repo>.git/config` contains the embedded credentials:
|
||||
|
||||
```
|
||||
# Unauthenticated
|
||||
url = https://github.com/org/repo.git
|
||||
|
||||
# Authenticated
|
||||
url = https://eblume:<pat>@github.com/org/repo.git
|
||||
```
|
||||
|
||||
The Forgejo API has no endpoint for updating pull mirror credentials, so the script updates the git config directly via SSH.
|
||||
|
||||
## Rotate the GitHub PAT
|
||||
|
||||
The GitHub fine-grained PAT has a 30-day expiry. Set a recurring reminder (every 20 days) to rotate it before it expires.
|
||||
|
||||
### 1. Create a New PAT on GitHub
|
||||
|
||||
Go to [GitHub fine-grained token settings](https://github.com/settings/personal-access-tokens/new) and create a new token:
|
||||
|
||||
- **Name:** `forgejo-mirror-sync` (or similar, include the date for tracking)
|
||||
- **Expiration:** 30 days
|
||||
- **Repository access:** Public repositories (read-only)
|
||||
- **Permissions:** None required — fine-grained PATs automatically include read-only access to all public repos
|
||||
|
||||
Copy the new PAT to your clipboard.
|
||||
|
||||
### 2. Update 1Password
|
||||
|
||||
With the new PAT on your clipboard:
|
||||
|
||||
```fish
|
||||
op item edit w3663ffnvkewbftncqxtcpeavy github-mirror-pat=(pbpaste) --vault blumeops
|
||||
```
|
||||
|
||||
Verify the update:
|
||||
|
||||
```fish
|
||||
op read "op://blumeops/w3663ffnvkewbftncqxtcpeavy/github-mirror-pat" | head -c 12
|
||||
# Should print the first 12 chars of the new PAT (github_pat_...)
|
||||
```
|
||||
|
||||
### 3. Push the PAT to All Mirrors
|
||||
|
||||
```fish
|
||||
mise run mirror-update-pats
|
||||
```
|
||||
|
||||
### 4. Delete the Old PAT on GitHub
|
||||
|
||||
Return to [GitHub token settings](https://github.com/settings/tokens?type=beta) and delete the previous token.
|
||||
|
||||
### 5. Verify
|
||||
|
||||
Trigger a manual sync on one mirror to confirm the new PAT works:
|
||||
|
||||
1. Go to any mirror repo on forge (e.g., `mirrors/cloudnative-pg`)
|
||||
2. Click the sync button (circular arrows icon) next to the mirror status
|
||||
3. Confirm the sync completes without errors
|
||||
|
||||
## Related
|
||||
|
||||
- [[forgejo]] — Forgejo service reference
|
||||
- [[upstream-fork-strategy]] — Stacked-branch forking for repos with local modifications
|
||||
- [[gandi-operations]] — Similar PAT rotation workflow for Gandi DNS
|
||||
|
|
@ -26,6 +26,7 @@ Task-oriented instructions for common BlumeOps operations. These guides assume y
|
|||
| [[gandi-operations]] | Manage DNS records and cycle the Gandi API token |
|
||||
| [[use-pypi-proxy]] | Configure pip and publish packages to devpi |
|
||||
| [[expose-service-publicly]] | Expose a service to the public internet via Fly.io + Tailscale |
|
||||
| [[manage-forgejo-mirrors]] | Create mirrors, update PATs, and rotate GitHub credentials |
|
||||
| [[update-documentation]] | Publish docs via build-blumeops workflow |
|
||||
| [[update-tooling-dependencies]] | Monthly update cycle for pre-commit, Fly, mise, and workflow deps |
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ Run `mise tasks --sort name` for the live list with descriptions.
|
|||
| `container-build-and-release` | Trigger container build workflows via Forgejo API |
|
||||
| `container-version-check` | Validate version consistency across Dockerfiles, nix, and manifests |
|
||||
| `mirror-create` | Create an upstream mirror in the `mirrors/` Forgejo org |
|
||||
| `mirror-update-pats` | Update GitHub PAT on all mirror repos on indri |
|
||||
|
||||
## Git & Forge
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ set -euo pipefail
|
|||
FORGE_API="https://forge.ops.eblu.me/api/v1"
|
||||
ORG="mirrors"
|
||||
OP_TOKEN_REF="op://blumeops/w3663ffnvkewbftncqxtcpeavy/api-token"
|
||||
OP_GITHUB_PAT_REF="op://blumeops/w3663ffnvkewbftncqxtcpeavy/github-mirror-pat"
|
||||
|
||||
url="${usage_url:?}"
|
||||
|
||||
|
|
@ -41,9 +42,16 @@ if [[ "${usage_dry_run:-}" == "true" ]]; then
|
|||
exit 0
|
||||
fi
|
||||
|
||||
echo "Reading Forgejo API token from 1Password..."
|
||||
echo "Reading secrets from 1Password..."
|
||||
token="$(op read "$OP_TOKEN_REF")"
|
||||
|
||||
# For GitHub upstreams, include the PAT for authenticated sync
|
||||
auth_token=""
|
||||
if [[ "$service" == "github" ]]; then
|
||||
auth_token="$(op read "$OP_GITHUB_PAT_REF")"
|
||||
echo "Using GitHub PAT for authenticated mirror sync"
|
||||
fi
|
||||
|
||||
payload=$(cat <<ENDJSON
|
||||
{
|
||||
"clone_addr": "$url",
|
||||
|
|
@ -51,7 +59,8 @@ payload=$(cat <<ENDJSON
|
|||
"repo_owner": "$ORG",
|
||||
"mirror": true,
|
||||
"service": "$service",
|
||||
"description": "$description"
|
||||
"description": "$description",
|
||||
"auth_token": "$auth_token"
|
||||
}
|
||||
ENDJSON
|
||||
)
|
||||
|
|
|
|||
69
mise-tasks/mirror-update-pats
Executable file
69
mise-tasks/mirror-update-pats
Executable file
|
|
@ -0,0 +1,69 @@
|
|||
#!/usr/bin/env bash
|
||||
|
eblume marked this conversation as resolved
|
||||
#MISE description="Update GitHub PAT on all mirror repos on indri"
|
||||
#USAGE flag "--dry-run" help="Show what would be done without changing anything"
|
||||
set -euo pipefail
|
||||
|
||||
OP_GITHUB_PAT_REF="op://blumeops/w3663ffnvkewbftncqxtcpeavy/github-mirror-pat"
|
||||
REPO_BASE="/opt/homebrew/var/forgejo/data/forgejo-repositories"
|
||||
DB_PATH="/opt/homebrew/var/forgejo/data/forgejo.db"
|
||||
DRY_RUN="${usage_dry_run:-false}"
|
||||
|
||||
echo "Reading GitHub PAT from 1Password..."
|
||||
pat="$(op read "$OP_GITHUB_PAT_REF")"
|
||||
|
||||
if [[ -z "$pat" ]]; then
|
||||
echo "Error: GitHub PAT is empty"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Querying mirrors on indri..."
|
||||
|
||||
# Get all GitHub mirrors: org_name, repo_name, upstream_url
|
||||
mirrors=$(ssh indri "sqlite3 '$DB_PATH' \"
|
||||
SELECT u.name, r.name, m.remote_address
|
||||
FROM mirror m
|
||||
JOIN repository r ON m.repo_id = r.id
|
||||
JOIN [user] u ON r.owner_id = u.id
|
||||
WHERE m.remote_address LIKE '%github.com%'
|
||||
\"" 2>/dev/null)
|
||||
|
||||
if [[ -z "$mirrors" ]]; then
|
||||
echo "No GitHub mirrors found."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
updated=0
|
||||
skipped=0
|
||||
|
||||
while IFS='|' read -r org repo upstream_url; do
|
||||
bare_repo="${REPO_BASE}/${org}/${repo}.git"
|
||||
|
||||
# Build authenticated URL: https://eblume:<pat>@github.com/...
|
||||
auth_url="${upstream_url/https:\/\/github.com/https:\/\/eblume:${pat}@github.com}"
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
echo "[dry-run] ${org}/${repo}: would set origin to authenticated URL"
|
||||
((updated++))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check current remote URL (< /dev/null prevents ssh from consuming loop stdin)
|
||||
current_url=$(ssh indri "git -C '${bare_repo}' config remote.origin.url" < /dev/null 2>/dev/null || echo "")
|
||||
|
||||
if [[ "$current_url" == "$auth_url" ]]; then
|
||||
echo " ${org}/${repo}: already up to date"
|
||||
((skipped++))
|
||||
continue
|
||||
fi
|
||||
|
||||
ssh indri "git -C '${bare_repo}' remote set-url origin '${auth_url}'" < /dev/null 2>/dev/null
|
||||
echo " ${org}/${repo}: updated"
|
||||
((updated++))
|
||||
done <<< "$mirrors"
|
||||
|
||||
echo
|
||||
echo "Done. Updated: ${updated}, Skipped: ${skipped}"
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
echo "(dry-run mode — no changes were made)"
|
||||
fi
|
||||
Loading…
Add table
Add a link
Reference in a new issue
I'm pretty sure there's a card somewhere that lists mise tasks, update that please