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
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:
+229
-73
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user