Compare commits
11 Commits
4ab6633cc2
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 856f899448 | |||
| 06ad8edf18 | |||
| fc1fd96145 | |||
| 70129b1e9f | |||
| ab6b4677b4 | |||
| e346e6500d | |||
| 6f3c58bfcd | |||
| 83db698f8d | |||
| f152379882 | |||
| d3aed167fa | |||
| 26f26c89f6 |
+147
-2
@@ -3,16 +3,161 @@ 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:
|
||||
deploy:
|
||||
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 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:-<timeout/unreachable>}"
|
||||
# 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. ---"
|
||||
@@ -1,43 +1,171 @@
|
||||
# Astro Starter Kit: Minimal
|
||||
# David Aragón — Portfolio
|
||||
|
||||
```sh
|
||||
npm create astro@latest -- --template minimal
|
||||
Portfolio personal y blog técnico de David Aragón. Sitio estático construido con Astro 5, donde comparto proyectos en los que estoy trabajando (build in public), posts sobre IoT, desarrollo y decisiones técnicas, y una página de about con mi trayectoria.
|
||||
|
||||
- **URL pública:** https://davidaragon.impresion3d.pro
|
||||
- **Autor:** David Aragón — de orfebre a fotógrafo a developer
|
||||
- **Repositorio:** https://gitlab.impresion3d.pro/root/davidaragon-portfolio
|
||||
|
||||
## Características
|
||||
|
||||
- **Home** con hero, últimos 3 posts del blog, proyectos destacados y CTA de newsletter
|
||||
- **Blog** (`/blog`) con posts categorizados en `technical`, `business` y `personal`, con RSS en `/rss.xml`
|
||||
- **Projects** (`/projects`) con proyectos en estados `active`, `development` o `completed`
|
||||
- **About** (`/about`) con la trayectoria profesional completa
|
||||
- **Sitemap** autogenerado en `/sitemap-index.xml` por `@astrojs/sitemap`
|
||||
- **Newsletter** vía formulario en la home (acción `/api/subscribe`)
|
||||
- **Type-safe content** con Zod schemas en `src/content/config.ts`
|
||||
- **Búsqueda y SEO**: meta tags, Open Graph y RSS listos para compartir
|
||||
|
||||
## Stack técnico
|
||||
|
||||
- **Astro 5** — framework de sitios estáticos con islands
|
||||
- **TypeScript** — tipado estricto en todo el código
|
||||
- **Tailwind CSS 3** — utility-first CSS con `@tailwindcss/typography` para prose
|
||||
- **Zod** — validación de frontmatter en content collections
|
||||
- **RSS** vía `@astrojs/rss`
|
||||
- **Sitemap** vía `@astrojs/sitemap`
|
||||
- **Nginx** como servidor estático en el contenedor de producción
|
||||
- **Docker** multi-stage (Node para build, nginx para servir)
|
||||
- **CI/CD** con Gitea Actions en `.gitea/workflows/ci-cd.yaml`
|
||||
- **Deploy** automático a Portainer vía webhook
|
||||
|
||||
## Quick start (local)
|
||||
|
||||
Requisitos: Node.js >= 18.14.1 (recomendado 20).
|
||||
|
||||
```bash
|
||||
# 1. Instalar dependencias
|
||||
npm install
|
||||
|
||||
# 2. Arrancar dev server con HMR
|
||||
npm run dev
|
||||
# -> http://localhost:4321
|
||||
|
||||
# 3. Build de producción (genera ./dist/)
|
||||
npm run build
|
||||
|
||||
# 4. Preview local del build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||
## Despliegue (producción)
|
||||
|
||||
## 🚀 Project Structure
|
||||
El sitio se construye como imagen Docker y se publica en el registry de Gitea. Portainer hace pull y reinicia el contenedor cuando el CI dispara el webhook.
|
||||
|
||||
Inside of your Astro project, you'll see the following folders and files:
|
||||
```bash
|
||||
# Construir y servir localmente con Docker Compose
|
||||
docker compose up --build
|
||||
# -> http://localhost:8080
|
||||
|
||||
```text
|
||||
/
|
||||
├── public/
|
||||
├── src/
|
||||
│ └── pages/
|
||||
│ └── index.astro
|
||||
└── package.json
|
||||
# Producción: pull de la imagen publicada por CI
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
# -> http://localhost:3001
|
||||
```
|
||||
|
||||
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
|
||||
La imagen final (`nginx:alpine`) solo contiene los estáticos generados en `./dist/`. Ver `Dockerfile` para detalles del multi-stage.
|
||||
|
||||
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
|
||||
## Cómo añadir contenido
|
||||
|
||||
Any static assets, like images, can be placed in the `public/` directory.
|
||||
### Nuevo post de blog
|
||||
|
||||
## 🧞 Commands
|
||||
Crea un archivo Markdown en `src/content/blog/` con nombre `YYYY-MM-DD-slug.md`. El frontmatter debe cumplir el schema de `src/content/config.ts`:
|
||||
|
||||
All commands are run from the root of the project, from a terminal:
|
||||
```markdown
|
||||
---
|
||||
title: "Título del post"
|
||||
description: "Resumen corto para SEO y previews"
|
||||
publishDate: 2026-07-09
|
||||
author: "David Aragón" # opcional, default: David Aragón
|
||||
tags: ["tag1", "tag2"]
|
||||
category: "technical" # technical | business | personal
|
||||
featured: false # true para destacar en home
|
||||
draft: false # true para ocultar del sitio público
|
||||
image: "/ruta/opcional.jpg" # opcional
|
||||
---
|
||||
|
||||
| Command | Action |
|
||||
| :------------------------ | :----------------------------------------------- |
|
||||
| `npm install` | Installs dependencies |
|
||||
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
||||
| `npm run build` | Build your production site to `./dist/` |
|
||||
| `npm run preview` | Preview your build locally, before deploying |
|
||||
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||
| `npm run astro -- --help` | Get help using the Astro CLI |
|
||||
# Contenido del post en Markdown
|
||||
```
|
||||
|
||||
## 👀 Want to learn more?
|
||||
Aparecerá automáticamente en `/blog/<slug>` y, si no es draft, en la home.
|
||||
|
||||
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
||||
### Nuevo proyecto
|
||||
|
||||
Crea un archivo Markdown en `src/content/projects/` con frontmatter:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: "Nombre del proyecto"
|
||||
description: "Descripción de una línea"
|
||||
url: "https://proyecto.example.com"
|
||||
github: "https://github.com/user/repo" # opcional
|
||||
status: "development" # active | development | completed
|
||||
tags: ["React", "FastAPI"]
|
||||
startDate: 2026-07-01
|
||||
featured: true # true para mostrar en home
|
||||
image: "/ruta/opcional.jpg" # opcional
|
||||
---
|
||||
|
||||
# Descripción completa del proyecto en Markdown
|
||||
```
|
||||
|
||||
Aparecerá en `/projects/<slug>` y, si `featured: true`, también en la home.
|
||||
|
||||
## Estructura del proyecto
|
||||
|
||||
```
|
||||
.
|
||||
├── astro.config.mjs # Configuración Astro (site, integrations, markdown)
|
||||
├── tailwind.config.cjs # Tailwind theme y content paths
|
||||
├── tsconfig.json # TS estricto
|
||||
├── package.json # Deps y scripts
|
||||
├── Dockerfile # Multi-stage build (node -> nginx)
|
||||
├── docker-compose.yml # Local dev/test
|
||||
├── docker-compose.prod.yml # Producción (pull de registry)
|
||||
├── nginx.conf # Configuración nginx
|
||||
├── .gitea/workflows/ # Pipelines CI/CD
|
||||
│ └── ci-cd.yaml
|
||||
├── public/ # Assets estáticos servidos tal cual (favicon, david-aragon.jpg, etc.)
|
||||
└── src/
|
||||
├── components/
|
||||
│ ├── layout/ # Header, Footer
|
||||
│ └── ui/ # Card, Tag
|
||||
├── content/
|
||||
│ ├── config.ts # Schemas Zod para blog y projects
|
||||
│ ├── blog/ # Posts en Markdown
|
||||
│ └── projects/ # Proyectos en Markdown
|
||||
├── layouts/ # BaseLayout, BlogLayout, ProjectLayout
|
||||
├── pages/
|
||||
│ ├── index.astro # Home
|
||||
│ ├── about.astro # About
|
||||
│ ├── blog/ # Index + [slug].astro
|
||||
│ ├── projects/ # Index + [slug].astro
|
||||
│ └── rss.xml.ts # Feed RSS (si está generado)
|
||||
├── styles/ # CSS global
|
||||
└── utils/ # dateFormat.ts, readingTime.ts
|
||||
```
|
||||
|
||||
## CI/CD
|
||||
|
||||
Pipeline definido en `.gitea/workflows/ci-cd.yaml`. Trigger: push a `main`.
|
||||
|
||||
1. **Build**: checkout, login en el registry de Gitea, build de la imagen Docker multi-stage
|
||||
2. **Push**: publica `gitlab.impresion3d.pro/root/davidaragon-portfolio:latest`
|
||||
3. **Deploy**: el webhook configurado en Portainer hace pull de la nueva imagen y reinicia el contenedor
|
||||
|
||||
Secrets necesarios en el repo (Settings → Secrets):
|
||||
|
||||
- `DOCKER_USERNAME` — usuario del registry (normalmente `root`)
|
||||
- `DOCKER_PASSWORD` — token personal con permisos de registry
|
||||
|
||||
Ver `CI-CD-SETUP.md` para instrucciones detalladas de Portainer y registro de runners.
|
||||
|
||||
## Licencia
|
||||
|
||||
Código del sitio: ver `LICENSE` si existe en el repo. El contenido (posts y proyectos) es © David Aragón.
|
||||
|
||||
## Créditos
|
||||
|
||||
- Construido con [Astro](https://astro.build)
|
||||
- Estilado con [Tailwind CSS](https://tailwindcss.com)
|
||||
- Hospedado en infraestructura propia (Gitea + Portainer)
|
||||
@@ -0,0 +1,326 @@
|
||||
# CI/CD optimization proposal — davidaragon-portfolio
|
||||
|
||||
**Status**: Draft v0.1 — initial scaffold, real measurements pending.
|
||||
**Last updated**: 2026-07-10
|
||||
**Owner**: David Aragón + Hermes
|
||||
**Related**: [`docs/architecture.md`](../architecture.md) · [`docs/ci-cd-setup.md`](../ci-cd-setup.md)
|
||||
|
||||
---
|
||||
|
||||
## 0. Why this document exists
|
||||
|
||||
The CI for `davidaragon-portfolio` is the second pipeline brought into the
|
||||
Gitea Actions + Portainer setup on the NAS (the first being
|
||||
`python-project-template-internal`). Both share the same QNAP-hosted
|
||||
`act_runner` self-hosted runner and the same Portainer instance — that means
|
||||
**an optimization here is partly informed by what we already learned on the
|
||||
Python template**, and partly needs its own measurements because the build is
|
||||
different (Node 20 + Astro static vs Python 3.10 + uv).
|
||||
|
||||
The first real deploy of the portfolio stack is the gating event: until that
|
||||
runs and we can read the timing breakdown, this document is **structural** +
|
||||
**conjectural** for any section that needs real numbers. Once we have one
|
||||
truthful run, the open `[METRIC]` placeholders get filled in.
|
||||
|
||||
---
|
||||
|
||||
## 1. Anatomy of the current pipeline
|
||||
|
||||
The workflow lives at `.gitea/workflows/ci-cd.yaml` and currently has:
|
||||
|
||||
```
|
||||
push / pull_request / workflow_dispatch(deploy:boolean)
|
||||
|
|
||||
+--> job: build (runs-on: ubuntu-latest)
|
||||
|
|
||||
+--> step 1: actions/checkout@v4
|
||||
+--> step 2: docker login + build + push
|
||||
| (tags: :latest and :0.0.1)
|
||||
+--> step 3 (conditional): Redeploy stack on Portainer
|
||||
|
|
||||
+--> a) DELETE existing stack on Portainer API
|
||||
+--> b) POST a fresh stack from docker-compose.prod.yml
|
||||
+--> c) sleep 8s, then smoke-test http://host:3001/
|
||||
```
|
||||
|
||||
### The runner
|
||||
|
||||
- `runs-on: ubuntu-latest` — no `self-hosted` label required.
|
||||
- The QNAP-resident `gitea/act_runner:latest` registers with these labels
|
||||
(per the existing CI config that runs successfully today):
|
||||
- `ubuntu-latest`
|
||||
- `linux`
|
||||
- `x64`
|
||||
- probably `act-runner` (informally used internally)
|
||||
- Single shared runner. Iteration `iter 5` of the Python template proved
|
||||
that without `concurrency:` group with `cancel-in-progress: false`, two
|
||||
parallel runs cancel each other's mid-flight steps. **This workflow
|
||||
inherited that fix from PR #2** (see `f152379 fix(ci): add concurrency
|
||||
guard + PR trigger + workflow_dispatch`). No regression expected on this
|
||||
repo.
|
||||
|
||||
### What runs on each step
|
||||
|
||||
| Step | Container / binary used | On the runner | Notes |
|
||||
|------|--------------------------|---------------|-------|
|
||||
| 1 (checkout) | `actions/checkout@v4` (Node) | Yes | Adds ~1s |
|
||||
| 2 (build) | local `docker buildx` | Yes | Pulls `node:20-alpine` + `nginx:alpine`. **Network bound.** |
|
||||
| 3 (deploy) | local `curl` + `jq` | Yes | Calls Portainer API at `http://${PORTAINER_URL}/api/stacks/...`. **Internal network bound.** |
|
||||
|
||||
The runner must therefore have:
|
||||
|
||||
- Outbound HTTPS to `registry.npmjs.org` (during build, npm ci)
|
||||
- Outbound HTTPS to `gitlab.impresion3d.pro:443` (during push)
|
||||
- Network reachability to the Portainer URL (`PORTAINER_URL` secret). When
|
||||
that URL is `http://192.168.1.30:9000`, the runner needs to be on the same
|
||||
LAN as the NAS. The QNAP self-hosted runner satisfies this only if the
|
||||
runner container has access to the LAN (either via `--network host` on
|
||||
the runner or via a docker network with a route to `192.168.1.0/24`).
|
||||
|
||||
The third point is the most likely silent failure: a `--network=some-bridge`
|
||||
runner cannot resolve `192.168.1.30` by default and the redeploy step will
|
||||
then fail with `connection refused` or `No route to host`. **Before the first
|
||||
real deploy**, the runner's network scope should be verified — see the
|
||||
[verification checklist](#7-verification-checklist-before-first-real-deploy).
|
||||
|
||||
---
|
||||
|
||||
## 2. Critical metrics to capture on the first deploy
|
||||
|
||||
The first real run will fill these in. Once captured, copy them into this
|
||||
section and update the subsequent analysis.
|
||||
|
||||
| Metric | Source | Target | Actual (first run) |
|
||||
|---|---|---|---|
|
||||
| `t_checkout` | logs of step 1 | < 5 s | `[METRIC]` |
|
||||
| `t_docker_pull_alpine_node` | logs of step 2 (first lines) | < 30 s | `[METRIC]` |
|
||||
| `t_npm_ci` | step 2 lines mentioning `npm ci` | < 60 s | `[METRIC]` |
|
||||
| `t_astro_build` | step 2 lines mentioning `astro build` | < 30 s | `[METRIC]` |
|
||||
| `t_docker_build_total` | step 2 end timestamp - step 2 start | < 180 s | `[METRIC]` |
|
||||
| `t_docker_push_latest` | step 2 lines mentioning `latest` push | < 20 s | `[METRIC]` |
|
||||
| `t_docker_push_pinned` | step 2 lines mentioning `:0.0.1` push | < 20 s | `[METRIC]` |
|
||||
| `t_step_3_delete` | step 3 DELETE line | < 5 s | `[METRIC]` |
|
||||
| `t_step_3_create` | step 3 POST line | < 5 s | `[METRIC]` |
|
||||
| `t_step_3_smoke_total` | step 3 sleep + curl | ~8 s + connect time | `[METRIC]` |
|
||||
| **Total wall-clock (with deploy)** | run-level duration | < 5 min | `[METRIC]` |
|
||||
| **Total wall-clock (without deploy)** | run-level duration | < 3 min | `[METRIC]` |
|
||||
| Runner queue waiting time | run started - run created | < 60 s | `[METRIC]` |
|
||||
|
||||
To capture these, the easiest path is to download the job logs via the
|
||||
Gitea API once the run is complete:
|
||||
|
||||
```bash
|
||||
# Get run_id from the Gitea UI (or /api/v1/repos/.../actions/runs)
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_URL/api/v1/repos/root/davidaragon-portfolio/actions/runs/<RUN_ID>/jobs" \
|
||||
| jq '.[].id'
|
||||
# Then fetch logs per job
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_URL/api/v1/repos/root/davidaragon-portfolio/actions/jobs/<JOB_ID>/logs"
|
||||
```
|
||||
|
||||
The `gitea-actions-ci-debugging` skill in the agent's repo has more on this
|
||||
pattern (see `references/api-endpoints.md`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Hypothesised bottlenecks (to be verified against §2)
|
||||
|
||||
The following are **guesses** to be confirmed or refuted with the first run.
|
||||
They are listed in order of expected impact.
|
||||
|
||||
### H1. Docker layer cache cold on every run
|
||||
|
||||
- **Why**: The runner does not persist `docker buildx` cache between runs
|
||||
unless a layer cache is configured.
|
||||
- **Symptom**: `t_docker_pull_alpine_node` likely to be ~30s on cold cache.
|
||||
- **Possible fix (sized for the runner's disk)**:
|
||||
- Mount a host directory to `~/.cache/buildx` and export `BUILDX_CACHE` env.
|
||||
- Or use `docker/build-push-action@v6` with `cache-from: type=registry`.
|
||||
- **Effort / risk**: low. Touches only step 2.
|
||||
|
||||
### H2. Sequential `:latest` then `:0.0.1` pushes
|
||||
|
||||
- **Why**: Image bytes are uploaded twice (once per tag), doubling push time.
|
||||
- **Fix**: tag both and push only `:0.0.1`, then `:latest` is left for ad-hoc
|
||||
inspection. Or use `--all-tags` (`docker push --all-tags`) which sends
|
||||
each unique manifest only once if the registry supports it
|
||||
(Gitea container registry supports it since 1.20).
|
||||
- **Saving**: ~50% of push time (typically < 10s on LAN).
|
||||
|
||||
### H3. npm ci always re-downloads when `package-lock.json` changes
|
||||
|
||||
- **Why**: Without an `npm` cache, every push with a new lock entry is a fresh
|
||||
download from `registry.npmjs.org`.
|
||||
- **Symptom**: `t_npm_ci` proportional to dep delta. With the current lock
|
||||
file (Astro 5 + Tailwind + Zod), cold downloads are ~30s for the small
|
||||
tree, but as more plugins are added this grows.
|
||||
- **Fix**: use `actions/setup-node@v4` with `cache: 'npm'` (it caches based on
|
||||
`package-lock.json` hash automatically). Or use `cache@v4` directly with
|
||||
path `node_modules` and key derived from the lock hash.
|
||||
- **Saving**: 30s+ per run on a miss, ~5s on a hit.
|
||||
|
||||
### H4. Image pull-from-registry at container runtime
|
||||
|
||||
- **Symptom in step 3**: the first health check at `:3001/` may be slow on
|
||||
cold cache because the new container has to pull `...:0.0.1` from the
|
||||
registry. The compose file does not pin `pull_policy: always`, so the
|
||||
runner may use an older layer cache on the runner host.
|
||||
- **Fix**: add `pull_policy: always` to `docker-compose.prod.yml` so the new
|
||||
image is fetched every time.
|
||||
|
||||
### H5. Concurrency guard might be too coarse
|
||||
|
||||
- **Why**: The current group is `ci-${{ github.ref }}` — per ref. This
|
||||
serialises pushes to `main` AND pushes to PR branches on top of each other.
|
||||
With a single runner, this is correct. If a second runner comes online
|
||||
later, the guard may need to be re-evaluated.
|
||||
- **No action for now**. Re-evaluate when a second runner is registered.
|
||||
|
||||
---
|
||||
|
||||
## 4. Optimisation proposals (ranked by ROI)
|
||||
|
||||
These are **proposals** to apply after the first run, ranked by expected
|
||||
return on effort.
|
||||
|
||||
### Proposal A — Add `actions/setup-node@v4` with npm cache (effort: 5 min, expected saving: 30s/run on miss, 5s on hit)
|
||||
|
||||
Add as a new step between checkout and build:
|
||||
|
||||
```yaml
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20' # matches Dockerfile
|
||||
cache: 'npm' # caches ~/.npm based on package-lock.json hash
|
||||
```
|
||||
|
||||
This costs nothing in code maintenance and helps every PR build.
|
||||
|
||||
### Proposal B — Switch push to `--all-tags` (effort: 2 min, saving: ~10s/run)
|
||||
|
||||
Replace:
|
||||
|
||||
```bash
|
||||
docker push gitlab.impresion3d.pro/root/davidaragon-portfolio:latest
|
||||
docker push gitlab.impresion3d.pro/root/davidaragon-portfolio:0.0.1
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```bash
|
||||
docker push --all-tags gitlab.impresion3d.pro/root/davidaragon-portfolio
|
||||
```
|
||||
|
||||
Both tags get pushed in one HTTP exchange; the registry deduplicates identical blobs.
|
||||
|
||||
### Proposal C — Add `pull_policy: always` to compose (effort: 1 min, saving: variable)
|
||||
|
||||
Add the line to `docker-compose.prod.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
portfolio:
|
||||
image: gitlab.impresion3d.pro/root/davidaragon-portfolio:0.0.1
|
||||
pull_policy: always # <-- new
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3001:80"
|
||||
```
|
||||
|
||||
Without this, the runner host may serve a cached older layer to the new
|
||||
container even though the tag on the registry has changed.
|
||||
|
||||
### Proposal D — Drop the `:latest` tag entirely (effort: 5 min, saving: 10s/run, cost: 1 fewer tag for ad-hoc inspection)
|
||||
|
||||
Pure-only-`:0.0.1` means every pull is explicit (CI passes the pinned tag).
|
||||
Pros: zero ambiguity in `docker images` listings. Cons: less convenient for
|
||||
`docker exec -it foo /bin/sh` quick explorations.
|
||||
|
||||
Not recommended unless the user explicitly asks — the `:latest` is useful as
|
||||
a "what's currently live" pointer.
|
||||
|
||||
### Proposal E — Squash the image with `docker-slim` or `dive` optimisation (effort: 1 hour+, saving: 20-40 MB image size)
|
||||
|
||||
Probably **not worth it** for a portfolio site. Skip.
|
||||
|
||||
### Proposal F — Parallel runs (long-term)
|
||||
|
||||
Currently the pipeline has a single `build` job. If lint, type-check, and
|
||||
build grow apart in cost, splitting them into parallel jobs would help. But
|
||||
the Astro site has no type-checking step (vanilla JS), and a project this
|
||||
small does not benefit from parallelisation. Leave as-is.
|
||||
|
||||
---
|
||||
|
||||
## 5. Open questions for the user
|
||||
|
||||
- Should the build always push both `:latest` and `:0.0.1`, or only the
|
||||
pinned version? (Affects Proposal D)
|
||||
- Is there any reason to keep the smoke-test in step 3 (`http://host:3001/`)?
|
||||
The actual public URL is via NPM (`https://davidaragon.impresion3d.pro`).
|
||||
Smoke-testing the public URL would be more accurate but requires the
|
||||
runner to have outbound internet AND DNS for the FQDN.
|
||||
|
||||
---
|
||||
|
||||
## 6. Risks of the current design
|
||||
|
||||
- **Single runner bottleneck**. The QNAP has exactly one `act_runner`
|
||||
container; pushes to two repos (template + portfolio) serialise. If both
|
||||
repos ever have long-running builds, developer iteration slows. **No fix
|
||||
for now** (registering a second runner is on the long-run roadmap).
|
||||
- **`pull_policy` not set**. The compose does not force a pull-on-create.
|
||||
See Proposal C above.
|
||||
- **No `if: failure()` retry or notify step**. If the Portainer redeploy
|
||||
fails, the run ends with `failure` but no notification pings anyone. The
|
||||
user has to look at the Actions tab manually. **Add a Slack/email
|
||||
notification step in a future iteration** (low priority while there is
|
||||
one user).
|
||||
- **The `IMAGE_NAME` env var in the workflow is hardcoded**. When the
|
||||
version bumps from `0.0.1` to `0.0.2`, a separate edit is needed in
|
||||
`ci-cd.yaml`. Could be moved to a `VERSION` repo variable later.
|
||||
|
||||
---
|
||||
|
||||
## 7. Verification checklist before first real deploy
|
||||
|
||||
Run through this list before firing `workflow_dispatch(deploy=true)` to
|
||||
reduce surprise:
|
||||
|
||||
- [ ] `act_runner` is `Idle` (or `Online`) in `https://gitlab.impresion3d.pro/-/admin/actions/runners`. Not in `Restarting`.
|
||||
- [ ] `act_runner` has the labels `ubuntu-latest`, `linux`, `x64` at minimum.
|
||||
- [ ] `act_runner`'s docker network can resolve `192.168.1.30` (verify with a debug step that runs `getent hosts gitlab.impresion3d.pro`).
|
||||
- [ ] Portainer URL responds from the runner (verify with a debug step: `curl -I ${PORTAINER_URL}/api/status`).
|
||||
- [ ] All 4 secrets are visible in `https://gitlab.impresion3d.pro/root/davidaragon-portfolio/settings/secrets/actions` with values `(hidden)`.
|
||||
- [ ] `DOCKER_USERNAME` and `DOCKER_PASSWORD` work (verify with `docker login ... -u $U -p $P && docker push <empty-image>` once).
|
||||
- [ ] The `act_runner` host has at least 2-3 GB free disk (for buildx cache + pulled base images).
|
||||
|
||||
The first three items live at the NAS / runner level — see the
|
||||
`qnap-nas` skill for the diagnostics. Items 4-7 are repo-level.
|
||||
|
||||
---
|
||||
|
||||
## 8. Proposed workflow to apply A, B, C
|
||||
|
||||
After the first deploy succeeds and §2 is filled in, apply A, B, C in a
|
||||
single PR. The PR title can be `ci: apply optimisations A B C from
|
||||
docs/ci-optimization-proposal.md`. Estimated effort: 10 minutes. Estimated
|
||||
saving: ~30-50 s per run.
|
||||
|
||||
If §2 reveals a metric that disagrees with §3 (e.g. push is actually fast
|
||||
and `astro build` is the slow part), the proposal list should be re-ranked
|
||||
**before applying**, not after.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — Convention: when to write a separate skill for this
|
||||
|
||||
If the same discovery shows up 3 times across this repo and the Python
|
||||
template, it graduates from "proposal" to "skill". Candidates so far:
|
||||
|
||||
- **Image tag strategy for self-hosted GitLab Container Registry** — both
|
||||
repos push to the same registry. Worth a skill once the second project
|
||||
ships the same pattern.
|
||||
- **Concise Portainer DELETE+CREATE pattern** — the bash snippet in step 3
|
||||
is reusable. Worth a skill the second time it's copy-pasted.
|
||||
@@ -54,9 +54,15 @@ import Footer from '../components/layout/Footer.astro';
|
||||
<section class="mb-12">
|
||||
<h2 class="text-3xl font-semibold mb-4">What I'm Doing Now</h2>
|
||||
<p class="text-text-secondary leading-relaxed mb-4">
|
||||
I work full-time at KEO as Field Application Engineer, where I handle technical
|
||||
I work full-time at KEO as Field Application Engineer, where I handle technical
|
||||
documentation, workflow automation, internal tools, and customer-facing support.
|
||||
</p>
|
||||
<p class="text-text-secondary leading-relaxed mb-4">
|
||||
<em class="text-text-tertiary text-sm">
|
||||
Last site update: 2026-07-09 (build sha visible in the CI run that produced this
|
||||
version of the page).
|
||||
</em>
|
||||
</p>
|
||||
<p class="text-text-secondary leading-relaxed mb-4">
|
||||
In parallel, I'm building <strong class="text-primary">WarrantyHub</strong> — a platform
|
||||
for end users to manage warranties for their home products from one place.
|
||||
|
||||
Reference in New Issue
Block a user