# Internet for Kids - Production Deployment # Deploys to infra VM at /opt/apps/internetforkids # # Features: # - Docker registry-based deployment (build → push → pull) # - Health check with automatic rollback on failure # - Smoke tests against public URLs # - Deploy-guard audit logging name: Deploy Internet for Kids on: push: branches: [main] paths-ignore: - '**.md' - 'docs/**' workflow_dispatch: inputs: force_deploy: description: 'Force deploy even if health checks fail' required: false default: 'false' env: PROJECT_NAME: "internetforkids" REGISTRY: gitea.agiliton.internal:3000 IMAGE: gitea.agiliton.internal:3000/christian/internetforkids REMOTE_HOST: infra.agiliton.internal REMOTE_USER: root DEPLOY_PATH: /opt/apps/internetforkids HEALTH_ENDPOINT: "http://localhost:3006/health" PUBLIC_URL: "https://internetforkids.org" HEALTH_TIMEOUT: 60 jobs: build-and-push: name: Build & Push runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: submodules: true - name: Login to registry run: | echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ env.REGISTRY }} -u christian --password-stdin - name: Build and push run: | DOCKER_BUILDKIT=1 docker build --pull \ -t ${{ env.IMAGE }}:${{ github.sha }} \ . docker push ${{ env.IMAGE }}:${{ github.sha }} - name: Cleanup if: always() run: docker builder prune -f --filter "until=24h" 2>/dev/null || true deploy: name: Deploy runs-on: ubuntu-latest needs: build-and-push outputs: previous_image: ${{ steps.deploy.outputs.previous_image }} steps: - uses: actions/checkout@v4 - name: Setup SSH run: | mkdir -p ~/.ssh && chmod 700 ~/.ssh echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519 chmod 600 ~/.ssh/id_ed25519 ssh-keyscan -H ${{ env.REMOTE_HOST }} >> ~/.ssh/known_hosts 2>/dev/null || true - name: Deploy id: deploy run: | # Capture current image digest for rollback PREVIOUS=$(ssh ${{ env.REMOTE_USER }}@${{ env.REMOTE_HOST }} \ "docker inspect --format='{{.Image}}' internetforkids 2>/dev/null || echo 'none'") echo "previous_image=$PREVIOUS" >> $GITHUB_OUTPUT echo "Previous image: $PREVIOUS" # Sync docker-compose.yml and deploy scp docker-compose.yml ${{ env.REMOTE_USER }}@${{ env.REMOTE_HOST }}:${{ env.DEPLOY_PATH }}/docker-compose.yml ssh ${{ env.REMOTE_USER }}@${{ env.REMOTE_HOST }} << EOF cd /opt/apps/internetforkids docker pull ${{ env.IMAGE }}:${{ github.sha }} docker tag ${{ env.IMAGE }}:${{ github.sha }} ${{ env.IMAGE }}:latest docker compose up -d --force-recreate --remove-orphans EOF echo "Deployment initiated" health-check: name: Health Check runs-on: ubuntu-latest needs: deploy steps: - name: Setup SSH run: | mkdir -p ~/.ssh && chmod 700 ~/.ssh echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519 chmod 600 ~/.ssh/id_ed25519 ssh-keyscan -H ${{ env.REMOTE_HOST }} >> ~/.ssh/known_hosts 2>/dev/null || true - name: Wait for health run: | echo "Waiting for health (timeout: ${HEALTH_TIMEOUT}s)..." ELAPSED=0 while [ $ELAPSED -lt $HEALTH_TIMEOUT ]; do if ssh ${{ env.REMOTE_USER }}@${{ env.REMOTE_HOST }} \ "curl -sf ${{ env.HEALTH_ENDPOINT }}" >/dev/null 2>&1; then echo "Health check passed!" exit 0 fi echo "Waiting... ($ELAPSED/${HEALTH_TIMEOUT}s)" sleep 10 ELAPSED=$((ELAPSED + 10)) done echo "::error::Health check timeout after ${HEALTH_TIMEOUT}s" ssh ${{ env.REMOTE_USER }}@${{ env.REMOTE_HOST }} \ "cd ${{ env.DEPLOY_PATH }} && docker compose ps && docker compose logs --tail 20" || true if [ "${{ github.event.inputs.force_deploy }}" = "true" ]; then echo "::warning::Force deploy — continuing despite health failure" exit 0 fi exit 1 smoke-test: name: Smoke Tests runs-on: ubuntu-latest needs: health-check steps: - name: Setup SSH run: | mkdir -p ~/.ssh && chmod 700 ~/.ssh echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519 chmod 600 ~/.ssh/id_ed25519 ssh-keyscan -H ${{ env.REMOTE_HOST }} >> ~/.ssh/known_hosts 2>/dev/null || true - name: Test key URLs run: | FAILED=0 for path in /health /en/ /de/ /fr/; do STATUS=$(ssh ${{ env.REMOTE_USER }}@${{ env.REMOTE_HOST }} \ "curl -so /dev/null -w '%{http_code}' http://localhost:3006${path}" 2>/dev/null || echo "000") if [ "$STATUS" = "200" ] || [ "$STATUS" = "302" ]; then echo "OK $path → $STATUS" else echo "FAIL $path → $STATUS" FAILED=$((FAILED + 1)) fi done if [ $FAILED -gt 0 ]; then echo "::error::$FAILED smoke test(s) failed" exit 1 fi echo "All smoke tests passed" indexnow: name: IndexNow Ping runs-on: ubuntu-latest needs: [smoke-test] steps: - name: Notify search engines run: | KEY="40101d97ec848f6ea016fac347b1a5bc" for url in \ "https://internetforkids.org/" \ "https://internetforkids.org/en/" \ "https://internetforkids.org/de/" \ "https://internetforkids.org/fr/" \ "https://internetforkids.org/sitemap.xml"; do curl -sf "https://api.indexnow.org/indexnow?url=${url}&key=${KEY}" || true done echo "IndexNow pings sent" promote: name: Promote to Latest runs-on: ubuntu-latest needs: [smoke-test] steps: - name: Login to registry run: | echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ env.REGISTRY }} -u christian --password-stdin - name: Tag and push latest run: | docker pull ${{ env.IMAGE }}:${{ github.sha }} docker tag ${{ env.IMAGE }}:${{ github.sha }} ${{ env.IMAGE }}:latest docker push ${{ env.IMAGE }}:latest echo "Promoted ${{ github.sha }} to :latest" rollback: name: Rollback runs-on: ubuntu-latest needs: [deploy, health-check, smoke-test] if: failure() && needs.deploy.outputs.previous_image != 'none' && needs.deploy.outputs.previous_image != '' steps: - name: Setup SSH run: | mkdir -p ~/.ssh && chmod 700 ~/.ssh echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519 chmod 600 ~/.ssh/id_ed25519 ssh-keyscan -H ${{ env.REMOTE_HOST }} >> ~/.ssh/known_hosts 2>/dev/null || true - name: Rollback to previous image run: | PREVIOUS="${{ needs.deploy.outputs.previous_image }}" echo "::warning::Rolling back to image $PREVIOUS" ssh ${{ env.REMOTE_USER }}@${{ env.REMOTE_HOST }} << EOF cd ${{ env.DEPLOY_PATH }} docker tag $PREVIOUS ${{ env.IMAGE }}:latest docker compose up -d --force-recreate # Push rolled-back image as :latest so Watchtower doesn't re-pull broken docker push ${{ env.IMAGE }}:latest EOF - name: Verify rollback run: | sleep 10 if ssh ${{ env.REMOTE_USER }}@${{ env.REMOTE_HOST }} \ "curl -sf ${{ env.HEALTH_ENDPOINT }}" >/dev/null 2>&1; then echo "Rollback successful" else echo "::error::Rollback may have failed — check manually" fi audit: name: Audit runs-on: ubuntu-latest needs: [deploy, health-check, smoke-test, promote, rollback] if: always() steps: - name: Setup SSH run: | mkdir -p ~/.ssh && chmod 700 ~/.ssh echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519 chmod 600 ~/.ssh/id_ed25519 ssh-keyscan -H ${{ env.REMOTE_HOST }} >> ~/.ssh/known_hosts 2>/dev/null || true - name: Log deployment run: | STATUS="success" [ "${{ needs.health-check.result }}" = "failure" ] && STATUS="failed" [ "${{ needs.smoke-test.result }}" = "failure" ] && STATUS="failed" [ "${{ needs.rollback.result }}" = "success" ] && STATUS="rolled_back" echo "${{ env.PROJECT_NAME }} deployment: $STATUS (sha: ${{ github.sha }})" ssh ${{ env.REMOTE_USER }}@${{ env.REMOTE_HOST }} \ "/opt/scripts/deploy-guard.sh check 'ci-deploy ${{ env.PROJECT_NAME }} $STATUS'" 2>/dev/null || true