name: CI/CD Pipeline on: push: branches: [main] pull_request: branches: [main] workflow_dispatch: inputs: deploy: description: "Recreate the davidaragon-portfolio stack on Portainer (deletes + creates with latest image)" type: boolean default: false required: false # Serialise runs on the shared act_runner: prevents two simultaneous runs from # cancelling each other's in-progress steps (observed in older portfolio runs). # See python-project-template-internal/.gitea/workflows/ci.yaml for the same # pattern with full context (runs 794 and 799). concurrency: group: ci-${{ github.ref }} cancel-in-progress: false jobs: build: name: Build & Deploy runs-on: ubuntu-latest env: # IMAGE_NAME used by the redeploy step + the deploy compose file. IMAGE_NAME: gitlab.impresion3d.pro/root/davidaragon-portfolio:0.0.1 steps: - uses: actions/checkout@v4 - name: Build and push Docker image run: | # The build always tags and pushes both :latest (for ad-hoc inspection) # and :0.0.1 (pinned version that the stack redeploy step uses). # The runner's `docker buildx` is set up by the QNAP self-hosted runner; # no QEMU emulation required for the linux/amd64 build target here. docker login gitlab.impresion3d.pro -u "${{ secrets.DOCKER_USERNAME }}" -p "${{ secrets.DOCKER_PASSWORD }}" docker build \ -t gitlab.impresion3d.pro/root/davidaragon-portfolio:latest \ -t gitlab.impresion3d.pro/root/davidaragon-portfolio:0.0.1 \ . docker push gitlab.impresion3d.pro/root/davidaragon-portfolio:latest docker push gitlab.impresion3d.pro/root/davidaragon-portfolio:0.0.1 # ---------------------------------------------------------------------- # Optional redeploy step (gated by workflow_dispatch.deploy=true). # # Approach: delete the existing stack on Portainer (idempotent), then # create a new one from the docker-compose.prod.yml at the repo root. # # We DELETE + CREATE rather than PUT-edit because: # * Put-edit requires the same stack ID + PRUNE; the simpler recreate # works for our size (1 service, no inter-service references). # * Earlier the stack was "unhealthy"; this gives a clean slate. # # Required secrets: # PORTAINER_URL e.g. http://192.168.1.30:9000 # PORTAINER_TOKEN Access token from a Portainer user (scope: admin) # PORTAINER_ENDPOINT_ID Numeric endpoint ID (usually 1) # PORTAINER_STACK_ID Numeric stack ID to delete before recreating # ---------------------------------------------------------------------- - name: Redeploy stack on Portainer if: github.event_name == 'workflow_dispatch' && inputs.deploy == true env: PORTAINER_TOKEN: ${{ secrets.PORTAINER_TOKEN }} PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID }} PORTAINER_STACK_ID: ${{ secrets.PORTAINER_STACK_ID }} run: | set -euo pipefail echo "--- Step 0: auto-discover a reachable Portainer URL ---" # The runner creates an ephemeral docker network per job, so the # canonical PORTAINER_URL (e.g. http://192.168.1.30:9000) often isn't # reachable from inside the job container. Strategy: probe every IP # we can find (container's own IPs + default gateways) against # :9000/api/status and use the first one that responds 200. PORTAINER_URL="" CANDIDATES=() # 1) Container's own IPv4 addresses for ip in $(hostname -I 2>/dev/null | tr ' ' '\n' | grep -E '^[0-9]+\.'); do CANDIDATES+=("http://${ip}:9000") done # 2) Default gateways of every default route while IFS= read -r gw; do [ -n "$gw" ] && CANDIDATES+=("http://${gw}:9000") done < <(ip -4 route show default 2>/dev/null | awk '{print $3}' | sort -u) # 3) Fallback: secret value (in case everything else fails) CANDIDATES+=("${{ secrets.PORTAINER_URL }}") echo "Candidates: ${CANDIDATES[@]}" for url in "${CANDIDATES[@]}"; do code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 3 "${url}/api/status" 2>/dev/null || echo "000") echo " probe ${url}/api/status -> ${code}" if [ "$code" = "200" ]; then PORTAINER_URL="$url" echo " -> using ${PORTAINER_URL}" break fi done if [ -z "$PORTAINER_URL" ]; then echo "ERROR: no candidate URL reached Portainer. Tried: ${CANDIDATES[@]}" >&2 exit 1 fi echo "--- Step 1: pre-flight (delete existing stack if present) ---" DELETE_HTTP_CODE=$(curl -sS -o /tmp/portainer-delete.json -w '%{http_code}' \ -X DELETE \ -H "X-API-Key: ${PORT...EN}" \ "${PORTAINER_URL}/api/stacks/${PORTAINER_STACK_ID}?endpointId=${PORTAINER_ENDPOINT_ID}") echo "DELETE HTTP ${DELETE_HTTP_CODE}" if [ "${DELETE_HTTP_CODE}" != "204" ] && [ "${DELETE_HTTP_CODE}" != "404" ]; then echo "ERROR: Portainer rejected DELETE on stack ${PORTAINER_STACK_ID}:" >&2 cat /tmp/portainer-delete.json >&2 exit 1 fi echo "--- Step 2: read docker-compose.prod.yml ---" if [ ! -f docker-compose.prod.yml ]; then echo "ERROR: docker-compose.prod.yml is missing from the repo root" >&2 exit 1 fi # Inline the compose file. Portainer expects `composeFileContent` as raw text. COMPOSE_BODY=$(jq -Rs --arg compose "$(cat docker-compose.prod.yml)" \ '{composeFileContent: $compose, env: []}' < /dev/null) echo "--- Step 3: create fresh stack from docker-compose.prod.yml ---" CREATE_HTTP_CODE=$(curl -sS -o /tmp/portainer-create.json -w '%{http_code}' \ -X POST \ -H "X-API-Key: ${PORTAINER_TOKEN}" \ -H "Content-Type: application/json" \ --data "${COMPOSE_BODY}" \ "${PORTAINER_URL}/api/stacks?endpointId=${PORTAINER_ENDPOINT_ID}&type=2&method=string&name=davidaragon-portfolio") echo "CREATE HTTP ${CREATE_HTTP_CODE}" if [ "${CREATE_HTTP_CODE}" != 201 ]; then echo "ERROR: Portainer rejected stack creation:" >&2 cat /tmp/portainer-create.json >&2 exit 1 fi echo "--- Step 4: smoke-test the freshly deployed stack ---" # Give the container a brief window to start before checking. sleep 8 # The Portainer host:9000 is reachable from the job container # (we just proved that with the auto-discovery step above). The # portfolio container itself is published on host port 3001, so # smoke-test through the same host. We don't fail the job if the # proxy upstream isn't reachable from the runner's network. HEALTH=$(curl -sS -o /dev/null -w '%{http_code}' \ --max-time 5 \ "${PORTAINER_URL%:[0-9]*}:3001/" 2>/dev/null || true) echo "Health check on ${PORTAINER_URL%:[0-9]*}:3001/ returned: ${HEALTH:-}" # We log but don't fail the job if 3001 isn't reachable — the upstream # proxy (Nginx Proxy Manager → davidaragon.impresion3d.pro) is a better # place to wire a hard-fail check in a future iteration. echo "--- Stack recreated successfully. ---"