fix: address multiple bugs from code review

- teardown_all.sh: replace `yes |` pipeline with `< <(yes)` process
  substitution to avoid SIGPIPE (exit 141) false failures under pipefail
- phase6_teardown.sh: extract push mirror `.id` instead of `.remote_name`
  to match the DELETE /push_mirrors/{id} API contract
- phase5_migrate_pipelines.sh: expand sed regex from `[a-z_]*` to
  `[a-z_.]*` to handle nested GitHub contexts like
  `github.event.pull_request.number`
- lib/common.sh: render_template now requires explicit variable list to
  prevent envsubst from eating Nginx variables ($host, $proxy_add_...)
- backup scripts: remove MacBook relay, use direct Unraid↔Fedora SCP;
  fix dump path to write to /data/ (mounted volume) instead of /tmp/
  (container-only); add unzip -t integrity verification
- preflight.sh: add --skip-port-checks flag for resuming with
  --start-from (ports already bound by earlier phases)
- run_all.sh: update run_step to pass extra args; use --skip-port-checks
  when --start-from > 1
- post-checks (phase4/7/9): wrap API calls in helper functions with
  >/dev/null redirection instead of passing -o /dev/null as API data
- phase8: replace GitHub archiving with [MIRROR] description marking
  and disable wiki/projects/Pages (archived repos reject push mirrors)
- restore_to_primary.sh: add require_vars for Fedora SSH variables

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
S
2026-02-28 20:18:35 -05:00
parent 07d27f7a9c
commit dc08375ad0
18 changed files with 199 additions and 133 deletions

View File

@@ -12,10 +12,11 @@ set -euo pipefail
# #
# Steps: # Steps:
# 1. Run `gitea dump` inside the container to create a zip archive # 1. Run `gitea dump` inside the container to create a zip archive
# 2. SCP the dump from Unraid to Fedora (offsite storage) # 2. SCP the dump directly from Unraid to Fedora (no MacBook relay)
# 3. Clean up the dump from Unraid /tmp # 3. Verify archive integrity on Fedora
# 4. Prune old backups beyond retention count # 4. Clean up the dump from Unraid /tmp
# 5. Print backup summary # 5. Prune old backups beyond retention count
# 6. Print backup summary
# ============================================================================= # =============================================================================
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
@@ -31,50 +32,56 @@ log_info "=== Gitea Primary Backup ==="
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Step 1: Run gitea dump inside the container # Step 1: Run gitea dump inside the container
# The -u git flag is important — gitea dump must run as the git user who # The -u git flag is important — gitea dump must run as the git user who
# owns the repository files. The dump is created in /tmp inside the container # owns the repository files. The dump is written to /data/ inside the
# which maps to /tmp on the host via the default Docker tmpfs mount. # container, which is mounted from ${DATA_PATH}/data on the host.
# Writing to /data/ (mounted volume) instead of /tmp/ (container-only
# filesystem) ensures the dump is accessible from the host for SCP.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
DATA_PATH="$UNRAID_GITEA_DATA_PATH"
TIMESTAMP=$(date +%Y%m%d-%H%M%S) TIMESTAMP=$(date +%Y%m%d-%H%M%S)
DUMP_FILENAME="gitea-dump-${TIMESTAMP}.zip" DUMP_FILENAME="gitea-dump-${TIMESTAMP}.zip"
DUMP_REMOTE_PATH="/tmp/${DUMP_FILENAME}" DUMP_CONTAINER_PATH="/data/${DUMP_FILENAME}"
DUMP_HOST_PATH="${DATA_PATH}/data/${DUMP_FILENAME}"
log_info "Creating Gitea dump on Unraid..." log_info "Creating Gitea dump on Unraid..."
ssh_exec UNRAID "docker exec -u git gitea gitea dump \ ssh_exec UNRAID "docker exec -u git gitea gitea dump \
-c /data/gitea/conf/app.ini \ -c /data/gitea/conf/app.ini \
-f '${DUMP_REMOTE_PATH}'" -f '${DUMP_CONTAINER_PATH}'"
log_success "Dump created: ${DUMP_FILENAME}" log_success "Dump created: ${DUMP_FILENAME}"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Step 2: Create backup storage directory on Fedora and transfer dump # Step 2: Transfer dump directly from Unraid to Fedora
# The dump goes from Unraid → local machine → Fedora because direct # Uses SSH from Unraid to SCP the file to Fedora. This avoids relaying
# Unraid→Fedora SCP may not have SSH keys set up. Using the MacBook as # through the MacBook, which would be slow for large dumps and requires
# a relay is more reliable with our existing SSH config. # the MacBook to be online.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
log_info "Transferring dump to Fedora backup storage..." log_info "Transferring dump to Fedora backup storage..."
ssh_exec FEDORA "mkdir -p '${BACKUP_STORAGE_PATH}'" ssh_exec FEDORA "mkdir -p '${BACKUP_STORAGE_PATH}'"
# SCP from Unraid to local temp, then to Fedora FEDORA_PORT="${FEDORA_SSH_PORT:-22}"
LOCAL_TMP=$(mktemp -d) ssh_exec UNRAID "scp -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new \
scp_to_local() { -o BatchMode=yes -P '${FEDORA_PORT}' \
local ip_var="UNRAID_IP" user_var="UNRAID_SSH_USER" port_var="UNRAID_SSH_PORT" '${DUMP_HOST_PATH}' '${FEDORA_SSH_USER}@${FEDORA_IP}:${BACKUP_STORAGE_PATH}/${DUMP_FILENAME}'"
local ip="${!ip_var:-}" user="${!user_var:-}" port="${!port_var:-22}"
scp -o ConnectTimeout=10 -o BatchMode=yes -P "$port" \
"${user}@${ip}:${DUMP_REMOTE_PATH}" "${LOCAL_TMP}/${DUMP_FILENAME}"
}
scp_to_local
scp_to FEDORA "${LOCAL_TMP}/${DUMP_FILENAME}" "${BACKUP_STORAGE_PATH}/${DUMP_FILENAME}"
rm -rf "$LOCAL_TMP"
log_success "Dump transferred to Fedora: ${BACKUP_STORAGE_PATH}/${DUMP_FILENAME}" log_success "Dump transferred to Fedora: ${BACKUP_STORAGE_PATH}/${DUMP_FILENAME}"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Step 3: Clean up dump from Unraid /tmp # Step 3: Verify archive integrity on Fedora
# CRC-checks every file in the zip. If corrupt, set -e aborts before
# pruning old (known-good) backups.
# ---------------------------------------------------------------------------
log_info "Verifying archive integrity..."
ssh_exec FEDORA "unzip -t '${BACKUP_STORAGE_PATH}/${DUMP_FILENAME}'" >/dev/null
log_success "Archive integrity verified"
# ---------------------------------------------------------------------------
# Step 4: Clean up dump from Unraid /tmp
# No reason to keep the dump on Unraid — it's on Fedora now. # No reason to keep the dump on Unraid — it's on Fedora now.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
ssh_exec UNRAID "rm -f '${DUMP_REMOTE_PATH}'" ssh_exec UNRAID "rm -f '${DUMP_HOST_PATH}'"
log_info "Cleaned up dump from Unraid" log_info "Cleaned up dump from Unraid"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Step 4: Prune old backups beyond retention count # Step 5: Prune old backups beyond retention count
# Lists all gitea-dump-*.zip files sorted by time (newest first), then # Lists all gitea-dump-*.zip files sorted by time (newest first), then
# removes everything beyond BACKUP_RETENTION_COUNT. # removes everything beyond BACKUP_RETENTION_COUNT.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -85,7 +92,7 @@ REMAINING=$(ssh_exec FEDORA "ls -1 '${BACKUP_STORAGE_PATH}'/gitea-dump-*.zip 2>/
log_info "Backups remaining: ${REMAINING}" log_info "Backups remaining: ${REMAINING}"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Step 5: Summary # Step 6: Summary
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
DUMP_SIZE=$(ssh_exec FEDORA "du -h '${BACKUP_STORAGE_PATH}/${DUMP_FILENAME}'" | awk '{print $1}') DUMP_SIZE=$(ssh_exec FEDORA "du -h '${BACKUP_STORAGE_PATH}/${DUMP_FILENAME}'" | awk '{print $1}')

