name: ci on: push: branches: - "**" pull_request: workflow_dispatch: concurrency: group: ci-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} env: REGISTRY: gcr.forust.xyz jobs: lint-compose: runs-on: [self-hosted, linux, arch, homelab] timeout-minutes: 10 steps: - name: Checkout repository uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 # Structure check for every committed Compose file, active or not. # Interpolation, env-file and bind-mount resolution are all switched off, # because inactive stacks have no .env here and would only fail on their # ${VAR:?} guards. Active stacks get the full check with interpolation in # the deploy workflow, where the real .env files live. - name: Validate Compose files shell: bash run: | set -euo pipefail source .gitea/workflows/compose-lint.sh mapfile -t safe_flags < <(compose_safe_flags) echo "docker compose config ${safe_flags[*]-}" mapfile -t files < <(compose_files) if [ "${#files[@]}" -eq 0 ]; then echo "No Compose files found." exit 0 fi failed=0 for f in "${files[@]}"; do if ! out="$(validate_compose_file "$f" ${safe_flags[@]+"${safe_flags[@]}"} 2>&1)"; then failed=1 echo "::error file=${f}::$(printf '%s' "$out" | head -1)" fi done if [ "$failed" -ne 0 ]; then echo "Compose validation failed." exit 1 fi echo "checked ${#files[@]} Compose file(s)" lint-actionlint: runs-on: [self-hosted, linux, arch, homelab] timeout-minutes: 10 steps: - name: Checkout repository uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Lint Gitea Actions workflows with actionlint shell: bash run: | set -euo pipefail tools_dir="$(bash .gitea/workflows/install-ci-tools.sh actionlint)" export PATH="$tools_dir:$PATH" actionlint -config-file .gitea/actionlint.yaml -color .gitea/workflows/*.yaml lint-shellcheck: runs-on: [self-hosted, linux, arch, homelab] timeout-minutes: 10 steps: - name: Checkout repository uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Lint shell scripts with ShellCheck shell: bash run: | set -euo pipefail tools_dir="$(bash .gitea/workflows/install-ci-tools.sh shellcheck)" export PATH="$tools_dir:$PATH" # userbot/ is a git subtree synced from forust/userbot, so its shell # scripts are upstream's to maintain, not ours. Linting them would let a # routine subtree pull turn the deploy gate red on code we do not own. mapfile -t scripts < <( git ls-files '*.sh' ':(glob)**/*.bash' ':!userbot/**' ) if [ "${#scripts[@]}" -eq 0 ]; then echo "No shell scripts found." exit 0 fi shellcheck --external-sources --source-path=SCRIPTDIR --severity=style "${scripts[@]}" lint-prettier: runs-on: [self-hosted, linux, arch, homelab] timeout-minutes: 10 steps: - name: Checkout repository uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Check formatting with Prettier shell: bash run: | set -euo pipefail tools_dir="$(bash .gitea/workflows/install-ci-tools.sh prettier)" export PATH="$tools_dir:$PATH" mapfile -t prettier_files < <( git ls-files \ | grep -E '\.(md|json|ya?ml|html|css)$' \ | grep -Ev '^(\.docs/|\.zed/|errorpages/html/|homepages/(forust_files|xdfnx_files)/)' ) if [ "${#prettier_files[@]}" -eq 0 ]; then echo "No Prettier-managed files found." exit 0 fi prettier --check --ignore-unknown "${prettier_files[@]}" lint-ruff: runs-on: [self-hosted, linux, arch, homelab] timeout-minutes: 10 steps: - name: Checkout repository uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Lint and format-check Python with Ruff shell: bash run: | set -euo pipefail tools_dir="$(bash .gitea/workflows/install-ci-tools.sh ruff)" export PATH="$tools_dir:$PATH" ruff check . ruff format --check . lint-yaml: runs-on: [self-hosted, linux, arch, homelab] timeout-minutes: 10 steps: - name: Checkout repository uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Lint YAML syntax shell: bash run: | set -euo pipefail tools_dir="$(bash .gitea/workflows/install-ci-tools.sh yamllint)" export PATH="$tools_dir:$PATH" mapfile -t yaml_files < <( git ls-files '*.yaml' '*.yml' \ ':!node_modules/**' \ ':!**/.venv/**' ) if [ "${#yaml_files[@]}" -eq 0 ]; then echo "No YAML files found." exit 0 fi yamllint -c .yamllint "${yaml_files[@]}" lint-dockerfiles: runs-on: [self-hosted, linux, arch, homelab] timeout-minutes: 10 steps: - name: Checkout repository uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Lint Dockerfiles shell: bash run: | set -euo pipefail tools_dir="$(bash .gitea/workflows/install-ci-tools.sh hadolint)" export PATH="$tools_dir:$PATH" mapfile -t dockerfiles < <( git ls-files ':(glob)**/Dockerfile' ':(glob)**/Dockerfile.*' ) if [ "${#dockerfiles[@]}" -eq 0 ]; then echo "No Dockerfiles found." exit 0 fi hadolint -c .hadolint.yaml "${dockerfiles[@]}" test-backend: runs-on: [self-hosted, linux, arch, homelab] timeout-minutes: 15 steps: - name: Checkout repository uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 # 25 tests over the panel's pydantic models, its auth flow, the SPA # fallback and the Kubernetes client it shells out with. They existed and # had never been executed by anything. # # Note that userbot/ is a subtree synced from forust/userbot, so a routine # sync can turn this red on upstream's code. Unlike the shellcheck job, # which skips that tree because style disagreements there are ours to # lose, a failing test here is a real defect in a service we deploy. - name: Run the panel backend test suite shell: bash run: | set -euo pipefail tools_dir="$(bash .gitea/workflows/install-ci-tools.sh uv)" export PATH="$tools_dir:$PATH" # A venv in a temp dir rather than a checked-out one: the runner is # shared, and a leftover .venv would let a dependency the # requirements no longer pin still satisfy an import. venv="$(mktemp -d)/venv" uv venv --quiet "$venv" uv pip install --quiet --python "$venv/bin/python" \ -r userbot/panel/backend/requirements-dev.txt # `python -m`, not bare `pytest`: the tests import `app.*` relative to # the backend directory, which only works if the cwd is on sys.path, # and only `python -m` puts it there. cd userbot/panel/backend "$venv/bin/python" -m pytest tests/ -q test-frontend: runs-on: [self-hosted, linux, arch, homelab] timeout-minutes: 15 steps: - name: Checkout repository uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 # One `npm ci` for both checks below: it is by far the slowest part of # this job, and a second one would learn nothing the first did not. # # `npm ci`, not `npm install`, for the same reason the Dockerfile uses it: # the lockfile is what makes the tree that gets checked the tree that # gets shipped. - name: Type-check and test the panel frontend shell: bash run: | set -euo pipefail cd userbot/panel/frontend npm ci # svelte-check has been a devDependency all along with no script # pointing at it, so the type errors it reports had nowhere to # surface. It is clean today, which is the only reason it can be a # gate: it stops at whatever upstream introduces rather than # reporting a backlog we inherited. npm run check npm test validate: runs-on: [self-hosted, linux, arch, homelab] timeout-minutes: 20 steps: - name: Checkout repository uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Validate Kubernetes manifests against JSON schemas shell: bash run: | set -euo pipefail tools_dir="$(bash .gitea/workflows/install-ci-tools.sh kubeconform)" export PATH="$tools_dir:$PATH" mapfile -t manifests < <( git ls-files ':(glob)**/k8s/**/*.yaml' ':(glob)**/k8s/**/*.yml' \ | grep -Ev '(^|/)(kustomization\.ya?ml|.*\.example\.ya?ml|.*values\.ya?ml|patch-.*\.ya?ml)$' ) if [ "${#manifests[@]}" -eq 0 ]; then echo "No Kubernetes manifests found." exit 0 fi kubeconform \ -strict \ -ignore-missing-schemas \ -summary \ "${manifests[@]}" # kubeconform has no schemas for CRDs, so every IngressRoute, Certificate, # PrometheusRule, Middleware, ServersTransport and ServiceMonitor is silently # skipped above. The live API server knows the real CRD schemas (and runs the # cert-manager / Traefik admission webhooks), so validate there too. # # Only services marked with a k8s/active marker are checked: server-side # dry-run needs the target namespace to exist, and inactive services are not # deployed. Services being enabled for the first time are still covered by # the JSON-schema pass above. # # Main pushes only. `--dry-run=server` persists nothing, but it does execute # the admission webhooks of the production API server, so anyone able to open # a pull request would be able to run arbitrary manifest content through # cert-manager and Traefik. A pull request has nothing to gain from it either: # only main is ever deployed, and this job runs to completion before the # deploy workflow is allowed to start, so a bad CRD is still caught before # anything reaches the cluster -- just on the push rather than on the PR. - name: Note the server-side check is not running here if: github.event_name == 'pull_request' || github.ref != 'refs/heads/main' shell: bash run: | echo "::notice::Skipping the server-side dry-run. It executes the cert-manager and" \ "Traefik admission webhooks against the production API server, so it is limited" \ "to pushes to main. CRDs are still schema-checked by kubeconform above, and the" \ "server-side pass still runs on main before the deploy." - name: Validate active manifests against the live API server if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' shell: bash run: | set -euo pipefail if ! kubectl get --raw='/readyz' --request-timeout=10s >/dev/null 2>&1; then echo "::warning::Cluster unreachable — skipped server-side validation of CRDs (IngressRoute, Certificate, PrometheusRule). Review manifest changes manually." exit 0 fi mapfile -t k8s_dirs < <( git ls-files '*.yaml' '*.yml' \ | grep -E '(^|/)k8s/' \ | sed -E 's#((^|.*/)k8s)/.*#\1#' \ | sort -u ) manifests=() kustomize_apps=() for dir in "${k8s_dirs[@]}"; do if [ ! -f "${dir}/active" ]; then echo "skip (no k8s/active): ${dir}" continue fi if [ -f "${dir}/overlays/prod/kustomization.yaml" ]; then kustomize_apps+=("${dir}/overlays/prod") elif [ -f "${dir}/base/kustomization.yaml" ]; then kustomize_apps+=("${dir}/base") else while IFS= read -r f; do [ -n "$f" ] && manifests+=("$f") done < <( git ls-files "${dir}/*.yaml" "${dir}/*.yml" \ | grep -Ev '(^|/)(kustomization\.ya?ml|.*\.example\.ya?ml|.*values\.ya?ml|patch-.*\.ya?ml)$' ) fi done echo "server-side dry-run: ${#manifests[@]} manifests, ${#kustomize_apps[@]} kustomize apps" failed=0 for m in ${manifests[@]+"${manifests[@]}"}; do if ! out="$(kubectl apply --dry-run=server -f "$m" 2>&1)"; then failed=1 echo "::error file=${m}::$(printf '%s' "$out" | head -1)" fi done for k in ${kustomize_apps[@]+"${kustomize_apps[@]}"}; do if ! out="$(kubectl apply -k "$k" --dry-run=server 2>&1)"; then failed=1 echo "::error file=${k}::$(printf '%s' "$out" | head -1)" fi done if [ "$failed" -ne 0 ]; then echo "Server-side validation failed. The API server (or an admission webhook) rejected these manifests." exit 1 fi echo "server-side dry-run: all active manifests accepted by the API server" build: needs: [lint-actionlint, lint-shellcheck, lint-compose, lint-prettier, lint-ruff, lint-yaml, lint-dockerfiles, validate] if: github.event_name != 'pull_request' && (github.ref_name == 'main' || github.ref_name == 'dev') runs-on: [self-hosted, linux, arch, homelab] timeout-minutes: 60 outputs: services: ${{ steps.services.outputs.services }} steps: - name: Checkout repository uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: fetch-depth: 0 - name: Detect changed docker-built services id: services shell: bash run: | base="${{ github.event.before }}" if [ -z "$base" ] || [ "$base" = "0000000000000000000000000000000000000000" ]; then base="$(git rev-list --max-parents=0 HEAD)" fi mapfile -t changed_files < <(git diff --name-only "$base" "${GITHUB_SHA}") services=() add_service() { local name="$1" local seen=0 for existing in "${services[@]}"; do if [ "$existing" = "$name" ]; then seen=1 break fi done if [ "$seen" -eq 0 ]; then services+=("$name") fi } for file in "${changed_files[@]}"; do case "$file" in dtek_notif/*) add_service dtek_notif ;; errorpages/*) add_service errorpages ;; userbot/*) add_service userbot ;; homepages/*) add_service homepages ;; edu_master/phpsessid-bot/*|edu_master/webinar-checker/*|edu_master/compose.yaml) add_service edu_master ;; esac done if [ "${#services[@]}" -eq 0 ]; then echo "No docker-built services changed." echo "services=" >> "$GITHUB_OUTPUT" exit 0 fi printf '%s\n' "${services[@]}" | tee /tmp/services.txt echo "services=$(paste -sd, /tmp/services.txt)" >> "$GITHUB_OUTPUT" - name: Log in to registry if: steps.services.outputs.services != '' shell: bash run: | echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "${REGISTRY}" \ -u "${{ secrets.REGISTRY_USERNAME }}" \ --password-stdin - name: Build and push changed images if: steps.services.outputs.services != '' shell: bash run: | IFS=, read -r -a services <<< "${{ steps.services.outputs.services }}" for service in "${services[@]}"; do case "$service" in dtek_notif) image="${REGISTRY}/forust/dtek-notif" tags=() case "${GITHUB_REF_NAME}" in main) tags+=("main" "prod") ;; dev) tags+=("dev") ;; esac build_args=() for tag in "${tags[@]}"; do build_args+=(-t "${image}:${tag}") done docker build \ --cache-from "type=registry,ref=${image}:buildcache" \ --cache-to "type=registry,ref=${image}:buildcache,mode=max" \ "${build_args[@]}" dtek_notif for tag in "${tags[@]}"; do docker push "${image}:${tag}" done ;; errorpages) image="${REGISTRY}/forust/error-pages" tags=() case "${GITHUB_REF_NAME}" in main) tags+=("main" "prod") ;; dev) tags+=("dev") ;; esac build_args=() for tag in "${tags[@]}"; do build_args+=(-t "${image}:${tag}") done docker build \ --cache-from "type=registry,ref=${image}:buildcache" \ --cache-to "type=registry,ref=${image}:buildcache,mode=max" \ "${build_args[@]}" errorpages for tag in "${tags[@]}"; do docker push "${image}:${tag}" done ;; userbot) tags=() case "${GITHUB_REF_NAME}" in main) tags+=("main" "prod") ;; dev) tags+=("dev") ;; esac for target in runtime panel; do case "$target" in runtime) context="userbot" image="${REGISTRY}/forust/userbot" ;; panel) context="userbot/panel" image="${REGISTRY}/forust/userbot-panel" ;; esac build_args=() for tag in "${tags[@]}"; do build_args+=(-t "${image}:${tag}") done docker build \ --cache-from "type=registry,ref=${image}:buildcache" \ --cache-to "type=registry,ref=${image}:buildcache,mode=max" \ "${build_args[@]}" "$context" for tag in "${tags[@]}"; do docker push "${image}:${tag}" done done ;; homepages) for variant in forust xdfnx; do case "$variant" in forust) image="${REGISTRY}/forust/forust-homepage" ;; xdfnx) image="${REGISTRY}/forust/xdfnx-homepage" ;; esac tags=() case "${GITHUB_REF_NAME}" in main) tags+=("main" "prod") ;; dev) tags+=("dev") ;; esac build_args=() for tag in "${tags[@]}"; do build_args+=(-t "${image}:${tag}") done docker build \ --cache-from "type=registry,ref=${image}:buildcache" \ --cache-to "type=registry,ref=${image}:buildcache,mode=max" \ "${build_args[@]}" -f "homepages/Dockerfile.${variant}" homepages for tag in "${tags[@]}"; do docker push "${image}:${tag}" done done ;; edu_master) for variant in session-keeper webinar-checker; do case "$variant" in session-keeper) context="edu_master/phpsessid-bot" image="${REGISTRY}/forust/session-keeper" ;; webinar-checker) context="edu_master/webinar-checker" image="${REGISTRY}/forust/webinar-checker" ;; esac tags=() case "${GITHUB_REF_NAME}" in main) tags+=("main" "prod") ;; dev) tags+=("dev") ;; esac build_args=() for tag in "${tags[@]}"; do build_args+=(-t "${image}:${tag}") done docker build \ --cache-from "type=registry,ref=${image}:buildcache" \ --cache-to "type=registry,ref=${image}:buildcache,mode=max" \ "${build_args[@]}" "$context" for tag in "${tags[@]}"; do docker push "${image}:${tag}" done done ;; esac done