# 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 |