Files
gitea-migration/PLAN.md
S 768701004d docs: update PLAN.md for REPO_NAMES
Replace REPO_1/2/3_NAME references with REPO_NAMES.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 10:09:20 -05:00

65 KiB
Raw Blame History

Gitea Migration Toolkit — Implementation Plan

Context

Migrating 3 GitHub repos to self-hosted Gitea (Unraid primary, Fedora backup mirror, GitHub as push mirror). All automation runs from MacBook, SSHing into remote machines. Scripts must be idempotent, .env-driven, with preflight + post-checks + teardown per phase.


Architecture Decisions

Decision Choice Rationale
Execution model All scripts run from MacBook, SSH into remotes Single control plane, no agent installs needed
Gitea deployment Docker Compose on both Unraid + Fedora Scriptable, reproducible, version-pinned
Runner deployment (Linux) Docker container via docker-compose Consistent with Gitea deployment
Runner deployment (macOS) Native binary + launchd plist Docker Desktop on Mac is heavyweight; native binary is lighter and more reliable
Idempotency pattern Check-before-act (query state, skip if already done) Every create operation first checks if the resource exists
Template rendering .tpl files + envsubst Keeps config templates separate from script logic
Error handling set -euo pipefail + trap cleanup Fail fast, no silent errors
API interaction Shared gitea_api() curl wrapper with JSON/jq Consistent auth, error checking, response parsing
HTTPS proxy Nginx config template + Certbot (Docker) Plain Nginx already on Unraid as Docker container — just add a server block + SSL cert

File Structure

gitea-migration/
├── .env.example
├── .env
├── runners.conf.example
├── runners.conf
├── PLAN.md
├── lib/
│   └── common.sh
├── setup/
│   ├── configure_env.sh
│   ├── macbook.sh
│   ├── unraid.sh
│   └── fedora.sh
├── templates/
│   ├── docker-compose-gitea.yml.tpl
│   ├── docker-compose-runner.yml.tpl
│   ├── app.ini.tpl
│   ├── runner-config.yaml.tpl
│   ├── com.gitea.runner.plist.tpl
│   ├── nginx-gitea.conf.tpl
│   └── workflows/
│       └── security-scan.yml.tpl
├── backup/
│   ├── backup_primary.sh
│   └── restore_to_primary.sh
├── contracts/
│   └── gitea-api.md
├── preflight.sh
├── phase1_gitea_unraid.sh
├── phase1_post_check.sh
├── phase1_teardown.sh
├── phase2_gitea_fedora.sh
├── phase2_post_check.sh
├── phase2_teardown.sh
├── phase3_runners.sh
├── phase3_post_check.sh
├── phase3_teardown.sh
├── phase4_migrate_repos.sh
├── phase4_post_check.sh
├── phase4_teardown.sh
├── phase5_migrate_pipelines.sh
├── phase5_post_check.sh
├── phase5_teardown.sh
├── phase6_github_mirrors.sh
├── phase6_post_check.sh
├── phase6_teardown.sh
├── phase7_branch_protection.sh
├── phase7_post_check.sh
├── phase7_teardown.sh
├── phase8_cutover.sh
├── phase8_post_check.sh
├── phase8_teardown.sh
├── phase9_security.sh
├── phase9_post_check.sh
├── phase9_teardown.sh
├── manage_runner.sh
├── run_all.sh
└── teardown_all.sh

Implementation Tracker

1. Foundation

# File Description Status
1.1 lib/common.sh Shared functions: logging, SSH, API wrappers, template rendering, checks TODO
1.2 .env.example Add SSL_MODE, SSL_CERT_PATH, SSL_KEY_PATH vars to Nginx section TODO
1.3 contracts/gitea-api.md Gitea REST API endpoints used across all phases TODO

2. Templates

# File Description Status
2.1 templates/docker-compose-gitea.yml.tpl Gitea + SQLite docker-compose TODO
2.2 templates/app.ini.tpl Gitea custom config (INSTALL_LOCK, Actions enabled, etc.) TODO
2.3 templates/docker-compose-runner.yml.tpl act_runner docker-compose (Linux) TODO
2.4 templates/runner-config.yaml.tpl act_runner config TODO
2.5 templates/com.gitea.runner.plist.tpl macOS launchd service for act_runner TODO
2.6 templates/nginx-gitea.conf.tpl Nginx reverse proxy server block TODO
2.7 templates/workflows/security-scan.yml.tpl Semgrep + Trivy + Gitleaks workflow TODO

3. Machine Setup

# File Description Status
3.1 setup/configure_env.sh Interactive wizard: prompts for each .env var, writes to .env TODO
3.2 setup/macbook.sh Homebrew, jq, curl, envsubst, git, Xcode CLI Tools, shellcheck, gh TODO
3.3 setup/unraid.sh Verify Docker, install docker-compose + jq (static binary) TODO
3.4 setup/fedora.sh Install Docker CE, compose plugin, jq, enable systemd services TODO

4. Preflight

# File Description Status
4.1 preflight.sh Validate .env, SSH, Docker, ports, DNS, GitHub token, Nginx, repos TODO

5. Phase 1 — Gitea on Unraid

# File Description Status
5.1 phase1_gitea_unraid.sh Deploy Gitea container, create admin user + token + org TODO
5.2 phase1_post_check.sh Verify Gitea HTTP 200, admin auth, token valid, org exists TODO
5.3 phase1_teardown.sh docker-compose down, optionally remove data TODO

6. Phase 2 — Gitea on Fedora

# File Description Status
6.1 phase2_gitea_fedora.sh Deploy Gitea container on Fedora, create admin user + token TODO
6.2 phase2_post_check.sh Verify Fedora Gitea HTTP 200, admin auth, token valid TODO
6.3 phase2_teardown.sh docker-compose down on Fedora TODO

7. Phase 3 — Runners

# File Description Status
7.1 runners.conf.example Runner definition format + example entries DONE
7.2 manage_runner.sh Add/remove/list runners dynamically (reads runners.conf) TODO
7.3 phase3_runners.sh Get registration token, deploy all runners defined in runners.conf TODO
7.4 phase3_post_check.sh Verify all runners from runners.conf are online in Gitea admin TODO
7.5 phase3_teardown.sh Stop + deregister all runners from runners.conf TODO

8. Phase 4 — Migrate Repos + Fedora Mirrors

# File Description Status
8.1 phase4_migrate_repos.sh Import repos from GitHub, set up Fedora pull mirrors TODO
8.2 phase4_post_check.sh Verify repos on primary + mirror repos on Fedora TODO
8.3 phase4_teardown.sh Delete repos from primary + Fedora TODO

9. Phase 5 — Migrate Pipelines

# File Description Status
9.1 phase5_migrate_pipelines.sh Copy .github/workflows/ → .gitea/workflows/, apply compat fixes TODO
9.2 phase5_post_check.sh Verify workflows visible in Gitea Actions UI TODO
9.3 phase5_teardown.sh Remove .gitea/workflows/ from repos TODO

10. Phase 6 — GitHub Push Mirrors

# File Description Status
10.1 phase6_github_mirrors.sh Configure push mirrors from Gitea → GitHub TODO
10.2 phase6_post_check.sh Verify mirror config, trigger sync, check GitHub TODO
10.3 phase6_teardown.sh Remove push mirror config TODO

11. Phase 7 — Branch Protection

# File Description Status
11.1 phase7_branch_protection.sh Set up branch protection rules on all repos TODO
11.2 phase7_post_check.sh Verify protection rules exist TODO
11.3 phase7_teardown.sh Delete branch protection rules TODO

12. Phase 8 — Cutover (HTTPS + Archive GitHub)

# File Description Status
12.1 phase8_cutover.sh Nginx config + Certbot SSL + archive GitHub repos TODO
12.2 phase8_post_check.sh Verify HTTPS, repos accessible, mirrors working TODO
12.3 phase8_teardown.sh Remove Nginx config, reload, un-archive GitHub TODO

13. Phase 9 — Security Scanning

# File Description Status
13.1 phase9_security.sh Deploy security workflow (Semgrep+Trivy+Gitleaks) to all repos TODO
13.2 phase9_post_check.sh Verify workflows exist, dry-run passes, branch protection updated TODO
13.3 phase9_teardown.sh Remove security workflows TODO

14. Backup & Restore (post-migration operational scripts)

# File Description Status
14.1 backup/backup_primary.sh Run gitea dump on Unraid (DB + repos + config + users), SCP archive to Fedora TODO
14.2 backup/restore_to_primary.sh Restore a gitea dump archive to Unraid (fresh or existing instance) TODO

15. Orchestration

# File Description Status
15.1 run_all.sh Run setup → preflight → phases 1-9 sequentially, --start-from=N TODO
15.2 teardown_all.sh Run teardowns in reverse, --through=N TODO

