Add Gitea Actions CI/CD pipeline
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:
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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" }}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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}
|
||||||
@@ -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}`)
|
||||||
Generated
+4380
-72
File diff suppressed because it is too large
Load Diff
+4
-3
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user