Add Gitea/GitHub CI pipeline, QA deploy, and Husky pre-push hook
Builds and tests are driven entirely by scripts/ci/*.sh so every step (build, test, image build/push, QA deploy) can be run identically in CI or on a workstation. QA runs as an isolated docker-compose stack with its Postgres volume wiped and recreated on each deploy.
This commit is contained in:
48
.github/workflows/ci.yml
vendored
Normal file
48
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# CI/CD pipeline for MicCheck. Read by both Gitea Actions and GitHub Actions
|
||||||
|
# (both look under .github/workflows/). Every non-checkout step just invokes a
|
||||||
|
# bash script under scripts/ci/, so the entire pipeline is reproducible by
|
||||||
|
# running the same scripts locally - no marketplace build/test/push actions.
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
REGISTRY: ${{ secrets.REGISTRY }}
|
||||||
|
REGISTRY_OWNER: ${{ secrets.REGISTRY_OWNER }}
|
||||||
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: ./scripts/ci/build.sh
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: ./scripts/ci/test.sh
|
||||||
|
|
||||||
|
- name: Build Docker images
|
||||||
|
run: ./scripts/ci/docker-build.sh
|
||||||
|
|
||||||
|
- name: Push Docker images
|
||||||
|
run: ./scripts/ci/docker-push.sh
|
||||||
|
|
||||||
|
deploy-qa:
|
||||||
|
needs: build-and-push
|
||||||
|
runs-on: [self-hosted, qa]
|
||||||
|
env:
|
||||||
|
REGISTRY: ${{ secrets.REGISTRY }}
|
||||||
|
REGISTRY_OWNER: ${{ secrets.REGISTRY_OWNER }}
|
||||||
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }}
|
||||||
|
QA_ADMIN_PORT: ${{ vars.QA_ADMIN_PORT }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Deploy to QA
|
||||||
|
run: ./scripts/ci/deploy-qa.sh
|
||||||
1
.husky/pre-push
Executable file
1
.husky/pre-push
Executable file
@@ -0,0 +1 @@
|
|||||||
|
exec ./scripts/ci/prepush.sh
|
||||||
12
deploy/qa/.env.example
Normal file
12
deploy/qa/.env.example
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Copy to .env (or export in CI) and fill in real values.
|
||||||
|
# Used by scripts/ci/*.sh and deploy/qa/docker-compose.qa.yml.
|
||||||
|
|
||||||
|
# Gitea container registry
|
||||||
|
REGISTRY=gitea.example.com
|
||||||
|
REGISTRY_OWNER=your-org-or-user
|
||||||
|
REGISTRY_USER=ci-bot
|
||||||
|
REGISTRY_TOKEN=changeme
|
||||||
|
|
||||||
|
# QA environment
|
||||||
|
JWT_SECRET_KEY=change-this-to-a-random-32-plus-char-secret
|
||||||
|
QA_ADMIN_PORT=3001
|
||||||
61
deploy/qa/docker-compose.qa.yml
Normal file
61
deploy/qa/docker-compose.qa.yml
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
name: miccheck-qa
|
||||||
|
|
||||||
|
# Dedicated, network-isolated QA stack. Not connected to the dev compose
|
||||||
|
# stack's network - runs entirely on its own bridge network below, and the
|
||||||
|
# database has no published host port.
|
||||||
|
services:
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: miccheck
|
||||||
|
POSTGRES_USER: miccheck
|
||||||
|
POSTGRES_PASSWORD: password
|
||||||
|
volumes:
|
||||||
|
- miccheck-qa-pgdata:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- qa
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U miccheck -d miccheck"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
api:
|
||||||
|
image: ${API_IMAGE}:qa
|
||||||
|
environment:
|
||||||
|
ASPNETCORE_ENVIRONMENT: Development
|
||||||
|
ASPNETCORE_URLS: http://+:8080
|
||||||
|
ConnectionStrings__miccheck: "Host=db;Database=miccheck;Username=miccheck;Password=password"
|
||||||
|
Jwt__SecretKey: ${JWT_SECRET_KEY}
|
||||||
|
Jwt__Issuer: MicCheck
|
||||||
|
Jwt__Audience: MicCheck
|
||||||
|
networks:
|
||||||
|
- qa
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/api/v1/health 2>/dev/null || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
admin:
|
||||||
|
image: ${ADMIN_IMAGE}:qa
|
||||||
|
ports:
|
||||||
|
- "${QA_ADMIN_PORT:-3001}:80"
|
||||||
|
networks:
|
||||||
|
- qa
|
||||||
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
networks:
|
||||||
|
qa:
|
||||||
|
name: miccheck-qa-net
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
miccheck-qa-pgdata:
|
||||||
|
name: miccheck-qa-pgdata
|
||||||
28
package-lock.json
generated
Normal file
28
package-lock.json
generated
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "mic-check",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "mic-check",
|
||||||
|
"devDependencies": {
|
||||||
|
"husky": "^9.1.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/husky": {
|
||||||
|
"version": "9.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
|
||||||
|
"integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
|
||||||
|
"dev": true,
|
||||||
|
"bin": {
|
||||||
|
"husky": "bin.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/typicode"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
package.json
Normal file
11
package.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "mic-check",
|
||||||
|
"private": true,
|
||||||
|
"description": "Repo-root package - hosts the Husky pre-push hook only.",
|
||||||
|
"scripts": {
|
||||||
|
"prepare": "husky"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"husky": "^9.1.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
18
scripts/ci/build.sh
Executable file
18
scripts/ci/build.sh
Executable file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Compiles the API (and its dependents) and builds the admin SPA.
|
||||||
|
# Acts as the compile gate before tests/image builds run. TreatWarningsAsErrors
|
||||||
|
# is enabled across the solution, so this also fails on any compiler warning.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
|
||||||
|
cd "$CI_ROOT"
|
||||||
|
|
||||||
|
log "Restoring and publishing MicCheck.Api (Release)"
|
||||||
|
dotnet publish src/api/MicCheck.Api/MicCheck.Api.csproj -c Release
|
||||||
|
|
||||||
|
log "Installing admin dependencies (npm ci)"
|
||||||
|
npm --prefix src/admin ci
|
||||||
|
|
||||||
|
log "Building admin SPA (vite build)"
|
||||||
|
npm --prefix src/admin run build
|
||||||
|
|
||||||
|
log "build.sh complete"
|
||||||
37
scripts/ci/deploy-qa.sh
Executable file
37
scripts/ci/deploy-qa.sh
Executable file
@@ -0,0 +1,37 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Deploys the freshly-pushed :qa images to the dedicated, network-isolated QA
|
||||||
|
# stack and recreates the miccheck database in an empty state. Safe/idempotent
|
||||||
|
# to re-run: `down -v` removes the Postgres data volume, and the API's own
|
||||||
|
# startup logic (EF Core migrations + idempotent dev seeding) rebuilds it.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
|
||||||
|
cd "$CI_ROOT"
|
||||||
|
image_names
|
||||||
|
registry_login
|
||||||
|
|
||||||
|
export API_IMAGE ADMIN_IMAGE
|
||||||
|
export JWT_SECRET_KEY="${JWT_SECRET_KEY:?JWT_SECRET_KEY env var is required}"
|
||||||
|
export QA_ADMIN_PORT="${QA_ADMIN_PORT:-3001}"
|
||||||
|
|
||||||
|
COMPOSE="docker compose -p miccheck-qa -f deploy/qa/docker-compose.qa.yml"
|
||||||
|
|
||||||
|
log "Pulling latest :qa images"
|
||||||
|
$COMPOSE pull
|
||||||
|
|
||||||
|
log "Tearing down existing QA stack and wiping the database volume"
|
||||||
|
$COMPOSE down -v
|
||||||
|
|
||||||
|
log "Starting QA stack"
|
||||||
|
$COMPOSE up -d
|
||||||
|
|
||||||
|
log "Waiting for API health check via admin proxy on port $QA_ADMIN_PORT"
|
||||||
|
attempts=30
|
||||||
|
until curl -fsS "http://localhost:${QA_ADMIN_PORT}/api/v1/health" >/dev/null 2>&1; do
|
||||||
|
attempts=$((attempts - 1))
|
||||||
|
if [[ "$attempts" -le 0 ]]; then
|
||||||
|
fail "QA stack did not become healthy in time"
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
log "QA environment deployed and healthy at http://localhost:${QA_ADMIN_PORT}"
|
||||||
23
scripts/ci/docker-build.sh
Executable file
23
scripts/ci/docker-build.sh
Executable file
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Builds self-contained CI images for the API and admin apps and tags them
|
||||||
|
# with both the current git sha and "qa" (the tag the QA compose stack pulls).
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
|
||||||
|
cd "$CI_ROOT"
|
||||||
|
image_names
|
||||||
|
|
||||||
|
log "Building $API_IMAGE:$GIT_SHA / :qa"
|
||||||
|
docker build \
|
||||||
|
-f src/api/MicCheck.Api/Dockerfile.ci \
|
||||||
|
-t "$API_IMAGE:$GIT_SHA" \
|
||||||
|
-t "$API_IMAGE:qa" \
|
||||||
|
.
|
||||||
|
|
||||||
|
log "Building $ADMIN_IMAGE:$GIT_SHA / :qa"
|
||||||
|
docker build \
|
||||||
|
-f src/admin/Dockerfile.ci \
|
||||||
|
-t "$ADMIN_IMAGE:$GIT_SHA" \
|
||||||
|
-t "$ADMIN_IMAGE:qa" \
|
||||||
|
src/admin
|
||||||
|
|
||||||
|
log "docker-build.sh complete"
|
||||||
17
scripts/ci/docker-push.sh
Executable file
17
scripts/ci/docker-push.sh
Executable file
@@ -0,0 +1,17 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Pushes the images built by docker-build.sh (git-sha and qa tags) to the
|
||||||
|
# Gitea container registry.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
|
||||||
|
cd "$CI_ROOT"
|
||||||
|
image_names
|
||||||
|
registry_login
|
||||||
|
|
||||||
|
for tag in "$GIT_SHA" qa; do
|
||||||
|
log "Pushing $API_IMAGE:$tag"
|
||||||
|
docker push "$API_IMAGE:$tag"
|
||||||
|
log "Pushing $ADMIN_IMAGE:$tag"
|
||||||
|
docker push "$ADMIN_IMAGE:$tag"
|
||||||
|
done
|
||||||
|
|
||||||
|
log "docker-push.sh complete"
|
||||||
46
scripts/ci/lib.sh
Executable file
46
scripts/ci/lib.sh
Executable file
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Shared helpers for scripts/ci/*.sh.
|
||||||
|
# Every script in this directory is meant to run identically in CI and on a
|
||||||
|
# developer's machine - no Gitea/GitHub-specific built-in actions, just bash.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] ERROR: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Repo root, regardless of caller's cwd.
|
||||||
|
CI_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
|
||||||
|
# Registry configuration. All values come from the environment (CI secrets or
|
||||||
|
# a developer's shell) - nothing is hardcoded, per project convention.
|
||||||
|
REGISTRY="${REGISTRY:-}"
|
||||||
|
REGISTRY_OWNER="${REGISTRY_OWNER:-}"
|
||||||
|
REGISTRY_USER="${REGISTRY_USER:-}"
|
||||||
|
REGISTRY_TOKEN="${REGISTRY_TOKEN:-}"
|
||||||
|
|
||||||
|
GIT_SHA="$(git -C "$CI_ROOT" rev-parse --short HEAD)"
|
||||||
|
|
||||||
|
require_registry_vars() {
|
||||||
|
[[ -n "$REGISTRY" ]] || fail "REGISTRY env var is required (e.g. gitea.example.com)"
|
||||||
|
[[ -n "$REGISTRY_OWNER" ]] || fail "REGISTRY_OWNER env var is required (e.g. your gitea org/user)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Populates API_IMAGE / ADMIN_IMAGE, e.g. gitea.example.com/james/miccheck-api
|
||||||
|
image_names() {
|
||||||
|
require_registry_vars
|
||||||
|
API_IMAGE="$REGISTRY/$REGISTRY_OWNER/miccheck-api"
|
||||||
|
ADMIN_IMAGE="$REGISTRY/$REGISTRY_OWNER/miccheck-admin"
|
||||||
|
}
|
||||||
|
|
||||||
|
registry_login() {
|
||||||
|
require_registry_vars
|
||||||
|
[[ -n "$REGISTRY_USER" ]] || fail "REGISTRY_USER env var is required to push images"
|
||||||
|
[[ -n "$REGISTRY_TOKEN" ]] || fail "REGISTRY_TOKEN env var is required to push images"
|
||||||
|
log "Logging in to $REGISTRY as $REGISTRY_USER"
|
||||||
|
echo "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
|
||||||
|
}
|
||||||
23
scripts/ci/prepush.sh
Executable file
23
scripts/ci/prepush.sh
Executable file
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Husky pre-push hook body. Compiles everything and runs the fast test suites
|
||||||
|
# (no Docker, no registry) so broken code/tests never leave the workstation.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
|
||||||
|
cd "$CI_ROOT"
|
||||||
|
|
||||||
|
log "Building full solution (Debug)"
|
||||||
|
dotnet build MicCheck.slnx -c Debug
|
||||||
|
|
||||||
|
log "Running MicCheck.Api.Tests.Unit"
|
||||||
|
dotnet test tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj -c Debug
|
||||||
|
|
||||||
|
log "Installing admin dependencies (npm ci)"
|
||||||
|
npm --prefix src/admin ci
|
||||||
|
|
||||||
|
log "Running admin Jest tests"
|
||||||
|
npm --prefix src/admin test
|
||||||
|
|
||||||
|
log "Building admin SPA (vite build)"
|
||||||
|
npm --prefix src/admin run build
|
||||||
|
|
||||||
|
log "prepush.sh complete - ok to push"
|
||||||
15
scripts/ci/test.sh
Executable file
15
scripts/ci/test.sh
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Runs the fast test suites: .NET unit tests (EF InMemory, no DB/Docker needed)
|
||||||
|
# and the admin Jest suite. Integration tests are intentionally excluded here -
|
||||||
|
# they require a live API + Postgres (see readme in the Integration test project).
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh
|
||||||
|
cd "$CI_ROOT"
|
||||||
|
|
||||||
|
log "Running MicCheck.Api.Tests.Unit"
|
||||||
|
dotnet test tests/api/MicCheck.Api.Tests.Unit/MicCheck.Api.Tests.Unit.csproj -c Release --logger trx
|
||||||
|
|
||||||
|
log "Running admin Jest tests"
|
||||||
|
npm --prefix src/admin test
|
||||||
|
|
||||||
|
log "test.sh complete"
|
||||||
19
src/admin/Dockerfile.ci
Normal file
19
src/admin/Dockerfile.ci
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Self-contained CI/QA image for the admin SPA. Unlike the dev Dockerfile
|
||||||
|
# (which serves a host-built ./dist via bind mount), this builds the SPA
|
||||||
|
# inside the image so it can be pushed to a registry and run standalone.
|
||||||
|
#
|
||||||
|
# Build context is src/admin:
|
||||||
|
# docker build -f src/admin/Dockerfile.ci -t miccheck-admin:qa src/admin
|
||||||
|
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
23
src/api/MicCheck.Api/Dockerfile.ci
Normal file
23
src/api/MicCheck.Api/Dockerfile.ci
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Self-contained CI/QA image for MicCheck.Api. Unlike the dev Dockerfile
|
||||||
|
# (which expects a host-published /app to be bind-mounted), this builds the
|
||||||
|
# app inside the image so it can be pushed to a registry and run standalone.
|
||||||
|
#
|
||||||
|
# Build context must be the repo root (needs ServiceDefaults + the API project):
|
||||||
|
# docker build -f src/api/MicCheck.Api/Dockerfile.ci -t miccheck-api:qa .
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
COPY src/MicCheck.ServiceDefaults/MicCheck.ServiceDefaults.csproj src/MicCheck.ServiceDefaults/
|
||||||
|
COPY src/api/MicCheck.Api/MicCheck.Api.csproj src/api/MicCheck.Api/
|
||||||
|
RUN dotnet restore src/api/MicCheck.Api/MicCheck.Api.csproj
|
||||||
|
|
||||||
|
COPY src/MicCheck.ServiceDefaults/ src/MicCheck.ServiceDefaults/
|
||||||
|
COPY src/api/MicCheck.Api/ src/api/MicCheck.Api/
|
||||||
|
RUN dotnet publish src/api/MicCheck.Api/MicCheck.Api.csproj -c Release -o /out --no-restore
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /out .
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["dotnet", "MicCheck.Api.dll"]
|
||||||
Reference in New Issue
Block a user