View File

@@ -65,7 +65,7 @@ fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Step 1: Transfer archive to Unraid /tmp if needed # Step 1: Transfer archive to Unraid /tmp if needed
# If the archive is a local file, SCP it directly. If it's on Fedora, # If the archive is a local file, SCP it directly. If it's on Fedora,
# we relay through the local machine. # SCP directly from Fedora to Unraid (no MacBook relay).
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
log_step 1 "Preparing archive..." log_step 1 "Preparing archive..."
ARCHIVE_NAME=$(basename "$ARCHIVE_PATH") ARCHIVE_NAME=$(basename "$ARCHIVE_PATH")
@@ -76,16 +76,13 @@ if [[ -f "$ARCHIVE_PATH" ]]; then
log_info "Uploading local archive to Unraid..." log_info "Uploading local archive to Unraid..."
scp_to UNRAID "$ARCHIVE_PATH" "$UNRAID_ARCHIVE" scp_to UNRAID "$ARCHIVE_PATH" "$UNRAID_ARCHIVE"
else else
# Assume path is on Fedora — relay through local machine # Assume path is on Fedora — SCP directly from Fedora to Unraid
require_vars FEDORA_IP FEDORA_SSH_USER UNRAID_IP UNRAID_SSH_USER
log_info "Transferring archive from Fedora to Unraid..." log_info "Transferring archive from Fedora to Unraid..."
LOCAL_TMP=$(mktemp -d) UNRAID_PORT="${UNRAID_SSH_PORT:-22}"
# SCP from Fedora to local ssh_exec FEDORA "scp -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new \
ip="${FEDORA_IP:-}" user="${FEDORA_SSH_USER:-}" port="${FEDORA_SSH_PORT:-22}" -o BatchMode=yes -P '${UNRAID_PORT}' \
scp -o ConnectTimeout=10 -o BatchMode=yes -P "$port" \ '${ARCHIVE_PATH}' '${UNRAID_SSH_USER}@${UNRAID_IP}:${UNRAID_ARCHIVE}'"
"${user}@${ip}:${ARCHIVE_PATH}" "${LOCAL_TMP}/${ARCHIVE_NAME}"
# SCP from local to Unraid
scp_to UNRAID "${LOCAL_TMP}/${ARCHIVE_NAME}" "$UNRAID_ARCHIVE"
rm -rf "$LOCAL_TMP"
fi fi
log_success "Archive ready on Unraid: ${UNRAID_ARCHIVE}" log_success "Archive ready on Unraid: ${UNRAID_ARCHIVE}"

View File

