|
| 1 | +# ctx CI/CD Pipeline |
| 2 | +# github.com/GottZ/ctx — The memory your LLM pretends to have. |
| 3 | +# GottZ 4-Way RRF | GottZ Scope Model | GottZ Guard |
| 4 | +# |
| 5 | +# Branch-Strategie: root (Haupt-Branch), keine main/master-Konvention. |
| 6 | +# Alle Stages laufen auf: push to root, PR to root, workflow_dispatch. |
| 7 | + |
| 8 | +name: ctx CI/CD |
| 9 | + |
| 10 | +on: |
| 11 | + push: |
| 12 | + branches: [root] |
| 13 | + pull_request: |
| 14 | + branches: [root] |
| 15 | + workflow_dispatch: |
| 16 | + inputs: |
| 17 | + skip_deploy: |
| 18 | + description: "Skip deploy stage (true/false)" |
| 19 | + required: false |
| 20 | + default: "false" |
| 21 | + |
| 22 | +# Ein concurrent Run pro Branch/PR — neuerer Run cancelt älteren. |
| 23 | +# Deploy-Jobs sind davon ausgenommen (concurrency-group ohne cancel). |
| 24 | +concurrency: |
| 25 | + group: ${{ github.workflow }}-${{ github.ref }} |
| 26 | + cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }} |
| 27 | + |
| 28 | +env: |
| 29 | + GO_VERSION: "1.25" |
| 30 | + # Registry: GitHub Container Registry (ghcr.io) |
| 31 | + REGISTRY: ghcr.io |
| 32 | + IMAGE_NAME: ghcr.io/gottz/ctx |
| 33 | + |
| 34 | +jobs: |
| 35 | + # ───────────────────────────────────────────────────────────────────────────── |
| 36 | + # Stage 1: Lint |
| 37 | + # golangci-lint mit der .golangci.yml aus dem Repo (Fallback: Default-Config). |
| 38 | + # Läuft auf: alle Trigger. Kein Netzwerk, kein Docker. |
| 39 | + # ───────────────────────────────────────────────────────────────────────────── |
| 40 | + lint: |
| 41 | + name: Lint |
| 42 | + runs-on: ubuntu-latest |
| 43 | + timeout-minutes: 10 |
| 44 | + steps: |
| 45 | + - name: Checkout |
| 46 | + uses: actions/checkout@v4 |
| 47 | + |
| 48 | + - name: Setup Go ${{ env.GO_VERSION }} |
| 49 | + uses: actions/setup-go@v5 |
| 50 | + with: |
| 51 | + go-version: ${{ env.GO_VERSION }} |
| 52 | + # Go module cache wird von actions/setup-go automatisch gecacht. |
| 53 | + # Key: go-${{ runner.os }}-${{ hashFiles('**/go.sum') }} |
| 54 | + cache: true |
| 55 | + |
| 56 | + - name: golangci-lint |
| 57 | + uses: golangci/golangci-lint-action@v6 |
| 58 | + with: |
| 59 | + version: latest |
| 60 | + # Cache für golangci-lint selbst + Analyse-Ergebnisse |
| 61 | + args: --timeout=5m |
| 62 | + # Nur geänderte Files bei PRs (schneller, weniger Noise) |
| 63 | + only-new-issues: ${{ github.event_name == 'pull_request' }} |
| 64 | + |
| 65 | + # ───────────────────────────────────────────────────────────────────────────── |
| 66 | + # Stage 2: Unit Tests |
| 67 | + # go test ./... -short — kein Docker, kein Netzwerk, rein deterministisch. |
| 68 | + # Läuft auf: alle Trigger. Race-Detector aktiviert. |
| 69 | + # ───────────────────────────────────────────────────────────────────────────── |
| 70 | + unit-tests: |
| 71 | + name: Unit Tests |
| 72 | + runs-on: ubuntu-latest |
| 73 | + timeout-minutes: 10 |
| 74 | + steps: |
| 75 | + - name: Checkout |
| 76 | + uses: actions/checkout@v4 |
| 77 | + |
| 78 | + - name: Setup Go ${{ env.GO_VERSION }} |
| 79 | + uses: actions/setup-go@v5 |
| 80 | + with: |
| 81 | + go-version: ${{ env.GO_VERSION }} |
| 82 | + cache: true |
| 83 | + |
| 84 | + - name: Run unit tests |
| 85 | + run: | |
| 86 | + go test \ |
| 87 | + -v \ |
| 88 | + -short \ |
| 89 | + -race \ |
| 90 | + -count=1 \ |
| 91 | + -timeout=5m \ |
| 92 | + -coverprofile=coverage.out \ |
| 93 | + -covermode=atomic \ |
| 94 | + ./... |
| 95 | +
|
| 96 | + - name: Upload coverage |
| 97 | + uses: actions/upload-artifact@v4 |
| 98 | + with: |
| 99 | + name: coverage-unit |
| 100 | + path: coverage.out |
| 101 | + retention-days: 7 |
| 102 | + |
| 103 | + # ───────────────────────────────────────────────────────────────────────────── |
| 104 | + # Stage 3: Integration Tests |
| 105 | + # Testcontainers mit pgvector/pgvector:pg17. |
| 106 | + # Build-Tag: integration. Braucht Docker-in-Docker (ubuntu-latest hat Docker). |
| 107 | + # Image-Caching via TESTCONTAINERS_PULL_POLICY + GitHub Actions Cache. |
| 108 | + # ───────────────────────────────────────────────────────────────────────────── |
| 109 | + integration-tests: |
| 110 | + name: Integration Tests |
| 111 | + runs-on: ubuntu-latest |
| 112 | + timeout-minutes: 20 |
| 113 | + needs: [unit-tests] |
| 114 | + |
| 115 | + env: |
| 116 | + # Testcontainers: Ryuk (Cleanup-Container) muss im CI-Kontext erreichbar sein. |
| 117 | + TESTCONTAINERS_RYUK_DISABLED: "false" |
| 118 | + # Nicht bei jedem Pull versuchen — nutze lokalen Cache falls vorhanden. |
| 119 | + TESTCONTAINERS_PULL_POLICY: "default" |
| 120 | + # Für init-data.sh im Testcontainer |
| 121 | + CONTEXT_DB: context_store |
| 122 | + CONTEXT_DB_USER: context_user |
| 123 | + CONTEXT_DB_PASSWORD: testpassword |
| 124 | + |
| 125 | + steps: |
| 126 | + - name: Checkout |
| 127 | + uses: actions/checkout@v4 |
| 128 | + |
| 129 | + - name: Setup Go ${{ env.GO_VERSION }} |
| 130 | + uses: actions/setup-go@v5 |
| 131 | + with: |
| 132 | + go-version: ${{ env.GO_VERSION }} |
| 133 | + cache: true |
| 134 | + |
| 135 | + # Docker Layer Cache für testcontainers Images. |
| 136 | + # Key: pgvector:pg17-Image-Hash (ändert sich selten → hohe Hit-Rate). |
| 137 | + - name: Cache testcontainers Docker images |
| 138 | + uses: ScribeMD/docker-cache@0.5.0 |
| 139 | + with: |
| 140 | + key: docker-testcontainers-pgvector-pg17-${{ runner.os }} |
| 141 | + |
| 142 | + - name: Run integration tests |
| 143 | + run: | |
| 144 | + go test \ |
| 145 | + -v \ |
| 146 | + -tags=integration \ |
| 147 | + -count=1 \ |
| 148 | + -timeout=10m \ |
| 149 | + -coverprofile=coverage-integration.out \ |
| 150 | + -covermode=atomic \ |
| 151 | + ./... |
| 152 | +
|
| 153 | + - name: Upload integration coverage |
| 154 | + uses: actions/upload-artifact@v4 |
| 155 | + with: |
| 156 | + name: coverage-integration |
| 157 | + path: coverage-integration.out |
| 158 | + retention-days: 7 |
| 159 | + |
| 160 | + # ───────────────────────────────────────────────────────────────────────────── |
| 161 | + # Stage 4: Build Binary |
| 162 | + # CGO_ENABLED=0, linux/amd64, statisch gelinkt. |
| 163 | + # Artifact wird an Docker-Stage übergeben — kein doppelter Build. |
| 164 | + # ───────────────────────────────────────────────────────────────────────────── |
| 165 | + build: |
| 166 | + name: Build |
| 167 | + runs-on: ubuntu-latest |
| 168 | + timeout-minutes: 10 |
| 169 | + needs: [lint, unit-tests] |
| 170 | + # Integration Tests sind optional für den Build (laufen parallel). |
| 171 | + # Docker-Push wartet auf integration-tests. |
| 172 | + |
| 173 | + steps: |
| 174 | + - name: Checkout |
| 175 | + uses: actions/checkout@v4 |
| 176 | + |
| 177 | + - name: Setup Go ${{ env.GO_VERSION }} |
| 178 | + uses: actions/setup-go@v5 |
| 179 | + with: |
| 180 | + go-version: ${{ env.GO_VERSION }} |
| 181 | + cache: true |
| 182 | + |
| 183 | + - name: Build binary |
| 184 | + env: |
| 185 | + CGO_ENABLED: "0" |
| 186 | + GOOS: linux |
| 187 | + GOARCH: amd64 |
| 188 | + run: | |
| 189 | + go build \ |
| 190 | + -trimpath \ |
| 191 | + -ldflags="-s -w \ |
| 192 | + -X main.Version=${{ github.ref_name }} \ |
| 193 | + -X main.Commit=${{ github.sha }} \ |
| 194 | + -X main.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ |
| 195 | + -o ctxd \ |
| 196 | + ./go/cmd/ctxd/ |
| 197 | +
|
| 198 | + - name: Verify binary |
| 199 | + run: | |
| 200 | + file ctxd |
| 201 | + ./ctxd --version 2>/dev/null || ./ctxd version 2>/dev/null || true |
| 202 | +
|
| 203 | + - name: Upload binary artifact |
| 204 | + uses: actions/upload-artifact@v4 |
| 205 | + with: |
| 206 | + name: ctxd-linux-amd64 |
| 207 | + path: ctxd |
| 208 | + retention-days: 1 |
| 209 | + |
| 210 | + # ───────────────────────────────────────────────────────────────────────────── |
| 211 | + # Stage 5: Docker Build + Push |
| 212 | + # ghcr.io/gottz/ctx — GitHub Container Registry. |
| 213 | + # Multi-Layer-Cache: GitHub Actions Cache + inline Registry Cache. |
| 214 | + # Tags: root-branch → :latest + :sha-SHORT + :YYYY-MM-DD |
| 215 | + # Nur auf root-Branch und workflow_dispatch — nicht auf PRs. |
| 216 | + # ───────────────────────────────────────────────────────────────────────────── |
| 217 | + docker: |
| 218 | + name: Docker Build + Push |
| 219 | + runs-on: ubuntu-latest |
| 220 | + timeout-minutes: 20 |
| 221 | + needs: [build, integration-tests] |
| 222 | + # PRs builden das Image (Smoke Test), pushen aber nicht. |
| 223 | + # Push nur auf root-Branch oder manuellem Dispatch. |
| 224 | + if: >- |
| 225 | + github.ref == 'refs/heads/root' || |
| 226 | + github.event_name == 'workflow_dispatch' |
| 227 | +
|
| 228 | + permissions: |
| 229 | + contents: read |
| 230 | + packages: write # Pflicht für ghcr.io Push |
| 231 | + |
| 232 | + steps: |
| 233 | + - name: Checkout |
| 234 | + uses: actions/checkout@v4 |
| 235 | + |
| 236 | + - name: Download binary artifact |
| 237 | + uses: actions/download-artifact@v4 |
| 238 | + with: |
| 239 | + name: ctxd-linux-amd64 |
| 240 | + |
| 241 | + - name: Make binary executable |
| 242 | + run: chmod +x ctxd |
| 243 | + |
| 244 | + # Docker Buildx für Cache-Export-Support |
| 245 | + - name: Setup Docker Buildx |
| 246 | + uses: docker/setup-buildx-action@v3 |
| 247 | + |
| 248 | + - name: Login to GitHub Container Registry |
| 249 | + uses: docker/login-action@v3 |
| 250 | + with: |
| 251 | + registry: ${{ env.REGISTRY }} |
| 252 | + username: ${{ github.actor }} |
| 253 | + # GITHUB_TOKEN hat automatisch packages:write auf dem eigenen Repo. |
| 254 | + password: ${{ secrets.GITHUB_TOKEN }} |
| 255 | + |
| 256 | + # Meta: Tags + Labels nach OCI-Standard generieren. |
| 257 | + # :latest — root-Branch |
| 258 | + # :sha-XXXX — deterministische Referenz für Rollback |
| 259 | + # :YYYY-MM-DD — human-readable Datum |
| 260 | + - name: Docker metadata |
| 261 | + id: meta |
| 262 | + uses: docker/metadata-action@v5 |
| 263 | + with: |
| 264 | + images: ${{ env.IMAGE_NAME }} |
| 265 | + tags: | |
| 266 | + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/root' }} |
| 267 | + type=sha,prefix=sha-,format=short |
| 268 | + type=raw,value={{date 'YYYY-MM-DD'}} |
| 269 | + labels: | |
| 270 | + org.opencontainers.image.title=ctx |
| 271 | + org.opencontainers.image.description=The memory your LLM pretends to have. GottZ 4-Way RRF + GottZ Scope Model. |
| 272 | + org.opencontainers.image.url=https://github.com/GottZ/ctx |
| 273 | + org.opencontainers.image.source=https://github.com/GottZ/ctx |
| 274 | + org.opencontainers.image.revision=${{ github.sha }} |
| 275 | + org.opencontainers.image.licenses=MIT |
| 276 | +
|
| 277 | + # Docker Layer Cache: |
| 278 | + # - cache-from: Registry Cache (persistiert zwischen Runs) |
| 279 | + # - cache-to: Registry Cache (exportiert nach erfolgreichem Build) |
| 280 | + # Fallback: GitHub Actions Cache (type=gha) als sekundärer Cache. |
| 281 | + - name: Build and push Docker image |
| 282 | + uses: docker/build-push-action@v6 |
| 283 | + with: |
| 284 | + context: . |
| 285 | + file: ./Dockerfile |
| 286 | + platforms: linux/amd64 |
| 287 | + push: true |
| 288 | + tags: ${{ steps.meta.outputs.tags }} |
| 289 | + labels: ${{ steps.meta.outputs.labels }} |
| 290 | + # Inline Registry Cache: kein separater Cache-Manifest nötig |
| 291 | + cache-from: | |
| 292 | + type=registry,ref=${{ env.IMAGE_NAME }}:buildcache |
| 293 | + type=gha |
| 294 | + cache-to: | |
| 295 | + type=registry,ref=${{ env.IMAGE_NAME }}:buildcache,mode=max |
| 296 | + type=gha,mode=max |
| 297 | + # Binary aus Stage 4 — kein Go-Build im Dockerfile nötig |
| 298 | + build-args: | |
| 299 | + VERSION=${{ github.ref_name }} |
| 300 | + COMMIT=${{ github.sha }} |
| 301 | +
|
| 302 | + - name: Image digest |
| 303 | + run: echo "Pushed ${{ env.IMAGE_NAME }} — digest ${{ steps.docker_build.outputs.digest }}" |
| 304 | + |
| 305 | + # ───────────────────────────────────────────────────────────────────────────── |
| 306 | + # Stage 6: Deploy |
| 307 | + # SSH-basiert: docker compose pull + up -d auf dem Remote-Host. |
| 308 | + # Nur nach erfolgreichem Docker-Push auf root-Branch. |
| 309 | + # Skip via workflow_dispatch input oder SKIP_DEPLOY Secret. |
| 310 | + # ───────────────────────────────────────────────────────────────────────────── |
| 311 | + deploy: |
| 312 | + name: Deploy |
| 313 | + runs-on: ubuntu-latest |
| 314 | + timeout-minutes: 10 |
| 315 | + needs: [docker] |
| 316 | + # Deploy nur auf root-Branch, nicht auf PRs oder manuellen Skips. |
| 317 | + if: >- |
| 318 | + github.ref == 'refs/heads/root' && |
| 319 | + github.event.inputs.skip_deploy != 'true' |
| 320 | +
|
| 321 | + # Keine concurrency-cancel beim Deploy — kein Deployment-Split-Brain. |
| 322 | + concurrency: |
| 323 | + group: deploy-production |
| 324 | + cancel-in-progress: false |
| 325 | + |
| 326 | + environment: |
| 327 | + name: production |
| 328 | + url: ${{ vars.CTX_BASE_URL }} |
| 329 | + |
| 330 | + steps: |
| 331 | + - name: Deploy via SSH |
| 332 | + uses: appleboy/ssh-action@v1.2.0 |
| 333 | + with: |
| 334 | + host: ${{ secrets.DEPLOY_SSH_HOST }} |
| 335 | + username: ${{ secrets.DEPLOY_SSH_USER }} |
| 336 | + # Ed25519 Key empfohlen. Im Repo-Secret als PEM gespeichert. |
| 337 | + key: ${{ secrets.DEPLOY_SSH_KEY }} |
| 338 | + port: ${{ secrets.DEPLOY_SSH_PORT || 22 }} |
| 339 | + # Timeout für den SSH-Command selbst (nicht für die GitHub-Action) |
| 340 | + command_timeout: 5m |
| 341 | + script: | |
| 342 | + set -euo pipefail |
| 343 | +
|
| 344 | + # Wechsle ins Deploy-Verzeichnis |
| 345 | + cd ${{ vars.DEPLOY_DIR || '/compose/n8n' }} |
| 346 | +
|
| 347 | + # Aktuelles Image pullen |
| 348 | + docker compose pull |
| 349 | +
|
| 350 | + # Rolling Update: kein Downtime bei n8n (healthcheck-abhängig) |
| 351 | + docker compose up -d --remove-orphans |
| 352 | +
|
| 353 | + # Verifikation: Container läuft und ist healthy |
| 354 | + echo "Waiting for health check..." |
| 355 | + timeout 60 bash -c ' |
| 356 | + until docker compose ps --format json | python3 -c " |
| 357 | + import sys, json |
| 358 | + services = [json.loads(l) for l in sys.stdin if l.strip()] |
| 359 | + unhealthy = [s for s in services if s.get(\"Health\") not in (\"healthy\", \"\", None)] |
| 360 | + sys.exit(len(unhealthy)) |
| 361 | + "; do |
| 362 | + sleep 5 |
| 363 | + done |
| 364 | + ' |
| 365 | +
|
| 366 | + echo "Deploy successful: $(docker compose ps --format 'table {{.Name}}\t{{.Status}}')" |
| 367 | +
|
| 368 | + - name: Smoke test (post-deploy) |
| 369 | + uses: appleboy/ssh-action@v1.2.0 |
| 370 | + with: |
| 371 | + host: ${{ secrets.DEPLOY_SSH_HOST }} |
| 372 | + username: ${{ secrets.DEPLOY_SSH_USER }} |
| 373 | + key: ${{ secrets.DEPLOY_SSH_KEY }} |
| 374 | + port: ${{ secrets.DEPLOY_SSH_PORT || 22 }} |
| 375 | + command_timeout: 2m |
| 376 | + script: | |
| 377 | + set -euo pipefail |
| 378 | + cd ${{ vars.DEPLOY_DIR || '/compose/n8n' }} |
| 379 | +
|
| 380 | + # Health-Endpoint prüfen (interner Port, kein SSL erforderlich) |
| 381 | + HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ |
| 382 | + --max-time 10 \ |
| 383 | + http://localhost:5678/healthz 2>/dev/null || echo "000") |
| 384 | +
|
| 385 | + if [ "$HTTP_STATUS" != "200" ]; then |
| 386 | + echo "Health check failed: HTTP $HTTP_STATUS" |
| 387 | + docker compose logs --tail=50 n8n |
| 388 | + exit 1 |
| 389 | + fi |
| 390 | +
|
| 391 | + echo "Smoke test PASS: HTTP $HTTP_STATUS" |
0 commit comments