#!/usr/bin/env bash # # Gitea 1.25.4 to Forgejo Migration Script # # This script copies data from Gitea to Forgejo and rolls back the database # schema from version 322/323 to 304, allowing Forgejo to run its own migrations. # # IMPORTANT: This script NEVER modifies source data. All operations work on copies, # so the original Gitea instance can be restarted as a rollback. # # Usage: # 1. Copy migrate-gitea-to-forgejo.env.example to migrate-gitea-to-forgejo.env # 2. Edit the .env file with your paths # 3. Stop Gitea # 4. Run: ./scripts/migrate-gitea-to-forgejo.sh # 5. Update NixOS config and deploy # set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ENV_FILE="${SCRIPT_DIR}/migrate-gitea-to-forgejo.env" # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color log_info() { echo -e "${BLUE}[INFO]${NC} $*"; } log_success() { echo -e "${GREEN}[OK]${NC} $*"; } log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } # Load environment file if [[ ! -f "$ENV_FILE" ]]; then log_error "Environment file not found: $ENV_FILE" log_info "Copy migrate-gitea-to-forgejo.env.example to migrate-gitea-to-forgejo.env and configure it." exit 1 fi # shellcheck source=/dev/null source "$ENV_FILE" # Verify required variables : "${SOURCE_DATA:?SOURCE_DATA must be set in $ENV_FILE}" : "${TARGET_DATA:?TARGET_DATA must be set in $ENV_FILE}" : "${TARGET_USER:?TARGET_USER must be set in $ENV_FILE}" : "${TARGET_GROUP:?TARGET_GROUP must be set in $ENV_FILE}" echo "========================================" echo "Gitea to Forgejo Migration Script" echo "========================================" echo "" echo "Source: $SOURCE_DATA (read-only)" echo "Target: $TARGET_DATA" echo "User: $TARGET_USER:$TARGET_GROUP" echo "" # ============================================ # PHASE 1: Pre-flight Checks # ============================================ log_info "Phase 1: Pre-flight checks..." # Check if running as root (needed for chown) if [[ $EUID -ne 0 ]]; then log_error "This script must be run as root (for chown operations)" exit 1 fi # Verify SQLite version >= 3.35 (required for DROP COLUMN) if ! command -v sqlite3 &> /dev/null; then log_error "sqlite3 command not found. Please install SQLite." exit 1 fi sqlite_version=$(sqlite3 --version | cut -d' ' -f1) sqlite_major=$(echo "$sqlite_version" | cut -d'.' -f1) sqlite_minor=$(echo "$sqlite_version" | cut -d'.' -f2) if [[ "$sqlite_major" -lt 3 ]] || { [[ "$sqlite_major" -eq 3 ]] && [[ "$sqlite_minor" -lt 35 ]]; }; then log_error "SQLite $sqlite_version is too old. Need 3.35+ for DROP COLUMN support." exit 1 fi log_success "SQLite version: $sqlite_version" # Verify rsync is available (needed for incremental copying) if ! command -v rsync &> /dev/null; then log_error "rsync command not found. Please install rsync." exit 1 fi log_success "rsync available" # Verify source exists if [[ ! -d "$SOURCE_DATA" ]]; then log_error "Source directory not found: $SOURCE_DATA" exit 1 fi log_success "Source directory exists" # Find source database (could be gitea.db or forgejo.db depending on setup) SOURCE_DB="" if [[ -f "$SOURCE_DATA/data/gitea.db" ]]; then SOURCE_DB="$SOURCE_DATA/data/gitea.db" elif [[ -f "$SOURCE_DATA/gitea.db" ]]; then SOURCE_DB="$SOURCE_DATA/gitea.db" else log_error "Source database not found in $SOURCE_DATA/data/ or $SOURCE_DATA/" exit 1 fi log_success "Source database found: $SOURCE_DB" # Verify source app.ini exists SOURCE_INI="" if [[ -f "$SOURCE_DATA/custom/conf/app.ini" ]]; then SOURCE_INI="$SOURCE_DATA/custom/conf/app.ini" elif [[ -f "$SOURCE_DATA/conf/app.ini" ]]; then SOURCE_INI="$SOURCE_DATA/conf/app.ini" else log_error "Source app.ini not found in $SOURCE_DATA/custom/conf/ or $SOURCE_DATA/conf/" exit 1 fi log_success "Source app.ini found: $SOURCE_INI" # Check disk space (need 1.2x source size) source_size=$(du -sb "$SOURCE_DATA" | cut -f1) required=$((source_size * 12 / 10)) target_parent=$(dirname "$TARGET_DATA") mkdir -p "$target_parent" available=$(df --output=avail -B1 "$target_parent" | tail -1) if [[ "$available" -lt "$required" ]]; then log_error "Not enough disk space. Need $(numfmt --to=iec $required), have $(numfmt --to=iec $available)" exit 1 fi log_success "Disk space OK: need $(numfmt --to=iec $required), have $(numfmt --to=iec $available)" # Warn if target exists (rsync will sync incrementally) if [[ -d "$TARGET_DATA" ]]; then log_warn "Target directory exists: $TARGET_DATA" log_info "rsync will perform incremental sync (only copying changed files)" read -p "Continue with incremental sync? (y/N) " -n 1 -r echo if [[ ! $REPLY =~ ^[Yy]$ ]]; then log_error "Aborted by user" exit 1 fi fi # ============================================ # PHASE 2: Copy All Data # ============================================ log_info "Phase 2: Copying data..." mkdir -p "$TARGET_DATA/data" mkdir -p "$TARGET_DATA/custom/conf" # Copy database log_info "Copying database..." rsync -a --info=progress2 "$SOURCE_DB" "$TARGET_DATA/data/forgejo.db" log_success "Database copied" # Copy all data directories (preserve attributes, sync incrementally) for dir in repositories avatars attachments packages lfs custom queues indexers; do if [[ -d "$SOURCE_DATA/$dir" ]]; then log_info "Syncing $dir..." mkdir -p "$TARGET_DATA/$dir" rsync -a --delete --info=progress2 "$SOURCE_DATA/$dir/" "$TARGET_DATA/$dir/" log_success "Synced $dir" fi done # Also check data/ subdirectory structure for dir in repositories avatars attachments packages lfs; do if [[ -d "$SOURCE_DATA/data/$dir" ]]; then log_info "Syncing data/$dir..." mkdir -p "$TARGET_DATA/data/$dir" rsync -a --delete --info=progress2 "$SOURCE_DATA/data/$dir/" "$TARGET_DATA/data/$dir/" log_success "Synced data/$dir" fi done # ============================================ # PHASE 3: Database Schema Rollback # ============================================ log_info "Phase 3: Rolling back database schema..." TARGET_DB="$TARGET_DATA/data/forgejo.db" # Show current schema version current_version=$(sqlite3 "$TARGET_DB" "SELECT version FROM version WHERE id=1;") log_info "Current Gitea schema version: $current_version" log_info "Target version: 304" # Create rollback SQL script ROLLBACK_SQL=$(mktemp) cat > "$ROLLBACK_SQL" << 'ROLLBACK_EOF' -- ================================================================ -- Gitea 1.25.4 to Forgejo Rollback Script -- Rolls back migrations 305-322 to allow Forgejo to migrate cleanly -- ================================================================ -- Enable foreign keys check after we're done PRAGMA foreign_keys = OFF; -- ============================================ -- MIGRATION 305: Drop repo_license table -- ============================================ DROP TABLE IF EXISTS repo_license; -- ============================================ -- MIGRATION 308 & 317: Drop action table indices -- (These are the main conflict source) -- ============================================ DROP INDEX IF EXISTS IDX_action_r_u_d; DROP INDEX IF EXISTS IDX_action_au_r_c_u_d; DROP INDEX IF EXISTS IDX_action_c_u_d; DROP INDEX IF EXISTS IDX_action_c_u; DROP INDEX IF EXISTS IDX_action_au_c_u; -- Alternative naming conventions DROP INDEX IF EXISTS UQE_action_r_u_d; DROP INDEX IF EXISTS UQE_action_au_r_c_u_d; DROP INDEX IF EXISTS UQE_action_c_u_d; DROP INDEX IF EXISTS UQE_action_c_u; DROP INDEX IF EXISTS UQE_action_au_c_u; -- ============================================ -- MIGRATION 309: Drop notification table indices -- ============================================ DROP INDEX IF EXISTS IDX_notification_u_s_uu; DROP INDEX IF EXISTS IDX_notification_user_id; DROP INDEX IF EXISTS IDX_notification_repo_id; DROP INDEX IF EXISTS IDX_notification_status; DROP INDEX IF EXISTS IDX_notification_source; DROP INDEX IF EXISTS IDX_notification_issue_id; DROP INDEX IF EXISTS IDX_notification_commit_id; DROP INDEX IF EXISTS IDX_notification_updated_by; DROP INDEX IF EXISTS UQE_notification_u_s_uu; -- ============================================ -- MIGRATION 313: Drop issue_pin table -- (pin_order restoration handled separately) -- ============================================ DROP TABLE IF EXISTS issue_pin; -- ============================================ -- MIGRATION 306: Drop protected_branch column -- ============================================ ALTER TABLE protected_branch DROP COLUMN IF EXISTS block_admin_merge_override; -- ============================================ -- MIGRATION 310: Drop protected_branch column -- ============================================ ALTER TABLE protected_branch DROP COLUMN IF EXISTS priority; -- ============================================ -- MIGRATION 311: Drop issue column -- ============================================ ALTER TABLE issue DROP COLUMN IF EXISTS time_estimate; -- ============================================ -- MIGRATION 312: Drop pull_auto_merge column -- ============================================ ALTER TABLE pull_auto_merge DROP COLUMN IF EXISTS delete_branch_after_merge; -- ============================================ -- MIGRATION 315: Drop action_runner column -- ============================================ ALTER TABLE action_runner DROP COLUMN IF EXISTS ephemeral; -- ============================================ -- MIGRATION 316: Drop description columns -- ============================================ ALTER TABLE secret DROP COLUMN IF EXISTS description; ALTER TABLE action_variable DROP COLUMN IF EXISTS description; -- ============================================ -- MIGRATION 318: Drop repo_unit column -- ============================================ ALTER TABLE repo_unit DROP COLUMN IF EXISTS anonymous_access_mode; -- ============================================ -- MIGRATION 319: Drop label column -- ============================================ ALTER TABLE label DROP COLUMN IF EXISTS exclusive_order; -- ============================================ -- MIGRATION 320: Drop login_source column -- ============================================ ALTER TABLE login_source DROP COLUMN IF EXISTS two_factor_policy; -- ============================================ -- SET VERSION TO 304 -- ============================================ UPDATE version SET version = 304 WHERE id = 1; PRAGMA foreign_keys = ON; ROLLBACK_EOF log_info "Executing schema rollback..." # SQLite doesn't support DROP COLUMN IF EXISTS, so we need to handle errors gracefully # Execute each ALTER TABLE separately to handle missing columns sqlite3 "$TARGET_DB" << 'SQL_PART1' PRAGMA foreign_keys = OFF; -- Drop tables DROP TABLE IF EXISTS repo_license; DROP TABLE IF EXISTS issue_pin; -- Drop indices (these always work, even if index doesn't exist) DROP INDEX IF EXISTS IDX_action_r_u_d; DROP INDEX IF EXISTS IDX_action_au_r_c_u_d; DROP INDEX IF EXISTS IDX_action_c_u_d; DROP INDEX IF EXISTS IDX_action_c_u; DROP INDEX IF EXISTS IDX_action_au_c_u; DROP INDEX IF EXISTS UQE_action_r_u_d; DROP INDEX IF EXISTS UQE_action_au_r_c_u_d; DROP INDEX IF EXISTS UQE_action_c_u_d; DROP INDEX IF EXISTS UQE_action_c_u; DROP INDEX IF EXISTS UQE_action_au_c_u; DROP INDEX IF EXISTS IDX_notification_u_s_uu; DROP INDEX IF EXISTS IDX_notification_user_id; DROP INDEX IF EXISTS IDX_notification_repo_id; DROP INDEX IF EXISTS IDX_notification_status; DROP INDEX IF EXISTS IDX_notification_source; DROP INDEX IF EXISTS IDX_notification_issue_id; DROP INDEX IF EXISTS IDX_notification_commit_id; DROP INDEX IF EXISTS IDX_notification_updated_by; DROP INDEX IF EXISTS UQE_notification_u_s_uu; SQL_PART1 # Function to drop column if it exists drop_column_if_exists() { local table="$1" local column="$2" local exists exists=$(sqlite3 "$TARGET_DB" "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name='$column';") if [[ "$exists" -gt 0 ]]; then log_info "Dropping column $table.$column..." sqlite3 "$TARGET_DB" "ALTER TABLE $table DROP COLUMN $column;" log_success "Dropped $table.$column" else log_info "Column $table.$column does not exist, skipping" fi } # Drop columns added in migrations 306-320 drop_column_if_exists "protected_branch" "block_admin_merge_override" drop_column_if_exists "protected_branch" "priority" drop_column_if_exists "issue" "time_estimate" drop_column_if_exists "pull_auto_merge" "delete_branch_after_merge" drop_column_if_exists "action_runner" "ephemeral" drop_column_if_exists "secret" "description" drop_column_if_exists "action_variable" "description" drop_column_if_exists "repo_unit" "anonymous_access_mode" drop_column_if_exists "label" "exclusive_order" drop_column_if_exists "login_source" "two_factor_policy" # Check if pin_order column needs to be added back to issue table (migration 313 removed it) log_info "Checking if pin_order column needs to be restored to issue table..." has_pin_order=$(sqlite3 "$TARGET_DB" "SELECT COUNT(*) FROM pragma_table_info('issue') WHERE name='pin_order';") if [[ "$has_pin_order" -eq 0 ]]; then log_info "Adding pin_order column back to issue table..." sqlite3 "$TARGET_DB" "ALTER TABLE issue ADD COLUMN pin_order INTEGER DEFAULT 0;" log_success "Added pin_order column to issue table" else log_info "pin_order column already exists in issue table" fi # Set version to 304 (allows Forgejo to run migration 305 which converts two_factor.secret from TEXT to BLOB) sqlite3 "$TARGET_DB" "UPDATE version SET version = 304 WHERE id = 1;" log_success "Database version set to 304" rm -f "$ROLLBACK_SQL" # ============================================ # PHASE 4: Clear Regeneratable Data # ============================================ log_info "Phase 4: Clearing regeneratable data..." # Remove indexers (will be rebuilt on first start) if [[ -d "$TARGET_DATA/indexers" ]]; then rm -rf "$TARGET_DATA/indexers" log_success "Removed indexers (will be rebuilt)" fi # Remove queues (will be recreated) if [[ -d "$TARGET_DATA/queues" ]]; then rm -rf "$TARGET_DATA/queues" log_success "Removed queues (will be recreated)" fi # ============================================ # PHASE 5: Update Configuration # ============================================ log_info "Phase 5: Updating configuration..." # Copy app.ini rsync -a --info=progress2 "$SOURCE_INI" "$TARGET_DATA/custom/conf/app.ini" log_success "Copied app.ini" # Update paths from gitea to forgejo sed -i 's|/var/lib/gitea|/var/lib/forgejo|g' "$TARGET_DATA/custom/conf/app.ini" log_success "Updated paths in app.ini" # Check if WAL mode is already configured if ! grep -q "SQLITE_JOURNAL_MODE" "$TARGET_DATA/custom/conf/app.ini"; then # Add WAL mode after [database] section sed -i '/^\[database\]/a SQLITE_JOURNAL_MODE = WAL' "$TARGET_DATA/custom/conf/app.ini" log_success "Enabled SQLite WAL mode" else log_info "SQLite journal mode already configured" fi # ============================================ # PHASE 6: Set Permissions # ============================================ log_info "Phase 6: Setting permissions..." chown -R "$TARGET_USER:$TARGET_GROUP" "$TARGET_DATA" chmod 750 "$TARGET_DATA" chmod 640 "$TARGET_DATA/data/forgejo.db" log_success "Permissions set for $TARGET_USER:$TARGET_GROUP" # ============================================ # PHASE 7: Verify Database Integrity # ============================================ log_info "Phase 7: Verifying database integrity..." sqlite3 "$TARGET_DB" << 'VERIFY_SQL' .headers off .mode list -- Verify version was set correctly SELECT 'Version: ' || CASE WHEN version = 304 THEN 'PASS (304)' ELSE 'FAIL (version=' || version || ')' END FROM version WHERE id = 1; -- Check critical tables exist SELECT 'Users: ' || CASE WHEN COUNT(*) > 0 THEN 'PASS (' || COUNT(*) || ' users)' ELSE 'WARN (empty)' END FROM user; SELECT 'Repositories: ' || CASE WHEN COUNT(*) > 0 THEN 'PASS (' || COUNT(*) || ' repos)' ELSE 'WARN (empty)' END FROM repository; SELECT 'Secrets: PASS (' || COUNT(*) || ' secrets)' FROM secret; SELECT 'Runners: PASS (' || COUNT(*) || ' runners)' FROM action_runner; SELECT 'Variables: PASS (' || COUNT(*) || ' variables)' FROM action_variable; VERIFY_SQL # Verify dropped tables are gone repo_license_exists=$(sqlite3 "$TARGET_DB" "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='repo_license';") issue_pin_exists=$(sqlite3 "$TARGET_DB" "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='issue_pin';") if [[ "$repo_license_exists" -eq 0 ]]; then log_success "repo_license table: DROPPED" else log_warn "repo_license table: STILL EXISTS" fi if [[ "$issue_pin_exists" -eq 0 ]]; then log_success "issue_pin table: DROPPED" else log_warn "issue_pin table: STILL EXISTS" fi # ============================================ # PHASE 8: Print Next Steps # ============================================ echo "" echo "========================================" echo -e "${GREEN}Migration complete!${NC}" echo "========================================" echo "" echo "Data copied to: $TARGET_DATA" echo "Database schema rolled back to version 304" echo "" echo "Next steps:" echo "" echo "1. Update NixOS configuration:" echo " - Create hosts/fw/modules/forgejo.nix based on gitea.nix" echo " - Change services.gitea to services.forgejo" echo " - Update bind mount paths in container config" echo " - Update runner configuration for Forgejo" echo "" echo "2. Deploy:" echo " nixos-rebuild switch" echo "" echo "3. Monitor first startup:" echo " journalctl -u container@git -f" echo "" echo "4. Verify functionality:" echo " [ ] Forgejo starts without errors" echo " [ ] Login via OpenID (auth.cloonar.com)" echo " [ ] All repositories visible" echo " [ ] Can push/pull to repositories" echo " [ ] CI/CD runners connect" echo " [ ] Workflow with secrets runs" echo " [ ] Packages registry accessible" echo "" echo -e "${YELLOW}ROLLBACK:${NC} If anything fails, original Gitea data is untouched." echo "Just revert NixOS config and restart Gitea container." echo "========================================"