name: ci "on": push: branches: - main pull_request: null workflow_dispatch: null permissions: contents: read actions: read concurrency: group: ci-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: compose: name: Compose runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }} timeout-minutes: 15 steps: - name: Checkout repository uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 id: source - 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)" id: check - name: Write the job result if: always() env: SUMMARY_CHECK: Compose SUMMARY_RESULT: ${{ job.status }} SUMMARY_FAILED_STEP: ${{ steps.check.conclusion == 'failure' && 'Check or image build' || steps.source.conclusion == 'failure' && 'Source checkout' || '' }} shell: bash run: | if [ -f .gitea/workflows/release.py ]; then python3 .gitea/workflows/release.py check-summary elif [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then printf '## %s\n\n- Result: **%s**\n- Failed step: %s\n' "$SUMMARY_CHECK" "$SUMMARY_RESULT" "$SUMMARY_FAILED_STEP" >>"$GITHUB_STEP_SUMMARY" || true fi workflows: name: Workflows runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }} timeout-minutes: 15 steps: - name: Checkout repository uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 id: source - name: Prepare pinned tools shell: bash run: | set -euo pipefail tools_dir="$(bash .gitea/workflows/install-ci-tools.sh actionlint shellcheck)" echo "$tools_dir" >> "$GITHUB_PATH" id: tools - name: Lint Gitea Actions workflows with actionlint shell: bash run: | set -euo pipefail actionlint -config-file .gitea/actionlint.yaml -color .gitea/workflows/*.yaml id: check - name: Write the job result if: always() env: SUMMARY_CHECK: Workflows SUMMARY_RESULT: ${{ job.status }} SUMMARY_FAILED_STEP: ${{ steps.check.conclusion == 'failure' && 'Check or image build' || steps.tools.conclusion == 'failure' && 'Tool setup' || steps.source.conclusion == 'failure' && 'Source checkout' || '' }} shell: bash run: | if [ -f .gitea/workflows/release.py ]; then python3 .gitea/workflows/release.py check-summary elif [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then printf '## %s\n\n- Result: **%s**\n- Failed step: %s\n' "$SUMMARY_CHECK" "$SUMMARY_RESULT" "$SUMMARY_FAILED_STEP" >>"$GITHUB_STEP_SUMMARY" || true fi shell: name: Shell runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }} timeout-minutes: 15 steps: - name: Checkout repository uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 id: source - name: Prepare pinned tools shell: bash run: | set -euo pipefail tools_dir="$(bash .gitea/workflows/install-ci-tools.sh shellcheck jq)" echo "$tools_dir" >> "$GITHUB_PATH" id: tools - name: Lint shell scripts with ShellCheck shell: bash run: | set -euo pipefail mapfile -t scripts < <( git ls-files '*.sh' ':(glob)**/*.bash' ) if [ "${#scripts[@]}" -eq 0 ]; then echo "No shell scripts found." exit 0 fi shellcheck --external-sources --source-path=SCRIPTDIR --severity=style "${scripts[@]}" bash .gitea/tests/deploy-validation.sh id: check - name: Write the job result if: always() env: SUMMARY_CHECK: Shell SUMMARY_RESULT: ${{ job.status }} SUMMARY_FAILED_STEP: ${{ steps.check.conclusion == 'failure' && 'Check or image build' || steps.tools.conclusion == 'failure' && 'Tool setup' || steps.source.conclusion == 'failure' && 'Source checkout' || '' }} shell: bash run: | if [ -f .gitea/workflows/release.py ]; then python3 .gitea/workflows/release.py check-summary elif [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then printf '## %s\n\n- Result: **%s**\n- Failed step: %s\n' "$SUMMARY_CHECK" "$SUMMARY_RESULT" "$SUMMARY_FAILED_STEP" >>"$GITHUB_STEP_SUMMARY" || true fi formatting: name: Formatting runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }} timeout-minutes: 15 steps: - name: Checkout repository uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 id: source - name: Prepare pinned tools shell: bash run: | set -euo pipefail tools_dir="$(bash .gitea/workflows/install-ci-tools.sh prettier)" echo "$tools_dir" >> "$GITHUB_PATH" id: tools - name: Check formatting with Prettier shell: bash run: | set -euo pipefail 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[@]}" id: check - name: Write the job result if: always() env: SUMMARY_CHECK: Formatting SUMMARY_RESULT: ${{ job.status }} SUMMARY_FAILED_STEP: ${{ steps.check.conclusion == 'failure' && 'Check or image build' || steps.tools.conclusion == 'failure' && 'Tool setup' || steps.source.conclusion == 'failure' && 'Source checkout' || '' }} shell: bash run: | if [ -f .gitea/workflows/release.py ]; then python3 .gitea/workflows/release.py check-summary elif [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then printf '## %s\n\n- Result: **%s**\n- Failed step: %s\n' "$SUMMARY_CHECK" "$SUMMARY_RESULT" "$SUMMARY_FAILED_STEP" >>"$GITHUB_STEP_SUMMARY" || true fi python: name: Python and tests runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }} timeout-minutes: 15 steps: - name: Checkout repository uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 id: source - name: Prepare pinned tools shell: bash run: | set -euo pipefail tools_dir="$(bash .gitea/workflows/install-ci-tools.sh ruff jq)" echo "$tools_dir" >> "$GITHUB_PATH" id: tools - name: Lint and format-check Python with Ruff shell: bash run: | set -euo pipefail ruff check . .gitea/workflows ruff format --check . .gitea/workflows python3 -m unittest discover -s tests -v id: check - name: Write the job result if: always() env: SUMMARY_CHECK: Python and tests SUMMARY_RESULT: ${{ job.status }} SUMMARY_FAILED_STEP: ${{ steps.check.conclusion == 'failure' && 'Check or image build' || steps.tools.conclusion == 'failure' && 'Tool setup' || steps.source.conclusion == 'failure' && 'Source checkout' || '' }} shell: bash run: | if [ -f .gitea/workflows/release.py ]; then python3 .gitea/workflows/release.py check-summary elif [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then printf '## %s\n\n- Result: **%s**\n- Failed step: %s\n' "$SUMMARY_CHECK" "$SUMMARY_RESULT" "$SUMMARY_FAILED_STEP" >>"$GITHUB_STEP_SUMMARY" || true fi yaml: name: YAML runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }} timeout-minutes: 15 steps: - name: Checkout repository uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 id: source - name: Prepare pinned tools shell: bash run: | set -euo pipefail tools_dir="$(bash .gitea/workflows/install-ci-tools.sh yamllint)" echo "$tools_dir" >> "$GITHUB_PATH" id: tools - name: Lint YAML syntax shell: bash run: | set -euo pipefail 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[@]}" id: check - name: Write the job result if: always() env: SUMMARY_CHECK: YAML SUMMARY_RESULT: ${{ job.status }} SUMMARY_FAILED_STEP: ${{ steps.check.conclusion == 'failure' && 'Check or image build' || steps.tools.conclusion == 'failure' && 'Tool setup' || steps.source.conclusion == 'failure' && 'Source checkout' || '' }} shell: bash run: | if [ -f .gitea/workflows/release.py ]; then python3 .gitea/workflows/release.py check-summary elif [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then printf '## %s\n\n- Result: **%s**\n- Failed step: %s\n' "$SUMMARY_CHECK" "$SUMMARY_RESULT" "$SUMMARY_FAILED_STEP" >>"$GITHUB_STEP_SUMMARY" || true fi dockerfiles: name: Dockerfiles runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }} timeout-minutes: 15 steps: - name: Checkout repository uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 id: source - name: Prepare pinned tools shell: bash run: | set -euo pipefail tools_dir="$(bash .gitea/workflows/install-ci-tools.sh hadolint)" echo "$tools_dir" >> "$GITHUB_PATH" id: tools - name: Lint Dockerfiles shell: bash run: | set -euo pipefail 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[@]}" id: check - name: Write the job result if: always() env: SUMMARY_CHECK: Dockerfiles SUMMARY_RESULT: ${{ job.status }} SUMMARY_FAILED_STEP: ${{ steps.check.conclusion == 'failure' && 'Check or image build' || steps.tools.conclusion == 'failure' && 'Tool setup' || steps.source.conclusion == 'failure' && 'Source checkout' || '' }} shell: bash run: | if [ -f .gitea/workflows/release.py ]; then python3 .gitea/workflows/release.py check-summary elif [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then printf '## %s\n\n- Result: **%s**\n- Failed step: %s\n' "$SUMMARY_CHECK" "$SUMMARY_RESULT" "$SUMMARY_FAILED_STEP" >>"$GITHUB_STEP_SUMMARY" || true fi kubernetes: name: Kubernetes runs-on: ${{ github.ref == 'refs/heads/main' && 'homelab' || 'homelab-pr' }} timeout-minutes: 15 steps: - name: Checkout repository uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 id: source - name: Prepare pinned tools shell: bash run: | set -euo pipefail tools_dir="$(bash .gitea/workflows/install-ci-tools.sh kubeconform)" echo "$tools_dir" >> "$GITHUB_PATH" id: tools - name: Validate Kubernetes manifests against JSON schemas shell: bash run: | set -euo pipefail 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[@]}" id: check - name: Write the job result if: always() env: SUMMARY_CHECK: Kubernetes SUMMARY_RESULT: ${{ job.status }} SUMMARY_FAILED_STEP: ${{ steps.check.conclusion == 'failure' && 'Check or image build' || steps.tools.conclusion == 'failure' && 'Tool setup' || steps.source.conclusion == 'failure' && 'Source checkout' || '' }} shell: bash run: | if [ -f .gitea/workflows/release.py ]; then python3 .gitea/workflows/release.py check-summary elif [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then printf '## %s\n\n- Result: **%s**\n- Failed step: %s\n' "$SUMMARY_CHECK" "$SUMMARY_RESULT" "$SUMMARY_FAILED_STEP" >>"$GITHUB_STEP_SUMMARY" || true fi image-plan: needs: [compose, workflows, shell, formatting, python, yaml, dockerfiles, kubernetes] if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' runs-on: homelab timeout-minutes: 10 outputs: matrix: ${{ steps.plan.outputs.matrix }} steps: - name: Checkout repository id: source uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 with: fetch-depth: 0 - name: Detect build inputs against successful CI id: plan env: GITEA_TOKEN: ${{ github.token }} run: python3 .gitea/workflows/release.py prepare --output build-plan.json - name: Store the image plan id: artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: build-plan path: build-plan.json if-no-files-found: error retention-days: 30 - name: Write the plan result if: always() env: SUMMARY_CHECK: Image plan SUMMARY_RESULT: ${{ job.status }} SUMMARY_FAILED_STEP: >- ${{ steps.plan.conclusion == 'failure' && 'Build input detection' || steps.artifact.conclusion == 'failure' && 'Plan upload' || steps.source.conclusion == 'failure' && 'Source checkout' || '' }} shell: bash run: | if [ -f .gitea/workflows/release.py ]; then python3 .gitea/workflows/release.py check-summary elif [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then printf '## Image plan\n\nResult: %s\n' "$SUMMARY_RESULT" >>"$GITHUB_STEP_SUMMARY" || true fi images: name: Image (${{ matrix.name }}) needs: [image-plan] if: needs.image-plan.result == 'success' runs-on: homelab timeout-minutes: 60 strategy: max-parallel: 1 fail-fast: false matrix: ${{ fromJSON(needs.image-plan.outputs.matrix || '{"include":[{"name":"inactive"}]}') }} steps: - name: Checkout repository id: source uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - name: Download the checked image plan id: inputs uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: build-plan - name: Build or reuse this image id: check env: IMAGE_NAME: ${{ matrix.name }} REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} run: python3 .gitea/workflows/release.py image --image "$IMAGE_NAME" --output image.json - name: Store the image result id: artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: image-${{ matrix.name }} path: image.json if-no-files-found: error retention-days: 30 - name: Write the job result if: always() env: SUMMARY_CHECK: Image (${{ matrix.name }}) SUMMARY_RESULT: ${{ job.status }} SUMMARY_FAILED_STEP: >- ${{ steps.check.conclusion == 'failure' && 'Build or tag images' || steps.artifact.conclusion == 'failure' && 'Artifact upload' || steps.inputs.conclusion == 'failure' && 'Artifact download' || steps.source.conclusion == 'failure' && 'Source checkout' || '' }} shell: bash run: | if [ -f .gitea/workflows/release.py ]; then python3 .gitea/workflows/release.py check-summary elif [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then printf '## %s\n\n- Result: **%s**\n- Failed step: %s\n' "$SUMMARY_CHECK" "$SUMMARY_RESULT" "$SUMMARY_FAILED_STEP" >>"$GITHUB_STEP_SUMMARY" || true fi # Retain the build job name required by the immutable release deployment gate. build: needs: [image-plan, images] runs-on: homelab timeout-minutes: 15 steps: - name: Checkout repository id: source uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - name: Download all image results id: inputs uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: path: artifacts - name: Pin SHA tags and write the complete release id: check env: REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} run: >- python3 .gitea/workflows/release.py finalize --plan artifacts/build-plan/build-plan.json - name: Store commit release id: artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: release-${{ github.sha }} path: release.json if-no-files-found: error retention-days: 30 - name: Write the job result if: always() env: SUMMARY_CHECK: Image release and SHA tags SUMMARY_RESULT: ${{ job.status }} SUMMARY_FAILED_STEP: >- ${{ steps.check.conclusion == 'failure' && 'Build or tag images' || steps.artifact.conclusion == 'failure' && 'Artifact upload' || steps.inputs.conclusion == 'failure' && 'Artifact download' || steps.source.conclusion == 'failure' && 'Source checkout' || '' }} shell: bash run: | if [ -f .gitea/workflows/release.py ]; then python3 .gitea/workflows/release.py check-summary elif [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then printf '## %s\n\n- Result: **%s**\n- Failed step: %s\n' "$SUMMARY_CHECK" "$SUMMARY_RESULT" "$SUMMARY_FAILED_STEP" >>"$GITHUB_STEP_SUMMARY" || true fi