From 11527fc6792dc7dc3b02f378ad3d9c7a56bf2848 Mon Sep 17 00:00:00 2001 From: James Wampler Date: Thu, 2 Jul 2026 12:38:43 -0700 Subject: [PATCH] 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. --- .github/workflows/ci.yml | 48 +++++++++++++++++++++++ .husky/pre-push | 1 + deploy/qa/.env.example | 12 ++++++ deploy/qa/docker-compose.qa.yml | 61 ++++++++++++++++++++++++++++++ package-lock.json | 28 ++++++++++++++ package.json | 11 ++++++ scripts/ci/build.sh | 18 +++++++++ scripts/ci/deploy-qa.sh | 37 ++++++++++++++++++ scripts/ci/docker-build.sh | 23 +++++++++++ scripts/ci/docker-push.sh | 17 +++++++++ scripts/ci/lib.sh | 46 ++++++++++++++++++++++ scripts/ci/prepush.sh | 23 +++++++++++ scripts/ci/test.sh | 15 ++++++++ src/admin/Dockerfile.ci | 19 ++++++++++ src/api/MicCheck.Api/Dockerfile.ci | 23 +++++++++++ 15 files changed, 382 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100755 .husky/pre-push create mode 100644 deploy/qa/.env.example create mode 100644 deploy/qa/docker-compose.qa.yml create mode 100644 package-lock.json create mode 100644 package.json create mode 100755 scripts/ci/build.sh create mode 100755 scripts/ci/deploy-qa.sh create mode 100755 scripts/ci/docker-build.sh create mode 100755 scripts/ci/docker-push.sh create mode 100755 scripts/ci/lib.sh create mode 100755 scripts/ci/prepush.sh create mode 100755 scripts/ci/test.sh create mode 100644 src/admin/Dockerfile.ci create mode 100644 src/api/MicCheck.Api/Dockerfile.ci diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9878d8b --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 0000000..d8d14fe --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1 @@ +exec ./scripts/ci/prepush.sh diff --git a/deploy/qa/.env.example b/deploy/qa/.env.example new file mode 100644 index 0000000..5fa2c5e --- /dev/null +++ b/deploy/qa/.env.example @@ -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 diff --git a/deploy/qa/docker-compose.qa.yml b/deploy/qa/docker-compose.qa.yml new file mode 100644 index 0000000..01b3d24 --- /dev/null +++ b/deploy/qa/docker-compose.qa.yml @@ -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 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5a28c3d --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4690341 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/scripts/ci/build.sh b/scripts/ci/build.sh new file mode 100755 index 0000000..9faa9a3 --- /dev/null +++ b/scripts/ci/build.sh @@ -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" diff --git a/scripts/ci/deploy-qa.sh b/scripts/ci/deploy-qa.sh new file mode 100755 index 0000000..cd62263 --- /dev/null +++ b/scripts/ci/deploy-qa.sh @@ -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}" diff --git a/scripts/ci/docker-build.sh b/scripts/ci/docker-build.sh new file mode 100755 index 0000000..be07457 --- /dev/null +++ b/scripts/ci/docker-build.sh @@ -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" diff --git a/scripts/ci/docker-push.sh b/scripts/ci/docker-push.sh new file mode 100755 index 0000000..36f6879 --- /dev/null +++ b/scripts/ci/docker-push.sh @@ -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" diff --git a/scripts/ci/lib.sh b/scripts/ci/lib.sh new file mode 100755 index 0000000..372a771 --- /dev/null +++ b/scripts/ci/lib.sh @@ -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 +} diff --git a/scripts/ci/prepush.sh b/scripts/ci/prepush.sh new file mode 100755 index 0000000..b9ce38c --- /dev/null +++ b/scripts/ci/prepush.sh @@ -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" diff --git a/scripts/ci/test.sh b/scripts/ci/test.sh new file mode 100755 index 0000000..5d7c5db --- /dev/null +++ b/scripts/ci/test.sh @@ -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" diff --git a/src/admin/Dockerfile.ci b/src/admin/Dockerfile.ci new file mode 100644 index 0000000..f720d40 --- /dev/null +++ b/src/admin/Dockerfile.ci @@ -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;"] diff --git a/src/api/MicCheck.Api/Dockerfile.ci b/src/api/MicCheck.Api/Dockerfile.ci new file mode 100644 index 0000000..57fc0b5 --- /dev/null +++ b/src/api/MicCheck.Api/Dockerfile.ci @@ -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"]