name: GoChat CI/CD Pipeline on: pull_request: branches: [main] push: branches: [main] tags: ['v*.*.*'] permissions: contents: read env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} jobs: backend: name: Backend (SQLite) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: backend/go.mod cache-dependency-path: backend/go.sum - uses: actions/setup-python@v5 with: python-version: "3.12" - uses: browser-actions/setup-chrome@v2 id: chrome - name: Install browser harness run: python -m pip install browser-harness==0.1.9 - name: Test working-directory: backend env: GOCHAT_TEST_DB: sqlite GOCHAT_BROWSER_SMOKE: "1" BU_CDP_URL: http://127.0.0.1:9222 run: | "${{ steps.chrome.outputs.chrome-path }}" --headless=new --no-sandbox --disable-dev-shm-usage --remote-debugging-port=9222 --user-data-dir="$RUNNER_TEMP/chrome" about:blank & chrome_pid=$! trap 'kill "$chrome_pid" 2>/dev/null || true; browser-harness --reload > /dev/null 2>&1 || true' EXIT curl --fail --silent --show-error --retry 10 --retry-connrefused --retry-delay 1 http://127.0.0.1:9222/json/version > /dev/null browser-harness <<'PY' print(page_info()) PY go test ./internal/... ./pkg/... ./cmd/... - name: Build working-directory: backend run: go build ./... - name: Vet working-directory: backend run: go vet ./... backend-postgres: name: Backend (PostgreSQL) runs-on: ubuntu-latest services: postgres: image: pgvector/pgvector:pg16 env: POSTGRES_DB: gochat_test POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres ports: - 5432:5432 options: >- --health-cmd "pg_isready -U postgres -d gochat_test" --health-interval 5s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: backend/go.mod cache-dependency-path: backend/go.sum - name: Test auto-assignment concurrency working-directory: backend env: GOCHAT_TEST_DB: postgres GOCHAT_TEST_DB_URL: postgres://postgres:postgres@localhost:5432/gochat_test?sslmode=disable run: | set -euo pipefail output="$RUNNER_TEMP/autoassignment-concurrency.json" go test -json ./internal/autoassignment -run '^TestAssignmentServiceOnlyOneConcurrentWorkerWinsPostgres$' -count=3 | tee "$output" grep -Eq '"Action":"pass".*"Test":"TestAssignmentServiceOnlyOneConcurrentWorkerWinsPostgres"' "$output" || { echo "PostgreSQL auto-assignment concurrency test did not run" >&2 exit 1 } - name: Test PostgreSQL E2E working-directory: backend env: GOCHAT_TEST_DB: postgres GOCHAT_TEST_DB_URL: postgres://postgres:postgres@localhost:5432/gochat_test?sslmode=disable run: | set -euo pipefail output="$RUNNER_TEMP/postgres-e2e.json" go test -json -count=1 -timeout 10m ./tests/e2e/... | tee "$output" python3 - "$output" <<'PY' import json import sys required = { "TestAuthE2ESuite", "TestAccountCRUDE2ESuite", "TestConversationE2ESuite", "TestCRME2ESuite", "TestCSRFE2ETestSuite", "TestRBACE2ESuite", } results = {} with open(sys.argv[1], encoding="utf-8") as events: for line in events: event = json.loads(line) if event.get("Test") and event.get("Action") in {"pass", "skip", "fail"}: results[event["Test"]] = event["Action"] missing = sorted(suite for suite in required if results.get(suite) != "pass") passed_subtests = {suite: 0 for suite in required} skipped_subtests = {suite: 0 for suite in required} for test, action in results.items(): suite, separator, _ = test.partition("/") if separator and suite in required: if action == "pass": passed_subtests[suite] += 1 elif action == "skip": skipped_subtests[suite] += 1 empty = sorted(suite for suite, count in passed_subtests.items() if count == 0) for suite in sorted(required): print(f"{suite}: {passed_subtests[suite]} passed, {skipped_subtests[suite]} skipped") print( "E2E totals: " f"{sum(action == 'pass' for action in results.values())} passed, " f"{sum(action == 'skip' for action in results.values())} skipped" ) if missing: raise SystemExit(f"required E2E suites did not pass: {', '.join(missing)}") if empty: raise SystemExit(f"required E2E suites ran no passing subtests: {', '.join(empty)}") PY frontend: name: Frontend Build runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: corepack enable - run: pnpm install --frozen-lockfile - name: ESLint representative JS/Vue files run: pnpm --dir frontend exec eslint app/javascript/dashboard/components-next/captain/assistant/AssistantPlayground.spec.js app/javascript/dashboard/components-next/captain/assistant/AssistantPlayground.vue - name: Prettier representative JS/TS/Vue files run: pnpm --dir frontend exec prettier --check app/javascript/dashboard/components-next/captain/assistant/AssistantPlayground.spec.js app/javascript/dashboard/components-next/captain/assistant/AssistantPlayground.vue vite.config.ts app/javascript/histoire.setup.ts - run: pnpm --dir frontend build production-smoke: name: Production image smoke if: github.event_name == 'pull_request' runs-on: ubuntu-latest env: GOCHAT_IMAGE_REF: gochat:production-smoke GOCHAT_PORT: "38080" GOCHAT_SERVER_CORS_ALLOWED_ORIGINS: https://chat.example.test POSTGRES_PASSWORD: ci-postgres-secret REDIS_PASSWORD: ci-redis-secret MEILI_MASTER_KEY: ci-meili-secret-16 GOCHAT_JWT_SECRET: ci-smoke-jwt-secret-at-least-32-characters GOCHAT_JWT_PREVIOUS_SECRETS: ci-previous-jwt-secret-at-least-32-characters steps: - uses: actions/checkout@v4 - name: Build production images from repository root run: | docker build -t "$GOCHAT_IMAGE_REF" -f deploy/docker/Dockerfile . docker build -t shangwutong:production-smoke -f channels/shangwutong/Dockerfile . docker run -d --name gochat-ci-registry -p 127.0.0.1:5000:5000 registry:2 timeout 30 sh -c 'until curl -fsS http://127.0.0.1:5000/v2/; do sleep 1; done' docker tag "$GOCHAT_IMAGE_REF" localhost:5000/gochat:production-smoke docker tag shangwutong:production-smoke localhost:5000/shangwutong:production-smoke docker push localhost:5000/gochat:production-smoke docker push localhost:5000/shangwutong:production-smoke echo "GOCHAT_IMAGE_REF=$(docker inspect --format '{{index .RepoDigests 0}}' localhost:5000/gochat:production-smoke)" >> "$GITHUB_ENV" echo "SHANGWUTONG_IMAGE_REF=$(docker inspect --format '{{index .RepoDigests 0}}' localhost:5000/shangwutong:production-smoke)" >> "$GITHUB_ENV" - name: Verify artifact contents run: >- docker run --rm --entrypoint sh "$GOCHAT_IMAGE_REF" -c 'test -s /app/configs/config.production.yaml && test -d /app/migrations && test -s /app/frontend/dist/index.html' - name: Start production Compose and smoke core pages run: | if MEILI_MASTER_KEY=too-short deploy/docker/preflight.sh; then echo "preflight accepted a short Meilisearch key" >&2 exit 1 fi if POSTGRES_IMAGE_REF=pgvector/pgvector:pg16 deploy/docker/preflight.sh; then echo "preflight accepted a mutable PostgreSQL image" >&2 exit 1 fi deploy/docker/preflight.sh docker compose -f deploy/docker/docker-compose.prod.yml config --format json | python3 -c 'import json, os, sys; config = json.load(sys.stdin); assert all(config["services"][service]["environment"]["GOCHAT_JWT_PREVIOUS_SECRETS"] == os.environ["GOCHAT_JWT_PREVIOUS_SECRETS"] for service in ("gochat", "worker"))' docker compose -f deploy/docker/docker-compose.prod.yml up -d --wait gochat curl -fsS "http://127.0.0.1:$GOCHAT_PORT/health" | grep -q '"status":"ok"' curl -fsS "http://127.0.0.1:$GOCHAT_PORT/app" | grep -q '/assets/' - name: Stop production Compose if: always() run: | docker compose -f deploy/docker/docker-compose.prod.yml down -v docker rm -f gochat-ci-registry || true release: name: Publish immutable image if: startsWith(github.ref, 'refs/tags/v') needs: [backend, backend-postgres, frontend] runs-on: ubuntu-latest permissions: contents: read packages: write outputs: image_ref: ${{ steps.ref.outputs.image_ref }} image_digest: ${{ steps.build.outputs.digest }} steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract immutable tags id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} flavor: latest=false tags: | type=semver,pattern={{version}} type=sha - name: Capture build date id: date run: echo "value=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_OUTPUT" - name: Build and push id: build uses: docker/build-push-action@v6 with: context: . file: deploy/docker/Dockerfile push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} build-args: | VERSION=${{ github.ref_name }} COMMIT_SHA=${{ github.sha }} BUILD_DATE=${{ steps.date.outputs.value }} cache-from: type=gha,scope=gochat cache-to: type=gha,mode=max,scope=gochat - name: Record digest reference id: ref run: | image_ref="${REGISTRY}/${IMAGE_NAME}@${{ steps.build.outputs.digest }}" echo "image_ref=$image_ref" >> "$GITHUB_OUTPUT" echo "GoChat image: \`$image_ref\`" >> "$GITHUB_STEP_SUMMARY"