Files
gitea-migration/PLAN.md
S 4895d24a2d docs: update PLAN.md for single GitHub token
Replace GITHUB_MIRROR_TOKEN references with GITHUB_TOKEN.

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

1316 lines
65 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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_INTERVAL` | Non-empty | 8h |
| 37 | `NGINX_CONTAINER_NAME` | Non-empty | — |
| 39 | `NGINX_CONF_PATH` | Absolute path | — |
| 40 | `SSL_MODE` | `letsencrypt` or `existing` | letsencrypt |
| 41 | `SSL_EMAIL` | Email *(only if SSL_MODE=letsencrypt)* | — |
| 42 | `SSL_CERT_PATH` | Absolute path *(only if SSL_MODE=existing)* | — |
| 43 | `SSL_KEY_PATH` | Absolute path *(only if SSL_MODE=existing)* | — |
| 44 | `PROTECTED_BRANCH` | Non-empty | main |
| 45 | `REQUIRE_PR_REVIEW` | true/false | false |
| 46 | `REQUIRED_APPROVALS` | Integer | 1 |
| 47 | `SEMGREP_VERSION` | Non-empty | latest |
| 48 | `TRIVY_VERSION` | Non-empty | latest |
| 49 | `GITLEAKS_VERSION` | Non-empty | latest |
| 50 | `SECURITY_FAIL_ON_ERROR` | true/false | true |
**Done when**:
- [ ] Each prompt shows progress: `[N/50]` with section header when entering a new section
- [ ] Running with no existing `.env` walks through all 50 prompts and produces a valid `.env`
- [ ] SSL prompts are conditional: if `SSL_MODE=letsencrypt`, prompt for `SSL_EMAIL` only; if `SSL_MODE=existing`, prompt for `SSL_CERT_PATH` and `SSL_KEY_PATH` only
- [ ] Running with an existing `.env` shows current values and only overwrites what user changes
- [ ] Invalid input (bad IP, path not starting with `/`, password too short) re-prompts with error message
- [ ] Auto-populated vars are skipped entirely (no prompt, no overwrite)
- [ ] Summary at end shows all values (passwords masked with `****`)
- [ ] `.env` file preserves comment structure from `.env.example`
- [ ] `shellcheck setup/configure_env.sh` passes
---
### 3.2 — `setup/macbook.sh`
**Depends on**: nothing (first script to run)
**Runs**: locally on MacBook
**Steps**:
1. Check for Homebrew → if missing, print install command and exit (don't auto-install — it's interactive)
2. `brew install jq curl gettext shellcheck gh` — skip any already installed
3. Verify built-in tools exist: `ssh`, `git`, `scp`, `envsubst` (from gettext)
4. Check Xcode CLI Tools: `xcode-select -p` → if fails, run `xcode-select --install` and wait
5. Print summary of what was installed vs already present
**Idempotency**: Running twice produces no errors and no redundant installs.
**Done when**:
- [ ] Running on a Mac where all tools exist prints "All prerequisites satisfied" and exits 0
- [ ] Running on a Mac missing `jq` installs it via brew and exits 0
- [ ] Script does NOT install Homebrew automatically (security — user should do it themselves)
- [ ] `envsubst --version` works after script runs (this is the most commonly missing tool)
- [ ] `shellcheck setup/macbook.sh` passes
---
### 3.3 — `setup/unraid.sh`
**Depends on**: SSH access to Unraid (.env vars: UNRAID_IP, UNRAID_SSH_USER, UNRAID_SSH_PORT)
**Runs**: from MacBook via SSH into Unraid
**Steps**:
1. `ssh_check UNRAID` — fail if can't connect
2. Check Docker: `ssh_exec UNRAID "docker --version"` — if missing, exit 1 with message "Docker not found on Unraid. Install it via Unraid's Docker settings."
3. Check docker-compose: try `docker compose version`, then `docker-compose --version`. If neither works, download standalone docker-compose binary to `/usr/local/bin/`
4. Check jq: `ssh_exec UNRAID "jq --version"` — if missing, download static binary from GitHub releases to `/usr/local/bin/jq`, chmod +x
5. Verify data path: `ssh_exec UNRAID "mkdir -p $UNRAID_GITEA_DATA_PATH && touch $UNRAID_GITEA_DATA_PATH/.write-test && rm $UNRAID_GITEA_DATA_PATH/.write-test"`
**Done when**:
- [ ] `docker --version` works on Unraid via SSH
- [ ] `docker compose version` OR `docker-compose --version` works on Unraid via SSH
- [ ] `jq --version` works on Unraid via SSH
- [ ] `UNRAID_GITEA_DATA_PATH` exists and is writable
- [ ] Script does NOT attempt to install Docker on Unraid (could break Unraid's custom setup)
- [ ] `shellcheck setup/unraid.sh` passes
---
### 3.4 — `setup/fedora.sh`
**Depends on**: SSH access to Fedora (.env vars: FEDORA_IP, FEDORA_SSH_USER, FEDORA_SSH_PORT)
**Runs**: from MacBook via SSH into Fedora
**Steps**:
1. `ssh_check FEDORA` — fail if can't connect
2. Check Docker: `docker --version` — if missing:
- `sudo dnf -y install dnf-plugins-core`
- `sudo dnf config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo`
- `sudo dnf -y install docker-ce docker-ce-cli containerd.io docker-compose-plugin`
- `sudo systemctl enable --now docker`
- `sudo usermod -aG docker $FEDORA_SSH_USER`
- Print warning: "User added to docker group. You may need to re-login for this to take effect."
3. Check jq: if missing, `sudo dnf -y install jq`
4. Verify Docker works: `docker run --rm hello-world` (must succeed without sudo after group membership)
5. Verify data path writable (same as Unraid step)
**Done when**:
- [ ] `docker --version` works on Fedora via SSH
- [ ] `docker compose version` works on Fedora via SSH
- [ ] `jq --version` works on Fedora via SSH
- [ ] `docker run --rm hello-world` succeeds **without sudo** (if it requires sudo, the docker group membership hasn't taken effect — script must warn about re-login)
- [ ] `FEDORA_GITEA_DATA_PATH` exists and is writable
- [ ] `shellcheck setup/fedora.sh` passes
---
### 4.1 — `preflight.sh`
**Depends on**: setup scripts completed, `.env` populated
**Runs**: locally on MacBook
**Purpose**: Pure validation. Installs nothing. Exits 0 only if EVERYTHING is ready.
**Checks** (each prints PASS/FAIL with specific message):
| # | Check | Pass condition | Fail message |
|---|-------|----------------|--------------|
| 1 | `.env` exists | File present in project root | ".env not found. Copy .env.example to .env and fill in values." |
| 2 | `runners.conf` exists | File present in project root | "runners.conf not found. Copy runners.conf.example to runners.conf." |
| 3 | Required .env vars set | Every var in the list below is non-empty | "Missing required var: VAR_NAME" |
**Check #3 — Required variables** (must be non-empty):
| Section | Variable |
|---------|----------|
| Unraid | `UNRAID_IP` |
| Unraid | `UNRAID_SSH_USER` |
| Unraid | `UNRAID_GITEA_DATA_PATH` |
| Fedora | `FEDORA_IP` |
| Fedora | `FEDORA_SSH_USER` |
| Fedora | `FEDORA_GITEA_DATA_PATH` |
| Shared creds | `GITEA_ADMIN_USER` |
| Shared creds | `GITEA_ADMIN_PASSWORD` |
| Shared creds | `GITEA_ADMIN_EMAIL` |
| Shared creds | `GITEA_ORG_NAME` |
| Shared creds | `GITEA_INSTANCE_NAME` |
| Primary | `GITEA_DOMAIN` |
| Primary | `GITEA_INTERNAL_URL` |
| Backup | `GITEA_BACKUP_INTERNAL_URL` |
| Backup | `BACKUP_STORAGE_PATH` |
| Repos | `GITHUB_USERNAME` |
| Repos | `GITHUB_TOKEN` |
| Repos | `REPO_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.sh` passes
---
### 5.1 — `phase1_gitea_unraid.sh`
**Depends on**: preflight passed, templates exist
**Produces**: Running Gitea instance on Unraid, admin user, API token in .env, org created
**`require_vars`**: `UNRAID_IP`, `UNRAID_SSH_USER`, `UNRAID_SSH_PORT`, `UNRAID_GITEA_PORT`, `UNRAID_GITEA_SSH_PORT`, `UNRAID_GITEA_DATA_PATH`, `GITEA_ADMIN_USER`, `GITEA_ADMIN_PASSWORD`, `GITEA_ADMIN_EMAIL`, `GITEA_ORG_NAME`, `GITEA_INSTANCE_NAME`, `GITEA_DB_TYPE`, `GITEA_VERSION`, `GITEA_INTERNAL_URL`, `GITEA_DOMAIN`
**Steps with idempotency**:
| # | Action | Idempotency check (skip if true) |
|---|--------|----------------------------------|
| 1 | Create data dirs on Unraid | `ssh_exec UNRAID "test -d $UNRAID_GITEA_DATA_PATH/data"` |
| 2 | Render + SCP docker-compose-gitea.yml.tpl | `ssh_exec UNRAID "test -f $UNRAID_GITEA_DATA_PATH/docker-compose.yml"` |
| 3 | Render + SCP app.ini.tpl | `ssh_exec UNRAID "test -f $UNRAID_GITEA_DATA_PATH/config/app.ini"` |
| 4 | `docker-compose up -d` on Unraid | `ssh_exec UNRAID "docker ps --filter name=gitea --format '{{.Status}}'"` contains "Up" |
| 5 | Wait for Gitea HTTP ready | `curl -sf $GITEA_INTERNAL_URL/api/v1/version` returns 200 |
| 6 | Create admin user via `docker exec gitea gitea admin user create` | `curl -sf -u $GITEA_ADMIN_USER:$GITEA_ADMIN_PASSWORD $GITEA_INTERNAL_URL/api/v1/user` returns 200 |
| 7 | Generate API token | `GITEA_ADMIN_TOKEN` is non-empty in .env AND `gitea_api GET /user` returns 200 |
| 8 | Save token to .env | Token is in .env file |
| 9 | Create org | `gitea_api GET /orgs/$GITEA_ORG_NAME` returns 200 |
**Done when**:
- [ ] `curl $GITEA_INTERNAL_URL` returns Gitea HTML page
- [ ] `curl -H "Authorization: token $GITEA_ADMIN_TOKEN" $GITEA_INTERNAL_URL/api/v1/user` returns admin user JSON
- [ ] `GITEA_ADMIN_TOKEN` is written to `.env` and is non-empty
- [ ] Org exists: `gitea_api GET /orgs/$GITEA_ORG_NAME` returns 200
- [ ] Running the script again changes nothing (all steps skip with "already exists" messages)
- [ ] `shellcheck phase1_gitea_unraid.sh` passes
---
### 5.2 — `phase1_post_check.sh`
**Depends on**: phase1 completed
**Purpose**: Independent verification that phase 1 succeeded (can be run separately)
**Checks**:
- [ ] Gitea responds at `$GITEA_INTERNAL_URL` with HTTP 200
- [ ] Admin user authenticates: `curl -u user:pass .../api/v1/user` returns 200
- [ ] API token works: `gitea_api GET /user` returns 200 with correct username
- [ ] Org exists: `gitea_api GET /orgs/$GITEA_ORG_NAME` returns 200
- [ ] Gitea Actions enabled: `gitea_api GET /api/v1/settings/api` or check app.ini
**Done when**:
- [ ] Runs all checks, prints PASS/FAIL for each
- [ ] Exits 0 only if ALL pass
---
### 5.3 — `phase1_teardown.sh`
**Depends on**: phase1 was run
**Destructive**: yes — prompts for confirmation
**Steps**:
1. Prompt: "This will stop Gitea on Unraid. Continue? [y/N]"
2. `ssh_exec UNRAID "cd $UNRAID_GITEA_DATA_PATH && docker-compose down"`
3. Prompt: "Remove all Gitea data ($UNRAID_GITEA_DATA_PATH)? This is irreversible. [y/N]"
4. If confirmed: `ssh_exec UNRAID "rm -rf $UNRAID_GITEA_DATA_PATH"`
5. Clear `GITEA_ADMIN_TOKEN` from .env: `save_env_var GITEA_ADMIN_TOKEN ""`
**Done when**:
- [ ] Gitea container is stopped and removed
- [ ] Data is only deleted if user explicitly confirms
- [ ] `GITEA_ADMIN_TOKEN` is cleared from .env
- [ ] Running against an already-torn-down instance doesn't error
---
### 6.1 — `phase2_gitea_fedora.sh`
**Depends on**: preflight passed, templates exist
**Produces**: Running Gitea instance on Fedora, admin user, API token in .env
**`require_vars`**: `FEDORA_IP`, `FEDORA_SSH_USER`, `FEDORA_SSH_PORT`, `FEDORA_GITEA_PORT`, `FEDORA_GITEA_SSH_PORT`, `FEDORA_GITEA_DATA_PATH`, `GITEA_ADMIN_USER`, `GITEA_ADMIN_PASSWORD`, `GITEA_ADMIN_EMAIL`, `GITEA_INSTANCE_NAME`, `GITEA_DB_TYPE`, `GITEA_VERSION`, `GITEA_BACKUP_INTERNAL_URL`
**Identical to phase1 except**:
- Target: Fedora (uses `FEDORA_IP`, `FEDORA_SSH_USER`, `FEDORA_SSH_PORT`, `FEDORA_GITEA_DATA_PATH`, `FEDORA_GITEA_PORT`)
- Uses `GITEA_BACKUP_INTERNAL_URL` for API calls
- Saves token as `GITEA_BACKUP_ADMIN_TOKEN`
- Uses same admin credentials (`GITEA_ADMIN_USER`/`GITEA_ADMIN_PASSWORD`/`GITEA_ADMIN_EMAIL`)
- Does NOT create an org (mirrors will be under admin user's namespace)
**Done when**:
- [ ] Same criteria as 5.1 but targeting Fedora URLs/paths
- [ ] `GITEA_BACKUP_ADMIN_TOKEN` is in .env and works
- [ ] No org was created on Fedora instance
---
### 6.2 — `phase2_post_check.sh`
Same as 5.2 but targeting Fedora instance. No org check.
---
### 6.3 — `phase2_teardown.sh`
Same as 5.3 but targeting Fedora. Clears `GITEA_BACKUP_ADMIN_TOKEN`.
---
### 7.2 — `manage_runner.sh`
**Depends on**: `lib/common.sh`, `runners.conf`, templates
**Purpose**: Standalone tool to add/remove/list individual runners
**Subcommands**:
**`manage_runner.sh add --name <runner_name>`**:
1. Read runner entry from `runners.conf` by name
2. If `type=docker`:
- Render `docker-compose-runner.yml.tpl` with runner's vars
- SCP to `$DATA_PATH/docker-compose.yml` on runner host
- Render `runner-config.yaml.tpl`, SCP to `$DATA_PATH/config.yaml`
- `ssh_exec HOST "cd $DATA_PATH && docker-compose up -d"`
- `wait_for_http` on Gitea API for runner to appear
3. If `type=native`:
- Download `act_runner` binary for host's OS/arch to `$DATA_PATH/act_runner`
- Run `$DATA_PATH/act_runner register --no-interactive --instance $GITEA_INTERNAL_URL --token $GITEA_RUNNER_REGISTRATION_TOKEN --name $NAME --labels $LABELS`
- Render launchd plist, copy to `~/Library/LaunchAgents/`
- `launchctl load ~/Library/LaunchAgents/com.gitea.runner.$NAME.plist`
**`manage_runner.sh remove --name <runner_name>`**:
1. Read runner entry from `runners.conf`
2. If `type=docker`: `ssh_exec HOST "cd $DATA_PATH && docker-compose down"`, optionally rm data
3. If `type=native`: `launchctl unload` plist, rm binary + plist
4. Deregister from Gitea via API if possible (or just let it go offline)
**`manage_runner.sh list`**:
1. Read all entries from `runners.conf`
2. For each: query Gitea API for runner status
3. Print table: name, host, labels, type, status (online/offline)
**Done when**:
- [ ] `add` with a docker-type runner: container is running, runner appears in Gitea admin panel
- [ ] `add` with a native-type runner: launchd service is loaded, runner appears in Gitea admin panel
- [ ] `remove` stops the runner and it disappears from Gitea admin (or shows offline)
- [ ] `list` shows all runners with current status
- [ ] `add` on an already-deployed runner prints "already running" and exits 0
- [ ] `remove` on a non-existent runner prints warning and exits 0
- [ ] `shellcheck manage_runner.sh` passes
---
### 7.3 — `phase3_runners.sh`
**Depends on**: Phase 1 completed (Gitea running), `runners.conf` populated
**Produces**: All runners from `runners.conf` deployed and registered
**`require_vars`**: `GITEA_INTERNAL_URL`, `GITEA_ADMIN_TOKEN` *(auto)*, `ACT_RUNNER_VERSION`
**Steps**:
1. Get registration token: `gitea_api GET /admin/runners/registration-token`
2. `save_env_var GITEA_RUNNER_REGISTRATION_TOKEN <token>`
3. For each entry in `runners.conf`: call `manage_runner.sh add --name <name>`
**Done when**:
- [ ] `GITEA_RUNNER_REGISTRATION_TOKEN` is in .env
- [ ] Every runner in `runners.conf` is deployed and shows "online" in Gitea admin
- [ ] Running again skips all already-deployed runners
---
### 7.4 — `phase3_post_check.sh`
**Checks**:
- [ ] For each runner in `runners.conf`: runner exists in Gitea admin API response
- [ ] For each runner in `runners.conf`: status is "online" (not just registered but idle/active)
- [ ] Runner count in Gitea matches line count in `runners.conf`
---
### 7.5 — `phase3_teardown.sh`
For each runner in `runners.conf`: `manage_runner.sh remove --name <name>`. Clears `GITEA_RUNNER_REGISTRATION_TOKEN`.
---
### 8.1 — `phase4_migrate_repos.sh`
**Depends on**: Phase 1 + Phase 2 completed (both Gitea instances running)
**Produces**: All 3 repos on Unraid primary under org + pull mirrors on Fedora
**`require_vars`**: `GITEA_ADMIN_TOKEN` *(auto)*, `GITEA_BACKUP_ADMIN_TOKEN` *(auto)*, `GITEA_INTERNAL_URL`, `GITEA_BACKUP_INTERNAL_URL`, `GITEA_ORG_NAME`, `GITEA_ADMIN_USER`, `GITEA_ADMIN_PASSWORD`, `GITHUB_USERNAME`, `GITHUB_TOKEN`, `REPO_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_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_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 2>/dev/null | openssl x509 -noout -issuer` contains "Let's Encrypt"
- [ ] HTTP requests redirect to HTTPS: `curl -sI http://$GITEA_DOMAIN` returns 301 with `Location: https://...`
- [ ] Cert auto-renewal cron is installed
- [ ] All GitHub repos show as archived with original description preserved in the new description
- [ ] Nginx config test passes before every reload (never reload a broken config)
- [ ] Running again skips cert generation, skips already-archived repos
- [ ] If Nginx container doesn't have required volume mounts, script fails with clear instructions
---
### 12.2 — `phase8_post_check.sh`
- [ ] HTTPS works with valid cert: `curl -sf https://$GITEA_DOMAIN/` returns 200
- [ ] Certificate is from Let's Encrypt (not self-signed): check with `openssl s_client`
- [ ] All repos accessible: `curl -sf https://$GITEA_DOMAIN/$ORG/$REPO` returns 200
- [ ] GitHub repos are archived: `github_api GET /repos/$USER/$REPO` has `"archived": true`
---
### 12.3 — `phase8_teardown.sh`
1. Remove Nginx config: `ssh_exec UNRAID "rm -f $NGINX_CONF_PATH/gitea.conf"`
2. Reload Nginx: `ssh_exec UNRAID "docker exec $NGINX_CONTAINER_NAME nginx -s reload"`
3. Remove cert renewal cron: `ssh_exec UNRAID "crontab -l 2>/dev/null | grep -v certbot | crontab -"`
4. Prompt: "Remove SSL certificates for $GITEA_DOMAIN? [y/N]"
- If yes: `ssh_exec UNRAID "rm -rf /etc/letsencrypt/live/$GITEA_DOMAIN /etc/letsencrypt/archive/$GITEA_DOMAIN /etc/letsencrypt/renewal/$GITEA_DOMAIN.conf"`
5. Un-archive GitHub repos: for each repo, `github_api PATCH /repos/$GITHUB_USERNAME/$REPO {"archived": false}`. Restore original description if it was saved in the archive description (parse after "was: ").
---
### 13.1 — `phase9_security.sh`
**Depends on**: Phase 5 completed (repos have `.gitea/workflows/`)
**Produces**: Security scan workflow in all repos, branch protection updated
**`require_vars`**: `GITEA_ADMIN_TOKEN` *(auto)*, `GITEA_INTERNAL_URL`, `GITEA_ORG_NAME`, `GITEA_ADMIN_USER`, `REPO_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>` (path on Fedora or local)
2. Prompt: "This will REPLACE all Gitea data on Unraid. Continue? [y/N]"
3. If archive is on Fedora: SCP to Unraid `/tmp/`
4. Stop Gitea: `ssh_exec UNRAID "cd $UNRAID_GITEA_DATA_PATH && docker-compose down"`
5. Back up current data (safety): `ssh_exec UNRAID "mv $UNRAID_GITEA_DATA_PATH/data $UNRAID_GITEA_DATA_PATH/data.pre-restore"`
6. Extract archive: `ssh_exec UNRAID "cd $UNRAID_GITEA_DATA_PATH && unzip /tmp/gitea-dump-*.zip"`
7. Move extracted files to correct locations (data, config)
8. Start Gitea: `ssh_exec UNRAID "cd $UNRAID_GITEA_DATA_PATH && docker-compose up -d"`
9. `wait_for_http $GITEA_INTERNAL_URL 60`
10. Verify admin login works: `curl -sf -u $GITEA_ADMIN_USER:$GITEA_ADMIN_PASSWORD $GITEA_INTERNAL_URL/api/v1/user`
11. Regenerate API token (old token from dump may conflict): create new token via basic auth, `save_env_var GITEA_ADMIN_TOKEN`
**Done when**:
- [ ] Gitea is running with restored data
- [ ] Admin can log in
- [ ] All repos are accessible
- [ ] All users from the backup exist
- [ ] New API token is generated and saved to .env
- [ ] Pre-restore data is preserved (not deleted) in case restore went wrong
---
### 15.1 — `run_all.sh`
**Depends on**: all scripts exist
**Steps**:
1. Parse args: `--start-from=N` (default 0), `--skip-setup`
2. Define execution order array:
- **Step 0 — Setup**:
1. `setup/configure_env.sh` — interactive env wizard (populates `.env`)
2. `setup/macbook.sh` — install MacBook prerequisites
3. `setup/unraid.sh` — install Unraid prerequisites (via SSH)
4. `setup/fedora.sh` — install Fedora prerequisites (via SSH)
- **Step P — Preflight**: `preflight.sh` (validates everything before proceeding)
- **Steps 1-9 — Phases**: `phaseN_*.sh` + `phaseN_post_check.sh`
3. Execute sequentially from start point
4. On any script exit code != 0: stop, print which step failed, print summary of what completed
5. On success: print full summary with checkmarks
6. **`--start-from=N`** (where N >= 1): skips setup + preflight only for phases. Still runs `preflight.sh` to validate .env vars (but NOT `configure_env.sh` or machine setup — assumes those were already done).
**Done when**:
- [ ] Running without args executes: configure_env → macbook → unraid → fedora → preflight → phase 1-9
- [ ] `--start-from=3` skips phases 1-2 but still runs preflight, then starts at phase 3
- [ ] `--skip-setup` skips configure_env + machine setup scripts, starts at preflight
- [ ] Failure in any step stops execution (doesn't continue to next phase)
- [ ] Summary at end shows pass/fail for each step that ran
---
### 15.2 — `teardown_all.sh`
**Steps**:
1. Parse args: `--through=N` (default 1 = tear down everything)
2. Execute in REVERSE order: phase9_teardown → phase8_teardown → ... → phaseN_teardown
3. Each teardown prompts for confirmation (unless `--yes` flag)
**Done when**:
- [ ] `--through=5` tears down phases 5-9 but leaves 1-4 intact
- [ ] Each phase teardown runs independently (doesn't fail because a later phase is already torn down)
- [ ] `--yes` skips all confirmation prompts
---
### 16.1 — Git repo + .gitignore
**`.gitignore` must contain**:
```
# Secrets — never commit
.env
runners.conf
*.pem
*.key
*.crt
# macOS
.DS_Store
# Temp files from script runs
/tmp/
*.log
# Backup archives
*.zip
# Editor / IDE
.vscode/
.idea/
*.swp
*~
```
**Done when**:
- [ ] `git init` completed
- [ ] `.gitignore` excludes `.env`, `runners.conf`, temp files
- [ ] `.env.example` and `runners.conf.example` are NOT ignored (they're templates)
---
### 16.2 — `CLAUDE.md`
Project-specific instructions for AI assistants working on this codebase.
**Done when**:
- [ ] Documents: project structure, how to run scripts, .env setup, script conventions
- [ ] Notes the `set -euo pipefail` + shellcheck requirement for all scripts
---
### 17.1 / 17.2 — Validation
**Done when**:
- [ ] `shellcheck *.sh lib/*.sh setup/*.sh backup/*.sh` exits 0 with zero warnings
- [ ] `bash -n` on every `.sh` file exits 0
- [ ] No file has unexpanded template variables (grep for `\$[A-Z_]` in rendered outputs should find nothing unexpected)
---
## Git Commit Milestones
Each milestone is a git commit. Commit **after** all files in that group pass `shellcheck` and `bash -n`. Do not batch multiple milestones into one commit.
| # | Commit message | Files included | Tracker sections |
|---|---------------|----------------|-----------------|
| 1 | `init: project structure, .gitignore, .env.example, runners.conf.example` | `.gitignore`, `.env.example`, `runners.conf.example`, `PLAN.md`, `CLAUDE.md` | 16.1, 16.2 |
| 2 | `feat: add shared library (lib/common.sh)` | `lib/common.sh` | 1.1 |
| 3 | `feat: add API contracts` | `contracts/gitea-api.md` | 1.3 |
| 4 | `feat: add configuration templates` | `templates/*.tpl`, `templates/workflows/*.tpl` | 2.12.7 |
| 5 | `feat: add setup scripts (configure_env, macbook, unraid, fedora)` | `setup/configure_env.sh`, `setup/macbook.sh`, `setup/unraid.sh`, `setup/fedora.sh` | 3.13.4 |
| 6 | `feat: add preflight validation` | `preflight.sh` | 4.1 |
| 7 | `feat: add Phase 1 — Gitea on Unraid` | `phase1_gitea_unraid.sh`, `phase1_post_check.sh`, `phase1_teardown.sh` | 5.15.3 |
| 8 | `feat: add Phase 2 — Gitea on Fedora` | `phase2_gitea_fedora.sh`, `phase2_post_check.sh`, `phase2_teardown.sh` | 6.16.3 |
| 9 | `feat: add Phase 3 — Runners` | `phase3_runners.sh`, `phase3_post_check.sh`, `phase3_teardown.sh`, `manage_runner.sh` | 7.27.5 |
| 10 | `feat: add Phase 4 — Migrate repos + Fedora mirrors` | `phase4_migrate_repos.sh`, `phase4_post_check.sh`, `phase4_teardown.sh` | 8.18.3 |
| 11 | `feat: add Phase 5 — Migrate pipelines` | `phase5_migrate_pipelines.sh`, `phase5_post_check.sh`, `phase5_teardown.sh` | 9.19.3 |
| 12 | `feat: add Phase 6 — GitHub push mirrors` | `phase6_github_mirrors.sh`, `phase6_post_check.sh`, `phase6_teardown.sh` | 10.110.3 |
| 13 | `feat: add Phase 7 — Branch protection` | `phase7_branch_protection.sh`, `phase7_post_check.sh`, `phase7_teardown.sh` | 11.111.3 |
| 14 | `feat: add Phase 8 — Cutover (HTTPS + archive GitHub)` | `phase8_cutover.sh`, `phase8_post_check.sh`, `phase8_teardown.sh` | 12.112.3 |
| 15 | `feat: add Phase 9 — Security scanning` | `phase9_security.sh`, `phase9_post_check.sh`, `phase9_teardown.sh` | 13.113.3 |
| 16 | `feat: add backup and restore scripts` | `backup/backup_primary.sh`, `backup/restore_to_primary.sh` | 14.114.2 |
| 17 | `feat: add orchestration (run_all.sh, teardown_all.sh)` | `run_all.sh`, `teardown_all.sh` | 15.115.2 |
| 18 | `chore: shellcheck + syntax validation fixes` | Any files fixed during validation | 17.117.2 |