From 44f722019bc8bce5f97577d64dc39b0e0f1028e4 Mon Sep 17 00:00:00 2001 From: James Wampler Date: Tue, 18 Aug 2026 18:25:01 -0700 Subject: [PATCH] Mirror mic-check's Gitea/GitHub CI-CD pipeline for novelly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dual-engine workflow (.github/workflows/ci.yml, read by both GitHub Actions and Gitea Actions): build, test, coverage badge on every push; on Gitea main pushes only, build+push API/web images to the Gitea registry and redeploy the persistent LAN stack via the shared [self-hosted, qa] runner. Replaces the ad hoc docker-compose.deploy.yml manual workflow with deploy/qa/docker-compose.qa.yml, pulled by CI — data volume preserved across deploys (no -v on down), unlike mic-check's throwaway QA stack. --- .env.deploy.example | 1 - .github/workflows/ci.yml | 75 +++++++++++++++ .gitignore | 2 + README.md | 4 + badges/coverage.svg | 138 +++++++++++++++++++++++++++ deploy/qa/docker-compose.qa.yml | 43 +++++++++ docker-compose.deploy.yml | 27 ------ scripts/ci/build.sh | 19 ++++ scripts/ci/coverage.sh | 31 ++++++ scripts/ci/deploy.sh | 36 +++++++ scripts/ci/docker-build.sh | 25 +++++ scripts/ci/docker-push.sh | 17 ++++ scripts/ci/lib.sh | 46 +++++++++ scripts/ci/publish-coverage-badge.sh | 30 ++++++ scripts/ci/test.sh | 22 +++++ src/Novelly.Api/Dockerfile | 5 + 16 files changed, 493 insertions(+), 28 deletions(-) delete mode 100644 .env.deploy.example create mode 100644 .github/workflows/ci.yml create mode 100644 badges/coverage.svg create mode 100644 deploy/qa/docker-compose.qa.yml delete mode 100644 docker-compose.deploy.yml create mode 100755 scripts/ci/build.sh create mode 100755 scripts/ci/coverage.sh create mode 100755 scripts/ci/deploy.sh create mode 100755 scripts/ci/docker-build.sh create mode 100755 scripts/ci/docker-push.sh create mode 100755 scripts/ci/publish-coverage-badge.sh create mode 100755 scripts/ci/test.sh diff --git a/.env.deploy.example b/.env.deploy.example deleted file mode 100644 index 8b3e125..0000000 --- a/.env.deploy.example +++ /dev/null @@ -1 +0,0 @@ -ANTHROPIC_API_KEY= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0a8ef9c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,75 @@ +# CI/CD pipeline for Novelly. 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. +# +# Gitea (origin) is the internal remote and runs the full pipeline: build, test, +# coverage badge, docker push, deploy, health check. GitHub is the public mirror and +# only needs to prove the code builds and tests pass — it has no registry secrets and +# no [self-hosted, qa] runner, so the docker push/deploy job is skipped there via the +# `github.server_url` check below (identical on both engines: https://github.com on +# GitHub, the Gitea instance URL on Gitea). +name: CI + +on: + push: + paths-ignore: [badges/**] + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: write + 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: Coverage report + run: ./scripts/ci/coverage.sh + + - name: Publish coverage badge + env: + GITHUB_TOKEN: ${{ github.token }} + run: ./scripts/ci/publish-coverage-badge.sh + + - name: Build Docker images + if: github.server_url != 'https://github.com' && github.ref_name == 'main' + run: ./scripts/ci/docker-build.sh + + - name: Push Docker images + if: github.server_url != 'https://github.com' && github.ref_name == 'main' + run: ./scripts/ci/docker-push.sh + + deploy: + needs: build-and-push + if: github.server_url != 'https://github.com' && github.ref_name == 'main' + runs-on: [self-hosted, qa] + env: + REGISTRY: ${{ secrets.REGISTRY }} + REGISTRY_OWNER: ${{ secrets.REGISTRY_OWNER }} + REGISTRY_USER: ${{ secrets.REGISTRY_USER }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + WEB_PORT: ${{ vars.WEB_PORT }} + steps: + # actions/checkout@v4 is a Node-based action; this runner has no node in PATH, so + # checkout plain git instead of via marketplace action. + - name: Checkout + run: | + git init -q . + git remote add origin "${{ github.server_url }}/${{ github.repository }}.git" + git -c http.extraheader="AUTHORIZATION: bearer ${{ github.token }}" fetch --depth=1 origin "${{ github.sha }}" + git checkout -q FETCH_HEAD + + - name: Deploy + run: ./scripts/ci/deploy.sh diff --git a/.gitignore b/.gitignore index 7302726..0217999 100644 --- a/.gitignore +++ b/.gitignore @@ -176,6 +176,7 @@ _TeamCity* coverage*.json coverage*.xml coverage*.info +coverage/ # Visual Studio code coverage results *.coverage @@ -440,6 +441,7 @@ mcp-server/ # Toolchains installed locally by scripts/ci/lib.sh .dotnet/ +.dotnet-tools/ .node/ .idea/ diff --git a/README.md b/README.md index 3982a10..e9f771a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # Novelly +[![GitHub CI](https://github.com/wamplerj/novelly/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/wamplerj/novelly/actions/workflows/ci.yml) +[![Gitea CI](https://git.wampler.us/wamplerj/novelly/actions/workflows/ci.yml/badge.svg?branch=main)](https://git.wampler.us/wamplerj/novelly/actions?workflow=ci.yml) +![Coverage](badges/coverage.svg) + Software for planning and writing a novel. You outline the book, keep character dossiers, break chapters into scenes, and draft prose — with a Claude-powered agent embedded in the app that can read and edit the same data you can, and an MCP server that diff --git a/badges/coverage.svg b/badges/coverage.svg new file mode 100644 index 0000000..205b90b --- /dev/null +++ b/badges/coverage.svg @@ -0,0 +1,138 @@ + + + Code coverage + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Generated by: ReportGenerator 5.5.11.0 + + + + Coverage + Coverage + 65.7%65.7% + + + + + + + Line coverage + + + + + \ No newline at end of file diff --git a/deploy/qa/docker-compose.qa.yml b/deploy/qa/docker-compose.qa.yml new file mode 100644 index 0000000..1f9cdb1 --- /dev/null +++ b/deploy/qa/docker-compose.qa.yml @@ -0,0 +1,43 @@ +name: novelly + +# Persistent LAN deployment, pulled and recreated by CI on every push to main. +# Unlike a throwaway QA stack, this one keeps its data volume across deploys — `down` +# is run without `-v` so the author's novel data survives a redeploy. +services: + + api: + image: ${API_IMAGE}:latest + restart: unless-stopped + environment: + ConnectionStrings__Novel: "Data Source=/data/novel.db" + Cors__Origins__0: "http://localhost:${WEB_PORT:-6173}" + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + volumes: + - novelly-data:/data + networks: + - novelly + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://localhost:8080/api/health || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + + web: + image: ${WEB_IMAGE}:latest + restart: unless-stopped + depends_on: + api: + condition: service_healthy + networks: + - novelly + ports: + - "${WEB_PORT:-6173}:80" + +networks: + novelly: + name: novelly-net + +volumes: + novelly-data: + name: novelly-data diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml deleted file mode 100644 index a8142b1..0000000 --- a/docker-compose.deploy.yml +++ /dev/null @@ -1,27 +0,0 @@ -services: - api: - build: - context: . - dockerfile: src/Novelly.Api/Dockerfile - restart: unless-stopped - environment: - ConnectionStrings__Novel: "Data Source=/data/novel.db" - Cors__Origins__0: "http://localhost:6173" - ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}" - volumes: - - novelly-data:/data - ports: - - "6080:8080" - - web: - build: - context: src/Novelly.Web - dockerfile: Dockerfile - restart: unless-stopped - depends_on: - - api - ports: - - "6173:80" - -volumes: - novelly-data: diff --git a/scripts/ci/build.sh b/scripts/ci/build.sh new file mode 100755 index 0000000..692e671 --- /dev/null +++ b/scripts/ci/build.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Compiles the API (Release) and builds the web SPA. Acts as the compile gate before +# tests/image builds run. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh +cd "$CI_ROOT" + +ensure_dotnet + +log "Restoring and publishing Novelly.Api (Release)" +dotnet publish src/Novelly.Api/Novelly.Api.csproj -c Release + +log "Installing web dependencies (npm ci)" +npm --prefix src/Novelly.Web ci + +log "Building the web client (vite build)" +npm --prefix src/Novelly.Web run build + +log "build.sh complete" diff --git a/scripts/ci/coverage.sh b/scripts/ci/coverage.sh new file mode 100755 index 0000000..521f223 --- /dev/null +++ b/scripts/ci/coverage.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Turns the Cobertura output from test.sh into a badge + summary via reportgenerator, +# prints it, appends a build-report summary when running under Actions, and refreshes +# the coverage badge committed at badges/coverage.svg. Readme embeds that badge via a +# relative path, which resolves on both GitHub and Gitea since the same repo content is +# pushed to both remotes. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh +cd "$CI_ROOT" + +ensure_dotnet +ensure_reportgenerator + +REPORT_DIR="$CI_ROOT/coverage/report" + +log "Generating coverage report with reportgenerator" +reportgenerator \ + -reports:"coverage/dotnet/coverage.cobertura.xml" \ + -targetdir:"$REPORT_DIR" \ + -reporttypes:"Badges;MarkdownSummaryGithub;TextSummary" + +cat "$REPORT_DIR/Summary.txt" + +if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + cat "$REPORT_DIR/SummaryGithub.md" >> "$GITHUB_STEP_SUMMARY" +fi + +mkdir -p "$CI_ROOT/badges" +cp "$REPORT_DIR/badge_linecoverage.svg" "$CI_ROOT/badges/coverage.svg" + +log "coverage.sh complete" diff --git a/scripts/ci/deploy.sh b/scripts/ci/deploy.sh new file mode 100755 index 0000000..90c0dbc --- /dev/null +++ b/scripts/ci/deploy.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Deploys the freshly-pushed :latest images to the persistent LAN novelly stack and +# waits for the API to report healthy. Unlike a throwaway QA stack, `down` is run +# without `-v` — the novelly-data volume (the author's actual novel) must survive +# every redeploy. Recreating containers against an unchanged image is a no-op, so +# this is safe to re-run. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh +cd "$CI_ROOT" +image_names +registry_login + +export API_IMAGE WEB_IMAGE +export ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}" +export WEB_PORT="${WEB_PORT:-6173}" + +COMPOSE="docker compose -f deploy/qa/docker-compose.qa.yml" + +log "Pulling latest :latest images" +$COMPOSE pull + +log "Recreating the novelly stack (data volume preserved)" +$COMPOSE down +$COMPOSE up -d + +log "Waiting for the API health check" +attempts=30 +until $COMPOSE exec -T api curl -fsS http://localhost:8080/api/health > /dev/null 2>&1; do + attempts=$((attempts - 1)) + if [[ "$attempts" -le 0 ]]; then + fail "novelly stack did not become healthy in time" + fi + sleep 2 +done + +log "novelly deployed and healthy at http://localhost:${WEB_PORT}" diff --git a/scripts/ci/docker-build.sh b/scripts/ci/docker-build.sh new file mode 100755 index 0000000..c0addbf --- /dev/null +++ b/scripts/ci/docker-build.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Builds the API and web images and tags them with both the current git sha and +# "latest" (the tag the deploy compose stack pulls). Both Dockerfiles are already +# self-contained multi-stage builds (used directly by docker-compose.deploy.yml today), +# so no separate CI-only Dockerfile variant is needed. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh +cd "$CI_ROOT" +image_names + +log "Building $API_IMAGE:$GIT_SHA / :latest" +docker build \ + -f src/Novelly.Api/Dockerfile \ + -t "$API_IMAGE:$GIT_SHA" \ + -t "$API_IMAGE:latest" \ + . + +log "Building $WEB_IMAGE:$GIT_SHA / :latest" +docker build \ + -f src/Novelly.Web/Dockerfile \ + -t "$WEB_IMAGE:$GIT_SHA" \ + -t "$WEB_IMAGE:latest" \ + src/Novelly.Web + +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..3bac856 --- /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 latest 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" latest; do + log "Pushing $API_IMAGE:$tag" + docker push "$API_IMAGE:$tag" + log "Pushing $WEB_IMAGE:$tag" + docker push "$WEB_IMAGE:$tag" +done + +log "docker-push.sh complete" diff --git a/scripts/ci/lib.sh b/scripts/ci/lib.sh index 75e8de9..d3cd80a 100755 --- a/scripts/ci/lib.sh +++ b/scripts/ci/lib.sh @@ -25,6 +25,10 @@ DOTNET_CHANNEL="${DOTNET_CHANNEL:-10.0}" # one as a last resort, so a bare runner behaves the same as a dev machine. ensure_dotnet() { if command -v dotnet > /dev/null 2>&1; then + # DOTNET_ROOT is unset by default even when dotnet is already on PATH (e.g. a + # per-user install at ~/.dotnet) — apphost binaries like reportgenerator's fail to + # find the runtime without it. + export DOTNET_ROOT="${DOTNET_ROOT:-$(dirname "$(command -v dotnet)")}" return 0 fi @@ -51,3 +55,45 @@ ensure_dotnet() { ensure_node() { command -v npm > /dev/null 2>&1 || fail "npm not found on PATH; install Node.js to build the web client" } + +ensure_reportgenerator() { + if command -v reportgenerator > /dev/null 2>&1; then + return 0 + fi + + local tool_dir="$CI_ROOT/.dotnet-tools" + if [[ ! -x "$tool_dir/reportgenerator" ]]; then + log "reportgenerator not found on PATH; installing dotnet-reportgenerator-globaltool" + dotnet tool install dotnet-reportgenerator-globaltool --tool-path "$tool_dir" + fi + export PATH="$tool_dir:$PATH" +} + +# 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. git.wampler.us)" + [[ -n "$REGISTRY_OWNER" ]] || fail "REGISTRY_OWNER env var is required (e.g. your gitea org/user)" +} + +# Populates API_IMAGE / WEB_IMAGE, e.g. git.wampler.us/wamplerj/novelly-api +image_names() { + require_registry_vars + API_IMAGE="$REGISTRY/$REGISTRY_OWNER/novelly-api" + WEB_IMAGE="$REGISTRY/$REGISTRY_OWNER/novelly-web" +} + +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/publish-coverage-badge.sh b/scripts/ci/publish-coverage-badge.sh new file mode 100755 index 0000000..495ad25 --- /dev/null +++ b/scripts/ci/publish-coverage-badge.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Commits the coverage badge refreshed by coverage.sh straight back to the +# branch that triggered this run, so readme.md's relative badges/coverage.svg +# link stays current. GITHUB_SERVER_URL/GITHUB_REPOSITORY/GITHUB_REF_NAME are +# default context env vars on both GitHub Actions and Gitea Actions (Gitea's +# engine is GitHub-Actions-compatible); GITHUB_TOKEN must be passed in +# explicitly from the workflow (${{ github.token }}) on both platforms. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh +cd "$CI_ROOT" + +[[ -n "${GITHUB_TOKEN:-}" ]] || fail "GITHUB_TOKEN env var is required to push the badge commit" +[[ -n "${GITHUB_SERVER_URL:-}" ]] || fail "GITHUB_SERVER_URL env var is required to push the badge commit" +[[ -n "${GITHUB_REPOSITORY:-}" ]] || fail "GITHUB_REPOSITORY env var is required to push the badge commit" +[[ -n "${GITHUB_REF_NAME:-}" ]] || fail "GITHUB_REF_NAME env var is required to push the badge commit" + +if git diff --quiet -- badges/coverage.svg; then + log "badges/coverage.svg unchanged; nothing to publish" + exit 0 +fi + +git config user.name "novelly-ci" +git config user.email "ci@novelly.local" +git add badges/coverage.svg +git commit -m "chore: refresh coverage badge [skip ci]" + +remote_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" +git -c http.extraheader="AUTHORIZATION: bearer ${GITHUB_TOKEN}" push "$remote_url" "HEAD:${GITHUB_REF_NAME}" + +log "publish-coverage-badge.sh complete" diff --git a/scripts/ci/test.sh b/scripts/ci/test.sh new file mode 100755 index 0000000..02656ef --- /dev/null +++ b/scripts/ci/test.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Runs Novelly.Api.Tests with coverage collection. No web test suite exists yet +# (src/Novelly.Web/package.json has no "test" script) — nothing to run there. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")" && source ./lib.sh +cd "$CI_ROOT" + +ensure_dotnet + +# Coverlet doesn't clear prior output — on a runner that reuses its workspace +# (self-hosted, unlike GitHub's ephemeral ones), stale coverage from past runs would +# otherwise get merged in by coverage.sh and silently skew the combined percentage. +rm -rf "$CI_ROOT/coverage/dotnet" + +log "Running Novelly.Api.Tests" +dotnet test tests/Novelly.Api.Tests/Novelly.Api.Tests.csproj -c Release --logger trx \ + /p:CollectCoverage=true /p:CoverletOutputFormat=cobertura \ + /p:CoverletOutput="$CI_ROOT/coverage/dotnet/" \ + /p:Exclude="[Novelly.ServiceDefaults]*" \ + /p:ExcludeByFile="**/Data/Migrations/*.cs" + +log "test.sh complete" diff --git a/src/Novelly.Api/Dockerfile b/src/Novelly.Api/Dockerfile index 5254976..2060e6e 100644 --- a/src/Novelly.Api/Dockerfile +++ b/src/Novelly.Api/Dockerfile @@ -9,6 +9,11 @@ RUN dotnet publish src/Novelly.Api/Novelly.Api.csproj -c Release -o /app FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime WORKDIR /app + +# curl is used by the docker-compose healthcheck; the base image ships neither curl nor +# wget, so the healthcheck silently fails as "unhealthy" without it. +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* + COPY --from=build /app . ENV ASPNETCORE_URLS=http://0.0.0.0:8080