name: Build and Deploy on: release: types: [created] jobs: # ========================================================================== # Test jobs — all three run concurrently. No build or deploy job may # proceed until every test job has succeeded. # ========================================================================== test-api: name: Test - API runs-on: ubuntu-latest steps: - name: Checkout source code uses: actions/checkout@v4 - name: Setup Bun uses: oven-sh/setup-bun@v2 with: bun-version: "1.3.6" - name: Install dependencies run: bun install --frozen-lockfile - name: Generate API Prisma client run: DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy" bunx prisma generate working-directory: api - name: Run API tests run: bun test --preload ./tests/setup.ts working-directory: api test-dalpuri: name: Test - Dalpuri runs-on: ubuntu-latest steps: - name: Checkout source code uses: actions/checkout@v4 - name: Setup Bun uses: oven-sh/setup-bun@v2 with: bun-version: "1.3.6" - name: Install dependencies run: bun install --frozen-lockfile - name: Generate Dalpuri Prisma client (CW MSSQL) run: DATABASE_URL="sqlserver://localhost:1433;database=dummy;user=dummy;password=dummy;trustServerCertificate=true" bunx prisma generate working-directory: dalpuri - name: Generate API Prisma client (required by Dalpuri translators) run: DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy" bunx prisma generate working-directory: api - name: Run Dalpuri tests run: bun test working-directory: dalpuri test-ui: name: Test - UI runs-on: ubuntu-latest defaults: run: working-directory: ui steps: - name: Checkout source code uses: actions/checkout@v4 - name: Setup Bun uses: oven-sh/setup-bun@v2 with: bun-version: "1.3.11" - name: Install dependencies run: bun install --frozen-lockfile - name: Run UI unit tests run: bun run test:unit -- --run env: PUBLIC_API_URL: "https://api.example.com" # ========================================================================== # Build jobs — run concurrently, but all require every test to pass first. # ========================================================================== build-api: name: Build - API needs: [test-api, test-dalpuri, test-ui] runs-on: ubuntu-latest permissions: contents: read packages: write steps: - name: Checkout source code uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push the API runtime image uses: docker/build-push-action@v6 with: context: . file: api/Dockerfile push: true target: runtime tags: | ghcr.io/horizonstacksoftware/optima-api:latest ghcr.io/horizonstacksoftware/optima-api:${{ github.event.release.tag_name }} - name: Build and push the API migration image uses: docker/build-push-action@v6 with: context: . file: api/Dockerfile push: true target: migration tags: | ghcr.io/horizonstacksoftware/optima-api-migrate:latest ghcr.io/horizonstacksoftware/optima-api-migrate:${{ github.event.release.tag_name }} - name: Build and push the dalpuri sync image uses: docker/build-push-action@v6 with: context: . file: api/Dockerfile push: true target: dalpuri-sync tags: | ghcr.io/horizonstacksoftware/optima-dalpuri-sync:latest ghcr.io/horizonstacksoftware/optima-dalpuri-sync:${{ github.event.release.tag_name }} build-worker: name: Build - Worker needs: [test-api, test-dalpuri, test-ui] runs-on: ubuntu-latest permissions: contents: read packages: write steps: - name: Checkout source code uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push the worker image uses: docker/build-push-action@v6 with: context: . file: api/Dockerfile push: true target: worker tags: | ghcr.io/horizonstacksoftware/optima-worker:latest ghcr.io/horizonstacksoftware/optima-worker:${{ github.event.release.tag_name }} build-ui-server: name: Build - UI Server needs: [test-api, test-dalpuri, test-ui] runs-on: ubuntu-latest permissions: contents: read packages: write steps: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Checkout source code uses: actions/checkout@v4 - name: Build and push the UI server image uses: docker/build-push-action@v6 with: context: . file: ui/Dockerfile push: true build-args: | PUBLIC_API_URL=https://opt-api.osdci.net tags: | ghcr.io/horizonstacksoftware/optima-ui:latest ghcr.io/horizonstacksoftware/optima-ui:${{ github.event.release.tag_name }} build-ui-desktop-macos: name: Build - UI Desktop (macOS) needs: [test-api, test-dalpuri, test-ui] runs-on: macos-latest permissions: contents: write defaults: run: working-directory: ui steps: - name: Checkout source code uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: 22 - name: Install Bun uses: oven-sh/setup-bun@v2 - name: Install dependencies run: bun install --frozen-lockfile - name: Rebuild native modules run: npm rebuild env: HUSKY: "0" - name: Build macOS distributables run: bun run make:macos env: PUBLIC_API_URL: https://opt-api.osdci.net - name: Upload macOS artifacts to release uses: softprops/action-gh-release@v2 with: files: | ui/out/make/**/*.dmg ui/out/make/**/*.zip build-ui-desktop-windows: name: Build - UI Desktop (Windows) needs: [test-api, test-dalpuri, test-ui] runs-on: windows-latest permissions: contents: write defaults: run: working-directory: ui steps: - name: Checkout source code uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: 22 - name: Install Bun uses: oven-sh/setup-bun@v2 - name: Install dependencies run: bun install --frozen-lockfile - name: Rebuild native modules run: npm rebuild env: HUSKY: "0" - name: Build Windows distributables run: bun run make -- --platform win32 env: PUBLIC_API_URL: https://opt-api.osdci.net - name: Upload Windows artifacts to release uses: softprops/action-gh-release@v2 with: files: | ui/out/make/**/*.exe # Runs a full CW → API data sync as a Kubernetes Job (the CW MSSQL and # API Postgres addresses are internal to the cluster and unreachable from # GitHub-hosted runners). Waits for both images to be built first and # must succeed before either the API or worker deploys. sync-cw-to-api: name: Sync - CW to API needs: [build-api, build-worker] runs-on: ubuntu-latest steps: - name: Set the Kubernetes context uses: azure/k8s-set-context@v2 with: method: kubeconfig kubeconfig: ${{ secrets.KUBECONFIG }} - name: Checkout source code uses: actions/checkout@v4 - name: Delete previous sync job if exists run: kubectl delete job -n optima -l app=dalpuri-sync --ignore-not-found - name: Apply sync job run: | TAG=${{ github.event.release.tag_name }} sed "s/RELEASE_TAG/${TAG}/g" dalpuri/kubernetes/sync-job.yaml | kubectl apply -f - - name: Wait for sync to complete run: | TAG=${{ github.event.release.tag_name }} JOB="job/dalpuri-sync-${TAG}" kubectl wait --for=condition=complete --timeout=1800s -n optima "$JOB" & WAIT_COMPLETE=$! kubectl wait --for=condition=failed --timeout=1800s -n optima "$JOB" & WAIT_FAILED=$! wait -n $WAIT_COMPLETE $WAIT_FAILED echo "--- Sync job logs ---" kubectl logs -n optima "$JOB" --tail=500 || true if kubectl get -n optima "$JOB" -o jsonpath='{.status.conditions[?(@.type=="Complete")].status}' | grep -q "True"; then echo "Sync completed successfully." exit 0 else echo "Sync FAILED." exit 1 fi # ========================================================================== # Deploy jobs # ========================================================================== migrate-api: name: Migrate - API Database needs: [build-api] runs-on: ubuntu-latest steps: - name: Set the Kubernetes context uses: azure/k8s-set-context@v2 with: method: kubeconfig kubeconfig: ${{ secrets.KUBECONFIG }} - name: Checkout source code uses: actions/checkout@v4 - name: Delete previous migration job if exists run: kubectl delete job -n optima -l app=prisma-migrate --ignore-not-found - name: Apply migration job run: | TAG=${{ github.event.release.tag_name }} sed "s/RELEASE_TAG/${TAG}/g" api/kubernetes/migration-job.yaml | kubectl apply -f - - name: Wait for migration to complete run: | TAG=${{ github.event.release.tag_name }} JOB="job/prisma-migrate-${TAG}" # Wait for either success or failure — whichever comes first. kubectl wait --for=condition=complete --timeout=180s -n optima "$JOB" & WAIT_COMPLETE=$! kubectl wait --for=condition=failed --timeout=180s -n optima "$JOB" & WAIT_FAILED=$! # wait -n returns when the first background job exits wait -n $WAIT_COMPLETE $WAIT_FAILED FIRST_EXIT=$? # Print logs regardless of outcome so failures are diagnosable echo "--- Migration pod logs ---" kubectl logs -n optima "$JOB" --tail=200 || true # Determine outcome by checking the job's actual conditions if kubectl get -n optima "$JOB" -o jsonpath='{.status.conditions[?(@.type=="Complete")].status}' | grep -q "True"; then echo "Migration completed successfully." exit 0 else echo "Migration FAILED." exit 1 fi deploy-api: name: Deploy - API needs: [migrate-api, sync-cw-to-api] runs-on: ubuntu-latest steps: - name: Set the Kubernetes context uses: azure/k8s-set-context@v2 with: method: kubeconfig kubeconfig: ${{ secrets.KUBECONFIG }} - name: Checkout source code uses: actions/checkout@v4 - name: Lint API Kubernetes manifests uses: azure/k8s-lint@v3 with: lintType: dryrun manifests: | api/kubernetes/deployment.yaml api/kubernetes/ingress.yaml namespace: optima - name: Deploy API to the Kubernetes cluster uses: azure/k8s-deploy@v5 with: namespace: optima force: true skip-tls-verify: true manifests: | api/kubernetes/deployment.yaml api/kubernetes/ingress.yaml images: | ghcr.io/horizonstacksoftware/optima-api:${{ github.event.release.tag_name }} deploy-ui: name: Deploy - UI Server needs: [build-ui-server] runs-on: ubuntu-latest steps: - name: Set the Kubernetes context uses: azure/k8s-set-context@v2 with: method: kubeconfig kubeconfig: ${{ secrets.KUBECONFIG }} - name: Checkout source code uses: actions/checkout@v4 - name: Lint UI Kubernetes manifests uses: azure/k8s-lint@v3 with: lintType: dryrun manifests: | ui/kubernetes/deployment.yaml ui/kubernetes/ingress.yaml namespace: optima - name: Deploy UI to the Kubernetes cluster uses: azure/k8s-deploy@v5 with: namespace: optima force: true skip-tls-verify: true manifests: | ui/kubernetes/deployment.yaml ui/kubernetes/ingress.yaml images: | ghcr.io/horizonstacksoftware/optima-ui:${{ github.event.release.tag_name }} deploy-worker: name: Deploy - Worker needs: [build-worker, sync-cw-to-api] runs-on: ubuntu-latest steps: - name: Set the Kubernetes context uses: azure/k8s-set-context@v2 with: method: kubeconfig kubeconfig: ${{ secrets.KUBECONFIG }} - name: Checkout source code uses: actions/checkout@v4 - name: Lint worker Kubernetes manifests uses: azure/k8s-lint@v3 with: lintType: dryrun manifests: | api/kubernetes/worker-deployment.yaml namespace: optima - name: Deploy worker to the Kubernetes cluster uses: azure/k8s-deploy@v5 with: namespace: optima force: true skip-tls-verify: true manifests: | api/kubernetes/worker-deployment.yaml images: | ghcr.io/horizonstacksoftware/optima-worker:${{ github.event.release.tag_name }}