@@ -303,14 +303,20 @@ github_api() {
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
render_template() { render_template() {
local src="$1" dest="$2" local src="$1" dest="$2" vars="${3:-}"
if [[ ! -f "$src" ]]; then if [[ ! -f "$src" ]]; then
log_error "Template not found: $src" log_error "Template not found: $src"
return 1 return 1
fi fi
envsubst < "$src" > "$dest" if [[ -z "$vars" ]]; then
log_error "render_template requires an explicit variable list (third argument)"
log_error "Example: render_template src dest '\${VAR1} \${VAR2}'"
return 1
fi
envsubst "$vars" < "$src" > "$dest"
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -163,13 +163,15 @@ add_docker_runner() {
tmpfile=$(mktemp) tmpfile=$(mktemp)
export RUNNER_NAME RUNNER_LABELS RUNNER_DATA_PATH export RUNNER_NAME RUNNER_LABELS RUNNER_DATA_PATH
export GITEA_RUNNER_REGISTRATION_TOKEN="${GITEA_RUNNER_REGISTRATION_TOKEN:-}" export GITEA_RUNNER_REGISTRATION_TOKEN="${GITEA_RUNNER_REGISTRATION_TOKEN:-}"
render_template "${SCRIPT_DIR}/templates/docker-compose-runner.yml.tpl" "$tmpfile" render_template "${SCRIPT_DIR}/templates/docker-compose-runner.yml.tpl" "$tmpfile" \
'${ACT_RUNNER_VERSION} ${RUNNER_NAME} ${GITEA_INTERNAL_URL} ${GITEA_RUNNER_REGISTRATION_TOKEN} ${RUNNER_LABELS} ${RUNNER_DATA_PATH}'
runner_scp "$tmpfile" "${RUNNER_DATA_PATH}/docker-compose.yml" runner_scp "$tmpfile" "${RUNNER_DATA_PATH}/docker-compose.yml"
rm -f "$tmpfile" rm -f "$tmpfile"
# Render runner config # Render runner config
tmpfile=$(mktemp) tmpfile=$(mktemp)
render_template "${SCRIPT_DIR}/templates/runner-config.yaml.tpl" "$tmpfile" render_template "${SCRIPT_DIR}/templates/runner-config.yaml.tpl" "$tmpfile" \
'${RUNNER_NAME} ${RUNNER_LABELS}'
runner_scp "$tmpfile" "${RUNNER_DATA_PATH}/config.yaml" runner_scp "$tmpfile" "${RUNNER_DATA_PATH}/config.yaml"
rm -f "$tmpfile" rm -f "$tmpfile"
@@ -246,13 +248,15 @@ add_native_runner() {
local tmpfile local tmpfile
tmpfile=$(mktemp) tmpfile=$(mktemp)
export RUNNER_NAME RUNNER_LABELS RUNNER_DATA_PATH export RUNNER_NAME RUNNER_LABELS RUNNER_DATA_PATH
render_template "${SCRIPT_DIR}/templates/runner-config.yaml.tpl" "$tmpfile" render_template "${SCRIPT_DIR}/templates/runner-config.yaml.tpl" "$tmpfile" \
'${RUNNER_NAME} ${RUNNER_LABELS}'
cp "$tmpfile" "${RUNNER_DATA_PATH}/config.yaml" cp "$tmpfile" "${RUNNER_DATA_PATH}/config.yaml"
rm -f "$tmpfile" rm -f "$tmpfile"
# Render launchd plist # Render launchd plist
tmpfile=$(mktemp) tmpfile=$(mktemp)
render_template "${SCRIPT_DIR}/templates/com.gitea.runner.plist.tpl" "$tmpfile" render_template "${SCRIPT_DIR}/templates/com.gitea.runner.plist.tpl" "$tmpfile" \
'${RUNNER_NAME} ${RUNNER_DATA_PATH}'
mkdir -p "$HOME/Library/LaunchAgents" mkdir -p "$HOME/Library/LaunchAgents"
cp "$tmpfile" "$plist_path" cp "$tmpfile" "$plist_path"
rm -f "$tmpfile" rm -f "$tmpfile"

View File

@@ -45,7 +45,8 @@ else
TMPFILE=$(mktemp) TMPFILE=$(mktemp)
# Set variables for template # Set variables for template
export DATA_PATH GITEA_PORT="${UNRAID_GITEA_PORT}" GITEA_SSH_PORT="${UNRAID_GITEA_SSH_PORT}" export DATA_PATH GITEA_PORT="${UNRAID_GITEA_PORT}" GITEA_SSH_PORT="${UNRAID_GITEA_SSH_PORT}"
render_template "${SCRIPT_DIR}/templates/docker-compose-gitea.yml.tpl" "$TMPFILE" render_template "${SCRIPT_DIR}/templates/docker-compose-gitea.yml.tpl" "$TMPFILE" \
'${GITEA_VERSION} ${DATA_PATH} ${GITEA_PORT} ${GITEA_SSH_PORT}'
scp_to UNRAID "$TMPFILE" "${DATA_PATH}/docker-compose.yml" scp_to UNRAID "$TMPFILE" "${DATA_PATH}/docker-compose.yml"
rm -f "$TMPFILE" rm -f "$TMPFILE"
log_success "docker-compose.yml deployed" log_success "docker-compose.yml deployed"
@@ -62,7 +63,8 @@ else
# Generate a random secret key for this instance # Generate a random secret key for this instance
GITEA_SECRET_KEY=$(openssl rand -hex 32) GITEA_SECRET_KEY=$(openssl rand -hex 32)
export GITEA_SECRET_KEY export GITEA_SECRET_KEY
render_template "${SCRIPT_DIR}/templates/app.ini.tpl" "$TMPFILE" render_template "${SCRIPT_DIR}/templates/app.ini.tpl" "$TMPFILE" \
'${GITEA_DOMAIN} ${GITEA_DB_TYPE} ${GITEA_SECRET_KEY}'
scp_to UNRAID "$TMPFILE" "${DATA_PATH}/config/app.ini" scp_to UNRAID "$TMPFILE" "${DATA_PATH}/config/app.ini"
rm -f "$TMPFILE" rm -f "$TMPFILE"
log_success "app.ini deployed" log_success "app.ini deployed"

View File

@@ -43,7 +43,8 @@ if ssh_exec FEDORA "test -f '${DATA_PATH}/docker-compose.yml'"; then
else else
TMPFILE=$(mktemp) TMPFILE=$(mktemp)
export DATA_PATH GITEA_PORT="${FEDORA_GITEA_PORT}" GITEA_SSH_PORT="${FEDORA_GITEA_SSH_PORT}" export DATA_PATH GITEA_PORT="${FEDORA_GITEA_PORT}" GITEA_SSH_PORT="${FEDORA_GITEA_SSH_PORT}"
render_template "${SCRIPT_DIR}/templates/docker-compose-gitea.yml.tpl" "$TMPFILE" render_template "${SCRIPT_DIR}/templates/docker-compose-gitea.yml.tpl" "$TMPFILE" \
'${GITEA_VERSION} ${DATA_PATH} ${GITEA_PORT} ${GITEA_SSH_PORT}'
scp_to FEDORA "$TMPFILE" "${DATA_PATH}/docker-compose.yml" scp_to FEDORA "$TMPFILE" "${DATA_PATH}/docker-compose.yml"
rm -f "$TMPFILE" rm -f "$TMPFILE"
log_success "docker-compose.yml deployed" log_success "docker-compose.yml deployed"
@@ -67,7 +68,8 @@ else
# the Fedora instance doesn't have a public domain # the Fedora instance doesn't have a public domain
GITEA_DOMAIN="${FEDORA_IP}:${FEDORA_GITEA_PORT}" GITEA_DOMAIN="${FEDORA_IP}:${FEDORA_GITEA_PORT}"
export GITEA_DOMAIN export GITEA_DOMAIN
render_template "${SCRIPT_DIR}/templates/app.ini.tpl" "$TMPFILE" render_template "${SCRIPT_DIR}/templates/app.ini.tpl" "$TMPFILE" \
'${GITEA_DOMAIN} ${GITEA_DB_TYPE} ${GITEA_SECRET_KEY}'
scp_to FEDORA "$TMPFILE" "${DATA_PATH}/config/app.ini" scp_to FEDORA "$TMPFILE" "${DATA_PATH}/config/app.ini"
rm -f "$TMPFILE" rm -f "$TMPFILE"
log_success "app.ini deployed" log_success "app.ini deployed"

View File

@@ -44,8 +44,10 @@ for repo in "${REPOS[@]}"; do
log_info "--- Checking repo: ${repo} ---" log_info "--- Checking repo: ${repo} ---"
# Check 1: Repo exists on primary # Check 1: Repo exists on primary
run_check "Primary: ${GITEA_ORG_NAME}/${repo} exists" \ check_repo_exists() {
gitea_api GET "/repos/${GITEA_ORG_NAME}/${repo}" -o /dev/null gitea_api GET "/repos/${GITEA_ORG_NAME}/$1" >/dev/null
}
run_check "Primary: ${GITEA_ORG_NAME}/${repo} exists" check_repo_exists "$repo"
# Check 2: Repo has commits (migration imported content) # Check 2: Repo has commits (migration imported content)
check_commits() { check_commits() {
@@ -67,8 +69,10 @@ for repo in "${REPOS[@]}"; do
run_check "Primary: ${repo} default branch matches GitHub" check_default_branch "$repo" run_check "Primary: ${repo} default branch matches GitHub" check_default_branch "$repo"
# Check 4: Mirror exists on Fedora # Check 4: Mirror exists on Fedora
run_check "Fedora: ${GITEA_ADMIN_USER}/${repo} exists" \ check_mirror_exists() {
gitea_backup_api GET "/repos/${GITEA_ADMIN_USER}/${repo}" -o /dev/null gitea_backup_api GET "/repos/${GITEA_ADMIN_USER}/$1" >/dev/null
}
run_check "Fedora: ${GITEA_ADMIN_USER}/${repo} exists" check_mirror_exists "$repo"
# Check 5: Mirror has mirror=true # Check 5: Mirror has mirror=true
check_mirror_flag() { check_mirror_flag() {

View File

@@ -113,15 +113,15 @@ for repo in "${REPOS[@]}"; do
cat "$dest" >> "$tmpwf" cat "$dest" >> "$tmpwf"
mv "$tmpwf" "$dest" mv "$tmpwf" "$dest"
# Replace GitHub-specific context variables with Gitea equivalents # Replace GitHub-specific context variables with Gitea equivalents.
# Using sed with a temp file for portability (macOS sed -i requires '', # Only match inside ${{ ... }} expression delimiters to avoid mangling
# GNU sed -i requires no arg — avoiding both by writing to a temp file) # URLs, comments, or other strings that happen to contain "github.".
# Two patterns: with spaces (${{ github.X }}) and without (${{github.X}}).
# Character class [a-z_.] covers nested contexts like github.event.pull_request.number.
tmpwf=$(mktemp) tmpwf=$(mktemp)
sed \ sed \
-e 's/github\.repository/gitea.repository/g' \ -e 's/\${{ github\.\([a-z_.]*\) }}/\${{ gitea.\1 }}/g' \
-e 's/github\.event/gitea.event/g' \ -e 's/\${{github\.\([a-z_.]*\)}}/\${{gitea.\1}}/g' \
-e 's/github\.token/gitea.token/g' \
-e 's/github\.server_url/gitea.server_url/g' \
"$dest" > "$tmpwf" "$dest" > "$tmpwf"
mv "$tmpwf" "$dest" mv "$tmpwf" "$dest"

View File

@@ -32,7 +32,7 @@ for repo in "${REPOS[@]}"; do
# Get push mirror IDs (there could be multiple, delete all) # Get push mirror IDs (there could be multiple, delete all)
MIRRORS=$(gitea_api GET "/repos/${GITEA_ORG_NAME}/${repo}/push_mirrors" 2>/dev/null || echo "[]") MIRRORS=$(gitea_api GET "/repos/${GITEA_ORG_NAME}/${repo}/push_mirrors" 2>/dev/null || echo "[]")
MIRROR_IDS=$(printf '%s' "$MIRRORS" | jq -r '.[].remote_name' 2>/dev/null || true) MIRROR_IDS=$(printf '%s' "$MIRRORS" | jq -r '.[].id' 2>/dev/null || true)
if [[ -z "$MIRROR_IDS" ]]; then if [[ -z "$MIRROR_IDS" ]]; then
log_info "No push mirrors found for ${repo} — already clean" log_info "No push mirrors found for ${repo} — already clean"

View File

@@ -37,8 +37,10 @@ for repo in "${REPOS[@]}"; do
log_info "--- Checking repo: ${repo} ---" log_info "--- Checking repo: ${repo} ---"
# Check 1: Protection rule exists # Check 1: Protection rule exists
run_check "Branch protection exists for '${PROTECTED_BRANCH}' on ${repo}" \ check_protection_exists() {
gitea_api GET "/repos/${GITEA_ORG_NAME}/${repo}/branch_protections/${PROTECTED_BRANCH}" -o /dev/null gitea_api GET "/repos/${GITEA_ORG_NAME}/$1/branch_protections/${PROTECTED_BRANCH}" >/dev/null
}
run_check "Branch protection exists for '${PROTECTED_BRANCH}' on ${repo}" check_protection_exists "$repo"
# Check 2: Push is blocked (enable_push should be false) # Check 2: Push is blocked (enable_push should be false)
check_push_blocked() { check_push_blocked() {

View File

@@ -58,7 +58,8 @@ render_nginx_http_only() {
# Set dummy cert paths (not used in HTTP-only mode) # Set dummy cert paths (not used in HTTP-only mode)
export SSL_CERT_FULLPATH="/dev/null" export SSL_CERT_FULLPATH="/dev/null"
export SSL_KEY_FULLPATH="/dev/null" export SSL_KEY_FULLPATH="/dev/null"
render_template "${SCRIPT_DIR}/templates/nginx-gitea.conf.tpl" "$rendered" render_template "${SCRIPT_DIR}/templates/nginx-gitea.conf.tpl" "$rendered" \
'${GITEA_DOMAIN} ${UNRAID_IP} ${UNRAID_GITEA_PORT} ${SSL_CERT_FULLPATH} ${SSL_KEY_FULLPATH}'
# Strip the HTTPS server block (everything between markers inclusive) # Strip the HTTPS server block (everything between markers inclusive)
sed '/# SSL_HTTPS_BLOCK_START/,/# SSL_HTTPS_BLOCK_END/d' "$rendered" > "$tmpfile" sed '/# SSL_HTTPS_BLOCK_START/,/# SSL_HTTPS_BLOCK_END/d' "$rendered" > "$tmpfile"
@@ -75,7 +76,8 @@ render_nginx_https() {
export GITEA_DOMAIN UNRAID_IP UNRAID_GITEA_PORT export GITEA_DOMAIN UNRAID_IP UNRAID_GITEA_PORT
export SSL_CERT_FULLPATH="$cert_path" export SSL_CERT_FULLPATH="$cert_path"
export SSL_KEY_FULLPATH="$key_path" export SSL_KEY_FULLPATH="$key_path"
render_template "${SCRIPT_DIR}/templates/nginx-gitea.conf.tpl" "$rendered" render_template "${SCRIPT_DIR}/templates/nginx-gitea.conf.tpl" "$rendered" \
'${GITEA_DOMAIN} ${UNRAID_IP} ${UNRAID_GITEA_PORT} ${SSL_CERT_FULLPATH} ${SSL_KEY_FULLPATH}'
# Replace the redirect block content with a 301 redirect to HTTPS # Replace the redirect block content with a 301 redirect to HTTPS
# The block between markers gets replaced with just the redirect # The block between markers gets replaced with just the redirect
@@ -266,39 +268,47 @@ else
fi fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Step 11: Archive GitHub repos # Step 11: Mark GitHub repos as offsite backup only
# Marks repos as archived with a "[MOVED]" description pointing to Gitea. # Updates description + homepage to indicate Gitea is primary.
# Preserves the original description by appending it after "— was: ". # Disables wiki and Pages to avoid unnecessary resource usage.
# Does NOT archive — archived repos reject pushes, which would break
# the push mirrors configured in Phase 6.
# Preserves original description by appending after "— was: ".
# GitHub Actions already disabled in Phase 6 Step D.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
log_step 11 "Archiving GitHub repos..." log_step 11 "Marking GitHub repos as offsite backup..."
for repo in "${REPOS[@]}"; do for repo in "${REPOS[@]}"; do
# Check if already archived # Fetch repo metadata (single API call)
IS_ARCHIVED=$(github_api GET "/repos/${GITHUB_USERNAME}/${repo}" 2>/dev/null | jq -r '.archived' || echo "false") REPO_DATA=$(github_api GET "/repos/${GITHUB_USERNAME}/${repo}" 2>/dev/null || echo "{}")
if [[ "$IS_ARCHIVED" == "true" ]]; then CURRENT_DESC=$(printf '%s' "$REPO_DATA" | jq -r '.description // ""')
log_info "GitHub repo ${repo} already archived — skipping"
# Skip if already marked
if [[ "$CURRENT_DESC" == "[MIRROR]"* ]]; then
log_info "GitHub repo ${repo} already marked as mirror — skipping"
continue continue
fi fi
# Get original description to preserve it # Build new description preserving original
ORIGINAL_DESC=$(github_api GET "/repos/${GITHUB_USERNAME}/${repo}" 2>/dev/null | jq -r '.description // ""' || echo "") NEW_DESC="[MIRROR] Offsite backup — primary at https://${GITEA_DOMAIN}/${GITEA_ORG_NAME}/${repo}"
if [[ -n "$CURRENT_DESC" ]]; then
# Build new description with moved notice NEW_DESC="${NEW_DESC} — was: ${CURRENT_DESC}"
NEW_DESC="[MOVED] Now at https://${GITEA_DOMAIN}/${GITEA_ORG_NAME}/${repo}"
if [[ -n "$ORIGINAL_DESC" ]]; then
NEW_DESC="${NEW_DESC} — was: ${ORIGINAL_DESC}"
fi fi
# Archive the repo with the new description # Update description + homepage, disable wiki and projects
ARCHIVE_PAYLOAD=$(jq -n \ UPDATE_PAYLOAD=$(jq -n \
--arg description "$NEW_DESC" \ --arg description "$NEW_DESC" \
'{archived: true, description: $description}') --arg homepage "https://${GITEA_DOMAIN}/${GITEA_ORG_NAME}/${repo}" \
'{description: $description, homepage: $homepage, has_wiki: false, has_projects: false}')
if github_api PATCH "/repos/${GITHUB_USERNAME}/${repo}" "$ARCHIVE_PAYLOAD" >/dev/null 2>&1; then if github_api PATCH "/repos/${GITHUB_USERNAME}/${repo}" "$UPDATE_PAYLOAD" >/dev/null 2>&1; then
log_success "Archived GitHub repo: ${repo}" log_success "Marked GitHub repo as mirror: ${repo}"
else else
log_error "Failed to archive GitHub repo: ${repo}" log_error "Failed to update GitHub repo: ${repo}"
fi fi
# Disable GitHub Pages if enabled (Pages can incur bandwidth costs)
github_api DELETE "/repos/${GITHUB_USERNAME}/${repo}/pages" >/dev/null 2>&1 || true
done done
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -306,4 +316,4 @@ done
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
printf '\n' printf '\n'
log_success "Phase 8 complete — Gitea is live at https://${GITEA_DOMAIN}" log_success "Phase 8 complete — Gitea is live at https://${GITEA_DOMAIN}"
log_info "GitHub repos have been archived. Gitea is now the primary git host." log_info "GitHub repos marked as offsite backup. Push mirrors remain active."

View File

@@ -7,7 +7,7 @@ set -euo pipefail
# 1. HTTPS works with valid cert # 1. HTTPS works with valid cert
# 2. HTTP redirects to HTTPS # 2. HTTP redirects to HTTPS
# 3. All repos accessible via HTTPS # 3. All repos accessible via HTTPS
# 4. GitHub repos are archived # 4. GitHub repos are marked as offsite backup
# Exits 0 only if ALL checks pass. # Exits 0 only if ALL checks pass.
# ============================================================================= # =============================================================================
@@ -64,14 +64,14 @@ for repo in "${REPOS[@]}"; do
curl -sf -o /dev/null -H "Authorization: token ${GITEA_ADMIN_TOKEN}" "https://${GITEA_DOMAIN}/api/v1/repos/${GITEA_ORG_NAME}/${repo}" curl -sf -o /dev/null -H "Authorization: token ${GITEA_ADMIN_TOKEN}" "https://${GITEA_DOMAIN}/api/v1/repos/${GITEA_ORG_NAME}/${repo}"
done done
# Check 5: GitHub repos are archived # Check 5: GitHub repos are marked as offsite backup
for repo in "${REPOS[@]}"; do for repo in "${REPOS[@]}"; do
check_archived() { check_mirror_marked() {
local is_archived local desc
is_archived=$(github_api GET "/repos/${GITHUB_USERNAME}/$1" | jq -r '.archived') desc=$(github_api GET "/repos/${GITHUB_USERNAME}/$1" | jq -r '.description // ""')
[[ "$is_archived" == "true" ]] [[ "$desc" == "[MIRROR]"* ]]
} }
run_check "GitHub repo ${repo} is archived" check_archived "$repo" run_check "GitHub repo ${repo} marked as mirror" check_mirror_marked "$repo"
done done
# Summary # Summary

View File

@@ -2,12 +2,12 @@
set -euo pipefail set -euo pipefail
# ============================================================================= # =============================================================================
# phase8_teardown.sh — Reverse the cutover: remove HTTPS, un-archive GitHub # phase8_teardown.sh — Reverse the cutover: remove HTTPS, restore GitHub repos
# Steps: # Steps:
# 1. Remove Nginx gitea.conf + reload # 1. Remove Nginx gitea.conf + reload
# 2. Remove cert renewal cron # 2. Remove cert renewal cron
# 3. Optionally remove SSL certificates # 3. Optionally remove SSL certificates
# 4. Un-archive GitHub repos + restore original descriptions # 4. Restore GitHub repo descriptions, re-enable wiki/projects
# ============================================================================= # =============================================================================
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
@@ -67,37 +67,35 @@ if [[ "$SSL_MODE" == "letsencrypt" ]]; then
fi fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Step 4: Un-archive GitHub repos + restore original descriptions # Step 4: Restore GitHub repos — description, wiki, projects
# The archive description format is: "[MOVED] ... — was: ORIGINAL_DESC" # The mirror description format is: "[MIRROR] ... — was: ORIGINAL_DESC"
# We parse the original description from after "— was: " to restore it. # We parse the original description from after "— was: " to restore it.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
printf 'Un-archive GitHub repos and restore descriptions? [y/N] ' printf 'Restore GitHub repo descriptions and re-enable wiki/projects? [y/N] '
read -r confirm read -r confirm
if [[ "$confirm" =~ ^[Yy]$ ]]; then if [[ "$confirm" =~ ^[Yy]$ ]]; then
for repo in "${REPOS[@]}"; do for repo in "${REPOS[@]}"; do
IS_ARCHIVED=$(github_api GET "/repos/${GITHUB_USERNAME}/${repo}" 2>/dev/null | jq -r '.archived' || echo "false") CURRENT_DESC=$(github_api GET "/repos/${GITHUB_USERNAME}/${repo}" 2>/dev/null | jq -r '.description // ""')
if [[ "$IS_ARCHIVED" != "true" ]]; then if [[ "$CURRENT_DESC" != "[MIRROR]"* ]]; then
log_info "GitHub repo ${repo} not archived — skipping" log_info "GitHub repo ${repo} not marked as mirror — skipping"
continue continue
fi fi
# Extract original description from the archived description # Extract original description from the mirror description
CURRENT_DESC=$(github_api GET "/repos/${GITHUB_USERNAME}/${repo}" 2>/dev/null | jq -r '.description // ""')
ORIGINAL_DESC="" ORIGINAL_DESC=""
if [[ "$CURRENT_DESC" == *" — was: "* ]]; then if [[ "$CURRENT_DESC" == *" — was: "* ]]; then
# Extract everything after "— was: "
ORIGINAL_DESC="${CURRENT_DESC##* — was: }" ORIGINAL_DESC="${CURRENT_DESC##* — was: }"
fi fi
# Un-archive and restore description # Restore description, homepage, and re-enable wiki/projects
RESTORE_PAYLOAD=$(jq -n \ RESTORE_PAYLOAD=$(jq -n \
--arg description "$ORIGINAL_DESC" \ --arg description "$ORIGINAL_DESC" \
'{archived: false, description: $description}') '{description: $description, homepage: "", has_wiki: true, has_projects: true}')
if github_api PATCH "/repos/${GITHUB_USERNAME}/${repo}" "$RESTORE_PAYLOAD" >/dev/null 2>&1; then if github_api PATCH "/repos/${GITHUB_USERNAME}/${repo}" "$RESTORE_PAYLOAD" >/dev/null 2>&1; then
log_success "Un-archived GitHub repo: ${repo}" log_success "Restored GitHub repo: ${repo}"
else else
log_error "Failed to un-archive GitHub repo: ${repo}" log_error "Failed to restore GitHub repo: ${repo}"
fi fi
done done
else else

View File

@@ -38,8 +38,10 @@ for repo in "${REPOS[@]}"; do
log_info "--- Checking repo: ${repo} ---" log_info "--- Checking repo: ${repo} ---"
# Check 1: security-scan.yml exists # Check 1: security-scan.yml exists
run_check "security-scan.yml exists in ${repo}" \ check_workflow_exists() {
gitea_api GET "/repos/${GITEA_ORG_NAME}/${repo}/contents/.gitea/workflows/security-scan.yml" -o /dev/null gitea_api GET "/repos/${GITEA_ORG_NAME}/$1/contents/.gitea/workflows/security-scan.yml" >/dev/null
}
run_check "security-scan.yml exists in ${repo}" check_workflow_exists "$repo"
# Check 2: Branch protection includes security checks (if required) # Check 2: Branch protection includes security checks (if required)
if [[ "$SECURITY_FAIL_ON_ERROR" == "true" ]]; then if [[ "$SECURITY_FAIL_ON_ERROR" == "true" ]]; then

View File

@@ -71,7 +71,8 @@ for repo in "${REPOS[@]}"; do
mkdir -p "${CLONE_DIR}/.gitea/workflows" mkdir -p "${CLONE_DIR}/.gitea/workflows"
export SEMGREP_VERSION TRIVY_VERSION GITLEAKS_VERSION PROTECTED_BRANCH export SEMGREP_VERSION TRIVY_VERSION GITLEAKS_VERSION PROTECTED_BRANCH
render_template "${SCRIPT_DIR}/templates/workflows/security-scan.yml.tpl" \ render_template "${SCRIPT_DIR}/templates/workflows/security-scan.yml.tpl" \
"${CLONE_DIR}/.gitea/workflows/security-scan.yml" "${CLONE_DIR}/.gitea/workflows/security-scan.yml" \
'${PROTECTED_BRANCH} ${SEMGREP_VERSION} ${TRIVY_VERSION} ${GITLEAKS_VERSION}'
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Step 3: Commit and push # Step 3: Commit and push

View File

@@ -4,11 +4,22 @@ set -euo pipefail
# ============================================================================= # =============================================================================
# preflight.sh — Validate everything before running migration phases # preflight.sh — Validate everything before running migration phases
# Installs nothing. Exits 0 only if ALL checks pass. # Installs nothing. Exits 0 only if ALL checks pass.
#
# Usage:
# ./preflight.sh # Run all checks
# ./preflight.sh --skip-port-checks # Skip port-free checks (for --start-from)
# ============================================================================= # =============================================================================
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "${SCRIPT_DIR}/lib/common.sh" source "${SCRIPT_DIR}/lib/common.sh"
SKIP_PORT_CHECKS=false
for arg in "$@"; do
case "$arg" in
--skip-port-checks) SKIP_PORT_CHECKS=true ;;
esac
done
log_info "=== Preflight Checks ===" log_info "=== Preflight Checks ==="
PASS_COUNT=0 PASS_COUNT=0
@@ -204,26 +215,33 @@ fi
# Check 13: Port free on Unraid # Check 13: Port free on Unraid
# Uses ss (socket statistics) to check if any process is listening on the port. # Uses ss (socket statistics) to check if any process is listening on the port.
# The ! negates the grep — we PASS if the port is NOT found in use. # The ! negates the grep — we PASS if the port is NOT found in use.
# Skipped when --skip-port-checks is set (e.g. resuming with --start-from
# after phases 1-2 have Gitea already running on these ports).
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
check_port_unraid() { if [[ "$SKIP_PORT_CHECKS" == "true" ]]; then
log_info "[13] Port ${UNRAID_GITEA_PORT:-3000} free on Unraid — SKIPPED (--skip-port-checks)"
log_info "[14] Port ${FEDORA_GITEA_PORT:-3000} free on Fedora — SKIPPED (--skip-port-checks)"
else
check_port_unraid() {
local port="${UNRAID_GITEA_PORT:-3000}" local port="${UNRAID_GITEA_PORT:-3000}"
! ssh_exec UNRAID "ss -tlnp | grep -q ':${port} '" 2>/dev/null ! ssh_exec UNRAID "ss -tlnp | grep -q ':${port} '" 2>/dev/null
} }
check 13 "Port ${UNRAID_GITEA_PORT:-3000} free on Unraid" check_port_unraid check 13 "Port ${UNRAID_GITEA_PORT:-3000} free on Unraid" check_port_unraid
if ! check_port_unraid 2>/dev/null; then if ! check_port_unraid 2>/dev/null; then
log_error " → Port ${UNRAID_GITEA_PORT:-3000} already in use on Unraid." log_error " → Port ${UNRAID_GITEA_PORT:-3000} already in use on Unraid."
fi fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Check 14: Port free on Fedora # Check 14: Port free on Fedora
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
check_port_fedora() { check_port_fedora() {
local port="${FEDORA_GITEA_PORT:-3000}" local port="${FEDORA_GITEA_PORT:-3000}"
! ssh_exec FEDORA "ss -tlnp | grep -q ':${port} '" 2>/dev/null ! ssh_exec FEDORA "ss -tlnp | grep -q ':${port} '" 2>/dev/null
} }
check 14 "Port ${FEDORA_GITEA_PORT:-3000} free on Fedora" check_port_fedora check 14 "Port ${FEDORA_GITEA_PORT:-3000} free on Fedora" check_port_fedora
if ! check_port_fedora 2>/dev/null; then if ! check_port_fedora 2>/dev/null; then
log_error " → Port ${FEDORA_GITEA_PORT:-3000} already in use on Fedora." log_error " → Port ${FEDORA_GITEA_PORT:-3000} already in use on Fedora."
fi
fi fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -69,11 +69,13 @@ record_step() {
STEP_RESULTS+=("$result") STEP_RESULTS+=("$result")
} }
# Run a script, record pass/fail, stop on failure # Run a script, record pass/fail, stop on failure.
# Usage: run_step "Step Name" "script.sh" [args...]
run_step() { run_step() {
local name="$1" script="$2" local name="$1" script="$2"
shift 2
log_info ">>> Running: ${name}" log_info ">>> Running: ${name}"
if "${SCRIPT_DIR}/${script}"; then if "${SCRIPT_DIR}/${script}" "$@"; then
record_step "$name" "PASS" record_step "$name" "PASS"
printf '\n' printf '\n'
else else
@@ -126,9 +128,16 @@ fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Preflight — always runs (validates .env and infrastructure) # Preflight — always runs (validates .env and infrastructure)
# Even with --start-from, preflight ensures the environment is healthy. # Even with --start-from, preflight ensures the environment is healthy.
# When resuming (--start-from > 1), port-free checks are skipped because
# earlier phases have Gitea already running on those ports.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
log_info "=== Preflight ===" log_info "=== Preflight ==="
run_step "Preflight checks" "preflight.sh" if [[ "$START_FROM" -gt 1 ]]; then
log_info "Resuming from phase ${START_FROM} — skipping port-free checks"
run_step "Preflight checks" "preflight.sh" --skip-port-checks
else
run_step "Preflight checks" "preflight.sh"
fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Phases 1-9 — run sequentially, each followed by its post-check # Phases 1-9 — run sequentially, each followed by its post-check

View File

@@ -105,8 +105,12 @@ for entry in "${TEARDOWNS[@]}"; do
log_info ">>> Tearing down Phase ${phase_num}..." log_info ">>> Tearing down Phase ${phase_num}..."
if [[ "$AUTO_YES" == "true" ]]; then if [[ "$AUTO_YES" == "true" ]]; then
# Pipe 'y' to all prompts in the teardown script # Feed unlimited 'y' responses via process substitution.
if echo "y" | "${SCRIPT_DIR}/${script}"; then # A pipeline (yes | script) would break under pipefail: when the script
# finishes and closes stdin, `yes` gets SIGPIPE (exit 141), making the
# pipeline report failure even though the teardown succeeded.
# Process substitution avoids this — only the script's exit code matters.
if "${SCRIPT_DIR}/${script}" < <(yes); then
PASS=$((PASS + 1)) PASS=$((PASS + 1))
else else
log_warn "Phase ${phase_num} teardown had issues (continuing)" log_warn "Phase ${phase_num} teardown had issues (continuing)"