feat(ci): change-gated pipeline — only test/build/deploy what changed
ci / changes (push) Successful in 3s
ci / tc_booking (push) Successful in 22s
ci / tc_portal (push) Successful in 23s
ci / tc_platform_api (push) Successful in 21s
ci / tc_operator (push) Successful in 24s
ci / tc_website (push) Successful in 22s
ci / test_platform_api (push) Successful in 33s
ci / build_booking (push) Successful in 12s
ci / build_portal (push) Successful in 5s
ci / build_operator (push) Successful in 5s
ci / build_platform_api (push) Successful in 4s
ci / deploy (push) Successful in 41s

A 'changes' job diffs the push range (github.event.before..sha; falls back
to everything on first/force pushes and when this workflow file itself
changes) and gates per-app typecheck/test/build jobs. Deploy is asymmetric
on purpose: app-only changes roll just the changed Deployments via
kubectl set image; manifest changes (fleet/apps/**) apply the kustomization
with every app pinned to its live image (or this push's sha) so an apply
never resets unchanged apps to :latest. Docs-only pushes run nothing.
This commit is contained in:
Ronni Baslund
2026-06-10 19:57:50 +02:00
parent 94270c1f22
commit 4907d0a856
+229 -73
View File
@@ -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/<app> …:<sha>` (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, :<sha> 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