diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index bb2a651..0c85487 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,6 +1,19 @@ -# CI 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. +# 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: @@ -9,94 +22,210 @@ on: pull_request: jobs: - typecheck: + changes: runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - target: - - { name: platform-api, dir: services/platform-api } - - { name: portal, dir: apps/portal } - - { name: booking, dir: apps/booking } - - { name: website, dir: apps/website } - - { name: operator, dir: apps/operator } - defaults: - run: - working-directory: ${{ matrix.target.dir }} + 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 - # 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. - - name: Install + # ── 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 - - name: Typecheck - run: pnpm typecheck - - test: + tc_booking: runs-on: ubuntu-latest - defaults: - run: - working-directory: services/platform-api + needs: [changes] + if: needs.changes.outputs.booking == 'true' + defaults: { run: { working-directory: apps/booking } } steps: - uses: actions/checkout@v4 - - name: Install + - name: Install + typecheck run: | corepack enable pnpm install --frozen-lockfile - - name: Test - run: pnpm test + pnpm typecheck - # Build + push app images to the Gitea container registry. Only on main, after - # typecheck + test pass. Uses the runner's job token to auth to the registry - # (same Gitea instance), and the dind sidecar for docker build. - build: + tc_operator: runs-on: ubuntu-latest - needs: [typecheck, test] - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - strategy: - fail-fast: false - matrix: - app: - - { name: portal, dir: apps/portal } - - { name: booking, dir: apps/booking } - - { name: platform-api, dir: services/platform-api } - - { name: operator, dir: apps/operator } + 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 - # REGISTRY_TOKEN is a Gitea personal access token with package read+write - # scope (repo Settings → Actions → Secrets). The per-job GITHUB_TOKEN - # stopped being accepted by the registry's /v2/ basic-auth endpoint after - # the act_runner → gitea/runner switch, so registry pushes use a PAT. - - name: Registry login - run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.lastcloud.io -u "${{ github.actor }}" --password-stdin - name: Build + push run: | - IMG=git.lastcloud.io/ronnibaslund/dezky/${{ matrix.app.name }} - # The commit SHA tag is what the deploy job pins the cluster to; - # ':latest' is kept for humans / manual pulls only. - docker build \ - -t "$IMG:latest" \ - -t "$IMG:${{ github.sha }}" \ - "${{ matrix.app.dir }}" + 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 }}" - # Deploy the freshly built images to the k3s cluster the runner already runs - # in. No GitOps controller in between: kustomize pins each Deployment to this - # commit's SHA tag and kubectl applies the manifests, so "push to main" IS - # the release. Auth is the KUBECONFIG_B64 repo secret — a kubeconfig for the - # ci-deployer ServiceAccount (see infrastructure/production/fleet/ci/ - # ci-deployer.yaml), reaching the API server on the in-cluster service IP. + 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: [build] - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + 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 @@ -113,12 +242,39 @@ jobs: run: | export KUBECONFIG=/tmp/kubeconfig echo "$KUBECONFIG_B64" | base64 -d > "$KUBECONFIG" - cd infrastructure/production/fleet/apps - for app in platform-api portal booking operator; do - kustomize edit set image \ - "git.lastcloud.io/ronnibaslund/dezky/$app=git.lastcloud.io/ronnibaslund/dezky/$app:${{ github.sha }}" - done - kubectl apply -k . - for app in platform-api portal booking operator; do + 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