name: Deploy to liderra.ru # Запускается вручную через web-интерфейс GitHub или через `gh workflow run`. # Решает проблему «дев-машина не достучится по SSH до прод-сервера через YC backbone»: # GitHub Actions runner — внешний по отношению к YC, его IP не блокируется тем # фильтром что блокирует мой dev-IP `89.144.17.119`. # # Требуемые secrets (Settings → Secrets and variables → Actions): # LIDERRA_SSH_KEY — содержимое приватного ключа `~/.ssh/liderra_deploy` # (начинается с `-----BEGIN OPENSSH PRIVATE KEY-----`). # Host/user захардкожены — публичная информация, нет смысла в secrets. on: workflow_dispatch: inputs: ref: description: 'Branch/tag/SHA для деплоя (по умолчанию main)' required: true default: 'main' type: string backfill_snapshot: description: 'Запустить snapshot:backfill за сегодня (default yes)' required: false default: true type: boolean jobs: deploy: name: Deploy code + run redeploy.sh runs-on: ubuntu-latest timeout-minutes: 20 concurrency: group: liderra-prod-deploy cancel-in-progress: false env: LIDERRA_HOST: 111.88.246.137 LIDERRA_USER: ubuntu steps: - name: Checkout uses: actions/checkout@v4 with: ref: ${{ github.event.inputs.ref }} - name: Setup Node 22 uses: actions/setup-node@v4 with: node-version: '22' cache: 'npm' cache-dependency-path: app/package-lock.json - name: Install frontend deps # --legacy-peer-deps: Histoire 1.0-beta.1 заявляет peerDep vite ^7, # установлено vite 8 — известный квирк проекта (memory feedback_environment.md #74). working-directory: app run: npm ci --legacy-peer-deps - name: Build frontend working-directory: app run: npm run build - name: Verify build artifacts present run: | test -f app/public/build/manifest.json ls app/public/build/assets/ | head -5 du -sh app/public/build/ - name: Create deploy tarball run: | tar czf /tmp/deploy.tgz \ --exclude='app/.env' \ --exclude='app/.env.example' \ --exclude='app/.env.production' \ --exclude='app/storage' \ --exclude='app/vendor' \ --exclude='app/node_modules' \ --exclude='app/bootstrap/cache' \ app db ls -lh /tmp/deploy.tgz - name: Setup SSH key run: | mkdir -p ~/.ssh echo "${{ secrets.LIDERRA_SSH_KEY }}" > ~/.ssh/liderra_deploy chmod 600 ~/.ssh/liderra_deploy ssh-keyscan -H ${{ env.LIDERRA_HOST }} >> ~/.ssh/known_hosts 2>/dev/null - name: Upload tarball to prod run: | scp -i ~/.ssh/liderra_deploy -o StrictHostKeyChecking=accept-new \ /tmp/deploy.tgz ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }}:/tmp/deploy.tgz - name: Pre-apply partitioned migrations via postgres superuser # Workaround for partitioned-table migrations: # 2026_05_27_120000_create_project_routing_snapshots_table.php has SET ROLE crm_migrator # which fails when pgsql connection = crm_app_user (not a member of crm_migrator), # poisoning the transaction. Established prod pattern (memory: paused_at migration 26.05): # apply schema via sudo -u postgres psql + insert into migrations table. # Idempotent — skips if already applied. run: | ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} 'bash -s' <<'REMOTE' set -euo pipefail MIG_NAME='2026_05_27_120000_create_project_routing_snapshots_table' ALREADY=$(sudo -u postgres psql -d liderra -tAc \ "SELECT 1 FROM migrations WHERE migration = '${MIG_NAME}' LIMIT 1") if [ "${ALREADY}" = "1" ]; then echo "Migration ${MIG_NAME} already in migrations table — skipping." exit 0 fi TABLE_EXISTS=$(sudo -u postgres psql -d liderra -tAc \ "SELECT 1 FROM information_schema.tables WHERE table_name='project_routing_snapshots' LIMIT 1") if [ "${TABLE_EXISTS}" != "1" ]; then echo "Applying CREATE TABLE project_routing_snapshots via postgres superuser..." sudo -u postgres psql -d liderra -v ON_ERROR_STOP=1 <<'PSQL' BEGIN; CREATE TABLE project_routing_snapshots ( snapshot_date DATE NOT NULL, project_id BIGINT NOT NULL, tenant_id BIGINT NOT NULL, daily_limit INT NOT NULL CHECK (daily_limit >= 0), delivery_days_mask INT NOT NULL CHECK (delivery_days_mask BETWEEN 0 AND 127), regions INT[] NOT NULL DEFAULT '{}', signal_type TEXT NOT NULL CHECK (signal_type IN ('call','site','sms')), signal_identifier TEXT, sms_senders JSONB, sms_keyword TEXT, expected_volume INT NOT NULL CHECK (expected_volume >= 0), delivered_count INT NOT NULL DEFAULT 0 CHECK (delivered_count >= 0), created_at TIMESTAMP NOT NULL DEFAULT NOW(), PRIMARY KEY (snapshot_date, project_id), FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE ) PARTITION BY RANGE (snapshot_date); ALTER TABLE project_routing_snapshots OWNER TO crm_migrator; CREATE INDEX project_routing_snapshots_tenant_date_idx ON project_routing_snapshots (tenant_id, snapshot_date); CREATE INDEX project_routing_snapshots_signal_idx ON project_routing_snapshots (snapshot_date, signal_type, lower(signal_identifier)); ALTER TABLE project_routing_snapshots ENABLE ROW LEVEL SECURITY; CREATE POLICY project_routing_snapshots_tenant_isolation ON project_routing_snapshots USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint); GRANT SELECT, INSERT, UPDATE ON project_routing_snapshots TO crm_app_user; GRANT SELECT, INSERT, UPDATE, DELETE ON project_routing_snapshots TO crm_supplier_worker; CREATE TABLE project_routing_snapshots_y2026_m05 PARTITION OF project_routing_snapshots FOR VALUES FROM ('2026-05-01') TO ('2026-06-01'); CREATE TABLE project_routing_snapshots_y2026_m06 PARTITION OF project_routing_snapshots FOR VALUES FROM ('2026-06-01') TO ('2026-07-01'); ALTER TABLE project_routing_snapshots_y2026_m05 OWNER TO crm_migrator; ALTER TABLE project_routing_snapshots_y2026_m06 OWNER TO crm_migrator; INSERT INTO system_settings (key, value, type, description, updated_at) VALUES ('partition_retention_months_project_routing_snapshots', '3', 'int', 'Retention в месяцах для project_routing_snapshots (90 дней)', NOW()) ON CONFLICT (key) DO NOTHING; COMMIT; PSQL else echo "Table project_routing_snapshots already exists but migration not marked — marking only." fi # Mark migration as applied so Laravel migrate skips it. # Laravel's migrations table has no UNIQUE on `migration` column, so # ON CONFLICT doesn't work — use INSERT...SELECT WHERE NOT EXISTS for idempotency. NEXT_BATCH=$(sudo -u postgres psql -d liderra -tAc "SELECT COALESCE(MAX(batch),0)+1 FROM migrations") sudo -u postgres psql -d liderra -c \ "INSERT INTO migrations (migration, batch) SELECT '${MIG_NAME}', ${NEXT_BATCH} WHERE NOT EXISTS (SELECT 1 FROM migrations WHERE migration='${MIG_NAME}');" echo "Marked ${MIG_NAME} as applied (batch ${NEXT_BATCH})" REMOTE - name: Extract + run redeploy.sh on prod run: | ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} 'bash -s' <<'REMOTE' set -euo pipefail TS=$(date -u +%Y%m%d-%H%M%S) echo "=== Backup current app ===" sudo tar czf /home/ubuntu/deploy-backups/app-pre-deploy-${TS}.tgz \ --exclude='storage' --exclude='vendor' --exclude='node_modules' --exclude='public/build' \ -C /var/www/liderra app ls -lh /home/ubuntu/deploy-backups/app-pre-deploy-${TS}.tgz echo "=== Extract overlay ===" cd /var/www/liderra sudo tar xzf /tmp/deploy.tgz sudo chown -R www-data:www-data /var/www/liderra/app /var/www/liderra/db echo "=== redeploy.sh (composer + migrate + optimize + restart) ===" sudo bash /var/www/liderra/redeploy.sh rm -f /tmp/deploy.tgz REMOTE - name: Backfill today's snapshot if: ${{ github.event.inputs.backfill_snapshot != 'false' }} run: | ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} 'bash -s' <<'REMOTE' set -e cd /var/www/liderra/app sudo -u www-data php artisan snapshot:backfill --date=$(date +%Y-%m-%d) || \ echo "WARN: backfill returned non-zero — проверь вручную" REMOTE - name: Smoke tests run: | ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} 'bash -s' <<'REMOTE' set -e cd /var/www/liderra/app echo '=== Migrations status (last 5) ===' sudo -u www-data php artisan migrate:status 2>&1 | tail -5 echo '=== Snapshots count (last 3 dates) ===' sudo -u postgres psql -d liderra -c "SELECT snapshot_date, COUNT(*) AS rows FROM project_routing_snapshots GROUP BY 1 ORDER BY 1 DESC LIMIT 3;" || true echo '=== Service status ===' systemctl is-active nginx php8.3-fpm postgresql liderra-queue echo '=== Internal portal health ===' curl -sf -o /dev/null -w 'https=%{http_code} time=%{time_total}s\n' --max-time 8 https://127.0.0.1/ -k || true REMOTE - name: External portal health (from runner) run: | curl -sf -o /dev/null -w 'external https=%{http_code} time=%{time_total}s\n' \ --max-time 15 https://liderra.ru/ || echo "external health returned non-zero" - name: Cleanup SSH key if: always() run: rm -f ~/.ssh/liderra_deploy