commit e4ed5c5879f867ee433d5adeb3198d41999a59bb Author: S Date: Thu Feb 26 14:59:17 2026 -0600 init: project structure, .gitignore, .env.example, runners.conf.example - .gitignore: excludes .env, runners.conf, certs, temp files, editor files - .env.example: all configuration variables with sections and descriptions - runners.conf.example: dynamic runner definition format (pipe-delimited) - PLAN.md: comprehensive implementation plan with DoD for all 18 milestones - CLAUDE.md: project conventions and instructions Co-Authored-By: Claude Opus 4.6 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..aea8753 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "permissions": { + "allow": ["Bash(*)"] + } +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..79ca7c0 --- /dev/null +++ b/.env.example @@ -0,0 +1,124 @@ +# ============================================================================= +# .env.example — Gitea Migration Configuration +# Copy to .env and populate all PRE-POPULATED values before running preflight +# AUTO-POPULATED values will be filled in by scripts during execution +# ============================================================================= + + +# ----------------------------------------------------------------------------- +# UNRAID SERVER +# ----------------------------------------------------------------------------- +UNRAID_IP= # Static IP of Unraid server +UNRAID_SSH_USER= # SSH username for Unraid +UNRAID_SSH_PORT=22 # SSH port (default 22) +UNRAID_GITEA_PORT=3000 # Port Gitea web UI will listen on +UNRAID_GITEA_SSH_PORT=2222 # Port for git-over-SSH (host 22 is taken by SSH server) +UNRAID_GITEA_DATA_PATH= # Absolute path on NVMe for Gitea data (e.g. /mnt/nvme/gitea) + + +# ----------------------------------------------------------------------------- +# FEDORA SERVER +# ----------------------------------------------------------------------------- +FEDORA_IP= # Static IP of Fedora server +FEDORA_SSH_USER= # SSH username for Fedora +FEDORA_SSH_PORT=22 # SSH port (default 22) +FEDORA_GITEA_PORT=3000 # Port Gitea web UI will listen on +FEDORA_GITEA_SSH_PORT=2222 # Port for git-over-SSH (host 22 is taken by SSH server) +FEDORA_GITEA_DATA_PATH= # Absolute path on NVMe for Gitea data (e.g. /mnt/nvme/gitea) + + +# ----------------------------------------------------------------------------- +# GITEA — SHARED CREDENTIALS (used on both Unraid + Fedora instances) +# ----------------------------------------------------------------------------- +GITEA_ADMIN_USER= # Admin username (same on both instances) +GITEA_ADMIN_PASSWORD= # Admin password (min 8 chars, same on both instances) +GITEA_ADMIN_EMAIL= # Admin email (same on both instances) +GITEA_ORG_NAME= # Organization name to create (e.g. mifi-llc) +GITEA_INSTANCE_NAME= # Display name for the Gitea instance (e.g. MIFI Git) +GITEA_DB_TYPE=sqlite3 # Database type — sqlite3 is sufficient for your scale +GITEA_VERSION=1.23 # Gitea Docker image tag (e.g. 1.23, 1.23.1, latest) +ACT_RUNNER_VERSION=0.2.11 # act_runner version for all runners (e.g. 0.2.11, latest) + + +# ----------------------------------------------------------------------------- +# GITEA — PRIMARY INSTANCE (Unraid) +# ----------------------------------------------------------------------------- +GITEA_DOMAIN= # Public domain/subdomain pointing to Unraid (e.g. git.yourdomain.com) +GITEA_INTERNAL_URL= # Internal URL (e.g. http://UNRAID_IP:3000) used by scripts +# AUTO-POPULATED by phase1 scripts: +GITEA_ADMIN_TOKEN= # API token for primary instance — do not fill manually + + +# ----------------------------------------------------------------------------- +# GITEA — BACKUP INSTANCE (Fedora) +# ----------------------------------------------------------------------------- +GITEA_BACKUP_INTERNAL_URL= # Internal URL of Fedora Gitea (e.g. http://FEDORA_IP:3000) +GITEA_BACKUP_MIRROR_INTERVAL=8h # How often Fedora pulls from Unraid (e.g. 8h, 24h) +BACKUP_STORAGE_PATH= # Absolute path on Fedora to store gitea dump archives (e.g. /mnt/nvme/gitea-backups) +BACKUP_RETENTION_COUNT=5 # Number of backup archives to keep (older ones are pruned) +# AUTO-POPULATED by phase2 scripts: +GITEA_BACKUP_ADMIN_TOKEN= # API token for backup instance — do not fill manually + + +# ----------------------------------------------------------------------------- +# RUNNERS +# Runner definitions live in runners.conf (see runners.conf.example) +# Use manage_runner.sh to add/remove runners at any time +# ----------------------------------------------------------------------------- +# AUTO-POPULATED by phase1 scripts — do not fill manually: +GITEA_RUNNER_REGISTRATION_TOKEN= # Retrieved from Gitea admin panel via API + + +# ----------------------------------------------------------------------------- +# REPOSITORIES +# ----------------------------------------------------------------------------- +# GitHub source repos (for migration import) +GITHUB_USERNAME= # GitHub username or org name +GITHUB_TOKEN= # GitHub personal access token (needs repo read scope) + +# Repo names — must match exactly as they appear on GitHub +REPO_1_NAME= # e.g. android-kotlin-app +REPO_2_NAME= # e.g. ios-swiftui-app +REPO_3_NAME= # e.g. go-cli-tool + +# Migration options (true/false) +MIGRATE_ISSUES=false # Migrate GitHub issues to Gitea +MIGRATE_LABELS=true # Migrate GitHub labels +MIGRATE_MILESTONES=false # Migrate GitHub milestones +MIGRATE_WIKI=false # Migrate GitHub wiki + + +# ----------------------------------------------------------------------------- +# GITHUB MIRROR (offsite backup) +# ----------------------------------------------------------------------------- +GITHUB_MIRROR_TOKEN= # GitHub PAT with repo write scope (for push mirroring) + # Can be same as GITHUB_TOKEN if it has write scope +GITHUB_MIRROR_INTERVAL=8h # How often Gitea pushes to GitHub + + +# ----------------------------------------------------------------------------- +# NGINX REVERSE PROXY (existing Docker container on Unraid) +# ----------------------------------------------------------------------------- +NGINX_CONTAINER_NAME= # Name of existing Nginx Docker container (e.g. nginx, swag) +NGINX_CONF_PATH= # Host path to Nginx conf.d directory (e.g. /mnt/user/appdata/nginx/conf.d) +SSL_MODE=letsencrypt # SSL mode: "letsencrypt" (auto-provision via Certbot) or "existing" (provide cert paths) +SSL_EMAIL= # Email for Let's Encrypt (only if SSL_MODE=letsencrypt) +SSL_CERT_PATH= # Absolute path to SSL cert on Unraid (only if SSL_MODE=existing) +SSL_KEY_PATH= # Absolute path to SSL key on Unraid (only if SSL_MODE=existing) + + +# ----------------------------------------------------------------------------- +# BRANCH PROTECTION +# ----------------------------------------------------------------------------- +PROTECTED_BRANCH=main # Branch to protect across all repos +REQUIRE_PR_REVIEW=false # Require PR review before merge (true/false) +REQUIRED_APPROVALS=1 # Number of approvals required if above is true + + +# ----------------------------------------------------------------------------- +# SECURITY (Phase 9 — post-migration) +# ----------------------------------------------------------------------------- +SEMGREP_VERSION=latest # Semgrep OSS version to pin +TRIVY_VERSION=latest # Trivy version to pin +GITLEAKS_VERSION=latest # Gitleaks version to pin +SECURITY_FAIL_ON_ERROR=true # Block PR merge if security scan fails (true/false) \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..859b4e4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# 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 +*~ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..60cdf27 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,46 @@ +# Gitea Migration Toolkit + +## Project Overview +Bash-based automation toolkit for migrating 3 GitHub repos to self-hosted Gitea. All scripts run from MacBook, SSHing into Unraid (primary) and Fedora (backup mirror). GitHub serves as offsite push mirror. + +## Architecture +- **Control plane**: MacBook runs all scripts locally, SSHs into remotes +- **Primary Gitea**: Docker Compose on Unraid +- **Backup Gitea**: Docker Compose on Fedora (pull mirrors) +- **Runners**: Docker on Unraid/Fedora, native binary + launchd on MacBook +- **HTTPS**: Nginx reverse proxy + Certbot on Unraid + +## Script Conventions +- All `.sh` files MUST start with `set -euo pipefail` +- All scripts source `lib/common.sh` for shared functions +- All scripts MUST pass `shellcheck` with zero warnings +- All scripts MUST pass `bash -n` syntax check +- Configuration via `.env` file (never hardcode values) +- Templates use `.tpl` extension and `envsubst` for rendering +- Every phase has: main script + post_check + teardown + +## Idempotency +Every create/deploy operation checks state first and skips if already done. Running any script twice produces the same result with no errors. + +## File Structure +``` +.env.example # Template — copy to .env and fill in +runners.conf.example # Template — copy to runners.conf +lib/common.sh # Shared functions (source this in every script) +setup/ # Machine setup + .env wizard +templates/ # Config templates (.tpl files) +contracts/ # API endpoint documentation +backup/ # Backup and restore scripts +``` + +## Key Commands +- `setup/configure_env.sh` — Interactive .env setup wizard +- `preflight.sh` — Validate everything before running phases +- `run_all.sh` — Execute all phases sequentially +- `teardown_all.sh` — Reverse teardown +- `manage_runner.sh add|remove|list` — Dynamic runner management + +## Sensitive Files (never commit) +- `.env` — contains passwords, tokens, IPs +- `runners.conf` — contains server IPs and paths +- `*.pem`, `*.key`, `*.crt` — SSL certificates diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..a2c1e07 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,1316 @@ +# 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_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_TOKEN` | Non-empty | — | +| 37 | `GITHUB_MIRROR_INTERVAL` | Non-empty | 8h | +| 38 | `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_1_NAME` | +| Repos | `REPO_2_NAME` | +| Repos | `REPO_3_NAME` | +| Mirror | `GITHUB_MIRROR_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 `**: +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 `**: +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 ` +3. For each entry in `runners.conf`: call `manage_runner.sh add --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 `. 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_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_1_NAME`, `REPO_2_NAME`, `REPO_3_NAME` + +**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.repository` → `gitea.repository` in expressions + - Replace `github.event` → `gitea.event` in expressions + - Replace `github.token` → `gitea.token` + - Replace `github.server_url` → `gitea.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_MIRROR_TOKEN`, `GITHUB_MIRROR_INTERVAL`, `REPO_1_NAME`, `REPO_2_NAME`, `REPO_3_NAME` + +**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_MIRROR_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_1_NAME`, `REPO_2_NAME`, `REPO_3_NAME`, `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_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 (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 | 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_1_NAME`, `REPO_2_NAME`, `REPO_3_NAME`, `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 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.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 | diff --git a/runners.conf.example b/runners.conf.example new file mode 100644 index 0000000..27922c0 --- /dev/null +++ b/runners.conf.example @@ -0,0 +1,20 @@ +# ============================================================================= +# runners.conf — Gitea Actions Runner Definitions +# Copy to runners.conf and edit. One runner per line. +# Use manage_runner.sh to add/remove runners dynamically. +# ============================================================================= +# +# FORMAT: name|ssh_host|ssh_user|ssh_port|data_path|labels|type +# +# name — Display name in Gitea admin panel +# ssh_host — IP address or hostname (for local machine, use "local") +# ssh_user — SSH username (ignored if ssh_host is "local") +# ssh_port — SSH port (ignored if ssh_host is "local") +# data_path — Absolute path for runner binary + data on that machine +# labels — Comma-separated runner labels (used in workflow runs-on) +# type — "docker" (Linux, runs jobs in containers) or "native" (macOS, runs jobs on host) +# +# EXAMPLES: +unraid-runner|192.168.1.10|root|22|/mnt/nvme/gitea-runner|linux|docker +fedora-runner|192.168.1.20|user|22|/mnt/nvme/gitea-runner|linux|docker +macbook-runner|local|_|_|~/gitea-runner|macos|native