16. Project Init

# File Description Status
16.1 Git repo init + .gitignore Initialize git repo, ignore .env and temp files TODO
16.2 CLAUDE.md Project-specific instructions for this codebase TODO

17. Validation

# File Description Status
17.1 Shellcheck all .sh files Must pass with no errors TODO
17.2 bash -n syntax check all scripts Verify syntax without executing TODO

Definition of Done — Every Item

1.1 — lib/common.sh

Depends on: nothing Steps:

  1. Write shell library with set -euo pipefail
  2. Implement every function listed below
  3. Every function handles errors (non-zero exit, clear message)

Functions and their contracts:

Function Inputs Behavior Returns
load_env none Sources .env, exports all vars. Exits 1 if .env missing. 0 on success
save_env_var KEY VALUE key name, value string If KEY exists in .env, replaces its line. If not, appends. Must not corrupt other lines. 0 on success
require_vars VAR1 VAR2... variable names For each: checks if exported and non-empty. Exits 1 naming the first missing var. 0 if all set
log_info MSG message string Prints [INFO] MSG to stderr in blue
log_warn MSG message string Prints [WARN] MSG to stderr in yellow
log_error MSG message string Prints [ERROR] MSG to stderr in red
log_success MSG message string Prints [OK] MSG to stderr in green
log_step N MSG step number, message Prints [N] MSG to stderr
phase_header NUM NAME phase number, name Prints \n=== Phase NUM: NAME ===\n to stderr
ssh_exec HOST_KEY CMD host key (e.g. UNRAID), command string Reads ${HOST_KEY}_IP, ${HOST_KEY}_SSH_USER, ${HOST_KEY}_SSH_PORT from env. Runs command via SSH with -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new. Streams stdout/stderr. Exits with remote exit code. remote exit code
ssh_check HOST_KEY host key Same as ssh_exec but runs true. Returns 0 if reachable, 1 if not. Does NOT exit on failure. 0 or 1
scp_to HOST_KEY SRC DST host key, local file path, remote file path Copies local file to remote via SCP. Exits 1 if transfer fails. 0 on success
gitea_api METHOD PATH [DATA] HTTP method, API path (e.g. /orgs), optional JSON body Calls curl -s -X METHOD ${GITEA_INTERNAL_URL}/api/v1${PATH} with Authorization: token ${GITEA_ADMIN_TOKEN}. Exits 1 if HTTP status >= 400 (prints status + response body). Returns JSON response on stdout. JSON on stdout
gitea_backup_api METHOD PATH [DATA] same Same as gitea_api but uses GITEA_BACKUP_INTERNAL_URL + GITEA_BACKUP_ADMIN_TOKEN JSON on stdout
github_api METHOD PATH [DATA] same Calls https://api.github.com${PATH} with Authorization: token ${GITHUB_TOKEN} JSON on stdout
render_template SRC DEST .tpl file path, output path Runs envsubst with all exported vars. Writes to DEST. 0 on success
wait_for_http URL MAX_SECS URL, timeout in seconds Polls with curl -sf -o /dev/null every 2 seconds. Exits 1 with timeout message if MAX_SECS exceeded. 0 when URL returns 200
wait_for_ssh HOST_KEY MAX_SECS host key, timeout Polls ssh_check every 2 seconds. Exits 1 on timeout. 0 when SSH connects

Done when:

  • Every function above exists with exact signature
  • shellcheck lib/common.sh passes with zero warnings
  • bash -n lib/common.sh passes
  • Sourcing the file (source lib/common.sh) does NOT execute anything — functions only, no side effects at source time
  • save_env_var tested: set a var, read it back, value matches. Set it again with different value, only one line exists for that key.
  • require_vars tested: prints the missing var's name, not a generic error
  • ssh_exec uses ConnectTimeout to avoid hanging forever
  • API functions return raw JSON on stdout (not mixed with log messages — logs go to stderr)

1.3 — contracts/gitea-api.md

Depends on: knowing all API calls used across phases 1-9 Steps:

  1. For every Gitea API call in the project, document: method, path, request body, expected response, which script uses it
  2. For every GitHub API call, same treatment
  3. Cross-reference: every gitea_api/github_api call in any script MUST have a matching entry

API endpoints to document (complete list):

Endpoint Used in
POST /api/v1/users/{user}/tokens Phase 1, Phase 2 (generate admin token)
GET /api/v1/user Preflight (validate token)
POST /api/v1/orgs Phase 1 (create org)
GET /api/v1/orgs/{org} Phase 1 (check if org exists)
POST /api/v1/repos/migrate Phase 4 (import repos + Fedora mirrors)
GET /api/v1/repos/{owner}/{repo} Phase 4 post-check (verify repo exists)
GET /api/v1/admin/runners/registration-token Phase 3 (get runner token)
GET /api/v1/admin/runners Phase 3 post-check (list runners)
POST /api/v1/repos/{owner}/{repo}/push_mirrors Phase 6 (create push mirror)
GET /api/v1/repos/{owner}/{repo}/push_mirrors Phase 6 post-check
POST /api/v1/repos/{owner}/{repo}/mirror-sync Phase 6 post-check (trigger sync)
POST /api/v1/repos/{owner}/{repo}/branch_protections Phase 7
GET /api/v1/repos/{owner}/{repo}/branch_protections Phase 7 post-check
DELETE /api/v1/repos/{owner}/{repo}/branch_protections/{name} Phase 7 teardown
DELETE /api/v1/repos/{owner}/{repo} Phase 4 teardown
GET /api/v1/repos/{owner}/{repo}/actions/workflows Phase 5 post-check
GitHub: GET /user Preflight (validate GitHub token)
GitHub: GET /repos/{owner}/{repo} Preflight (verify repos exist)
GitHub: PATCH /repos/{owner}/{repo} Phase 8 (archive repos)

Done when:

  • Every endpoint above is documented with: method, full path, request body schema, response schema, HTTP status codes
  • Every gitea_api/github_api call in the codebase has a corresponding entry
  • No endpoint is used in code but missing from the contract

2.1 — templates/docker-compose-gitea.yml.tpl

Depends on: .env vars defined Produces: Docker Compose file for Gitea + SQLite on a single host

Template must include:

  • Gitea image pinned to $GITEA_VERSION
  • Container name: gitea
  • Volumes: $DATA_PATH/data:/data, $DATA_PATH/config:/data/gitea/conf
  • Ports: $GITEA_PORT:3000, $GITEA_SSH_PORT:22 (add GITEA_SSH_PORT to .env if missing)
  • Environment: USER_UID=1000, USER_GID=1000
  • Restart policy: unless-stopped
  • No database service (SQLite is file-based)

Variables used (must all be in .env): GITEA_VERSION, DATA_PATH, GITEA_PORT

Done when:

  • render_template produces valid YAML (test with python3 -c "import yaml; yaml.safe_load(open('output.yml'))" or yq)
  • No unexpanded $VAR or ${VAR} tokens in rendered output
  • Container name is gitea (hardcoded, not variable — needed for docker exec)
  • Volume paths use the variable, not hardcoded paths

2.2 — templates/app.ini.tpl

Depends on: .env vars defined Produces: Gitea configuration file

Must set:

  • [security] INSTALL_LOCK = true — skip install wizard
  • [server] ROOT_URL = https://$GITEA_DOMAIN/
  • [server] SSH_DOMAIN = $GITEA_DOMAIN
  • [server] DOMAIN = $GITEA_DOMAIN
  • [database] DB_TYPE = $GITEA_DB_TYPE
  • [database] PATH = /data/gitea/gitea.db
  • [service] DISABLE_REGISTRATION = true — no public signups
  • [actions] ENABLED = true — enable Gitea Actions
  • [repository] DEFAULT_BRANCH = main
  • [mailer] section left unconfigured (can be added later)

