Add Gitea Actions CI/CD pipeline
Deploy / check (push) Successful in 9m45s
Deploy / build (push) Failing after 4s
Deploy / deploy (push) Has been skipped

Build and push images to the private registry, then deploy to the VPS by commit SHA with health check and rollback.
This commit is contained in:
2026-08-20 15:16:37 +02:00
parent 541e5c26c1
commit 4eef81fdfc
12 changed files with 4650 additions and 87 deletions
+5
View File
@@ -5,3 +5,8 @@ node_modules
!.env.example !.env.example
design design
*.md *.md
# Kept out of the build context so editing a workflow or a deploy script
# doesn't invalidate the `COPY . .` layer and force a full rebuild.
.gitea
deploy
docker-compose*.yaml
+6
View File
@@ -1,6 +1,12 @@
# Used by docker-compose.yml for the Traefik Host() rule # Used by docker-compose.yml for the Traefik Host() rule
DOMAIN= DOMAIN=
# Which image docker-compose.yml runs. CI passes IMAGE_TAG in the environment
# (shell env wins over this file), so it only matters for manual `docker compose
# up` on the VPS — set it to a specific sha to pin, or leave it for :latest.
# IMAGE_NAME=git.ksan.dev/ksan/ksan.dev
# IMAGE_TAG=
GMAIL_USER= GMAIL_USER=
GMAIL_APP_PASSWORD= GMAIL_APP_PASSWORD=
CONTACT_TO_EMAIL= CONTACT_TO_EMAIL=
+31
View File
@@ -0,0 +1,31 @@
name: CI
# Pushes to main are covered by deploy.yaml, which runs this same check job
# before it builds an image.
on:
pull_request:
push:
branches-ignore:
- main
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run typecheck
- run: npm run lint
# PLANS_PAGES is deliberately unset here: getPlanPages() falls back to an
# empty list, so the build succeeds without the secret and no pricing
# codes are exposed to a PR build.
- run: npm run build
+118
View File
@@ -0,0 +1,118 @@
name: Deploy
on:
push:
branches:
- main
# Two overlapping deploys would race on the same container. Queue instead.
concurrency:
group: deploy-production
cancel-in-progress: false
env:
IMAGE: git.ksan.dev/ksan/ksan.dev
SITE_DIR: /home/deploy/websites/ksan.dev
DOMAIN: ksan.dev
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run typecheck
- run: npm run lint
build:
needs: check
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.meta.outputs.tag }}
steps:
- uses: actions/checkout@v4
- id: meta
run: echo "tag=$(echo "${{ github.sha }}" | cut -c1-12)" >> "$GITHUB_OUTPUT"
- name: Log in to the Gitea registry
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: echo "$REGISTRY_TOKEN" | docker login git.ksan.dev -u "$REGISTRY_USER" --password-stdin
# PLANS_PAGES is a build arg: the codes are baked into the image via
# generateStaticParams, so this image must only ever go to the private
# package. Never push this repo's image to a public registry.
- name: Build and push
env:
PLANS_PAGES: ${{ secrets.PLANS_PAGES }}
TAG: ${{ steps.meta.outputs.tag }}
run: |
docker build \
--build-arg PLANS_PAGES="$PLANS_PAGES" \
-t "$IMAGE:$TAG" \
-t "$IMAGE:latest" \
.
docker push "$IMAGE:$TAG"
docker push "$IMAGE:latest"
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure SSH
env:
SSH_KEY: ${{ secrets.SSH_KEY }}
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
run: |
mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
# Pinned host key — never StrictHostKeyChecking=no on a deploy path.
printf '%s\n' "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts
- name: Ship compose file and deploy scripts
env:
SSH_HOST: ${{ secrets.SSH_HOST }}
SSH_USER: ${{ secrets.SSH_USER }}
run: |
scp -i ~/.ssh/id_ed25519 \
docker-compose.yaml deploy/deploy.sh deploy/rollback.sh \
"$SSH_USER@$SSH_HOST:$SITE_DIR/"
ssh -i ~/.ssh/id_ed25519 "$SSH_USER@$SSH_HOST" \
"chmod +x $SITE_DIR/deploy.sh $SITE_DIR/rollback.sh"
- name: Deploy
env:
SSH_HOST: ${{ secrets.SSH_HOST }}
SSH_USER: ${{ secrets.SSH_USER }}
TAG: ${{ needs.build.outputs.tag }}
run: |
ssh -i ~/.ssh/id_ed25519 "$SSH_USER@$SSH_HOST" \
"IMAGE_TAG=$TAG $SITE_DIR/deploy.sh"
# End-to-end, from outside the VPS: proves the container, the Traefik
# router labels, the frontend network attachment and the cert all work.
# A localhost probe on the VPS would miss a broken router rule.
- name: Verify the site is serving
run: |
curl -fsS --retry 10 --retry-delay 3 --retry-connrefused \
-o /dev/null "https://$DOMAIN/en"
- name: Roll back
if: failure()
env:
SSH_HOST: ${{ secrets.SSH_HOST }}
SSH_USER: ${{ secrets.SSH_USER }}
run: |
ssh -i ~/.ssh/id_ed25519 "$SSH_USER@$SSH_HOST" "$SITE_DIR/rollback.sh"
echo "::error::Deploy failed — rolled back to the last healthy build."
exit 1
+3
View File
@@ -37,6 +37,9 @@ yarn-error.log*
.env* .env*
!.env.example !.env.example
# deployment runbook — infra details stay off the remote
DEPLOYMENT.md
# vercel # vercel
.vercel .vercel
+4 -8
View File
@@ -73,15 +73,12 @@ export default function ProjectsCarousel({
.sort((a, b) => a.carouselOrder - b.carouselOrder); .sort((a, b) => a.carouselOrder - b.carouselOrder);
const cardsToShow = useCardsToShow(); const cardsToShow = useCardsToShow();
const [index, setIndex] = useState(0); const [rawIndex, setIndex] = useState(0);
const maxIndex = Math.max(0, projects.length - cardsToShow); const maxIndex = Math.max(0, projects.length - cardsToShow);
// Clamp index when viewport resize shrinks maxIndex // Clamp during render rather than in an effect: a resize that shrinks
useEffect(() => { // maxIndex takes effect on the same render instead of a second one.
if (index > maxIndex) { const index = Math.min(rawIndex, maxIndex);
setIndex(maxIndex);
}
}, [index, maxIndex]);
// ─── PIXEL-BASED MOTION VALUE ───────────────────────────────── // ─── PIXEL-BASED MOTION VALUE ─────────────────────────────────
const trackRef = useRef<HTMLDivElement>(null); const trackRef = useRef<HTMLDivElement>(null);
@@ -348,7 +345,6 @@ export default function ProjectsCarousel({
width: `calc(${cardPercent}% - ${(gapPx * (cardsToShow - 1)) / cardsToShow}px)`, width: `calc(${cardPercent}% - ${(gapPx * (cardsToShow - 1)) / cardsToShow}px)`,
}} }}
> >
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div <div
className="group h-full" className="group h-full"
style={{ cursor: "pointer" }} style={{ cursor: "pointer" }}
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Runs on the deploy VPS as the `deploy` user (member of the docker group).
# Shipped here by CI alongside docker-compose.yaml.
#
# IMAGE_TAG=<sha> /home/deploy/websites/ksan.dev/deploy.sh
#
# On success the tag is recorded in .tag.good, which rollback.sh reads.
set -euo pipefail
SITE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SITE_DIR"
: "${IMAGE_TAG:?IMAGE_TAG must be set}"
if [[ ! "$IMAGE_TAG" =~ ^[0-9a-f]{7,40}$ ]]; then
echo "deploy: refusing to deploy non-sha tag '$IMAGE_TAG'" >&2
exit 1
fi
export IMAGE_TAG
echo "deploy: pulling $IMAGE_TAG"
docker compose pull --quiet
echo "deploy: starting"
# --wait blocks on the healthcheck, so a container that boots and immediately
# dies fails here rather than being reported as a successful deploy.
if ! docker compose up -d --remove-orphans --wait --wait-timeout 120; then
echo "deploy: container did not become healthy" >&2
docker compose logs --tail 50 >&2 || true
exit 1
fi
echo "$IMAGE_TAG" > .tag.good
echo "deploy: $IMAGE_TAG is live"
# Old images accumulate fast on a multi-site box; keep a week for rollbacks.
docker image prune -f --filter until=168h >/dev/null 2>&1 || true
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Re-deploys the last tag that passed a health check, or an explicit tag:
#
# /home/deploy/websites/ksan.dev/rollback.sh # last known good
# /home/deploy/websites/ksan.dev/rollback.sh <sha> # a specific build
#
# The image is already on the host, so this is a container restart, not a build.
set -euo pipefail
SITE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SITE_DIR"
if [[ $# -ge 1 ]]; then
IMAGE_TAG="$1"
elif [[ -f .tag.good ]]; then
IMAGE_TAG="$(cat .tag.good)"
else
echo "rollback: no .tag.good and no tag given — nothing to roll back to" >&2
exit 1
fi
if [[ ! "$IMAGE_TAG" =~ ^[0-9a-f]{7,40}$ ]]; then
echo "rollback: invalid tag '$IMAGE_TAG'" >&2
exit 1
fi
export IMAGE_TAG
echo "rollback: restoring $IMAGE_TAG"
docker compose pull --quiet
docker compose up -d --remove-orphans --wait --wait-timeout 120
echo "rollback: $IMAGE_TAG is live"
+14
View File
@@ -0,0 +1,14 @@
# Local-dev override: restores the build context that docker-compose.yaml gave up
# when deploys moved to registry images.
#
# docker compose -f docker-compose.yaml -f docker-compose.build.yaml up -d --build
#
# CI does not use this file — the deploy workflow builds with `docker build`
# directly so it can tag and push in one step.
services:
ksan-dev:
build:
context: .
args:
PLANS_PAGES: ${PLANS_PAGES}
image: ${IMAGE_NAME:-git.ksan.dev/ksan/ksan.dev}:${IMAGE_TAG:-dev}
+18 -4
View File
@@ -1,15 +1,29 @@
services: services:
ksan-dev: ksan-dev:
build: # Built and pushed by CI; deployed by tag, never by :latest, so a rollback
context: . # is a re-run of `up -d` with an older tag instead of a rebuild.
args: image: ${IMAGE_NAME:-git.ksan.dev/ksan/ksan.dev}:${IMAGE_TAG:-latest}
PLANS_PAGES: ${PLANS_PAGES}
container_name: ksan-dev container_name: ksan-dev
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- .env - .env
networks: networks:
- frontend - frontend
# Shared VPS: keep one site's leak from taking the box down with it.
mem_limit: 512m
cpus: 0.5
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
# `/` 307-redirects to the default locale, so probe the locale path directly.
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:3000/en"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.http.routers.ksan-dev-http.rule=Host(`${DOMAIN}`) || Host(`www.${DOMAIN}`) - traefik.http.routers.ksan-dev-http.rule=Host(`${DOMAIN}`) || Host(`www.${DOMAIN}`)
+4380 -72
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -6,7 +6,8 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint",
"typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -26,8 +27,8 @@
"@types/nodemailer": "^8.0.0", "@types/nodemailer": "^8.0.0",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^10.8.0", "eslint": "^9.39.5",
"eslint-config-next": "^0.2.4", "eslint-config-next": "^16.3.1",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5"
} }