From ab6b4677b47b49919b0812462ea0bb8bcff42577 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Arag=C3=B3n?= Date: Fri, 10 Jul 2026 10:54:17 +0000 Subject: [PATCH] ci: gate redeploy step on workflow_dispatch.deploy=true - Build pushes both :latest and :0.0.1 tags so the redeploy step uses the pinned version regardless of how the runner happened to be tagged before. - Add a 4th step 'Redeploy stack on Portainer' that runs ONLY when the workflow is manually dispatched with deploy=true. - Step does idempotent DELETE+CREATE on the existing stack via Portainer REST (PUT-edit would require more plumbing for a single-service stack). - Smoke-tests http://:3001/ after the stack recreates; logs the code but does not hard-fail the job (network-proxy reachable via NPM upstream). Required new secrets (in addition to existing DOCKER_USERNAME/DOCKER_PASSWORD): - PORTAINER_URL e.g. http://192.168.1.30:9000 - PORTAINER_TOKEN API access token from a Portainer user - PORTAINER_ENDPOINT_ID numeric endpoint ID (usually 1) - PORTAINER_STACK_ID numeric stack ID to delete before recreating --- .gitea/workflows/ci-cd.yaml | 96 ++++++++++++++++++++++++++++++++++++- 1 file changed, 94 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/ci-cd.yaml b/.gitea/workflows/ci-cd.yaml index 9f61ff2..ff215f9 100644 --- a/.gitea/workflows/ci-cd.yaml +++ b/.gitea/workflows/ci-cd.yaml @@ -6,6 +6,12 @@ on: 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). @@ -19,11 +25,97 @@ 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 . - docker push gitlab.impresion3d.pro/root/davidaragon-portfolio:latest \ No newline at end of file + 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_URL: ${{ secrets.PORTAINER_URL }} + 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 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: ${PORTAINER_TOKEN}" \ + "${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 + HEALTH=$(curl -sS -o /dev/null -w '%{http_code}' \ + --max-time 5 \ + "http://${PORTAINER_URL#http://}:3001/" 2>/dev/null || true) + echo "Health check on http://: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. ---" \ No newline at end of file