# ADR-090-B: Gitea CI 自動 migration workflow # 建立時間: 2026-04-18 台北時區 # 建立者: ogt + Claude Opus 4.7 (1M) # # 目的: 每次 main 分支有新 migration SQL 檔,自動: # 1. 用 MIGRATION_DATABASE_URL (awoooi_migrator 限權帳號) 連 PG # 2. 只跑「新增」的 migration (比對已執行列表) # 3. 跑後寫 asset_discovery_run + automation_operation_log 記錄 # 4. 失敗自動 rollback (single transaction + ON_ERROR_STOP) # # 觸發: 受控手動觸發;apps/api/migrations/ 有變更時先做 source diff / dry-run / rollback verifier name: run-migration on: # 2026-06-28 Codex: migration 屬高價值 runtime change,且 110 runner 正在壓力事故保護期。 # 不得由 push 自動觸發;恢復前必須完成 runner 搬遷 / 硬限流與 migration dry-run gate。 workflow_dispatch: jobs: migrate: runs-on: awoooi-non110-ubuntu steps: - name: Checkout source from Gitea env: GITEA_SOURCE_URL: http://192.168.0.110:3001/wooo/awoooi.git run: | set -euo pipefail git init . git remote remove origin 2>/dev/null || true git remote add origin "$GITEA_SOURCE_URL" git fetch --no-tags --prune --depth=2 origin "$GITHUB_SHA" git checkout --force --detach FETCH_HEAD - name: Install migration tools run: | set -euo pipefail missing="" for bin in psql jq curl; do if ! command -v "$bin" >/dev/null 2>&1; then missing="$missing $bin" fi done if [ -z "$missing" ]; then exit 0 fi if command -v apt-get >/dev/null 2>&1; then apt-get update -qq apt-get install -y -q postgresql-client jq curl elif command -v apk >/dev/null 2>&1; then apk add --no-cache postgresql-client jq curl else echo "::error::missing required tools:$missing" exit 1 fi - name: Identify new migrations id: diff run: | ALL_NEW_FILES=$(git diff --no-renames --name-only --diff-filter=A HEAD~1 HEAD -- 'apps/api/migrations/*.sql' || true) NEW_FILES=$(echo "$ALL_NEW_FILES" | grep -Ev '(_down|rollback)\.sql$' || true) SKIPPED_ROLLBACK_FILES=$(echo "$ALL_NEW_FILES" | grep -E '(_down|rollback)\.sql$' || true) echo "new_files<> $GITHUB_OUTPUT echo "$NEW_FILES" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT echo "=== New migration files ===" echo "$NEW_FILES" if [ -n "$SKIPPED_ROLLBACK_FILES" ]; then echo "=== Rollback/down migrations skipped by design ===" echo "$SKIPPED_ROLLBACK_FILES" fi - name: Apply new migrations if: steps.diff.outputs.new_files != '' env: DATABASE_URL: ${{ secrets.DATABASE_URL }} MIGRATION_DATABASE_URL: ${{ secrets.MIGRATION_DATABASE_URL }} run: | set -euo pipefail # 2026-06-30 Codex: keep action secret expressions out of run scripts. # MIGRATION_DATABASE_URL 是限權帳號;DATABASE_URL 只在 PostgreSQL # 明確回報「必須是 table owner」時作為受控 fallback。 PGURL="${MIGRATION_DATABASE_URL:-}" OWNER_PGURL="${DATABASE_URL:-}" if [ -z "$PGURL" ]; then echo "::error::MIGRATION_DATABASE_URL secret not set in Gitea" exit 1 fi PGURL_PSQL="${PGURL/postgresql+asyncpg:\/\//postgresql:\/\/}" OWNER_PGURL_PSQL="${OWNER_PGURL/postgresql+asyncpg:\/\//postgresql:\/\/}" apply_migration() { local url="$1" local file="$2" psql "$url" \ -v ON_ERROR_STOP=1 \ --single-transaction \ -f "$file" } # 套用每個新檔 (single transaction per file) echo "${{ steps.diff.outputs.new_files }}" | while IFS= read -r file; do [ -z "$file" ] && continue echo "=== Applying: $file ===" migration_err="$(mktemp)" if ! apply_migration "$PGURL_PSQL" "$file" 2>"$migration_err"; then if grep -Eq "(must be owner of table|permission denied for table)" "$migration_err"; then if [ -z "$OWNER_PGURL_PSQL" ]; then cat "$migration_err" >&2 echo "::error::migration requires table owner but DATABASE_URL secret is not set" exit 1 fi echo "::warning::migration requires table owner; retrying with owner connection" apply_migration "$OWNER_PGURL_PSQL" "$file" else cat "$migration_err" >&2 exit 1 fi fi rm -f "$migration_err" echo "=== OK: $file ===" done - name: Seed asset_discovery_run (audit) if: steps.diff.outputs.new_files != '' env: DATABASE_URL: ${{ secrets.DATABASE_URL }} MIGRATION_DATABASE_URL: ${{ secrets.MIGRATION_DATABASE_URL }} run: | set -euo pipefail PGURL="${MIGRATION_DATABASE_URL:-}" OWNER_PGURL="${DATABASE_URL:-}" if [ -z "$PGURL" ]; then echo "::error::MIGRATION_DATABASE_URL secret not set in Gitea" exit 1 fi PGURL_PSQL="${PGURL/postgresql+asyncpg:\/\//postgresql:\/\/}" OWNER_PGURL_PSQL="${OWNER_PGURL/postgresql+asyncpg:\/\//postgresql:\/\/}" FILES_JSON=$(echo "${{ steps.diff.outputs.new_files }}" | jq -Rn '[inputs | select(length > 0)]') SUMMARY_JSON=$(jq -cn \ --arg commit_sha "${{ github.sha }}" \ --argjson files "$FILES_JSON" \ '{type: "ci_migration", commit_sha: $commit_sha, files: $files}') SUMMARY_JSON_SQL=${SUMMARY_JSON//\'/\'\'} seed_audit() { local url="$1" psql "$url" -v ON_ERROR_STOP=1 <"$audit_err"; then if grep -q "permission denied for table asset_discovery_run" "$audit_err"; then if [ -z "$OWNER_PGURL_PSQL" ]; then cat "$audit_err" >&2 echo "::error::audit requires table insert privilege but DATABASE_URL secret is not set" exit 1 fi echo "::warning::audit requires owner connection; retrying with owner connection" seed_audit "$OWNER_PGURL_PSQL" else cat "$audit_err" >&2 exit 1 fi fi rm -f "$audit_err" - name: Notify Telegram (if configured) if: always() env: TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} run: | TG_TOKEN="${TELEGRAM_BOT_TOKEN:-}" STATUS="${{ job.status }}" CICD_STATUS="success" [ "$STATUS" != "success" ] && CICD_STATUS="failed" if AWOOI_CICD_STATUS="${CICD_STATUS}" \ AWOOI_CICD_STAGE=run-migration \ AWOOI_CICD_JOB_NAME="Migration CI" \ AWOOI_CICD_COMMIT_SHA="${{ github.sha }}" \ AWOOI_CICD_SUMMARY="Migration CI: ${STATUS}" \ scripts/ci/notify-awoooi-cicd.sh; then echo "Migration notification mirrored through AWOOI API" exit 0 fi echo "AWOOI API notify failed; direct Telegram fallback disabled to preserve AwoooP receipt chain"