name: "πŸš€ Test: Deploy" on: workflow_call: inputs: distros: description: "🐧 Space-separated distro pool the matrix spreads its rows over. Empty: every declared distro." required: false type: string default: "" index: description: "Chunk index this call deploys; chunks run serially, one block per index." required: true type: string sweep: description: "Sweep number driving the mode and tor rotation and the regular-line offset." required: true type: string offset: description: "Regular rows to skip before filling the chunks; 0 starts at the head of the ranking." required: false type: string default: "0" chunk_size: description: "Rows one chunk holds; 0 derives it from the runner slots and the queue window." required: false type: string default: "0" whitelist: description: "Optional space-separated whitelist of app ids" required: false type: string default: "" priority: description: "Optional space-separated app ids for the priority line: sorted to the head of the list, cut into chunks of their own, and deployed in every combination they can take." required: false type: string default: "" lifecycles: description: "Lifecycle envelope for discovery (comma/space-separated)." required: false type: string default: "" modes: description: "Deploy modes rows may be drawn from: 'auto' for every mode, or a subset of 'host compose swarm'." required: false type: string default: "auto" filesystem: description: "πŸ—„οΈ Space-separated filesystem pool the matrix spreads its rows over (ext4, btrfs, zfs). Empty: all three." required: false type: string default: "" marker: description: "Optional suffix appended to the discover job name (e.g. ' ⭐' for a priority chunk)" required: false type: string default: "" tor: required: false type: string default: auto vpn: required: false type: string default: auto meshes: description: "WireGuard layout a meshed swarm row deploys. Empty: INFINITO_SWARM_MESHES from default.env." required: false type: string default: "" disable: description: "Comma-separated provider keys removed from the test inventory, on top of the matrix row's own tokens." required: false type: string default: "" workflow_dispatch: inputs: distros: description: "🐧 Distro pool the matrix spreads its rows over. Empty: every declared distro" required: false type: string default: "" index: description: "πŸ“¦ Chunk index to deploy." required: false type: string default: "0" sweep: description: "πŸ” Sweep number driving the mode/tor rotation and the regular-line offset." required: false type: string default: "0" whitelist: description: "🎯 Roles to deploy. Empty: diff vs origin/main. '__ALL__': every role." required: false type: string default: "" priority: description: "⭐ Roles to deploy first, in every combination they can take." required: false type: string default: "" lifecycles: description: "♻️ Lifecycle envelope for discovery (comma/space-separated)." required: false type: string default: "" modes: description: "πŸš€ Deploy modes to draw from: 'auto' or a subset of 'host compose swarm'" required: false type: string default: "auto" filesystem: description: "πŸ—„οΈ Filesystem pool the matrix spreads its rows over. Empty: ext4, btrfs, zfs" required: false type: string default: "" tor: description: "πŸ§… Tor axis; see .github/workflows/README.md for what each value does" required: false type: choice default: auto options: [auto, enforced, exclusive, disabled] vpn: description: "πŸš‡ WireGuard axis; see .github/workflows/README.md for what each value does" required: false type: choice default: auto options: [auto, enforced, disabled] meshes: description: "πŸ•ΈοΈ WireGuard layout meshed swarm rows deploy. Empty: the development lab's" required: false type: string default: "" disable: description: "🚫 Provider keys removed on top of the row's own (comma-separated)" required: false type: string default: "" permissions: contents: read packages: read env: INFINITO_DISTROS: ${{ inputs.distros }} NIX_CONFIG: | access-tokens = github.com=${{ secrets.GITHUB_TOKEN }} INFINITO_IMAGE_TAG: ci-${{ github.sha }} INFINITO_GHCR_MIRROR_PREFIX: mirror INFINITO_MAIL_PRODUCTION_HOST: ${{ vars.INFINITO_MAIL_PRODUCTION_HOST }} INFINITO_MAIL_PRODUCTION_RESOLVER: ${{ vars.INFINITO_MAIL_PRODUCTION_RESOLVER }} INFINITO_PLAYWRIGHT_KEEP: ${{ vars.PLAYWRIGHT_KEEP_ALL }} INFINITO_API_OPENAI_KEY: ${{ secrets.API_OPENAI_KEY || vars.API_OPENAI_KEY }} INFINITO_API_ANTHROPIC_KEY: ${{ secrets.API_ANTHROPIC_KEY || vars.API_ANTHROPIC_KEY }} INFINITO_API_OPENROUTER_KEY: ${{ secrets.API_OPENROUTER_KEY || vars.API_OPENROUTER_KEY }} INFINITO_APP_DISCOVERY_RUNNER: host INFINITO_DOCKER_FILESYSTEM_ALLOWED: ${{ inputs.filesystem }} INFINITO_LIFECYCLES: ${{ inputs.lifecycles }} INFINITO_DISCOVERY_SEED: ${{ github.run_id }} jobs: discover: name: πŸ” Chunk ${{ inputs.index }}${{ inputs.marker }} runs-on: ubuntu-26.04 timeout-minutes: 60 outputs: apps: ${{ steps.discover.outputs.apps }} steps: - uses: actions/checkout@v7.0.0 with: fetch-depth: 0 - name: πŸ” Resolve repository owner shell: bash run: | set -euo pipefail echo "GITHUB_REPOSITORY_OWNER=$(scripts/meta/resolve/repository/owner.sh)" >> "$GITHUB_ENV" - name: πŸ“¦ Install jq shell: bash run: ./scripts/install/apt.sh jq - name: πŸ”§ Set up Python uses: actions/setup-python@v6 with: python-version: "3.12" cache: pip cache-dependency-path: pyproject.toml - name: πŸ“¦ Install project (host venv) shell: bash run: | make install-force # shellcheck source=/dev/null source scripts/meta/env/load.sh echo "${VENV}/bin" >> "${GITHUB_PATH}" - name: πŸ” Resolve effective whitelist (diff vs origin/main) id: resolve_wl shell: bash env: INPUT_WHITELIST: ${{ inputs.whitelist }} run: ./scripts/github/resolve/effective_whitelist.sh - name: πŸ” Discover the chunk's rows id: discover shell: bash env: INFINITO_WHITELIST: ${{ steps.resolve_wl.outputs.whitelist }} INFINITO_PRIORITY: ${{ inputs.priority }} INFINITO_MODES: ${{ inputs.modes }} INFINITO_TOR: ${{ inputs.tor }} INFINITO_VPN: ${{ inputs.vpn }} INFINITO_CI_SWEEP: ${{ inputs.sweep }} INFINITO_CI_CHUNK: ${{ inputs.index }} INFINITO_CI_OFFSET: ${{ inputs.offset }} INFINITO_CI_CHUNK_SIZE: ${{ inputs.chunk_size }} run: ./scripts/github/resolve/output_apps.sh - name: πŸ—ΊοΈ Render the plan into the run summary shell: bash env: INFINITO_CI_SWEEP: ${{ inputs.sweep }} INFINITO_CI_CHUNK_SIZE: ${{ inputs.chunk_size }} PLAN_DISTROS: ${{ inputs.distros }} PLAN_FILESYSTEM: ${{ inputs.filesystem }} PLAN_WHITELIST: ${{ steps.resolve_wl.outputs.whitelist }} PLAN_PRIORITY: ${{ inputs.priority }} PLAN_MODES: ${{ inputs.modes }} PLAN_LIFECYCLES: ${{ inputs.lifecycles }} PLAN_TOR: ${{ inputs.tor }} PLAN_VPN: ${{ inputs.vpn }} PLAN_OFFSET: ${{ inputs.offset }} run: | source scripts/meta/env/load.sh "${PYTHON}" -m cli.meta.ci.plan \ --distros "${PLAN_DISTROS}" \ --filesystem "${PLAN_FILESYSTEM}" \ --whitelist "${PLAN_WHITELIST}" \ --priority "${PLAN_PRIORITY}" \ --modes "${PLAN_MODES}" \ --lifecycles "${PLAN_LIFECYCLES}" \ --tor "${PLAN_TOR}" \ --vpn "${PLAN_VPN}" \ --offset "${PLAN_OFFSET}" \ >> "$GITHUB_STEP_SUMMARY" deploy: name: "${{ matrix.label }}" if: needs.discover.outputs.apps != '' && needs.discover.outputs.apps != '[]' runs-on: ubuntu-26.04 timeout-minutes: 355 needs: discover # TODO: verify in a CI deploy that a cancel drops queued jobs from this # group and that an identical row of a second run queues behind it. concurrency: group: "infinito-deploy-${{ matrix.mode }}-${{ matrix.tor }}-${{ matrix.distro }}-${{ matrix.filesystem }}-${{ matrix.apps }}#${{ matrix.variant }}" cancel-in-progress: false queue: max strategy: fail-fast: ${{ github.event_name == 'pull_request' || github.event_name == 'pull_request_target' }} matrix: include: ${{ fromJson(needs.discover.outputs.apps) }} env: APP_ID: ${{ matrix.apps }} INFINITO_DEPLOY_MODE: ${{ matrix.mode }} INFINITO_DISTROS: ${{ matrix.distro }} INFINITO_DOCKER_FILESYSTEM_ALLOWED: ${{ inputs.filesystem }} INFINITO_DOCKER_FILESYSTEM_PICK: ${{ matrix.filesystem }} INFINITO_DOCKER_FILESYSTEM_ENFORCED: ${{ matrix.enforce_filesystem }} RESCHED_TIMEOUT: "120" SCRIPTS: scripts/tests/deploy/swarm/routine steps: - name: 🧹 Free disk space if: ${{ !env.ACT }} uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1 with: tool-cache: true android: true dotnet: true haskell: true large-packages: true docker-images: true swap-storage: true - name: "⬇️ Checkout" if: ${{ !env.ACT }} uses: actions/checkout@v7.0.0 - name: "πŸ“₯ Load rescue-diagnostics SPOT into job env" shell: bash run: grep '^INFINITO_RESCUE_DIAGNOSTICS_BASE=' default.env >> "$GITHUB_ENV" - name: πŸ’½ Mount the empty spare disk at /mnt if: ${{ !env.ACT }} shell: bash run: ./scripts/github/runner/mount_spare_disk.sh /mnt 75 - name: πŸ“¦ Relocate containerd and the Docker data-root onto /mnt when roomier if: ${{ !env.ACT }} shell: bash run: ./scripts/github/runner/relocate_docker_dataroot.sh 75 - name: πŸ’Ύ Enlarge swap space if: ${{ !env.ACT }} shell: bash run: ./scripts/github/runner/enlarge_swap.sh - name: πŸ›‘οΈ Tear down host AppArmor profiles (dev-host parity) if: ${{ !env.ACT }} shell: bash run: make security-apparmor-teardown - name: πŸ” Resolve repository owner shell: bash run: | set -euo pipefail echo "GITHUB_REPOSITORY_OWNER=$(scripts/meta/resolve/repository/owner.sh)" >> "$GITHUB_ENV" - name: πŸ“¦ Install jq and iproute2 shell: bash run: ./scripts/install/apt.sh jq iproute2 - name: "πŸ” Resolve SWARM_NAME (mandatory; default to the matrix app)" if: ${{ matrix.mode == 'swarm' }} shell: bash run: echo "SWARM_NAME=${SWARM_NAME:-${{ matrix.apps }}}" >> "$GITHUB_ENV" - name: "πŸ“₯ Load swarm topology SPOT into job env" if: ${{ matrix.mode == 'swarm' }} shell: bash run: scripts/tests/deploy/swarm/utils/topology/export.sh - name: πŸ”§ Set up Python if: ${{ matrix.mode == 'swarm' }} uses: actions/setup-python@v6 with: python-version: "3.12" cache: pip cache-dependency-path: pyproject.toml - name: πŸ“¦ Install project (host venv) if: ${{ matrix.mode == 'swarm' }} shell: bash run: | make install-force # shellcheck source=/dev/null source scripts/meta/env/load.sh echo "${VENV}/bin" >> "${GITHUB_PATH}" - name: "πŸš€ Drill ${{ matrix.apps }} on ${{ matrix.distro }}/${{ matrix.filesystem }}" if: ${{ matrix.mode == 'swarm' }} shell: bash env: variant: ${{ matrix.variant }} disable: ${{ format('{0} {1}', matrix.disable, inputs.disable) }} INFINITO_SWARM_VPN: ${{ matrix.vpn }} INFINITO_SWARM_MESHES: ${{ inputs.meshes }} run: ./scripts/tests/deploy/distros.sh "${{ env.SCRIPTS }}/00_one.sh" 2>&1 | tee "/tmp/ansible-${{ matrix.apps }}.log" - name: πŸš€ Deploy app if: ${{ matrix.mode != 'swarm' }} timeout-minutes: 350 shell: bash env: apps: ${{ matrix.apps }} variant: ${{ matrix.variant }} disable: ${{ format('{0} {1}', matrix.disable, inputs.disable) }} ANSIBLE_LOG_PATH: /tmp/ansible-${{ matrix.apps }}.log run: ./scripts/tests/deploy/ci/all.sh 2>&1 | tee "/tmp/ansible-${{ matrix.apps }}.log" - name: 🚫 Fail on deprecation warnings if: ${{ always() && matrix.mode != 'swarm' }} shell: bash run: ./scripts/tests/deploy/ci/assert_no_deprecation_warnings.sh "/tmp/ansible-${{ matrix.apps }}.log" - name: "πŸ—„οΈ Effective node filesystems" if: ${{ always() && matrix.mode == 'swarm' }} shell: bash run: ./scripts/tests/deploy/utils/filesystem/report.sh "/tmp/ansible-${{ matrix.apps }}.log" - name: "πŸ“Š Role runtimes" if: always() shell: bash run: | set -euo pipefail log="/tmp/ansible-${{ matrix.apps }}.log" if [ ! -f "${log}" ]; then echo "No ansible log at ${log}; deploy never started." exit 0 fi python3 -m cli.meta.ci.roles.runtime "${log}" \ --format csv --output "/tmp/role-runtimes-${{ matrix.apps }}.csv" python3 -m cli.meta.ci.roles.runtime "/tmp/role-runtimes-${{ matrix.apps }}.csv" \ --format markdown >> "$GITHUB_STEP_SUMMARY" - name: "⬆️ Upload role runtimes CSV" if: ${{ always() && !env.ACT }} uses: actions/upload-artifact@v7 with: name: role-runtimes-${{ matrix.artifact }} path: /tmp/role-runtimes-${{ matrix.apps }}.csv if-no-files-found: ignore - name: 🩺 Dump runner state (on failure) if: ${{ failure() && matrix.mode != 'swarm' }} timeout-minutes: 5 shell: bash run: ./scripts/github/runner/dump_state.sh "post-deploy failure (${{ matrix.apps }})" - name: "⬆️ Upload rescue diagnostics" if: ${{ always() && !env.ACT }} uses: actions/upload-artifact@v7 with: name: rescue-diagnostics-${{ matrix.artifact }} path: ${{ env.INFINITO_RESCUE_DIAGNOSTICS_BASE }}/ if-no-files-found: ignore - name: "πŸ—œοΈ Zip generated inventory for debugging" if: always() shell: bash env: APP_ID: ${{ matrix.apps }} run: | set -euo pipefail kind=swarm [ "${{ matrix.mode }}" = "swarm" ] || kind=compose bash "scripts/github/inventory/zip/${kind}_for_upload.sh" echo "INVENTORY_ZIP=/tmp/inventory-${kind}-${APP_ID}.zip" >> "$GITHUB_ENV" - name: "⬆️ Upload inventory artifact" if: ${{ always() && !env.ACT }} uses: actions/upload-artifact@v7 with: name: inventory-${{ matrix.artifact }} path: ${{ env.INVENTORY_ZIP }} if-no-files-found: ignore - name: "πŸ“‹ Playwright step summary" if: always() shell: bash run: | set -euo pipefail python3 utils/github/playwright_summary.py \ /tmp/playwright-artifacts \ "${{ matrix.apps }}" \ >> "$GITHUB_STEP_SUMMARY" - name: "⬆️ Upload Playwright artifacts" if: ${{ always() && !env.ACT }} uses: actions/upload-artifact@v7.0.1 with: name: playwright-${{ matrix.artifact }} path: /tmp/playwright-artifacts/ if-no-files-found: ignore - name: "🧹 Cleanup" if: ${{ always() && matrix.mode == 'swarm' }} timeout-minutes: 15 run: scripts/tests/deploy/swarm/utils/clean/teardown.sh - name: "πŸ“– Replay the ${{ matrix.apps }} README instructions" if: ${{ matrix.instructions != '' }} env: GUIDE_ROLE: ${{ matrix.apps }} GUIDE_MODE: ${{ matrix.instructions }} run: ./scripts/tests/deploy/distros.sh scripts/github/guide/one.sh - name: "⬆️ Upload instructions rescue diagnostics" if: ${{ always() && matrix.instructions != '' && !env.ACT }} uses: actions/upload-artifact@v7 with: name: rescue-diagnostics-instructions-${{ matrix.artifact }} path: ${{ env.INFINITO_RESCUE_DIAGNOSTICS_BASE }}/ if-no-files-found: ignore