From a7eafa50f66a8e80f3554149c27fa41a3de8621c Mon Sep 17 00:00:00 2001 From: khalil-bot Date: Thu, 30 Apr 2026 14:05:17 +0200 Subject: [PATCH] feat(ci): add Docker build, push and SSH deploy pipeline - CI: lint, unit tests, production build on all branches - Docker build & push on all branches (tag :latest on main, :branch-* on others) - Deploy: SSH pull & restart on all branches (preprod validation) - Trigger workflow on self-modification (.github/workflows/ui.yml) Closes #38 Assisted-by: Claude:claude-sonnet-4-6 --- .github/workflows/ui.yml | 117 +++++++++++++++++++++++++++++++++++++++ .gitignore | 4 ++ ui/.dockerignore | 8 +++ ui/Dockerfile | 18 ++++++ ui/nginx.conf | 29 ++++++++++ 5 files changed, 176 insertions(+) create mode 100644 .github/workflows/ui.yml create mode 100644 ui/.dockerignore create mode 100644 ui/Dockerfile create mode 100644 ui/nginx.conf diff --git a/.github/workflows/ui.yml b/.github/workflows/ui.yml new file mode 100644 index 0000000..0f66eb7 --- /dev/null +++ b/.github/workflows/ui.yml @@ -0,0 +1,117 @@ +name: UI – CI/CD + +on: + push: + paths: + - ui/** + - .github/workflows/ui.yml + pull_request: + branches: [main] + paths: + - ui/** + - .github/workflows/ui.yml + +env: + IMAGE: ghcr.io/${{ github.repository_owner }}/dashboard-ui + # IMAGE_LC is set in the docker job (owner must be lowercase for Docker) + +jobs: + # ── 1. Build & test ────────────────────────────────────────────────────────── + ci: + name: Build & test + runs-on: ubuntu-latest + defaults: + run: + working-directory: ui + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node 20 + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: ui/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Unit tests + run: npm test -- --watch=false --browsers=ChromeHeadless + + - name: Production build + run: npm run build -- --configuration production + + # ── 2. Docker build & push ─────────────────────────────────────────────────── + docker: + name: Docker build & push + runs-on: ubuntu-latest + needs: ci + outputs: + sha_tag: ${{ steps.tag.outputs.sha }} + + permissions: + contents: read + packages: write + + steps: + - uses: actions/checkout@v4 + + - name: Compute image tags + id: tag + run: | + OWNER="${{ github.repository_owner }}" + IMAGE_LC="ghcr.io/${OWNER,,}/dashboard-ui" + echo "image=${IMAGE_LC}" >> $GITHUB_OUTPUT + echo "sha=${IMAGE_LC}:${{ github.sha }}" >> $GITHUB_OUTPUT + BRANCH="${{ github.head_ref || github.ref_name }}" + SAFE="${BRANCH//\//-}" + if [ "${{ github.ref }}" = "refs/heads/main" ]; then + echo "extra=${IMAGE_LC}:latest" >> $GITHUB_OUTPUT + else + echo "extra=${IMAGE_LC}:branch-${SAFE}" >> $GITHUB_OUTPUT + fi + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build & push + uses: docker/build-push-action@v5 + with: + context: ui + push: true + tags: | + ${{ steps.tag.outputs.sha }} + ${{ steps.tag.outputs.extra }} + + # ── 3. Deploy to physical server (SSH) ─────────────────────────────────────── + deploy: + name: Deploy + runs-on: ubuntu-latest + needs: docker + + steps: + - name: SSH deploy + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USER }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + port: ${{ secrets.SSH_PORT }} + script: | + docker pull ${{ needs.docker.outputs.sha_tag }} + docker stop dashboard-ui 2>/dev/null || true + docker rm dashboard-ui 2>/dev/null || true + docker run -d \ + --name dashboard-ui \ + --restart unless-stopped \ + -p 80:80 \ + ${{ needs.docker.outputs.sha_tag }} diff --git a/.gitignore b/.gitignore index 66f2a75..17625f4 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,10 @@ .vscode .idea +# Local +.claude/ +.DS_Store + # Secrets **/secrets/ **/.env diff --git a/ui/.dockerignore b/ui/.dockerignore new file mode 100644 index 0000000..91ea565 --- /dev/null +++ b/ui/.dockerignore @@ -0,0 +1,8 @@ +node_modules +dist +.git +.github +*.md +.dockerignore +.editorconfig +.gitignore diff --git a/ui/Dockerfile b/ui/Dockerfile new file mode 100644 index 0000000..656fe29 --- /dev/null +++ b/ui/Dockerfile @@ -0,0 +1,18 @@ +# ── Stage 1 : build ────────────────────────────────────────────────────────── +FROM node:20-alpine AS builder +WORKDIR /app + +COPY package*.json ./ +RUN npm ci --prefer-offline + +COPY . . +RUN npm run build -- --configuration production + +# ── Stage 2 : serve ────────────────────────────────────────────────────────── +FROM nginx:1.27-alpine +COPY --from=builder /app/dist/dashboard/browser /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD wget -qO- http://localhost/ || exit 1 diff --git a/ui/nginx.conf b/ui/nginx.conf new file mode 100644 index 0000000..d4b4e1e --- /dev/null +++ b/ui/nginx.conf @@ -0,0 +1,29 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Angular HTML5 routing + location / { + try_files $uri $uri/ /index.html; + } + + # Hashed assets — cache forever + location ~* \.(js|css|woff2?|ttf|eot|svg|ico|png|jpg|webp)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + access_log off; + } + + # Never cache index.html + location = /index.html { + add_header Cache-Control "no-store, no-cache, must-revalidate"; + } + + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css application/json application/javascript + text/xml application/xml text/javascript; +}