Done when:

  • Rendered output is valid INI format
  • INSTALL_LOCK = true is present (critical — without it, Gitea shows install wizard)
  • ENABLED = true under [actions] (critical — without it, runners can't connect)
  • No unexpanded variables

2.3 — templates/docker-compose-runner.yml.tpl

Depends on: .env vars, runner config Produces: Docker Compose file for act_runner on Linux

Must include:

  • act_runner image: gitea/act_runner:$ACT_RUNNER_VERSION
  • Container name: gitea-runner-$RUNNER_NAME
  • Volume: Docker socket mount /var/run/docker.sock:/var/run/docker.sock (needed to spawn job containers)
  • Volume: $RUNNER_DATA_PATH:/data
  • Environment: GITEA_INSTANCE_URL, GITEA_RUNNER_REGISTRATION_TOKEN, GITEA_RUNNER_NAME, GITEA_RUNNER_LABELS
  • Restart policy: unless-stopped

Done when:

  • Rendered output is valid YAML
  • Docker socket is mounted (without this, runner can't create job containers)
  • Runner name and labels come from variables (not hardcoded)

2.4 — templates/runner-config.yaml.tpl

Depends on: act_runner config schema Produces: act_runner configuration file

Must set:

  • runner.name: $RUNNER_NAME
  • runner.labels: $RUNNER_LABELS
  • runner.capacity: 1 (one concurrent job per runner — safe default)
  • runner.timeout: 3h (max job duration)
  • cache.enabled: true

Done when:

  • Valid YAML after rendering
  • Labels format is correct for act_runner (comma-separated label:scheme pairs)

2.5 — templates/com.gitea.runner.plist.tpl

Depends on: macOS launchd plist schema Produces: launchd service definition for act_runner on macOS

Must include:

  • Label: com.gitea.runner.$RUNNER_NAME
  • ProgramArguments: path to act_runner binary, daemon subcommand
  • WorkingDirectory: $RUNNER_DATA_PATH
  • RunAtLoad: true
  • KeepAlive: true
  • StandardOutPath / StandardErrorPath: log file locations

Done when:

  • Valid XML plist (test with plutil -lint)
  • Binary path matches where manage_runner.sh installs it
  • KeepAlive ensures runner restarts if it crashes

2.6 — templates/nginx-gitea.conf.tpl

Depends on: .env vars Produces: Nginx server block for reverse-proxying Gitea

Must include:

  • server_name $GITEA_DOMAIN
  • listen 443 ssl
  • ssl_certificate / ssl_certificate_key paths (Certbot standard: /etc/letsencrypt/live/$GITEA_DOMAIN/)
  • proxy_pass http://$UNRAID_IP:$UNRAID_GITEA_PORT
  • Proxy headers: X-Real-IP, X-Forwarded-For, X-Forwarded-Proto, Host
  • WebSocket support: proxy_set_header Upgrade, Connection "upgrade"
  • HTTP→HTTPS redirect block (listen 80, return 301)
  • client_max_body_size 512m (for large git pushes)

Done when:

  • nginx -t passes against rendered config (can test by running nginx in Docker locally)
  • WebSocket headers present (needed for Gitea's live features)
  • HTTP→HTTPS redirect included
  • SSL cert paths match Certbot convention

2.7 — templates/workflows/security-scan.yml.tpl

Depends on: Gitea Actions workflow syntax Produces: Shared security workflow for all repos

Must include:

  • on: [pull_request] trigger
  • Three jobs: semgrep, trivy, gitleaks
  • Semgrep: returntocorp/semgrep:$SEMGREP_VERSION Docker image, semgrep scan --config auto .
  • Trivy: aquasec/trivy:$TRIVY_VERSION, trivy fs --exit-code 1 --severity HIGH,CRITICAL .
  • Gitleaks: zricethezav/gitleaks:$GITLEAKS_VERSION, gitleaks detect --source . --exit-code 1
  • All three jobs must report as status checks (default behavior in Gitea Actions)

Done when:

  • Valid YAML after rendering
  • All three tools pinned to version vars (not latest hardcoded)
  • Each job exits non-zero on findings (so branch protection can block merge)
  • runs-on labels match what Linux runners advertise in runners.conf

3.1 — setup/configure_env.sh

Depends on: .env.example exists Runs: locally on MacBook (interactive — requires terminal input) Purpose: Guided wizard that prompts for every required .env variable, validates input, and writes a complete .env file.

Behavior:

  1. If .env already exists, load current values as defaults (shown in brackets, press Enter to keep)
  2. If .env does not exist, copy .env.example to .env first
  3. Walk through each required variable in section order, prompting with:
    • Progress indicator: [12/47] ── GITEA SHARED CREDENTIALS ──────────────────
    • Variable name and description (from .env.example comments)
    • Current value if any (from existing .env): UNRAID_IP [192.168.1.10]:
    • Empty prompt if no current value: UNRAID_IP (Static IP of Unraid server):
  4. Basic input validation per variable:
    • IP addresses: regex match ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$
    • Ports: integer between 1-65535
    • Passwords: minimum 8 characters
    • Emails: contains @
    • Paths: starts with /
    • URLs: starts with http:// or https://
  5. After all prompts, write values to .env preserving the file structure (comments, sections)
  6. Do NOT prompt for auto-populated vars (GITEA_ADMIN_TOKEN, GITEA_BACKUP_ADMIN_TOKEN, GITEA_RUNNER_REGISTRATION_TOKEN)
  7. Do NOT prompt for vars with defaults unless user wants to change them — show default, press Enter to accept
  8. Print summary at end showing all configured values (passwords masked)

Variable prompt order (matches .env.example sections):

# Variable Validation Default
1 UNRAID_IP IP address
2 UNRAID_SSH_USER Non-empty
3 UNRAID_SSH_PORT Port 22
4 UNRAID_GITEA_PORT Port 3000
5 UNRAID_GITEA_SSH_PORT Port 2222
6 UNRAID_GITEA_DATA_PATH Absolute path
7 FEDORA_IP IP address
8 FEDORA_SSH_USER Non-empty
9 FEDORA_SSH_PORT Port 22
10 FEDORA_GITEA_PORT Port 3000
11 FEDORA_GITEA_SSH_PORT Port 2222
12 FEDORA_GITEA_DATA_PATH Absolute path
13 GITEA_ADMIN_USER Non-empty
14 GITEA_ADMIN_PASSWORD Min 8 chars
15 GITEA_ADMIN_EMAIL Email
16 GITEA_ORG_NAME Non-empty
17 GITEA_INSTANCE_NAME Non-empty
18 GITEA_DB_TYPE Non-empty sqlite3
19 GITEA_VERSION Non-empty 1.23
20 ACT_RUNNER_VERSION Non-empty 0.2.11
21 GITEA_DOMAIN Non-empty
22 GITEA_INTERNAL_URL URL
23 GITEA_BACKUP_INTERNAL_URL URL
24 GITEA_BACKUP_MIRROR_INTERVAL Non-empty 8h
25 BACKUP_STORAGE_PATH Absolute path
26 BACKUP_RETENTION_COUNT Integer 5
27 GITHUB_USERNAME Non-empty
28 GITHUB_TOKEN Non-empty
29 REPO_NAMES Non-empty
30 MIGRATE_ISSUES true/false false
33 MIGRATE_LABELS true/false true
34 MIGRATE_MILESTONES true/false false
35 MIGRATE_WIKI true/false false
36 GITHUB_MIRROR_INTERVAL Non-empty 8h
37 NGINX_CONTAINER_NAME Non-empty
39 NGINX_CONF_PATH Absolute path
40 SSL_MODE letsencrypt or existing letsencrypt
41 SSL_EMAIL Email (only if SSL_MODE=letsencrypt)
42 SSL_CERT_PATH Absolute path (only if SSL_MODE=existing)
43 SSL_KEY_PATH Absolute path (only if SSL_MODE=existing)
44 PROTECTED_BRANCH Non-empty main
45 REQUIRE_PR_REVIEW true/false false
46 REQUIRED_APPROVALS Integer 1
47 SEMGREP_VERSION Non-empty latest
48 TRIVY_VERSION Non-empty latest
49 GITLEAKS_VERSION Non-empty latest
50 SECURITY_FAIL_ON_ERROR true/false true

Done when:

  • Each prompt shows progress: [N/50] with section header when entering a new section
  • Running with no existing .env walks through all 50 prompts and produces a valid .env
  • SSL prompts are conditional: if SSL_MODE=letsencrypt, prompt for SSL_EMAIL only; if SSL_MODE=existing, prompt for SSL_CERT_PATH and SSL_KEY_PATH only
  • Running with an existing .env shows current values and only overwrites what user changes
  • Invalid input (bad IP, path not starting with /, password too short) re-prompts with error message
  • Auto-populated vars are skipped entirely (no prompt, no overwrite)
  • Summary at end shows all values (passwords masked with ****)
  • .env file preserves comment structure from .env.example
  • shellcheck setup/configure_env.sh passes

3.2 — setup/macbook.sh

Depends on: nothing (first script to run) Runs: locally on MacBook

Steps:

  1. Check for Homebrew → if missing, print install command and exit (don't auto-install — it's interactive)
  2. brew install jq curl gettext shellcheck gh — skip any already installed
  3. Verify built-in tools exist: ssh, git, scp, envsubst (from gettext)
  4. Check Xcode CLI Tools: xcode-select -p → if fails, run xcode-select --install and wait
  5. Print summary of what was installed vs already present

Idempotency: Running twice produces no errors and no redundant installs.

Done when:

  • Running on a Mac where all tools exist prints "All prerequisites satisfied" and exits 0
  • Running on a Mac missing jq installs it via brew and exits 0
  • Script does NOT install Homebrew automatically (security — user should do it themselves)
  • envsubst --version works after script runs (this is the most commonly missing tool)
  • shellcheck setup/macbook.sh passes

3.3 — setup/unraid.sh

Depends on: SSH access to Unraid (.env vars: UNRAID_IP, UNRAID_SSH_USER, UNRAID_SSH_PORT) Runs: from MacBook via SSH into Unraid

Steps:

  1. ssh_check UNRAID — fail if can't connect
  2. Check Docker: ssh_exec UNRAID "docker --version" — if missing, exit 1 with message "Docker not found on Unraid. Install it via Unraid's Docker settings."
  3. Check docker-compose: try docker compose version, then docker-compose --version. If neither works, download standalone docker-compose binary to /usr/local/bin/
  4. Check jq: ssh_exec UNRAID "jq --version" — if missing, download static binary from GitHub releases to /usr/local/bin/jq, chmod +x
  5. Verify data path: ssh_exec UNRAID "mkdir -p $UNRAID_GITEA_DATA_PATH && touch $UNRAID_GITEA_DATA_PATH/.write-test && rm $UNRAID_GITEA_DATA_PATH/.write-test"

Done when:

  • docker --version works on Unraid via SSH
  • docker compose version OR docker-compose --version works on Unraid via SSH
  • jq --version works on Unraid via SSH
  • UNRAID_GITEA_DATA_PATH exists and is writable
  • Script does NOT attempt to install Docker on Unraid (could break Unraid's custom setup)
  • shellcheck setup/unraid.sh passes

3.4 — setup/fedora.sh

Depends on: SSH access to Fedora (.env vars: FEDORA_IP, FEDORA_SSH_USER, FEDORA_SSH_PORT) Runs: from MacBook via SSH into Fedora

Steps:

  1. ssh_check FEDORA — fail if can't connect
  2. Check Docker: docker --version — if missing:
    • sudo dnf -y install dnf-plugins-core
    • sudo dnf config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo
    • sudo dnf -y install docker-ce docker-ce-cli containerd.io docker-compose-plugin
    • sudo systemctl enable --now docker
    • sudo usermod -aG docker $FEDORA_SSH_USER
    • Print warning: "User added to docker group. You may need to re-login for this to take effect."
  3. Check jq: if missing, sudo dnf -y install jq
  4. Verify Docker works: docker run --rm hello-world (must succeed without sudo after group membership)
  5. Verify data path writable (same as Unraid step)

Done when:

  • docker --version works on Fedora via SSH
  • docker compose version works on Fedora via SSH
  • jq --version works on Fedora via SSH
  • docker run --rm hello-world succeeds without sudo (if it requires sudo, the docker group membership hasn't taken effect — script must warn about re-login)
  • FEDORA_GITEA_DATA_PATH exists and is writable
  • shellcheck setup/fedora.sh passes

4.1 — preflight.sh

Depends on: setup scripts completed, .env populated Runs: locally on MacBook Purpose: Pure validation. Installs nothing. Exits 0 only if EVERYTHING is ready.

Checks (each prints PASS/FAIL with specific message):

# Check Pass condition Fail message
1 .env exists File present in project root ".env not found. Copy .env.example to .env and fill in values."
2 runners.conf exists File present in project root "runners.conf not found. Copy runners.conf.example to runners.conf."
3 Required .env vars set Every var in the list below is non-empty "Missing required var: VAR_NAME"

Check #3 — Required variables (must be non-empty):

Section Variable
Unraid UNRAID_IP
Unraid UNRAID_SSH_USER
Unraid UNRAID_GITEA_DATA_PATH
Fedora FEDORA_IP
Fedora FEDORA_SSH_USER
Fedora FEDORA_GITEA_DATA_PATH
Shared creds GITEA_ADMIN_USER
Shared creds GITEA_ADMIN_PASSWORD
Shared creds GITEA_ADMIN_EMAIL
Shared creds GITEA_ORG_NAME
Shared creds GITEA_INSTANCE_NAME
Primary GITEA_DOMAIN
Primary GITEA_INTERNAL_URL
Backup GITEA_BACKUP_INTERNAL_URL
Backup BACKUP_STORAGE_PATH
Repos GITHUB_USERNAME
Repos GITHUB_TOKEN
Repos REPO_NAMES
Mirror GITHUB_TOKEN
Nginx NGINX_CONTAINER_NAME
Nginx NGINX_CONF_PATH
Nginx SSL_EMAIL

Not checked (have defaults or auto-populated): UNRAID_SSH_PORT, UNRAID_GITEA_PORT, UNRAID_GITEA_SSH_PORT, FEDORA_SSH_PORT, FEDORA_GITEA_PORT, FEDORA_GITEA_SSH_PORT, GITEA_DB_TYPE, GITEA_VERSION, ACT_RUNNER_VERSION, GITEA_BACKUP_MIRROR_INTERVAL, BACKUP_RETENTION_COUNT, MIGRATE_*, GITHUB_MIRROR_INTERVAL, PROTECTED_BRANCH, REQUIRE_PR_REVIEW, REQUIRED_APPROVALS, SEMGREP_VERSION, TRIVY_VERSION, GITLEAKS_VERSION, SECURITY_FAIL_ON_ERROR, GITEA_ADMIN_TOKEN, GITEA_BACKUP_ADMIN_TOKEN, GITEA_RUNNER_REGISTRATION_TOKEN | 4 | SSH to Unraid | ssh_check UNRAID returns 0 | "Cannot SSH to Unraid at $UNRAID_IP. Run setup/unraid.sh or check SSH config." | | 5 | SSH to Fedora | ssh_check FEDORA returns 0 | Same pattern | | 6 | Docker on Unraid | ssh_exec UNRAID "docker --version" exits 0 | "Docker not found on Unraid. Run setup/unraid.sh." | | 7 | Docker on Fedora | Same | Same | | 8 | docker-compose on Unraid | ssh_exec UNRAID "docker compose version" or docker-compose --version | "docker-compose not found on Unraid. Run setup/unraid.sh." | | 9 | docker-compose on Fedora | Same | Same | | 10 | Port 3000 free on Unraid | ssh_exec UNRAID "! ss -tlnp \| grep -q ':$UNRAID_GITEA_PORT '" | "Port $UNRAID_GITEA_PORT already in use on Unraid." | | 11 | Port 3000 free on Fedora | Same | Same | | 12 | DNS resolves | dig +short $GITEA_DOMAIN returns $UNRAID_IP | "$GITEA_DOMAIN does not resolve to $UNRAID_IP." | | 13 | GitHub token valid | github_api GET /user returns 200 | "GitHub token invalid. Check GITHUB_TOKEN in .env." | | 14 | GitHub repos exist | For each REPO_N_NAME: github_api GET /repos/$GITHUB_USERNAME/$REPO_N_NAME returns 200 | "GitHub repo $REPO_N_NAME not found under $GITHUB_USERNAME." | | 15 | Nginx running on Unraid | ssh_exec UNRAID "docker ps --filter name=$NGINX_CONTAINER_NAME --format '{{.Status}}'" contains "Up" | "Nginx container '$NGINX_CONTAINER_NAME' not running on Unraid." | | 16 | Nginx conf dir writable | ssh_exec UNRAID "test -w $NGINX_CONF_PATH" | "Nginx config path $NGINX_CONF_PATH not writable." |

Exit behavior: Runs ALL checks (doesn't stop at first failure). Prints summary at end. Exits 0 if all pass, 1 if any fail.

Done when:

  • Every check in the table above is implemented
  • Failed checks point to the correct setup script or config to fix
  • All checks run even if earlier ones fail (user sees full picture)
  • Exit code is 1 if ANY check fails, 0 only if ALL pass
  • shellcheck preflight.sh passes

5.1 — phase1_gitea_unraid.sh

Depends on: preflight passed, templates exist Produces: Running Gitea instance on Unraid, admin user, API token in .env, org created require_vars: UNRAID_IP, UNRAID_SSH_USER, UNRAID_SSH_PORT, UNRAID_GITEA_PORT, UNRAID_GITEA_SSH_PORT, UNRAID_GITEA_DATA_PATH, GITEA_ADMIN_USER, GITEA_ADMIN_PASSWORD, GITEA_ADMIN_EMAIL, GITEA_ORG_NAME, GITEA_INSTANCE_NAME, GITEA_DB_TYPE, GITEA_VERSION, GITEA_INTERNAL_URL, GITEA_DOMAIN

Steps with idempotency:

# Action Idempotency check (skip if true)
1 Create data dirs on Unraid ssh_exec UNRAID "test -d $UNRAID_GITEA_DATA_PATH/data"
2 Render + SCP docker-compose-gitea.yml.tpl ssh_exec UNRAID "test -f $UNRAID_GITEA_DATA_PATH/docker-compose.yml"
3 Render + SCP app.ini.tpl ssh_exec UNRAID "test -f $UNRAID_GITEA_DATA_PATH/config/app.ini"
4 docker-compose up -d on Unraid ssh_exec UNRAID "docker ps --filter name=gitea --format '{{.Status}}'" contains "Up"
5 Wait for Gitea HTTP ready curl -sf $GITEA_INTERNAL_URL/api/v1/version returns 200
6 Create admin user via docker exec gitea gitea admin user create curl -sf -u $GITEA_ADMIN_USER:$GITEA_ADMIN_PASSWORD $GITEA_INTERNAL_URL/api/v1/user returns 200
7 Generate API token GITEA_ADMIN_TOKEN is non-empty in .env AND gitea_api GET /user returns 200
8 Save token to .env Token is in .env file
9 Create org gitea_api GET /orgs/$GITEA_ORG_NAME returns 200

Done when:

  • curl $GITEA_INTERNAL_URL returns Gitea HTML page
  • curl -H "Authorization: token $GITEA_ADMIN_TOKEN" $GITEA_INTERNAL_URL/api/v1/user returns admin user JSON
  • GITEA_ADMIN_TOKEN is written to .env and is non-empty
  • Org exists: gitea_api GET /orgs/$GITEA_ORG_NAME returns 200
  • Running the script again changes nothing (all steps skip with "already exists" messages)
  • shellcheck phase1_gitea_unraid.sh passes

5.2 — phase1_post_check.sh

Depends on: phase1 completed Purpose: Independent verification that phase 1 succeeded (can be run separately)

Checks:

  • Gitea responds at $GITEA_INTERNAL_URL with HTTP 200
  • Admin user authenticates: curl -u user:pass .../api/v1/user returns 200
  • API token works: gitea_api GET /user returns 200 with correct username
  • Org exists: gitea_api GET /orgs/$GITEA_ORG_NAME returns 200
  • Gitea Actions enabled: gitea_api GET /api/v1/settings/api or check app.ini

Done when:

  • Runs all checks, prints PASS/FAIL for each
  • Exits 0 only if ALL pass

5.3 — phase1_teardown.sh

Depends on: phase1 was run Destructive: yes — prompts for confirmation

Steps:

  1. Prompt: "This will stop Gitea on Unraid. Continue? [y/N]"
  2. ssh_exec UNRAID "cd $UNRAID_GITEA_DATA_PATH && docker-compose down"
  3. Prompt: "Remove all Gitea data ($UNRAID_GITEA_DATA_PATH)? This is irreversible. [y/N]"
  4. If confirmed: ssh_exec UNRAID "rm -rf $UNRAID_GITEA_DATA_PATH"
  5. Clear GITEA_ADMIN_TOKEN from .env: save_env_var GITEA_ADMIN_TOKEN ""

Done when:

  • Gitea container is stopped and removed
  • Data is only deleted if user explicitly confirms
  • GITEA_ADMIN_TOKEN is cleared from .env
  • Running against an already-torn-down instance doesn't error

6.1 — phase2_gitea_fedora.sh

Depends on: preflight passed, templates exist Produces: Running Gitea instance on Fedora, admin user, API token in .env require_vars: FEDORA_IP, FEDORA_SSH_USER, FEDORA_SSH_PORT, FEDORA_GITEA_PORT, FEDORA_GITEA_SSH_PORT, FEDORA_GITEA_DATA_PATH, GITEA_ADMIN_USER, GITEA_ADMIN_PASSWORD, GITEA_ADMIN_EMAIL, GITEA_INSTANCE_NAME, GITEA_DB_TYPE, GITEA_VERSION, GITEA_BACKUP_INTERNAL_URL

Identical to phase1 except:

  • Target: Fedora (uses FEDORA_IP, FEDORA_SSH_USER, FEDORA_SSH_PORT, FEDORA_GITEA_DATA_PATH, FEDORA_GITEA_PORT)
  • Uses GITEA_BACKUP_INTERNAL_URL for API calls
  • Saves token as GITEA_BACKUP_ADMIN_TOKEN
  • Uses same admin credentials (GITEA_ADMIN_USER/GITEA_ADMIN_PASSWORD/GITEA_ADMIN_EMAIL)
  • Does NOT create an org (mirrors will be under admin user's namespace)

Done when:

  • Same criteria as 5.1 but targeting Fedora URLs/paths
  • GITEA_BACKUP_ADMIN_TOKEN is in .env and works
  • No org was created on Fedora instance

6.2 — phase2_post_check.sh

Same as 5.2 but targeting Fedora instance. No org check.


6.3 — phase2_teardown.sh

Same as 5.3 but targeting Fedora. Clears GITEA_BACKUP_ADMIN_TOKEN.


7.2 — manage_runner.sh

Depends on: lib/common.sh, runners.conf, templates Purpose: Standalone tool to add/remove/list individual runners

Subcommands:

manage_runner.sh add --name <runner_name>:

  1. Read runner entry from runners.conf by name
  2. If type=docker:
    • Render docker-compose-runner.yml.tpl with runner's vars
    • SCP to $DATA_PATH/docker-compose.yml on runner host
    • Render runner-config.yaml.tpl, SCP to $DATA_PATH/config.yaml
    • ssh_exec HOST "cd $DATA_PATH && docker-compose up -d"
    • wait_for_http on Gitea API for runner to appear
  3. If type=native:
    • Download act_runner binary for host's OS/arch to $DATA_PATH/act_runner
    • Run $DATA_PATH/act_runner register --no-interactive --instance $GITEA_INTERNAL_URL --token $GITEA_RUNNER_REGISTRATION_TOKEN --name $NAME --labels $LABELS
    • Render launchd plist, copy to ~/Library/LaunchAgents/
    • launchctl load ~/Library/LaunchAgents/com.gitea.runner.$NAME.plist

manage_runner.sh remove --name <runner_name>:

  1. Read runner entry from runners.conf
  2. If type=docker: ssh_exec HOST "cd $DATA_PATH && docker-compose down", optionally rm data
  3. If type=native: launchctl unload plist, rm binary + plist
  4. Deregister from Gitea via API if possible (or just let it go offline)

manage_runner.sh list:

  1. Read all entries from runners.conf
  2. For each: query Gitea API for runner status
  3. Print table: name, host, labels, type, status (online/offline)

Done when:

  • add with a docker-type runner: container is running, runner appears in Gitea admin panel
  • add with a native-type runner: launchd service is loaded, runner appears in Gitea admin panel
  • remove stops the runner and it disappears from Gitea admin (or shows offline)
  • list shows all runners with current status
  • add on an already-deployed runner prints "already running" and exits 0
  • remove on a non-existent runner prints warning and exits 0
  • shellcheck manage_runner.sh passes

7.3 — phase3_runners.sh

Depends on: Phase 1 completed (Gitea running), runners.conf populated Produces: All runners from runners.conf deployed and registered require_vars: GITEA_INTERNAL_URL, GITEA_ADMIN_TOKEN (auto), ACT_RUNNER_VERSION

Steps:

  1. Get registration token: gitea_api GET /admin/runners/registration-token
  2. save_env_var GITEA_RUNNER_REGISTRATION_TOKEN <token>
  3. For each entry in runners.conf: call manage_runner.sh add --name <name>

Done when:

  • GITEA_RUNNER_REGISTRATION_TOKEN is in .env
  • Every runner in runners.conf is deployed and shows "online" in Gitea admin
  • Running again skips all already-deployed runners

7.4 — phase3_post_check.sh

Checks:

  • For each runner in runners.conf: runner exists in Gitea admin API response
  • For each runner in runners.conf: status is "online" (not just registered but idle/active)
  • Runner count in Gitea matches line count in runners.conf

7.5 — phase3_teardown.sh

For each runner in runners.conf: manage_runner.sh remove --name <name>. Clears GITEA_RUNNER_REGISTRATION_TOKEN.


8.1 — phase4_migrate_repos.sh

Depends on: Phase 1 + Phase 2 completed (both Gitea instances running) Produces: All 3 repos on Unraid primary under org + pull mirrors on Fedora require_vars: GITEA_ADMIN_TOKEN (auto), GITEA_BACKUP_ADMIN_TOKEN (auto), GITEA_INTERNAL_URL, GITEA_BACKUP_INTERNAL_URL, GITEA_ORG_NAME, GITEA_ADMIN_USER, GITEA_ADMIN_PASSWORD, GITHUB_USERNAME, GITHUB_TOKEN, REPO_NAMES, MIGRATE_ISSUES, MIGRATE_LABELS, MIGRATE_MILESTONES, MIGRATE_WIKI, GITEA_BACKUP_MIRROR_INTERVAL

Steps:

For each REPO_N_NAME (N=1,2,3):

# Action API call Idempotency check
1 Import repo from GitHub to Unraid gitea_api POST /repos/migrate with clone_addr=https://github.com/$GITHUB_USERNAME/$REPO_N_NAME.git, auth_token=$GITHUB_TOKEN, repo_owner=$GITEA_ORG_NAME, repo_name=$REPO_N_NAME, mirror=false, issues=$MIGRATE_ISSUES, labels=$MIGRATE_LABELS, milestones=$MIGRATE_MILESTONES, wiki=$MIGRATE_WIKI gitea_api GET /repos/$GITEA_ORG_NAME/$REPO_N_NAME returns 200
2 Wait for migration Poll gitea_api GET /repos/$GITEA_ORG_NAME/$REPO_N_NAME until empty=false (repo has content)
3 Create pull mirror on Fedora gitea_backup_api POST /repos/migrate with clone_addr=$GITEA_INTERNAL_URL/$GITEA_ORG_NAME/$REPO_N_NAME.git, auth_username=$GITEA_ADMIN_USER, auth_password=$GITEA_ADMIN_PASSWORD, repo_owner=$GITEA_BACKUP_ADMIN_USER, mirror=true, mirror_interval=$GITEA_BACKUP_MIRROR_INTERVAL gitea_backup_api GET /repos/$GITEA_BACKUP_ADMIN_USER/$REPO_N_NAME returns 200

Done when:

  • All 3 repos exist under $GITEA_ORG_NAME on Unraid with commits
  • Each repo's default branch matches the GitHub source
  • All 3 mirror repos exist on Fedora under admin user
  • Fedora mirrors show mirror=true in API response
  • Fedora mirrors have synced at least once (has commits)
  • Running again skips all existing repos

8.2 — phase4_post_check.sh

Checks:

  • Each repo exists on primary: gitea_api GET /repos/$ORG/$REPO returns 200
  • Each repo has commits: gitea_api GET /repos/$ORG/$REPO/commits?limit=1 returns at least 1 commit
  • Default branch matches source: compare default_branch field from Gitea vs GitHub API
  • Each mirror repo exists on Fedora: gitea_backup_api GET /repos/$ADMIN/$REPO returns 200
  • Each mirror has mirror: true in response

8.3 — phase4_teardown.sh

  1. For each repo: gitea_api DELETE /repos/$GITEA_ORG_NAME/$REPO_N_NAME
  2. For each mirror: gitea_backup_api DELETE /repos/$ADMIN/$REPO_N_NAME
  3. Prompt before each deletion

9.1 — phase5_migrate_pipelines.sh

Depends on: Phase 4 completed (repos exist on Gitea) Produces: .gitea/workflows/ directory in each repo with adapted workflows require_vars: GITEA_ADMIN_TOKEN (auto), GITEA_INTERNAL_URL, GITEA_ORG_NAME, GITEA_ADMIN_USER, REPO_NAMES

Steps for each repo:

  1. Clone repo from Gitea to temp dir: git clone $GITEA_INTERNAL_URL/$GITEA_ORG_NAME/$REPO.git /tmp/gitea-migration-$REPO
  2. Check if .github/workflows/ exists — if not, log warning "No GitHub workflows found" and skip
  3. Create .gitea/workflows/ directory
  4. Copy all .yml files from .github/workflows/ to .gitea/workflows/
  5. Apply compatibility fixes in each copied file:
    • Replace github.repositorygitea.repository in expressions
    • Replace github.eventgitea.event in expressions
    • Replace github.tokengitea.token
    • Replace github.server_urlgitea.server_url
    • Keep actions/checkout@v4 as-is (compatible with Gitea)
    • Add comment at top: # Migrated from GitHub Actions — review for Gitea compatibility
  6. git add .gitea/, git commit -m "Migrate workflows to Gitea Actions", git push
  7. Clean up temp dir

Idempotency: Skip repo if .gitea/workflows/ already exists with files.

Known limitations (document in script output):

  • GitHub-specific marketplace actions may not work in Gitea — script logs warnings but doesn't block
  • Self-hosted runner tool caches may differ — user may need to install tools manually
  • OIDC/secrets need to be re-configured in Gitea settings

Done when:

  • Each repo that had .github/workflows/ now has .gitea/workflows/ with adapted files
  • Context variable replacements applied (github.*gitea.*)
  • Each adapted workflow file has the migration comment header
  • Repos without workflows are skipped with a warning (not an error)
  • Running again skips repos that already have .gitea/workflows/

9.2 — phase5_post_check.sh

Checks:

  • Each repo has .gitea/workflows/ directory (check via API: gitea_api GET /repos/$ORG/$REPO/contents/.gitea/workflows)
  • At least one .yml file in that directory
  • Gitea Actions tab shows workflows (may require checking the Gitea web UI or API for action runs)

9.3 — phase5_teardown.sh

For each repo: clone, rm -rf .gitea/workflows, commit, push. Only if .gitea/workflows/ exists.


10.1 — phase6_github_mirrors.sh

Depends on: Phase 4 completed, GitHub mirror token set Produces: Push mirrors from Gitea → GitHub configured for all repos require_vars: GITEA_ADMIN_TOKEN (auto), GITEA_INTERNAL_URL, GITEA_ORG_NAME, GITHUB_USERNAME, GITHUB_TOKEN, GITHUB_MIRROR_INTERVAL, REPO_NAMES

Steps for each repo:

  1. Check if push mirror already exists: gitea_api GET /repos/$ORG/$REPO/push_mirrors — skip if non-empty
  2. Create push mirror: gitea_api POST /repos/$ORG/$REPO/push_mirrors with:
    • remote_address: https://github.com/$GITHUB_USERNAME/$REPO.git
    • remote_username: $GITHUB_USERNAME
    • remote_password: $GITHUB_TOKEN
    • interval: $GITHUB_MIRROR_INTERVAL
    • sync_on_commit: true
  3. Trigger initial sync: gitea_api POST /repos/$ORG/$REPO/push_mirrors-sync
  4. Disable GitHub Actions: github_api PATCH /repos/$GITHUB_USERNAME/$REPO with {"has_projects": false} — note: disabling Actions requires setting has_actions: false which may not be in the API. If not possible via API, print manual instructions.

Done when:

  • Each repo has exactly one push mirror configured
  • gitea_api GET /repos/$ORG/$REPO/push_mirrors returns the mirror config
  • After triggering sync, GitHub repo has the same latest commit as Gitea
  • Running again skips repos that already have mirrors

10.2 — phase6_post_check.sh

  • Each repo: push mirror exists in API response
  • Each repo: trigger sync and verify GitHub's latest commit SHA matches Gitea's HEAD

10.3 — phase6_teardown.sh

For each repo: gitea_api DELETE /repos/$ORG/$REPO/push_mirrors/{id}. Get mirror ID from list endpoint first.


11.1 — phase7_branch_protection.sh

Depends on: Phase 4 completed (repos exist) Produces: Branch protection on $PROTECTED_BRANCH for all repos require_vars: GITEA_ADMIN_TOKEN (auto), GITEA_INTERNAL_URL, GITEA_ORG_NAME, REPO_NAMES, PROTECTED_BRANCH, REQUIRE_PR_REVIEW, REQUIRED_APPROVALS

Steps for each repo:

  1. Check if protection exists: gitea_api GET /repos/$ORG/$REPO/branch_protections/$PROTECTED_BRANCH — skip if 200
  2. Create protection: gitea_api POST /repos/$ORG/$REPO/branch_protections with:
    • branch_name: $PROTECTED_BRANCH
    • enable_push: false
    • enable_push_whitelist: false
    • require_signed_commits: false
    • enable_status_check: true
    • enable_approvals_whitelist: $REQUIRE_PR_REVIEW
    • required_approvals: $REQUIRED_APPROVALS

Done when:

  • Each repo: gitea_api GET /repos/$ORG/$REPO/branch_protections returns the rule
  • Direct pushes to $PROTECTED_BRANCH are blocked
  • Running again skips existing protections

11.2 / 11.3 — post-check and teardown

Post-check: verify protection rules via API. Teardown: gitea_api DELETE /repos/$ORG/$REPO/branch_protections/$PROTECTED_BRANCH.


12.1 — phase8_cutover.sh

Depends on: Nginx running on Unraid, all prior phases Produces: HTTPS access to Gitea, GitHub repos archived require_vars: UNRAID_IP, UNRAID_SSH_USER, UNRAID_SSH_PORT, UNRAID_GITEA_PORT, GITEA_INTERNAL_URL, GITEA_DOMAIN, GITEA_ADMIN_TOKEN (auto), GITEA_ORG_NAME, NGINX_CONTAINER_NAME, NGINX_CONF_PATH, SSL_EMAIL, GITHUB_USERNAME, GITHUB_TOKEN, REPO_NAMES

Steps with idempotency:

# Action Detail Idempotency check (skip if true)
1 Deploy HTTP-only Nginx config Render nginx-gitea.conf.tpl in HTTP-only mode (no SSL directives). This serves: (a) reverse proxy to Gitea on port 80, (b) /.well-known/acme-challenge/ location for Certbot webroot validation. SCP to $NGINX_CONF_PATH/gitea.conf. ssh_exec UNRAID "test -f $NGINX_CONF_PATH/gitea.conf"
2 Test Nginx config ssh_exec UNRAID "docker exec $NGINX_CONTAINER_NAME nginx -t" — if fails, remove config and exit 1 — (always run)
3 Reload Nginx (HTTP) ssh_exec UNRAID "docker exec $NGINX_CONTAINER_NAME nginx -s reload"
4 Verify HTTP proxy works curl -sf http://$GITEA_DOMAIN/api/v1/version returns 200
5 Obtain or verify SSL cert If SSL_MODE=letsencrypt: ssh_exec UNRAID "docker run --rm -v /etc/letsencrypt:/etc/letsencrypt -v /var/www/html:/var/www/html certbot/certbot certonly --webroot -w /var/www/html -d $GITEA_DOMAIN --email $SSL_EMAIL --agree-tos --non-interactive". If SSL_MODE=existing: verify cert files exist at $SSL_CERT_PATH and $SSL_KEY_PATH on Unraid: ssh_exec UNRAID "test -f $SSL_CERT_PATH && test -f $SSL_KEY_PATH" — fail if missing. letsencrypt: ssh_exec UNRAID "test -f /etc/letsencrypt/live/$GITEA_DOMAIN/fullchain.pem". existing: cert files already verified.
6 Deploy HTTPS Nginx config Re-render nginx-gitea.conf.tpl in HTTPS mode (adds listen 443 ssl, cert paths, HTTP→HTTPS redirect). SCP to $NGINX_CONF_PATH/gitea.conf (overwrites HTTP-only version). Cert exists from step 5
7 Test Nginx config (HTTPS) ssh_exec UNRAID "docker exec $NGINX_CONTAINER_NAME nginx -t" — if fails, revert to HTTP-only config and exit 1 — (always run)
8 Reload Nginx (HTTPS) ssh_exec UNRAID "docker exec $NGINX_CONTAINER_NAME nginx -s reload"
9 Verify HTTPS works curl -sf https://$GITEA_DOMAIN/api/v1/version returns 200. Also: curl -sI https://$GITEA_DOMAIN to confirm no redirect loops.
10 Set up cert auto-renewal cron Only if SSL_MODE=letsencrypt: ssh_exec UNRAID "echo '0 3 * * * docker run --rm -v /etc/letsencrypt:/etc/letsencrypt -v /var/www/html:/var/www/html certbot/certbot renew --quiet && docker exec $NGINX_CONTAINER_NAME nginx -s reload' | crontab -" — runs daily at 3 AM. If SSL_MODE=existing: skip (user manages their own cert renewal). ssh_exec UNRAID "crontab -l 2>/dev/null | grep -q certbot"
11 Archive GitHub repos For each repo: (a) Save original description: github_api GET /repos/$GITHUB_USERNAME/$REPO → store description field, (b) github_api PATCH /repos/$GITHUB_USERNAME/$REPO {"archived": true, "description": "[MOVED] Now at https://$GITEA_DOMAIN/$GITEA_ORG_NAME/$REPO — was: $ORIGINAL_DESCRIPTION"} github_api GET /repos/$GITHUB_USERNAME/$REPO has "archived": true

Nginx template must support two render passes: The nginx-gitea.conf.tpl template is rendered with $SSL_ENABLED=true/false (set by the script, not .env):

  • HTTP-only (SSL_ENABLED=false): listen 80, proxy_pass, ACME challenge location (if SSL_MODE=letsencrypt), no SSL directives
  • HTTPS (SSL_ENABLED=true): listen 443 ssl, cert paths, listen 80 with 301 redirect, proxy_pass, WebSocket headers

Cert paths in the template depend on SSL_MODE:

  • letsencrypt: ssl_certificate /etc/letsencrypt/live/$GITEA_DOMAIN/fullchain.pem, ssl_certificate_key /etc/letsencrypt/live/$GITEA_DOMAIN/privkey.pem
  • existing: ssl_certificate $SSL_CERT_PATH, ssl_certificate_key $SSL_KEY_PATH

Certbot volume mounts:

  • /etc/letsencrypt on Unraid host → mounted into both Certbot container and Nginx container
  • /var/www/html on Unraid host → Nginx serves this for ACME challenges, Certbot writes challenge files here
  • Verify these mount paths exist on the Nginx container: ssh_exec UNRAID "docker inspect $NGINX_CONTAINER_NAME --format '{{json .Mounts}}'" — if /etc/letsencrypt or webroot is not mounted, the script must fail with instructions to add the volume mounts to the Nginx container config.

Done when:

  • https://$GITEA_DOMAIN returns valid HTTPS response (no cert errors)
  • curl https://$GITEA_DOMAIN/api/v1/version returns Gitea version JSON
  • Certificate is from Let's Encrypt: openssl s_client -connect $GITEA_DOMAIN:443 </dev/null 2>/dev/null | openssl x509 -noout -issuer contains "Let's Encrypt"
  • HTTP requests redirect to HTTPS: curl -sI http://$GITEA_DOMAIN returns 301 with Location: https://...
  • Cert auto-renewal cron is installed
  • All GitHub repos show as archived with original description preserved in the new description
  • Nginx config test passes before every reload (never reload a broken config)
  • Running again skips cert generation, skips already-archived repos
  • If Nginx container doesn't have required volume mounts, script fails with clear instructions

12.2 — phase8_post_check.sh

  • HTTPS works with valid cert: curl -sf https://$GITEA_DOMAIN/ returns 200
  • Certificate is from Let's Encrypt (not self-signed): check with openssl s_client
  • All repos accessible: curl -sf https://$GITEA_DOMAIN/$ORG/$REPO returns 200
  • GitHub repos are archived: github_api GET /repos/$USER/$REPO has "archived": true

12.3 — phase8_teardown.sh

  1. Remove Nginx config: ssh_exec UNRAID "rm -f $NGINX_CONF_PATH/gitea.conf"
  2. Reload Nginx: ssh_exec UNRAID "docker exec $NGINX_CONTAINER_NAME nginx -s reload"
  3. Remove cert renewal cron: ssh_exec UNRAID "crontab -l 2>/dev/null | grep -v certbot | crontab -"
  4. Prompt: "Remove SSL certificates for $GITEA_DOMAIN? [y/N]"
    • If yes: ssh_exec UNRAID "rm -rf /etc/letsencrypt/live/$GITEA_DOMAIN /etc/letsencrypt/archive/$GITEA_DOMAIN /etc/letsencrypt/renewal/$GITEA_DOMAIN.conf"
  5. Un-archive GitHub repos: for each repo, github_api PATCH /repos/$GITHUB_USERNAME/$REPO {"archived": false}. Restore original description if it was saved in the archive description (parse after "was: ").

13.1 — phase9_security.sh

Depends on: Phase 5 completed (repos have .gitea/workflows/) Produces: Security scan workflow in all repos, branch protection updated require_vars: GITEA_ADMIN_TOKEN (auto), GITEA_INTERNAL_URL, GITEA_ORG_NAME, GITEA_ADMIN_USER, REPO_NAMES, SEMGREP_VERSION, TRIVY_VERSION, GITLEAKS_VERSION, SECURITY_FAIL_ON_ERROR, PROTECTED_BRANCH

Steps for each repo:

  1. Clone from Gitea to temp dir
  2. Render security-scan.yml.tpl to .gitea/workflows/security-scan.yml
  3. git add, git commit -m "Add security scanning workflow", git push
  4. If SECURITY_FAIL_ON_ERROR=true: update branch protection to require status checks from security jobs
    • gitea_api PATCH /repos/$ORG/$REPO/branch_protections/$PROTECTED_BRANCH with status_check_contexts: ["semgrep", "trivy", "gitleaks"]

Idempotency: Skip if security-scan.yml already exists in repo.

Done when:

  • Each repo has .gitea/workflows/security-scan.yml
  • Workflow file references correct tool versions from .env
  • If SECURITY_FAIL_ON_ERROR=true: branch protection includes the three status checks
  • Creating a test PR triggers the security workflow (manual verification)

13.2 / 13.3 — post-check and teardown

Post-check: verify file exists in each repo via API, verify branch protection includes status checks. Teardown: remove file, update branch protection to remove status checks.


14.1 — backup/backup_primary.sh

Depends on: Phase 1 completed Produces: gitea-dump-*.zip archive on Fedora require_vars: UNRAID_IP, UNRAID_SSH_USER, UNRAID_SSH_PORT, UNRAID_GITEA_DATA_PATH, FEDORA_IP, FEDORA_SSH_USER, FEDORA_SSH_PORT, BACKUP_STORAGE_PATH, BACKUP_RETENTION_COUNT

Steps:

  1. ssh_exec UNRAID "docker exec -u git gitea gitea dump -c /data/gitea/conf/app.ini -f /tmp/gitea-dump-$(date +%Y%m%d-%H%M%S).zip"
  2. SCP dump from Unraid /tmp/ to $BACKUP_STORAGE_PATH/ on Fedora
  3. Remove dump from Unraid /tmp/
  4. Prune old backups: ssh_exec FEDORA "ls -t $BACKUP_STORAGE_PATH/gitea-dump-*.zip | tail -n +$((BACKUP_RETENTION_COUNT+1)) | xargs rm -f"
  5. Print: backup file name, size, path on Fedora, remaining backup count

What's in the dump (verify these are captured):

  • SQLite database (users, tokens, SSH keys, OAuth, webhooks, org/team membership, issues, PRs)
  • All git repositories
  • app.ini config

Done when:

  • Zip file exists on Fedora at $BACKUP_STORAGE_PATH/
  • Zip contains: gitea-db.sql (or gitea.db for SQLite), repos/ directory, app.ini
  • Old backups beyond retention count are deleted
  • Dump file on Unraid /tmp/ is cleaned up

14.2 — backup/restore_to_primary.sh

Depends on: A backup archive exists Produces: Restored Gitea instance on Unraid require_vars: UNRAID_IP, UNRAID_SSH_USER, UNRAID_SSH_PORT, UNRAID_GITEA_DATA_PATH, GITEA_INTERNAL_URL, GITEA_ADMIN_USER, GITEA_ADMIN_PASSWORD

Steps:

  1. Accept --archive <path> (path on Fedora or local)
  2. Prompt: "This will REPLACE all Gitea data on Unraid. Continue? [y/N]"
  3. If archive is on Fedora: SCP to Unraid /tmp/
  4. Stop Gitea: ssh_exec UNRAID "cd $UNRAID_GITEA_DATA_PATH && docker-compose down"
  5. Back up current data (safety): ssh_exec UNRAID "mv $UNRAID_GITEA_DATA_PATH/data $UNRAID_GITEA_DATA_PATH/data.pre-restore"
  6. Extract archive: ssh_exec UNRAID "cd $UNRAID_GITEA_DATA_PATH && unzip /tmp/gitea-dump-*.zip"
  7. Move extracted files to correct locations (data, config)
  8. Start Gitea: ssh_exec UNRAID "cd $UNRAID_GITEA_DATA_PATH && docker-compose up -d"
  9. wait_for_http $GITEA_INTERNAL_URL 60
  10. Verify admin login works: curl -sf -u $GITEA_ADMIN_USER:$GITEA_ADMIN_PASSWORD $GITEA_INTERNAL_URL/api/v1/user
  11. Regenerate API token (old token from dump may conflict): create new token via basic auth, save_env_var GITEA_ADMIN_TOKEN

Done when:

  • Gitea is running with restored data
  • Admin can log in
  • All repos are accessible
  • All users from the backup exist
  • New API token is generated and saved to .env
  • Pre-restore data is preserved (not deleted) in case restore went wrong

15.1 — run_all.sh

Depends on: all scripts exist Steps:

  1. Parse args: --start-from=N (default 0), --skip-setup
  2. Define execution order array:
    • Step 0 — Setup:
      1. setup/configure_env.sh — interactive env wizard (populates .env)
      2. setup/macbook.sh — install MacBook prerequisites
      3. setup/unraid.sh — install Unraid prerequisites (via SSH)
      4. setup/fedora.sh — install Fedora prerequisites (via SSH)
    • Step P — Preflight: preflight.sh (validates everything before proceeding)
    • Steps 1-9 — Phases: phaseN_*.sh + phaseN_post_check.sh
  3. Execute sequentially from start point
  4. On any script exit code != 0: stop, print which step failed, print summary of what completed
  5. On success: print full summary with checkmarks
  6. --start-from=N (where N >= 1): skips setup + preflight only for phases. Still runs preflight.sh to validate .env vars (but NOT configure_env.sh or machine setup — assumes those were already done).

Done when:

  • Running without args executes: configure_env → macbook → unraid → fedora → preflight → phase 1-9
  • --start-from=3 skips phases 1-2 but still runs preflight, then starts at phase 3
  • --skip-setup skips configure_env + machine setup scripts, starts at preflight
  • Failure in any step stops execution (doesn't continue to next phase)
  • Summary at end shows pass/fail for each step that ran

15.2 — teardown_all.sh

Steps:

  1. Parse args: --through=N (default 1 = tear down everything)
  2. Execute in REVERSE order: phase9_teardown → phase8_teardown → ... → phaseN_teardown
  3. Each teardown prompts for confirmation (unless --yes flag)

Done when:

  • --through=5 tears down phases 5-9 but leaves 1-4 intact
  • Each phase teardown runs independently (doesn't fail because a later phase is already torn down)
  • --yes skips all confirmation prompts

16.1 — Git repo + .gitignore

.gitignore must contain:

# Secrets — never commit
.env
runners.conf
*.pem
*.key
*.crt

# macOS
.DS_Store

# Temp files from script runs
/tmp/
*.log

# Backup archives
*.zip

# Editor / IDE
.vscode/
.idea/
*.swp
*~

Done when:

  • git init completed
  • .gitignore excludes .env, runners.conf, temp files
  • .env.example and runners.conf.example are NOT ignored (they're templates)

16.2 — CLAUDE.md

Project-specific instructions for AI assistants working on this codebase.

Done when:

  • Documents: project structure, how to run scripts, .env setup, script conventions
  • Notes the set -euo pipefail + shellcheck requirement for all scripts

17.1 / 17.2 — Validation

Done when:

  • shellcheck *.sh lib/*.sh setup/*.sh backup/*.sh exits 0 with zero warnings
  • bash -n on every .sh file exits 0
  • No file has unexpanded template variables (grep for \$[A-Z_] in rendered outputs should find nothing unexpected)

Git Commit Milestones

Each milestone is a git commit. Commit after all files in that group pass shellcheck and bash -n. Do not batch multiple milestones into one commit.

# Commit message Files included Tracker sections
1 init: project structure, .gitignore, .env.example, runners.conf.example .gitignore, .env.example, runners.conf.example, PLAN.md, CLAUDE.md 16.1, 16.2
2 feat: add shared library (lib/common.sh) lib/common.sh 1.1
3 feat: add API contracts contracts/gitea-api.md 1.3
4 feat: add configuration templates templates/*.tpl, templates/workflows/*.tpl 2.12.7
5 feat: add setup scripts (configure_env, macbook, unraid, fedora) setup/configure_env.sh, setup/macbook.sh, setup/unraid.sh, setup/fedora.sh 3.13.4
6 feat: add preflight validation preflight.sh 4.1
7 feat: add Phase 1 — Gitea on Unraid phase1_gitea_unraid.sh, phase1_post_check.sh, phase1_teardown.sh 5.15.3
8 feat: add Phase 2 — Gitea on Fedora phase2_gitea_fedora.sh, phase2_post_check.sh, phase2_teardown.sh 6.16.3
9 feat: add Phase 3 — Runners phase3_runners.sh, phase3_post_check.sh, phase3_teardown.sh, manage_runner.sh 7.27.5
10 feat: add Phase 4 — Migrate repos + Fedora mirrors phase4_migrate_repos.sh, phase4_post_check.sh, phase4_teardown.sh 8.18.3
11 feat: add Phase 5 — Migrate pipelines phase5_migrate_pipelines.sh, phase5_post_check.sh, phase5_teardown.sh 9.19.3
12 feat: add Phase 6 — GitHub push mirrors phase6_github_mirrors.sh, phase6_post_check.sh, phase6_teardown.sh 10.110.3
13 feat: add Phase 7 — Branch protection phase7_branch_protection.sh, phase7_post_check.sh, phase7_teardown.sh 11.111.3
14 feat: add Phase 8 — Cutover (HTTPS + archive GitHub) phase8_cutover.sh, phase8_post_check.sh, phase8_teardown.sh 12.112.3
15 feat: add Phase 9 — Security scanning phase9_security.sh, phase9_post_check.sh, phase9_teardown.sh 13.113.3
16 feat: add backup and restore scripts backup/backup_primary.sh, backup/restore_to_primary.sh 14.114.2
17 feat: add orchestration (run_all.sh, teardown_all.sh) run_all.sh, teardown_all.sh 15.115.2
18 chore: shellcheck + syntax validation fixes Any files fixed during validation 17.117.2