Mirror mic-check's Gitea/GitHub CI-CD pipeline for novelly
CI / build-and-push (push) Successful in 58s
CI / deploy (push) Successful in 10s

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.
This commit is contained in:
James Wampler
2026-08-18 18:25:01 -07:00
parent 56e64c6f06
commit 44f722019b
16 changed files with 493 additions and 28 deletions
-1
View File
@@ -1 +0,0 @@
ANTHROPIC_API_KEY=
+75
View File
@@ -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
+2
View File
@@ -176,6 +176,7 @@ _TeamCity*
coverage*.json coverage*.json
coverage*.xml coverage*.xml
coverage*.info coverage*.info
coverage/
# Visual Studio code coverage results # Visual Studio code coverage results
*.coverage *.coverage
@@ -440,6 +441,7 @@ mcp-server/
# Toolchains installed locally by scripts/ci/lib.sh # Toolchains installed locally by scripts/ci/lib.sh
.dotnet/ .dotnet/
.dotnet-tools/
.node/ .node/
.idea/ .idea/
+4
View File
@@ -1,5 +1,9 @@
# Novelly # 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 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 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 embedded in the app that can read and edit the same data you can, and an MCP server that
+138
View File
@@ -0,0 +1,138 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="155" height="20">
<style type="text/css">
<![CDATA[
@keyframes fade1 {
0% { visibility: visible; opacity: 1; }
23% { visibility: visible; opacity: 1; }
25% { visibility: hidden; opacity: 0; }
48% { visibility: hidden; opacity: 0; }
50% { visibility: hidden; opacity: 0; }
73% { visibility: hidden; opacity: 0; }
75% { visibility: hidden; opacity: 0; }
98% { visibility: hidden; opacity: 0; }
100% { visibility: visible; opacity: 1; }
}
@keyframes fade2 {
0% { visibility: hidden; opacity: 0; }
23% { visibility: hidden; opacity: 0; }
25% { visibility: visible; opacity: 1; }
48% { visibility: visible; opacity: 1; }
50% { visibility: hidden; opacity: 0; }
73% { visibility: hidden; opacity: 0; }
75% { visibility: hidden; opacity: 0; }
98% { visibility: hidden; opacity: 0; }
100% { visibility: hidden; opacity: 0; }
}
@keyframes fade3 {
0% { visibility: hidden; opacity: 0; }
23% { visibility: hidden; opacity: 0; }
25% { visibility: hidden; opacity: 0; }
48% { visibility: hidden; opacity: 0; }
50% { visibility: visible; opacity: 1; }
73% { visibility: visible; opacity: 1; }
75% { visibility: hidden; opacity: 0; }
98% { visibility: hidden; opacity: 0; }
100% { visibility: hidden; opacity: 0; }
}
@keyframes fade4 {
0% { visibility: hidden; opacity: 0; }
23% { visibility: hidden; opacity: 0; }
25% { visibility: hidden; opacity: 0; }
48% { visibility: hidden; opacity: 0; }
50% { visibility: hidden; opacity: 0; }
73% { visibility: hidden; opacity: 0; }
75% { visibility: visible; opacity: 1; }
98% { visibility: visible; opacity: 1; }
100% { visibility: hidden; opacity: 0; }
}
.linecoverage {
animation-duration: 15s;
animation-name: fade1;
animation-iteration-count: infinite;
}
.branchcoverage {
animation-duration: 15s;
animation-name: fade2;
animation-iteration-count: infinite;
}
.methodcoverage {
animation-duration: 15s;
animation-name: fade3;
animation-iteration-count: infinite;
}
.fullmethodcoverage {
animation-duration: 15s;
animation-name: fade4;
animation-iteration-count: infinite;
}
]]>
</style>
<title>Code coverage</title>
<defs>
<linearGradient id="gradient" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<linearGradient id="c">
<stop offset="0" stop-color="#d40000"/>
<stop offset="1" stop-color="#ff2a2a"/>
</linearGradient>
<linearGradient id="a">
<stop offset="0" stop-color="#e0e0de"/>
<stop offset="1" stop-color="#fff"/>
</linearGradient>
<linearGradient id="b">
<stop offset="0" stop-color="#37c837"/>
<stop offset="1" stop-color="#217821"/>
</linearGradient>
<linearGradient xlink:href="#a" id="e" x1="106.44" x2="69.96" y1="-11.96" y2="-46.84" gradientTransform="matrix(-.8426 -.00045 -.00045 -.8426 -94.27 -75.82)" gradientUnits="userSpaceOnUse"/>
<linearGradient xlink:href="#b" id="f" x1="56.19" x2="77.97" y1="-23.45" y2="10.62" gradientTransform="matrix(.8426 .00045 .00045 .8426 94.27 75.82)" gradientUnits="userSpaceOnUse"/>
<linearGradient xlink:href="#c" id="g" x1="79.98" x2="132.9" y1="10.79" y2="10.79" gradientTransform="matrix(.8426 .00045 .00045 .8426 94.27 75.82)" gradientUnits="userSpaceOnUse"/>
<mask id="mask">
<rect width="155" height="20" rx="3" fill="#fff"/>
</mask>
<g id="icon" transform="matrix(.04486 0 0 .04481 -.48 -.63)">
<rect width="52.92" height="52.92" x="-109.72" y="-27.13" fill="url(#e)" transform="rotate(-135)"/>
<rect width="52.92" height="52.92" x="70.19" y="-39.18" fill="url(#f)" transform="rotate(45)"/>
<rect width="52.92" height="52.92" x="80.05" y="-15.74" fill="url(#g)" transform="rotate(45)"/>
</g>
</defs>
<g mask="url(#mask)">
<rect x="0" y="0" width="90" height="20" fill="#444"/>
<rect x="90" y="0" width="20" height="20" fill="#c00"/>
<rect x="110" y="0" width="45" height="20" fill="#00B600"/>
<rect x="0" y="0" width="155" height="20" fill="url(#gradient)"/>
</g>
<g>
<path class="" stroke="#fff" d="M94 6.5 h12 M94 10.5 h12 M94 14.5 h12"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Arial,Geneva,sans-serif" font-size="11">
<a xlink:href="https://github.com/danielpalme/ReportGenerator" target="_top">
<title>Generated by: ReportGenerator 5.5.11.0</title>
<use xlink:href="#icon" transform="translate(3,1) scale(3.5)"/>
</a>
<text x="53" y="15" fill="#010101" fill-opacity=".3">Coverage</text>
<text x="53" y="14" fill="#fff">Coverage</text>
<text class="" x="132.5" y="15" fill="#010101" fill-opacity=".3">65.7%</text><text class="" x="132.5" y="14">65.7%</text>
</g>
<g>
<rect class="" x="90" y="0" width="65" height="20" fill-opacity="0"><title>Line coverage</title></rect>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.1 KiB

+43
View File
@@ -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
-27
View File
@@ -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:
+19
View File
@@ -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"
+31
View File
@@ -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"
+36
View File
@@ -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}"
+25
View File
@@ -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"
+17
View File
@@ -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"
+46
View File
@@ -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. # one as a last resort, so a bare runner behaves the same as a dev machine.
ensure_dotnet() { ensure_dotnet() {
if command -v dotnet > /dev/null 2>&1; then 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 return 0
fi fi
@@ -51,3 +55,45 @@ ensure_dotnet() {
ensure_node() { ensure_node() {
command -v npm > /dev/null 2>&1 || fail "npm not found on PATH; install Node.js to build the web client" 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
}
+30
View File
@@ -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"
+22
View File
@@ -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"
+5
View File
@@ -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 FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app 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 . COPY --from=build /app .
ENV ASPNETCORE_URLS=http://0.0.0.0:8080 ENV ASPNETCORE_URLS=http://0.0.0.0:8080