Replace GITHUB_MIRROR_TOKEN references with GITHUB_TOKEN. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
65 KiB
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:
- Write shell library with
set -euo pipefail - Implement every function listed below
- 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.shpasses with zero warningsbash -n lib/common.shpasses- Sourcing the file (
source lib/common.sh) does NOT execute anything — functions only, no side effects at source time save_env_vartested: set a var, read it back, value matches. Set it again with different value, only one line exists for that key.require_varstested: prints the missing var's name, not a generic errorssh_execuses 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:
- For every Gitea API call in the project, document: method, path, request body, expected response, which script uses it
- For every GitHub API call, same treatment
- Cross-reference: every
gitea_api/github_apicall 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_apicall 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(addGITEA_SSH_PORTto .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_templateproduces valid YAML (test withpython3 -c "import yaml; yaml.safe_load(open('output.yml'))"oryq)- No unexpanded
$VARor${VAR}tokens in rendered output - Container name is
gitea(hardcoded, not variable — needed fordocker 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 = trueis present (critical — without it, Gitea shows install wizard)ENABLED = trueunder[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_NAMErunner.labels: $RUNNER_LABELSrunner.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:schemepairs)
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,
daemonsubcommand - 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.shinstalls 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_DOMAINlisten 443 sslssl_certificate/ssl_certificate_keypaths (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 -tpasses 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_VERSIONDocker 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
latesthardcoded) - Each job exits non-zero on findings (so branch protection can block merge)
runs-onlabels match what Linux runners advertise inrunners.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:
- If
.envalready exists, load current values as defaults (shown in brackets, press Enter to keep) - If
.envdoes not exist, copy.env.exampleto.envfirst - Walk through each required variable in section order, prompting with:
- Progress indicator:
[12/47] ── GITEA SHARED CREDENTIALS ────────────────── - Variable name and description (from
.env.examplecomments) - 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):
- Progress indicator:
- 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://orhttps://
- IP addresses: regex match
- After all prompts, write values to
.envpreserving the file structure (comments, sections) - Do NOT prompt for auto-populated vars (
GITEA_ADMIN_TOKEN,GITEA_BACKUP_ADMIN_TOKEN,GITEA_RUNNER_REGISTRATION_TOKEN) - Do NOT prompt for vars with defaults unless user wants to change them — show default, press Enter to accept
- 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 |
— | |
| 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_1_NAME |
Non-empty | — |
| 30 | REPO_2_NAME |
Non-empty | — |
| 31 | REPO_3_NAME |
Non-empty | — |
| 32 | 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
.envwalks through all 50 prompts and produces a valid.env - SSL prompts are conditional: if
SSL_MODE=letsencrypt, prompt forSSL_EMAILonly; ifSSL_MODE=existing, prompt forSSL_CERT_PATHandSSL_KEY_PATHonly - Running with an existing
.envshows 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
****) .envfile preserves comment structure from.env.exampleshellcheck setup/configure_env.shpasses
3.2 — setup/macbook.sh
Depends on: nothing (first script to run) Runs: locally on MacBook
Steps:
- Check for Homebrew → if missing, print install command and exit (don't auto-install — it's interactive)
brew install jq curl gettext shellcheck gh— skip any already installed- Verify built-in tools exist:
ssh,git,scp,envsubst(from gettext) - Check Xcode CLI Tools:
xcode-select -p→ if fails, runxcode-select --installand wait - 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
jqinstalls it via brew and exits 0 - Script does NOT install Homebrew automatically (security — user should do it themselves)
envsubst --versionworks after script runs (this is the most commonly missing tool)shellcheck setup/macbook.shpasses
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:
ssh_check UNRAID— fail if can't connect- 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." - Check docker-compose: try
docker compose version, thendocker-compose --version. If neither works, download standalone docker-compose binary to/usr/local/bin/ - Check jq:
ssh_exec UNRAID "jq --version"— if missing, download static binary from GitHub releases to/usr/local/bin/jq, chmod +x - 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 --versionworks on Unraid via SSHdocker compose versionORdocker-compose --versionworks on Unraid via SSHjq --versionworks on Unraid via SSHUNRAID_GITEA_DATA_PATHexists and is writable- Script does NOT attempt to install Docker on Unraid (could break Unraid's custom setup)
shellcheck setup/unraid.shpasses
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:
ssh_check FEDORA— fail if can't connect- Check Docker:
docker --version— if missing:sudo dnf -y install dnf-plugins-coresudo dnf config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.reposudo dnf -y install docker-ce docker-ce-cli containerd.io docker-compose-pluginsudo systemctl enable --now dockersudo usermod -aG docker $FEDORA_SSH_USER- Print warning: "User added to docker group. You may need to re-login for this to take effect."
- Check jq: if missing,
sudo dnf -y install jq - Verify Docker works:
docker run --rm hello-world(must succeed without sudo after group membership) - Verify data path writable (same as Unraid step)
Done when:
docker --versionworks on Fedora via SSHdocker compose versionworks on Fedora via SSHjq --versionworks on Fedora via SSHdocker run --rm hello-worldsucceeds without sudo (if it requires sudo, the docker group membership hasn't taken effect — script must warn about re-login)FEDORA_GITEA_DATA_PATHexists and is writableshellcheck setup/fedora.shpasses
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_1_NAME |
| Repos | REPO_2_NAME |
| Repos | REPO_3_NAME |
| 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.shpasses
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_URLreturns Gitea HTML pagecurl -H "Authorization: token $GITEA_ADMIN_TOKEN" $GITEA_INTERNAL_URL/api/v1/userreturns admin user JSONGITEA_ADMIN_TOKENis written to.envand is non-empty- Org exists:
gitea_api GET /orgs/$GITEA_ORG_NAMEreturns 200 - Running the script again changes nothing (all steps skip with "already exists" messages)
shellcheck phase1_gitea_unraid.shpasses
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_URLwith HTTP 200 - Admin user authenticates:
curl -u user:pass .../api/v1/userreturns 200 - API token works:
gitea_api GET /userreturns 200 with correct username - Org exists:
gitea_api GET /orgs/$GITEA_ORG_NAMEreturns 200 - Gitea Actions enabled:
gitea_api GET /api/v1/settings/apior 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:
- Prompt: "This will stop Gitea on Unraid. Continue? [y/N]"
ssh_exec UNRAID "cd $UNRAID_GITEA_DATA_PATH && docker-compose down"- Prompt: "Remove all Gitea data ($UNRAID_GITEA_DATA_PATH)? This is irreversible. [y/N]"
- If confirmed:
ssh_exec UNRAID "rm -rf $UNRAID_GITEA_DATA_PATH" - Clear
GITEA_ADMIN_TOKENfrom .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_TOKENis 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_URLfor 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_TOKENis 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>:
- Read runner entry from
runners.confby name - If
type=docker:- Render
docker-compose-runner.yml.tplwith runner's vars - SCP to
$DATA_PATH/docker-compose.ymlon 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_httpon Gitea API for runner to appear
- Render
- If
type=native:- Download
act_runnerbinary 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
- Download
manage_runner.sh remove --name <runner_name>:
- Read runner entry from
runners.conf - If
type=docker:ssh_exec HOST "cd $DATA_PATH && docker-compose down", optionally rm data - If
type=native:launchctl unloadplist, rm binary + plist - Deregister from Gitea via API if possible (or just let it go offline)
manage_runner.sh list:
- Read all entries from
runners.conf - For each: query Gitea API for runner status
- Print table: name, host, labels, type, status (online/offline)
Done when:
addwith a docker-type runner: container is running, runner appears in Gitea admin paneladdwith a native-type runner: launchd service is loaded, runner appears in Gitea admin panelremovestops the runner and it disappears from Gitea admin (or shows offline)listshows all runners with current statusaddon an already-deployed runner prints "already running" and exits 0removeon a non-existent runner prints warning and exits 0shellcheck manage_runner.shpasses
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:
- Get registration token:
gitea_api GET /admin/runners/registration-token save_env_var GITEA_RUNNER_REGISTRATION_TOKEN <token>- For each entry in
runners.conf: callmanage_runner.sh add --name <name>
Done when:
GITEA_RUNNER_REGISTRATION_TOKENis in .env- Every runner in
runners.confis 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_1_NAME, REPO_2_NAME, REPO_3_NAME, 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_NAMEon 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=truein 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/$REPOreturns 200 - Each repo has commits:
gitea_api GET /repos/$ORG/$REPO/commits?limit=1returns at least 1 commit - Default branch matches source: compare
default_branchfield from Gitea vs GitHub API - Each mirror repo exists on Fedora:
gitea_backup_api GET /repos/$ADMIN/$REPOreturns 200 - Each mirror has
mirror: truein response
8.3 — phase4_teardown.sh
- For each repo:
gitea_api DELETE /repos/$GITEA_ORG_NAME/$REPO_N_NAME - For each mirror:
gitea_backup_api DELETE /repos/$ADMIN/$REPO_N_NAME - 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_1_NAME, REPO_2_NAME, REPO_3_NAME
Steps for each repo:
- Clone repo from Gitea to temp dir:
git clone $GITEA_INTERNAL_URL/$GITEA_ORG_NAME/$REPO.git /tmp/gitea-migration-$REPO - Check if
.github/workflows/exists — if not, log warning "No GitHub workflows found" and skip - Create
.gitea/workflows/directory - Copy all
.ymlfiles from.github/workflows/to.gitea/workflows/ - Apply compatibility fixes in each copied file:
- Replace
github.repository→gitea.repositoryin expressions - Replace
github.event→gitea.eventin expressions - Replace
github.token→gitea.token - Replace
github.server_url→gitea.server_url - Keep
actions/checkout@v4as-is (compatible with Gitea) - Add comment at top:
# Migrated from GitHub Actions — review for Gitea compatibility
- Replace
git add .gitea/,git commit -m "Migrate workflows to Gitea Actions",git push- 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
.ymlfile 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_1_NAME, REPO_2_NAME, REPO_3_NAME
Steps for each repo:
- Check if push mirror already exists:
gitea_api GET /repos/$ORG/$REPO/push_mirrors— skip if non-empty - Create push mirror:
gitea_api POST /repos/$ORG/$REPO/push_mirrorswith:remote_address:https://github.com/$GITHUB_USERNAME/$REPO.gitremote_username:$GITHUB_USERNAMEremote_password:$GITHUB_TOKENinterval:$GITHUB_MIRROR_INTERVALsync_on_commit: true
- Trigger initial sync:
gitea_api POST /repos/$ORG/$REPO/push_mirrors-sync - Disable GitHub Actions:
github_api PATCH /repos/$GITHUB_USERNAME/$REPOwith{"has_projects": false}— note: disabling Actions requires settinghas_actions: falsewhich 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_mirrorsreturns 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_1_NAME, REPO_2_NAME, REPO_3_NAME, PROTECTED_BRANCH, REQUIRE_PR_REVIEW, REQUIRED_APPROVALS
Steps for each repo:
- Check if protection exists:
gitea_api GET /repos/$ORG/$REPO/branch_protections/$PROTECTED_BRANCH— skip if 200 - Create protection:
gitea_api POST /repos/$ORG/$REPO/branch_protectionswith:branch_name:$PROTECTED_BRANCHenable_push: falseenable_push_whitelist: falserequire_signed_commits: falseenable_status_check: trueenable_approvals_whitelist:$REQUIRE_PR_REVIEWrequired_approvals:$REQUIRED_APPROVALS
Done when:
- Each repo:
gitea_api GET /repos/$ORG/$REPO/branch_protectionsreturns the rule - Direct pushes to
$PROTECTED_BRANCHare 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_1_NAME, REPO_2_NAME, REPO_3_NAME
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 (ifSSL_MODE=letsencrypt), no SSL directives - HTTPS (
SSL_ENABLED=true):listen 443 ssl, cert paths,listen 80with 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.pemexisting:ssl_certificate $SSL_CERT_PATH,ssl_certificate_key $SSL_KEY_PATH
Certbot volume mounts:
/etc/letsencrypton Unraid host → mounted into both Certbot container and Nginx container/var/www/htmlon 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/letsencryptor webroot is not mounted, the script must fail with instructions to add the volume mounts to the Nginx container config.
Done when:
https://$GITEA_DOMAINreturns valid HTTPS response (no cert errors)curl https://$GITEA_DOMAIN/api/v1/versionreturns Gitea version JSON- Certificate is from Let's Encrypt:
openssl s_client -connect $GITEA_DOMAIN:443 </dev/null 2>/dev/null | openssl x509 -noout -issuercontains "Let's Encrypt" - HTTP requests redirect to HTTPS:
curl -sI http://$GITEA_DOMAINreturns 301 withLocation: 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/$REPOreturns 200 - GitHub repos are archived:
github_api GET /repos/$USER/$REPOhas"archived": true
12.3 — phase8_teardown.sh
- Remove Nginx config:
ssh_exec UNRAID "rm -f $NGINX_CONF_PATH/gitea.conf" - Reload Nginx:
ssh_exec UNRAID "docker exec $NGINX_CONTAINER_NAME nginx -s reload" - Remove cert renewal cron:
ssh_exec UNRAID "crontab -l 2>/dev/null | grep -v certbot | crontab -" - 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"
- If yes:
- 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_1_NAME, REPO_2_NAME, REPO_3_NAME, SEMGREP_VERSION, TRIVY_VERSION, GITLEAKS_VERSION, SECURITY_FAIL_ON_ERROR, PROTECTED_BRANCH
Steps for each repo:
- Clone from Gitea to temp dir
- Render
security-scan.yml.tplto.gitea/workflows/security-scan.yml git add,git commit -m "Add security scanning workflow",git push- If
SECURITY_FAIL_ON_ERROR=true: update branch protection to require status checks from security jobsgitea_api PATCH /repos/$ORG/$REPO/branch_protections/$PROTECTED_BRANCHwithstatus_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:
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"- SCP dump from Unraid
/tmp/to$BACKUP_STORAGE_PATH/on Fedora - Remove dump from Unraid
/tmp/ - Prune old backups:
ssh_exec FEDORA "ls -t $BACKUP_STORAGE_PATH/gitea-dump-*.zip | tail -n +$((BACKUP_RETENTION_COUNT+1)) | xargs rm -f" - 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(orgitea.dbfor 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:
- Accept
--archive <path>(path on Fedora or local) - Prompt: "This will REPLACE all Gitea data on Unraid. Continue? [y/N]"
- If archive is on Fedora: SCP to Unraid
/tmp/ - Stop Gitea:
ssh_exec UNRAID "cd $UNRAID_GITEA_DATA_PATH && docker-compose down" - Back up current data (safety):
ssh_exec UNRAID "mv $UNRAID_GITEA_DATA_PATH/data $UNRAID_GITEA_DATA_PATH/data.pre-restore" - Extract archive:
ssh_exec UNRAID "cd $UNRAID_GITEA_DATA_PATH && unzip /tmp/gitea-dump-*.zip" - Move extracted files to correct locations (data, config)
- Start Gitea:
ssh_exec UNRAID "cd $UNRAID_GITEA_DATA_PATH && docker-compose up -d" wait_for_http $GITEA_INTERNAL_URL 60- Verify admin login works:
curl -sf -u $GITEA_ADMIN_USER:$GITEA_ADMIN_PASSWORD $GITEA_INTERNAL_URL/api/v1/user - 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:
- Parse args:
--start-from=N(default 0),--skip-setup - Define execution order array:
- Step 0 — Setup:
setup/configure_env.sh— interactive env wizard (populates.env)setup/macbook.sh— install MacBook prerequisitessetup/unraid.sh— install Unraid prerequisites (via SSH)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
- Step 0 — Setup:
- Execute sequentially from start point
- On any script exit code != 0: stop, print which step failed, print summary of what completed
- On success: print full summary with checkmarks
--start-from=N(where N >= 1): skips setup + preflight only for phases. Still runspreflight.shto validate .env vars (but NOTconfigure_env.shor machine setup — assumes those were already done).
Done when:
- Running without args executes: configure_env → macbook → unraid → fedora → preflight → phase 1-9
--start-from=3skips phases 1-2 but still runs preflight, then starts at phase 3--skip-setupskips 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:
- Parse args:
--through=N(default 1 = tear down everything) - Execute in REVERSE order: phase9_teardown → phase8_teardown → ... → phaseN_teardown
- Each teardown prompts for confirmation (unless
--yesflag)
Done when:
--through=5tears 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)
--yesskips 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 initcompleted.gitignoreexcludes.env,runners.conf, temp files.env.exampleandrunners.conf.exampleare 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/*.shexits 0 with zero warningsbash -non every.shfile 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.1–2.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.1–3.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.1–5.3 |
| 8 | feat: add Phase 2 — Gitea on Fedora |
phase2_gitea_fedora.sh, phase2_post_check.sh, phase2_teardown.sh |
6.1–6.3 |
| 9 | feat: add Phase 3 — Runners |
phase3_runners.sh, phase3_post_check.sh, phase3_teardown.sh, manage_runner.sh |
7.2–7.5 |
| 10 | feat: add Phase 4 — Migrate repos + Fedora mirrors |
phase4_migrate_repos.sh, phase4_post_check.sh, phase4_teardown.sh |
8.1–8.3 |
| 11 | feat: add Phase 5 — Migrate pipelines |
phase5_migrate_pipelines.sh, phase5_post_check.sh, phase5_teardown.sh |
9.1–9.3 |
| 12 | feat: add Phase 6 — GitHub push mirrors |
phase6_github_mirrors.sh, phase6_post_check.sh, phase6_teardown.sh |
10.1–10.3 |
| 13 | feat: add Phase 7 — Branch protection |
phase7_branch_protection.sh, phase7_post_check.sh, phase7_teardown.sh |
11.1–11.3 |
| 14 | feat: add Phase 8 — Cutover (HTTPS + archive GitHub) |
phase8_cutover.sh, phase8_post_check.sh, phase8_teardown.sh |
12.1–12.3 |
| 15 | feat: add Phase 9 — Security scanning |
phase9_security.sh, phase9_post_check.sh, phase9_teardown.sh |
13.1–13.3 |
| 16 | feat: add backup and restore scripts |
backup/backup_primary.sh, backup/restore_to_primary.sh |
14.1–14.2 |
| 17 | feat: add orchestration (run_all.sh, teardown_all.sh) |
run_all.sh, teardown_all.sh |
15.1–15.2 |
| 18 | chore: shellcheck + syntax validation fixes |
Any files fixed during validation | 17.1–17.2 |