name: CI on: pull_request: push: branches: - main tags: - "v*" workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true permissions: contents: read jobs: # Cheap gate so docs-only/roadmap PRs skip the heavyweight jobs. Skipped # required checks still satisfy branch protection ("skipped" = success). changes: name: Changed paths runs-on: ubuntu-latest outputs: code: ${{ steps.filter.outputs.code }} mobile: ${{ steps.filter.outputs.mobile }} steps: - uses: actions/checkout@v4 - uses: dorny/paths-filter@v3 id: filter with: filters: | code: - 'app/**' - 'infra/**' - 'docker-compose.yml' - 'install.sh' - '.github/workflows/**' - 'docs/api/**' mobile: - 'app/mobile/**' infra: name: Infra (Compose + Scripts) runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Validate Docker Compose config run: docker compose -f infra/docker-compose.yml config -q - name: Validate Docker Compose with .env.example run: docker compose -f infra/docker-compose.yml --env-file .env.example config -q - name: Validate root Docker Compose config run: docker compose -f docker-compose.yml config -q - name: Validate root Docker Compose with .env.example run: docker compose -f docker-compose.yml --env-file .env.example config -q - name: Validate startup scripts run: | bash -n start-unified.sh bash -n start.sh bash -n install.sh - name: Run startup preflight tests run: bash scripts/test-start-unified-preflight.sh backend: name: Backend (Go) runs-on: ubuntu-latest needs: changes if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request' defaults: run: working-directory: app/backend steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Go uses: actions/setup-go@v5 with: go-version-file: app/backend/go.mod cache: true - name: Vet run: go vet ./... - name: Run backend tests run: go test ./... frontend: name: Frontend (React) runs-on: ubuntu-latest needs: changes if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request' defaults: run: working-directory: app/frontend steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: "22" cache: npm cache-dependency-path: app/frontend/package-lock.json - name: Install dependencies run: npm ci - name: Lint run: npm run lint - name: Build (typecheck + bundle) run: npm run build:check - name: Test run: npm test mobile: name: Mobile (Expo) runs-on: ubuntu-latest needs: changes if: needs.changes.outputs.mobile == 'true' || github.event_name != 'pull_request' defaults: run: working-directory: app/mobile steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: "22" cache: npm cache-dependency-path: app/mobile/package-lock.json - name: Install dependencies run: npm ci - name: Typecheck run: npx tsc --noEmit - name: Lint run: npx expo lint smoke: name: Compose boot smoke runs-on: ubuntu-latest timeout-minutes: 20 needs: changes if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request' steps: - name: Checkout uses: actions/checkout@v4 - name: Boot stack # .github/compose.ci.yml swaps every docker.io ref for # mirror.gcr.io — anonymous Hub pulls 429 on shared CI IPs. run: | BETTER_AUTH_SECRET=$(openssl rand -hex 32) BETTER_AUTH_INTERNAL_TOKEN=$(openssl rand -hex 32) export BETTER_AUTH_SECRET BETTER_AUTH_INTERNAL_TOKEN docker compose -f infra/docker-compose.yml -f .github/compose.ci.yml up -d --build postgres dragonfly backend frontend traefik - name: Wait for backend health run: | for _ in $(seq 1 60); do if curl -sf http://localhost:8082/health; then exit 0; fi sleep 5 done docker compose -f infra/docker-compose.yml logs --tail 50 backend exit 1 - name: Check frontend responds run: | for _ in $(seq 1 30); do if curl -sf http://localhost:3000/ -o /dev/null; then exit 0; fi sleep 2 done docker compose -f infra/docker-compose.yml logs --tail 50 frontend exit 1 - name: Teardown if: always() run: docker compose -f infra/docker-compose.yml down -v docker: name: Build & publish images runs-on: ubuntu-latest needs: [backend, frontend, smoke] if: needs.backend.result == 'success' && needs.frontend.result == 'success' && needs.smoke.result == 'success' permissions: contents: read packages: write steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Docker Buildx # Mirror docker.io through gcr — shared CI IPs hit anonymous # rate limits on base-image metadata resolution otherwise. uses: docker/setup-buildx-action@v3 with: config-inline: | [registry."docker.io"] mirrors = ["mirror.gcr.io"] - name: Login to GHCR if: github.event_name != 'pull_request' uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Backend image metadata id: meta-backend uses: docker/metadata-action@v5 with: images: ghcr.io/${{ github.repository }}-backend tags: | type=ref,event=branch type=ref,event=pr type=ref,event=tag type=semver,pattern={{version}} type=sha,format=short type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} - name: Frontend image metadata id: meta-frontend uses: docker/metadata-action@v5 with: images: ghcr.io/${{ github.repository }}-frontend tags: | type=ref,event=branch type=ref,event=pr type=ref,event=tag type=semver,pattern={{version}} type=sha,format=short type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} - name: Build backend image uses: docker/build-push-action@v6 with: context: app/backend load: true push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta-backend.outputs.tags }} labels: ${{ steps.meta-backend.outputs.labels }} # Stamp the release tag into the binary so the in-app upgrade # check knows what it's running ("dev" disables it). build-args: | VERSION=${{ github.ref_type == 'tag' && github.ref_name || 'dev' }} cache-from: type=gha,scope=backend cache-to: type=gha,mode=max,scope=backend - name: Build frontend image uses: docker/build-push-action@v6 with: context: app/frontend load: true push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta-frontend.outputs.tags }} labels: ${{ steps.meta-frontend.outputs.labels }} cache-from: type=gha,scope=frontend cache-to: type=gha,mode=max,scope=frontend - name: Scan backend image uses: aquasecurity/trivy-action@v0.36.0 with: image-ref: ${{ fromJSON(steps.meta-backend.outputs.json).tags[0] }} format: table exit-code: "0" ignore-unfixed: true vuln-type: os,library severity: CRITICAL,HIGH - name: Scan frontend image uses: aquasecurity/trivy-action@v0.36.0 with: image-ref: ${{ fromJSON(steps.meta-frontend.outputs.json).tags[0] }} format: table exit-code: "0" ignore-unfixed: true vuln-type: os,library severity: CRITICAL,HIGH release: name: Release runs-on: ubuntu-latest needs: [docker] if: github.event_name != 'pull_request' && needs.docker.result == 'success' permissions: contents: write packages: write steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 # Conventional-commit bump. Tags pushed with GITHUB_TOKEN do not # retrigger this workflow, so images are retagged below instead. - name: Bump version and push tag if: github.ref == 'refs/heads/main' id: tag_version uses: mathieudutour/github-tag-action@v6.2 with: github_token: ${{ secrets.GITHUB_TOKEN }} default_bump: false - name: Resolve release tag id: release run: | if [ "${{ github.ref_type }}" = "tag" ]; then tag="${GITHUB_REF_NAME}" else tag="${{ steps.tag_version.outputs.new_tag }}" fi if [ -z "$tag" ]; then echo "No releasable commits since last tag - skipping release." fi echo "tag=$tag" >> "$GITHUB_OUTPUT" - name: Setup Go if: steps.release.outputs.tag != '' uses: actions/setup-go@v5 with: go-version-file: app/backend/go.mod cache: true - name: Build CLI binaries if: steps.release.outputs.tag != '' working-directory: app/backend env: VERSION: ${{ steps.release.outputs.tag }} run: bash ../../scripts/build-cli.sh - name: Checksums if: steps.release.outputs.tag != '' working-directory: app/backend/bin run: sha256sum containr-* > checksums.txt - name: Login to GHCR if: steps.release.outputs.tag != '' && github.ref == 'refs/heads/main' uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} # Tag pushes from github-tag-action do not trigger workflows, so the # release job retags the just-published :main images in-registry. - name: Tag release images in GHCR if: steps.release.outputs.tag != '' && github.ref == 'refs/heads/main' env: TAG: ${{ steps.release.outputs.tag }} run: | repo="${GITHUB_REPOSITORY,,}" for img in backend frontend; do docker buildx imagetools create \ --tag "ghcr.io/${repo}-${img}:${TAG}" \ --tag "ghcr.io/${repo}-${img}:latest" \ "ghcr.io/${repo}-${img}:main" done - name: Create GitHub Release if: steps.release.outputs.tag != '' working-directory: app/backend/bin env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ steps.release.outputs.tag }} run: | # Delete-then-create keeps reruns idempotent: --clobber uploads # race GitHub's asset tombstones and intermittently 422. The git # tag itself is kept (no --cleanup-tag). gh release delete "$TAG" --yes 2>/dev/null || true gh release create "$TAG" --title "$TAG" --generate-notes \ containr-* checksums.txt