58a2c8077d
Wraps Stalwart in EAS so iOS/Android native Mail/Calendar 'Exchange' accounts get two-way mail+calendar+contacts sync (BackendCombined: IMAP + CalDAV /dav/cal/%l/ + CardDAV, credentials pass through). - services/zpush: Z-Push 2.6.4 (AGPLv3, see LICENSE-NOTES.md) on php:8.2-apache-bookworm (trixie dropped libc-client); PHP 8 sysv sprintf fatal sed-patched; autodiscover dispatcher answers mobilesync schema, proxies outlook schema to Stalwart unchanged - prod: zpush Deployment (replicas:1, Recreate — file sync state), /Microsoft-Server-ActiveSync Ingress on mail.dezky.eu (no redirect, POST-heavy), autodiscover.dezky.eu repointed to the dispatcher, selectorless stalwart-imaps/-smtps Services (host-Stalwart is implicit-TLS only: 993/465, no plain 143/587 — verified on node1) - CI: build+deploy zpush like the other apps EAS tops out at 14.1: covers native mobile clients, NOT the Outlook mobile app (needs 16.1) and not new Outlook for Windows (no EAS).
318 lines
14 KiB
YAML
318 lines
14 KiB
YAML
# 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:
|
|
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 }}
|
|
zpush: ${{ steps.diff.outputs.zpush }}
|
|
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 '^services/zpush/' && out zpush true || out zpush false
|
|
hit '^infrastructure/production/fleet/apps/' && out infra true || out infra false
|
|
if hit '^apps/(portal|booking|operator)/|^services/(platform-api|zpush)/|^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, :<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
|
|
- 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 }}"
|
|
|
|
# zpush is PHP (Z-Push EAS gateway) — no typecheck/test stage; the docker
|
|
# build is the only gate.
|
|
build_zpush:
|
|
runs-on: ubuntu-latest
|
|
needs: [changes]
|
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.changes.outputs.zpush == '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/zpush
|
|
docker build -t "$IMG:latest" -t "$IMG:${{ github.sha }}" services/zpush
|
|
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, build_zpush]
|
|
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' &&
|
|
needs.build_zpush.result != 'failure' && needs.build_zpush.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 }}
|
|
# Build results, not just change flags: an app whose typecheck/test
|
|
# failed has result=skipped on its build — its image for this SHA
|
|
# does NOT exist, and pinning it would ImagePullBackOff the app.
|
|
BUILT_PORTAL: ${{ needs.build_portal.result }}
|
|
BUILT_BOOKING: ${{ needs.build_booking.result }}
|
|
BUILT_OPERATOR: ${{ needs.build_operator.result }}
|
|
BUILT_PLATFORM_API: ${{ needs.build_platform_api.result }}
|
|
BUILT_ZPUSH: ${{ needs.build_zpush.result }}
|
|
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 }}
|
|
[zpush]=${{ needs.changes.outputs.zpush }}
|
|
)
|
|
declare -A BUILT=(
|
|
[portal]=$BUILT_PORTAL
|
|
[booking]=$BUILT_BOOKING
|
|
[operator]=$BUILT_OPERATOR
|
|
[platform-api]=$BUILT_PLATFORM_API
|
|
[zpush]=$BUILT_ZPUSH
|
|
)
|
|
# Deployable = changed AND its image was actually built this run.
|
|
deployable() { [ "${CHANGED[$1]}" = "true" ] && [ "${BUILT[$1]}" = "success" ]; }
|
|
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 zpush; do
|
|
if deployable "$app"; 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 zpush"
|
|
else
|
|
# App-only change: roll just the changed apps; nothing else moves.
|
|
for app in portal booking platform-api operator zpush; do
|
|
if deployable "$app"; 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
|