# CI/CD for the dezky monorepo (Gitea Actions). Installs deps and typechecks # each app/service independently — the repo is NOT a single pnpm workspace yet, # so every app has its own lockfile and is built from its own directory. # # Change-gated: the `changes` job diffs the push range and every later job # only runs for the apps that actually changed. A docs-only push runs nothing; # an operator-only push typechecks, builds and deploys just operator. Editing # this workflow file counts as "everything changed". # # Deploys are intentionally asymmetric: # - app change → `kubectl set image deploy/ …:` (only that app # rolls; nothing else is touched) # - manifest change (fleet/apps/**) → `kubectl apply -k` with every app's # image pinned to what is LIVE right now (or the fresh sha # for apps built in the same push) — a plain apply would # reset unchanged apps to :latest. name: ci on: push: branches: [main] pull_request: jobs: changes: runs-on: ubuntu-latest outputs: portal: ${{ steps.diff.outputs.portal }} booking: ${{ steps.diff.outputs.booking }} operator: ${{ steps.diff.outputs.operator }} website: ${{ steps.diff.outputs.website }} platform_api: ${{ steps.diff.outputs.platform_api }} infra: ${{ steps.diff.outputs.infra }} deploy_any: ${{ steps.diff.outputs.deploy_any }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Detect changed paths id: diff run: | # Push range: event.before..sha. On the first push to a branch (or a # force-push where `before` is gone) fall back to "everything". BEFORE="${{ github.event.before }}" if [ -z "$BEFORE" ] || ! git cat-file -e "$BEFORE" 2>/dev/null || [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then FILES="ALL" else FILES=$(git diff --name-only "$BEFORE" "${{ github.sha }}") fi # PRs: diff against the target branch instead. if [ "${{ github.event_name }}" = "pull_request" ]; then git fetch origin "${{ github.base_ref }}" --depth=1 FILES=$(git diff --name-only "origin/${{ github.base_ref }}" "${{ github.sha }}") fi echo "changed files:"; echo "$FILES" hit() { [ "$FILES" = "ALL" ] || echo "$FILES" | grep -qE "$1"; } # The workflow itself changing re-runs everything. if hit '^\.gitea/workflows/ci\.yml$'; then FILES="ALL"; fi out() { echo "$1=$2" >> "$GITHUB_OUTPUT"; echo " $1=$2"; } hit '^apps/portal/' && out portal true || out portal false hit '^apps/booking/' && out booking true || out booking false hit '^apps/operator/' && out operator true || out operator false hit '^apps/website/' && out website true || out website false hit '^services/platform-api/' && out platform_api true || out platform_api false hit '^infrastructure/production/fleet/apps/' && out infra true || out infra false if hit '^apps/(portal|booking|operator)/|^services/platform-api/|^infrastructure/production/fleet/apps/'; then out deploy_any true else out deploy_any false fi # ── Typecheck (one job per app so each is individually skippable) ───────── # Node comes from the runner image (catthehacker ships node 24) — NOT # actions/setup-node, whose shared tool-cache races across concurrent jobs # ("node: Text file busy"). corepack (bundled with node) reads each app's # own packageManager — what this per-app monorepo (no root package.json) needs. tc_portal: runs-on: ubuntu-latest needs: [changes] if: needs.changes.outputs.portal == 'true' defaults: { run: { working-directory: apps/portal } } steps: - uses: actions/checkout@v4 - name: Install + typecheck run: | corepack enable pnpm install --frozen-lockfile pnpm typecheck tc_booking: runs-on: ubuntu-latest needs: [changes] if: needs.changes.outputs.booking == 'true' defaults: { run: { working-directory: apps/booking } } steps: - uses: actions/checkout@v4 - name: Install + typecheck run: | corepack enable pnpm install --frozen-lockfile pnpm typecheck tc_operator: runs-on: ubuntu-latest needs: [changes] if: needs.changes.outputs.operator == 'true' defaults: { run: { working-directory: apps/operator } } steps: - uses: actions/checkout@v4 - name: Install + typecheck run: | corepack enable pnpm install --frozen-lockfile pnpm typecheck tc_website: runs-on: ubuntu-latest needs: [changes] if: needs.changes.outputs.website == 'true' defaults: { run: { working-directory: apps/website } } steps: - uses: actions/checkout@v4 - name: Install + typecheck run: | corepack enable pnpm install --frozen-lockfile pnpm typecheck tc_platform_api: runs-on: ubuntu-latest needs: [changes] if: needs.changes.outputs.platform_api == 'true' defaults: { run: { working-directory: services/platform-api } } steps: - uses: actions/checkout@v4 - name: Install + typecheck run: | corepack enable pnpm install --frozen-lockfile pnpm typecheck test_platform_api: runs-on: ubuntu-latest needs: [changes] if: needs.changes.outputs.platform_api == 'true' defaults: { run: { working-directory: services/platform-api } } steps: - uses: actions/checkout@v4 - name: Install + test run: | corepack enable pnpm install --frozen-lockfile pnpm test # ── Build + push images (only on main, only for changed apps) ───────────── # Uses REGISTRY_TOKEN (PAT with package read+write) — the per-job # GITHUB_TOKEN is not accepted by the registry — and the dind sidecar for # docker build. Tags: :latest for humans, : for the deploy pin. build_portal: runs-on: ubuntu-latest needs: [changes, tc_portal] if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.changes.outputs.portal == 'true' steps: - uses: actions/checkout@v4 - name: Build + push run: | echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.lastcloud.io -u "${{ github.actor }}" --password-stdin IMG=git.lastcloud.io/ronnibaslund/dezky/portal docker build -t "$IMG:latest" -t "$IMG:${{ github.sha }}" apps/portal docker push "$IMG:latest" docker push "$IMG:${{ github.sha }}" build_booking: runs-on: ubuntu-latest needs: [changes, tc_booking] if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.changes.outputs.booking == 'true' steps: - uses: actions/checkout@v4 - name: Build + push run: | echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.lastcloud.io -u "${{ github.actor }}" --password-stdin IMG=git.lastcloud.io/ronnibaslund/dezky/booking docker build -t "$IMG:latest" -t "$IMG:${{ github.sha }}" apps/booking docker push "$IMG:latest" docker push "$IMG:${{ github.sha }}" build_operator: runs-on: ubuntu-latest needs: [changes, tc_operator] if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.changes.outputs.operator == 'true' steps: - uses: actions/checkout@v4 - name: Build + push run: | echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.lastcloud.io -u "${{ github.actor }}" --password-stdin IMG=git.lastcloud.io/ronnibaslund/dezky/operator docker build -t "$IMG:latest" -t "$IMG:${{ github.sha }}" apps/operator docker push "$IMG:latest" docker push "$IMG:${{ github.sha }}" build_platform_api: runs-on: ubuntu-latest needs: [changes, tc_platform_api, test_platform_api] if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.changes.outputs.platform_api == 'true' steps: - uses: actions/checkout@v4 - name: Build + push run: | echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.lastcloud.io -u "${{ github.actor }}" --password-stdin IMG=git.lastcloud.io/ronnibaslund/dezky/platform-api docker build -t "$IMG:latest" -t "$IMG:${{ github.sha }}" services/platform-api docker push "$IMG:latest" docker push "$IMG:${{ github.sha }}" # ── Deploy (only what changed) ───────────────────────────────────────────── # always() so skipped builds don't block it; the explicit result checks make # any FAILED build (or its typecheck/test, transitively) abort the deploy. deploy: runs-on: ubuntu-latest needs: [changes, build_portal, build_booking, build_operator, build_platform_api] if: >- always() && github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.changes.outputs.deploy_any == 'true' && needs.build_portal.result != 'failure' && needs.build_portal.result != 'cancelled' && needs.build_booking.result != 'failure' && needs.build_booking.result != 'cancelled' && needs.build_operator.result != 'failure' && needs.build_operator.result != 'cancelled' && needs.build_platform_api.result != 'failure' && needs.build_platform_api.result != 'cancelled' steps: - uses: actions/checkout@v4 - name: Install kubectl + kustomize run: | curl -fsSLo /usr/local/bin/kubectl https://dl.k8s.io/release/v1.33.4/bin/linux/amd64/kubectl chmod +x /usr/local/bin/kubectl curl -fsSL https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv5.6.0/kustomize_v5.6.0_linux_amd64.tar.gz \ | tar -xz -C /usr/local/bin kustomize - name: Deploy to k3s env: KUBECONFIG_B64: ${{ secrets.KUBECONFIG_B64 }} run: | export KUBECONFIG=/tmp/kubeconfig echo "$KUBECONFIG_B64" | base64 -d > "$KUBECONFIG" REG=git.lastcloud.io/ronnibaslund/dezky SHA=${{ github.sha }} declare -A CHANGED=( [portal]=${{ needs.changes.outputs.portal }} [booking]=${{ needs.changes.outputs.booking }} [operator]=${{ needs.changes.outputs.operator }} [platform-api]=${{ needs.changes.outputs.platform_api }} ) ROLL="" if [ "${{ needs.changes.outputs.infra }}" = "true" ]; then # Manifest change: apply the whole kustomization, but pin every # app to its CURRENT live image (or this push's sha if it was # rebuilt) so the apply never resets anything to :latest. cd infrastructure/production/fleet/apps for app in portal booking platform-api operator; do if [ "${CHANGED[$app]}" = "true" ]; then IMG="$REG/$app:$SHA" else IMG=$(kubectl -n dezky-apps get deploy "$app" -o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null || echo "$REG/$app:latest") fi kustomize edit set image "$REG/$app=$IMG" done kubectl apply -k . ROLL="portal booking platform-api operator" else # App-only change: roll just the changed apps; nothing else moves. for app in portal booking platform-api operator; do if [ "${CHANGED[$app]}" = "true" ]; then kubectl -n dezky-apps set image "deploy/$app" "$app=$REG/$app:$SHA" ROLL="$ROLL $app" fi done fi for app in $ROLL; do kubectl -n dezky-apps rollout status "deploy/$app" --timeout=180s done