# ============================================================================= # Deploy Workflow — Automated Deployment to VPS # ============================================================================= # # PURPOSE: # Deploy your application to a VPS after a successful push to the default # branch (main). Supports two deployment modes: # # (A) local-runner — The deploy job runs directly on a self-hosted act_runner # installed ON the VPS. No SSH needed. The runner is selected by a label # (DEPLOY_RUNNER_LABEL). This is the recommended mode. # # (B) ssh — The deploy job runs on any runner and SSHs into the VPS to # execute commands remotely. Requires SSH secrets. Use as fallback when # you can't install a runner on the target VPS. # # SAFE BY DEFAULT: # ENABLE_DEPLOY=false in .ci/config.env. Deploy never runs unless you # explicitly enable it. It also never runs on pull_request events. # # DEPLOY STRATEGIES: # compose — docker compose pull && docker compose up -d # systemd — systemctl restart # script — run a custom deploy script # # TRIGGERS: # - push to DEFAULT_BRANCH (main) → deploy if enabled # - tag v* (only if DEPLOY_ON_TAG=true) → deploy if enabled # - pull_request → NEVER (not in trigger list) # # REQUIRED SECRETS (ssh mode only): # DEPLOY_SSH_KEY — private SSH key (ed25519 or RSA) # DEPLOY_HOST — VPS hostname or IP # DEPLOY_USER — SSH username on VPS # DEPLOY_KNOWN_HOSTS — (optional) known_hosts entry for the VPS # # For local-runner mode: NO secrets needed. The runner already has local # access. Just ensure the runner is registered with the correct label. # # See docs/DEPLOY.md for full setup instructions. # ============================================================================= name: Deploy # --------------------------------------------------------------------------- # TRIGGERS # --------------------------------------------------------------------------- # Only push events — never pull_request. # Branch filter is further enforced in the "branch guard" step below, # because config.env may specify a different DEFAULT_BRANCH. on: push: branches: - main tags: - "v*" # ============================================================================= # JOB: deploy-local-runner # ============================================================================= # Runs directly on the VPS via a labeled self-hosted act_runner. # This job is skipped if DEPLOY_MODE != local-runner. # --------------------------------------------------------------------------- jobs: deploy-local-runner: # ------------------------------------------------------------------------- # Runner selection: # The 'runs-on' value is read from config at workflow parse time, but # Gitea Actions does not support dynamic runs-on from env vars. # We use the default label here; if you changed DEPLOY_RUNNER_LABEL in # config.env, you MUST also update this runs-on value to match. # # HOW TO CHANGE: replace 'deploy-ovh' below with your label. # ------------------------------------------------------------------------- runs-on: deploy-ovh steps: # ----------------------------------------------------------------------- # Step 1: Load configuration # ----------------------------------------------------------------------- - name: Checkout (for config only) uses: actions/checkout@v4 with: fetch-depth: 1 - name: Load config id: config run: | # Source config.env if [ -f .ci/config.env ]; then set -a source .ci/config.env set +a echo "Config loaded." else echo "WARNING: .ci/config.env not found, using defaults." fi # Export all deploy-related vars with safe defaults echo "ENABLE_DEPLOY=${ENABLE_DEPLOY:-false}" >> "$GITHUB_ENV" echo "DEPLOY_MODE=${DEPLOY_MODE:-local-runner}" >> "$GITHUB_ENV" echo "DEPLOY_WORKDIR=${DEPLOY_WORKDIR:-/opt/app}" >> "$GITHUB_ENV" echo "DEPLOY_STRATEGY=${DEPLOY_STRATEGY:-compose}" >> "$GITHUB_ENV" echo "DEPLOY_COMPOSE_FILE=${DEPLOY_COMPOSE_FILE:-docker-compose.yml}" >> "$GITHUB_ENV" echo "DEPLOY_SYSTEMD_SERVICE=${DEPLOY_SYSTEMD_SERVICE:-}" >> "$GITHUB_ENV" echo "DEPLOY_SCRIPT=${DEPLOY_SCRIPT:-scripts/deploy.sh}" >> "$GITHUB_ENV" echo "DEPLOY_ON_TAG=${DEPLOY_ON_TAG:-false}" >> "$GITHUB_ENV" echo "DEFAULT_BRANCH=${DEFAULT_BRANCH:-main}" >> "$GITHUB_ENV" # ----------------------------------------------------------------------- # Step 2: Gate checks — abort early if deploy should not run # ----------------------------------------------------------------------- - name: Check if deploy is enabled run: | if [ "$ENABLE_DEPLOY" != "true" ]; then echo "=========================================" echo " Deploy is DISABLED (ENABLE_DEPLOY=$ENABLE_DEPLOY)" echo " To enable: set ENABLE_DEPLOY=true in .ci/config.env" echo "=========================================" exit 0 fi # Ensure this job is for the correct mode if [ "$DEPLOY_MODE" != "local-runner" ]; then echo "DEPLOY_MODE=$DEPLOY_MODE (not local-runner). This job is a no-op." echo "The ssh job will handle deployment instead." exit 0 fi echo "DEPLOY_ACTIVE=true" >> "$GITHUB_ENV" # ----------------------------------------------------------------------- # Step 3: Branch guard # Only deploy from DEFAULT_BRANCH. For tags, check DEPLOY_ON_TAG. # This is a SAFETY net — even though 'on.push.branches' is set above, # DEFAULT_BRANCH might differ from 'main'. # ----------------------------------------------------------------------- - name: Branch guard if: env.DEPLOY_ACTIVE == 'true' run: | REF="${GITHUB_REF:-}" # Tag push? if echo "$REF" | grep -q '^refs/tags/v'; then if [ "$DEPLOY_ON_TAG" != "true" ]; then echo "Tag push detected but DEPLOY_ON_TAG=$DEPLOY_ON_TAG. Skipping." echo "DEPLOY_ACTIVE=false" >> "$GITHUB_ENV" exit 0 fi echo "Deploying on tag: $REF" exit 0 fi # Branch push — verify it's DEFAULT_BRANCH BRANCH="$(echo "$REF" | sed 's|refs/heads/||')" if [ "$BRANCH" != "$DEFAULT_BRANCH" ]; then echo "Branch '$BRANCH' is not DEFAULT_BRANCH '$DEFAULT_BRANCH'. Skipping." echo "DEPLOY_ACTIVE=false" >> "$GITHUB_ENV" exit 0 fi echo "Deploying on branch: $BRANCH" # ----------------------------------------------------------------------- # Step 4: Execute deploy strategy (LOCAL — runs on the VPS itself) # ----------------------------------------------------------------------- - name: "Deploy: compose" if: env.DEPLOY_ACTIVE == 'true' && env.DEPLOY_STRATEGY == 'compose' run: | echo ">>> Deploy strategy: compose" echo ">>> Working directory: $DEPLOY_WORKDIR" echo ">>> Compose file: $DEPLOY_COMPOSE_FILE" cd "$DEPLOY_WORKDIR" || { echo "ERROR: Cannot cd to $DEPLOY_WORKDIR"; exit 1; } echo ">>> docker compose -f $DEPLOY_COMPOSE_FILE pull" docker compose -f "$DEPLOY_COMPOSE_FILE" pull echo ">>> docker compose -f $DEPLOY_COMPOSE_FILE up -d" docker compose -f "$DEPLOY_COMPOSE_FILE" up -d echo "Deploy (compose) complete." - name: "Deploy: systemd" if: env.DEPLOY_ACTIVE == 'true' && env.DEPLOY_STRATEGY == 'systemd' run: | echo ">>> Deploy strategy: systemd" if [ -z "$DEPLOY_SYSTEMD_SERVICE" ]; then echo "ERROR: DEPLOY_SYSTEMD_SERVICE is not set." echo "Set it in .ci/config.env for strategy=systemd." exit 1 fi echo ">>> systemctl restart $DEPLOY_SYSTEMD_SERVICE" sudo systemctl restart "$DEPLOY_SYSTEMD_SERVICE" echo ">>> systemctl status $DEPLOY_SYSTEMD_SERVICE" sudo systemctl status "$DEPLOY_SYSTEMD_SERVICE" --no-pager echo "Deploy (systemd) complete." - name: "Deploy: script" if: env.DEPLOY_ACTIVE == 'true' && env.DEPLOY_STRATEGY == 'script' run: | echo ">>> Deploy strategy: script" echo ">>> Script: $DEPLOY_SCRIPT" echo ">>> Workdir arg: $DEPLOY_WORKDIR" if [ ! -f "$DEPLOY_SCRIPT" ]; then echo "ERROR: Deploy script not found: $DEPLOY_SCRIPT" exit 1 fi chmod +x "$DEPLOY_SCRIPT" "./$DEPLOY_SCRIPT" "$DEPLOY_WORKDIR" echo "Deploy (script) complete." # ----------------------------------------------------------------------- # Step 5: Summary # ----------------------------------------------------------------------- - name: Deploy summary if: always() run: | echo "==============================" echo " Deploy (local-runner)" echo " Enabled: ${ENABLE_DEPLOY:-false}" echo " Mode: ${DEPLOY_MODE:-local-runner}" echo " Strategy: ${DEPLOY_STRATEGY:-compose}" echo " Workdir: ${DEPLOY_WORKDIR:-/opt/app}" echo " Active: ${DEPLOY_ACTIVE:-false}" echo "==============================" # =========================================================================== # JOB: deploy-ssh # =========================================================================== # Runs on a normal runner and SSHs into the VPS to deploy. # This job is skipped if DEPLOY_MODE != ssh. # --------------------------------------------------------------------------- deploy-ssh: runs-on: ubuntu-latest steps: # ----------------------------------------------------------------------- # Step 1: Load configuration # ----------------------------------------------------------------------- - name: Checkout (for config + scripts) uses: actions/checkout@v4 with: fetch-depth: 1 - name: Load config run: | if [ -f .ci/config.env ]; then set -a source .ci/config.env set +a echo "Config loaded." else echo "WARNING: .ci/config.env not found, using defaults." fi echo "ENABLE_DEPLOY=${ENABLE_DEPLOY:-false}" >> "$GITHUB_ENV" echo "DEPLOY_MODE=${DEPLOY_MODE:-local-runner}" >> "$GITHUB_ENV" echo "DEPLOY_WORKDIR=${DEPLOY_WORKDIR:-/opt/app}" >> "$GITHUB_ENV" echo "DEPLOY_STRATEGY=${DEPLOY_STRATEGY:-compose}" >> "$GITHUB_ENV" echo "DEPLOY_COMPOSE_FILE=${DEPLOY_COMPOSE_FILE:-docker-compose.yml}" >> "$GITHUB_ENV" echo "DEPLOY_SYSTEMD_SERVICE=${DEPLOY_SYSTEMD_SERVICE:-}" >> "$GITHUB_ENV" echo "DEPLOY_SCRIPT=${DEPLOY_SCRIPT:-scripts/deploy.sh}" >> "$GITHUB_ENV" echo "DEPLOY_ON_TAG=${DEPLOY_ON_TAG:-false}" >> "$GITHUB_ENV" echo "DEFAULT_BRANCH=${DEFAULT_BRANCH:-main}" >> "$GITHUB_ENV" # ----------------------------------------------------------------------- # Step 2: Gate checks # ----------------------------------------------------------------------- - name: Check if deploy is enabled run: | if [ "$ENABLE_DEPLOY" != "true" ]; then echo "=========================================" echo " Deploy is DISABLED (ENABLE_DEPLOY=$ENABLE_DEPLOY)" echo " To enable: set ENABLE_DEPLOY=true in .ci/config.env" echo "=========================================" exit 0 fi if [ "$DEPLOY_MODE" != "ssh" ]; then echo "DEPLOY_MODE=$DEPLOY_MODE (not ssh). This job is a no-op." echo "The local-runner job will handle deployment instead." exit 0 fi echo "DEPLOY_ACTIVE=true" >> "$GITHUB_ENV" # ----------------------------------------------------------------------- # Step 3: Branch guard (same logic as local-runner) # ----------------------------------------------------------------------- - name: Branch guard if: env.DEPLOY_ACTIVE == 'true' run: | REF="${GITHUB_REF:-}" if echo "$REF" | grep -q '^refs/tags/v'; then if [ "$DEPLOY_ON_TAG" != "true" ]; then echo "Tag push detected but DEPLOY_ON_TAG=$DEPLOY_ON_TAG. Skipping." echo "DEPLOY_ACTIVE=false" >> "$GITHUB_ENV" exit 0 fi echo "Deploying on tag: $REF" exit 0 fi BRANCH="$(echo "$REF" | sed 's|refs/heads/||')" if [ "$BRANCH" != "$DEFAULT_BRANCH" ]; then echo "Branch '$BRANCH' is not DEFAULT_BRANCH '$DEFAULT_BRANCH'. Skipping." echo "DEPLOY_ACTIVE=false" >> "$GITHUB_ENV" exit 0 fi echo "Deploying on branch: $BRANCH" # ----------------------------------------------------------------------- # Step 4: Set up SSH # # Secrets required: # DEPLOY_SSH_KEY — private key (ed25519 recommended) # DEPLOY_HOST — VPS IP or hostname # DEPLOY_USER — SSH username # DEPLOY_KNOWN_HOSTS — (optional) output of ssh-keyscan for the host # # If DEPLOY_KNOWN_HOSTS is not set, StrictHostKeyChecking is disabled. # This is less secure but avoids first-connect failures in CI. # For production, always set DEPLOY_KNOWN_HOSTS. # ----------------------------------------------------------------------- - name: Set up SSH if: env.DEPLOY_ACTIVE == 'true' run: | mkdir -p ~/.ssh chmod 700 ~/.ssh # Write private key (never echo it) echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key chmod 600 ~/.ssh/deploy_key # Known hosts — if provided, use it; otherwise disable strict checking KNOWN_HOSTS="${{ secrets.DEPLOY_KNOWN_HOSTS }}" if [ -n "$KNOWN_HOSTS" ]; then echo "$KNOWN_HOSTS" > ~/.ssh/known_hosts chmod 644 ~/.ssh/known_hosts echo "known_hosts configured from secret." else echo "WARNING: DEPLOY_KNOWN_HOSTS not set. Disabling StrictHostKeyChecking." echo "For production, set DEPLOY_KNOWN_HOSTS (run: ssh-keyscan your-host)" { echo "Host *" echo " StrictHostKeyChecking no" echo " UserKnownHostsFile /dev/null" } > ~/.ssh/config chmod 600 ~/.ssh/config fi # Build SSH command for reuse SSH_CMD="ssh -i ~/.ssh/deploy_key ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" echo "SSH_CMD=${SSH_CMD}" >> "$GITHUB_ENV" # Verify connectivity echo "Testing SSH connection..." $SSH_CMD "echo 'SSH connection successful.'" || { echo "ERROR: SSH connection failed." exit 1 } # ----------------------------------------------------------------------- # Step 5: Execute deploy strategy (REMOTE — via SSH) # ----------------------------------------------------------------------- - name: "Deploy via SSH: compose" if: env.DEPLOY_ACTIVE == 'true' && env.DEPLOY_STRATEGY == 'compose' run: | echo ">>> Deploy strategy: compose (via SSH)" $SSH_CMD << DEPLOY_EOF set -e echo ">>> cd $DEPLOY_WORKDIR" cd "$DEPLOY_WORKDIR" || { echo "ERROR: Cannot cd to $DEPLOY_WORKDIR"; exit 1; } echo ">>> docker compose -f $DEPLOY_COMPOSE_FILE pull" docker compose -f "$DEPLOY_COMPOSE_FILE" pull echo ">>> docker compose -f $DEPLOY_COMPOSE_FILE up -d" docker compose -f "$DEPLOY_COMPOSE_FILE" up -d echo "Deploy (compose) complete." DEPLOY_EOF - name: "Deploy via SSH: systemd" if: env.DEPLOY_ACTIVE == 'true' && env.DEPLOY_STRATEGY == 'systemd' run: | echo ">>> Deploy strategy: systemd (via SSH)" if [ -z "$DEPLOY_SYSTEMD_SERVICE" ]; then echo "ERROR: DEPLOY_SYSTEMD_SERVICE is not set." exit 1 fi $SSH_CMD << DEPLOY_EOF set -e echo ">>> sudo systemctl restart $DEPLOY_SYSTEMD_SERVICE" sudo systemctl restart "$DEPLOY_SYSTEMD_SERVICE" echo ">>> systemctl status $DEPLOY_SYSTEMD_SERVICE" sudo systemctl status "$DEPLOY_SYSTEMD_SERVICE" --no-pager echo "Deploy (systemd) complete." DEPLOY_EOF - name: "Deploy via SSH: script" if: env.DEPLOY_ACTIVE == 'true' && env.DEPLOY_STRATEGY == 'script' run: | echo ">>> Deploy strategy: script (via SSH)" # Copy the deploy script to the VPS scp -i ~/.ssh/deploy_key \ "$DEPLOY_SCRIPT" \ "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/tmp/_deploy_script.sh" $SSH_CMD << DEPLOY_EOF set -e chmod +x /tmp/_deploy_script.sh /tmp/_deploy_script.sh "$DEPLOY_WORKDIR" rm -f /tmp/_deploy_script.sh echo "Deploy (script) complete." DEPLOY_EOF # ----------------------------------------------------------------------- # Step 6: Clean up SSH key (always runs) # ----------------------------------------------------------------------- - name: Clean up SSH if: always() run: | rm -rf ~/.ssh/deploy_key ~/.ssh/config 2>/dev/null || true # ----------------------------------------------------------------------- # Step 7: Summary # ----------------------------------------------------------------------- - name: Deploy summary if: always() run: | echo "==============================" echo " Deploy (ssh)" echo " Enabled: ${ENABLE_DEPLOY:-false}" echo " Mode: ${DEPLOY_MODE:-ssh}" echo " Strategy: ${DEPLOY_STRATEGY:-compose}" echo " Workdir: ${DEPLOY_WORKDIR:-/opt/app}" echo " Active: ${DEPLOY_ACTIVE:-false}" echo "=============================="