name: Tests on: pull_request: push: branches: - master - beta - canary - internal permissions: contents: read concurrency: group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: php: name: PHP ${{ matrix.suite }} (required) # Pull requests are untrusted and use ephemeral runners; trusted pushes use the local pool. runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-24.04' || 'backend' }} strategy: fail-fast: false matrix: suite: [unit, integration, api, legacy] env: DOCKER_HOST: unix:///var/run/docker.sock COMPOSE_PROJECT_NAME: php-${{ github.run_id }}-${{ github.job }}-${{ matrix.suite }}-${{ github.run_attempt }} steps: - name: Checkout uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: persist-credentials: false - name: Ensure Docker access run: | set -euo pipefail docker ps >/dev/null 2>&1 || { echo "Docker is unavailable to the runner identity. Fix the isolated runner configuration; the workflow will not weaken /var/run/docker.sock permissions." >&2 exit 1 } - name: Setup Node.js if: ${{ matrix.suite == 'unit' }} uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 - name: Check AI workflow sync if: ${{ matrix.suite == 'unit' }} run: node scripts/sync-ai-workflow.mjs --check - name: Run PHP ${{ matrix.suite }} suite run: bash scripts/php-ci-test.sh ${{ matrix.suite }} - name: Upload PHP suite logs if: ${{ failure() }} continue-on-error: true uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: php-${{ matrix.suite }}-logs path: .tmp/ci-logs/${{ matrix.suite }} if-no-files-found: warn retention-days: 3 edge-agent: name: Edge Agent (required) runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-24.04' || 'backend' }} steps: - name: Checkout uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: persist-credentials: false - name: Setup Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 - name: Install native build tools run: | set -euo pipefail if command -v make >/dev/null 2>&1 && command -v g++ >/dev/null 2>&1; then exit 0 fi if ! command -v apt-get >/dev/null 2>&1; then echo "make and g++ are required to install node-pty, but apt-get is not available on this runner." >&2 exit 1 fi apt_cmd=(apt-get) if [ "$(id -u)" -ne 0 ]; then if ! command -v sudo >/dev/null 2>&1; then echo "make and g++ are missing, and sudo is not available to install them." >&2 exit 1 fi apt_cmd=(sudo apt-get) fi "${apt_cmd[@]}" update "${apt_cmd[@]}" install -y --no-install-recommends build-essential python3 - name: Install dependencies working-directory: services/edge-agent run: npm ci - name: Run edge agent tests working-directory: services/edge-agent run: npm test edge-broker: name: Edge Broker (required) runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-24.04' || 'backend' }} env: DOCKER_HOST: unix:///var/run/docker.sock steps: - name: Checkout uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: persist-credentials: false - name: Ensure Docker access run: | set -euo pipefail docker ps >/dev/null 2>&1 || { echo "Docker is unavailable to the runner identity. Fix the isolated runner configuration; the workflow will not weaken /var/run/docker.sock permissions." >&2 exit 1 } - name: Materialize CI compose env files run: | set -euo pipefail cp .github/ci.env .env cp .github/ci.env.staging .env.staging - name: Validate compose contracts run: | docker compose -f docker-compose.yml -f docker-compose.prod.yml config > /dev/null docker compose -f docker-compose.example.yml config > /dev/null - name: Setup Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 - name: Install dependencies working-directory: services/edge-broker run: npm ci - name: Run edge broker tests working-directory: services/edge-broker run: npm test edge-gateway-backend: name: Edge Gateway Backend (required) runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-24.04' || 'backend' }} env: DOCKER_HOST: unix:///var/run/docker.sock COMPOSE_FILE: docker-compose.yml:.github/docker-compose.ci.yml COMPOSE_PROJECT_NAME: edge-gateway-backend-${{ github.run_id }}-${{ github.run_attempt }} COMPOSE_PROFILES: dev TRAEFIK_WEB_PORT: "18080" TRAEFIK_WEBSECURE_PORT: "18443" TRAEFIK_WEBSECURE_STAGING_PORT: "18433" TRAEFIK_METRICS_PORT: "19100" EDGE_BROKER_CI_PORT: "14300" EDGE_GATEWAY_E2E_BASE_URL: "http://localhost:18080/api" steps: - name: Checkout uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: persist-credentials: false - name: Ensure Docker access run: | set -euo pipefail docker ps >/dev/null 2>&1 || { echo "Docker is unavailable to the runner identity. Fix the isolated runner configuration; the workflow will not weaken /var/run/docker.sock permissions." >&2 exit 1 } - name: Allocate CI ports run: | set -euo pipefail find_free_port() { start="$1" end="$2" port="$start" while [ "$port" -le "$end" ]; do if ! ss -H -ltn "sport = :$port" 2>/dev/null | grep -q .; then echo "$port" return 0 fi port=$((port + 1)) done echo "No free port in range ${start}-${end}." >&2 exit 1 } base=$((20000 + (GITHUB_RUN_ID % 20000))) web_port="$(find_free_port "$base" "$((base + 2000))")" websecure_port="$(find_free_port "$((web_port + 1))" "$((web_port + 2000))")" staging_port="$(find_free_port "$((websecure_port + 1))" "$((websecure_port + 2000))")" metrics_port="$(find_free_port "$((staging_port + 1))" "$((staging_port + 2000))")" broker_port="$(find_free_port "$((metrics_port + 1))" "$((metrics_port + 2000))")" checksum="$(printf '%s' "$COMPOSE_PROJECT_NAME" | cksum | awk '{print $1}')" subnet_second=$((64 + ((checksum / 256) % 64))) subnet_third=$((checksum % 256)) ci_docker_subnet="10.${subnet_second}.${subnet_third}.0/24" { echo "TRAEFIK_WEB_PORT=${web_port}" echo "TRAEFIK_WEBSECURE_PORT=${websecure_port}" echo "TRAEFIK_WEBSECURE_STAGING_PORT=${staging_port}" echo "TRAEFIK_METRICS_PORT=${metrics_port}" echo "EDGE_BROKER_CI_PORT=${broker_port}" echo "CI_DOCKER_SUBNET=${ci_docker_subnet}" echo "EDGE_GATEWAY_E2E_BASE_URL=http://localhost:${web_port}/api" echo "EDGE_GATEWAY_E2E_COMPOSE_PROJECT=${COMPOSE_PROJECT_NAME}" } >> "$GITHUB_ENV" - name: Materialize CI compose env files run: | set -euo pipefail cp .github/ci.env .env cp .github/ci.env.staging .env.staging printf '\nEDGE_PUBLIC_BROKER_URL=http://edge-broker:4300/edge-broker\n' >> .env - name: Setup Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 - name: Boot local stack run: sh scripts/ci-docker-compose-up.sh traefik redis mysql-debug edge-broker php1 php2 php3 php4 php5 caddy - name: Sync PHP app checkout run: > tar --exclude='./vendor' --exclude='./.phpunit.cache' --exclude='./build/logs' -C services/nginx/app -cf - . | docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 tar --no-same-owner -C /var/www/html -xf - - name: Resolve dependencies run: | set -euo pipefail composer_install() { install_mode="$1" max_attempts="$2" attempt=1 while :; do if docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc "cd /var/www/html && composer install --no-interaction ${install_mode} --no-progress"; then return 0 fi if [ "$attempt" -ge "$max_attempts" ]; then return 1 fi sleep_seconds=$((attempt * 5)) echo "composer install ${install_mode} failed; retrying in ${sleep_seconds}s (attempt $((attempt + 1))/${max_attempts})" >&2 sleep "$sleep_seconds" attempt=$((attempt + 1)) done } composer_install --prefer-dist 3 || { echo "Composer dist install failed; retrying with --prefer-source." >&2 composer_install --prefer-source 2 } - name: Verify edge gateway test files run: > docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc "cd /var/www/html && php -r '\$composer = json_decode(file_get_contents(\"composer.json\"), true); echo \"Composer scripts: \", implode(\",\", array_keys(\$composer[\"scripts\"] ?? [])), PHP_EOL;' && find tests/Api -maxdepth 1 -type f -name 'EdgeGateway*ApiTest.php' -print && test -f tests/Api/EdgeGatewayAgentApiTest.php && test -f tests/Api/EdgeGatewayBrokerApiTest.php && test -f tests/Api/EdgeGatewayOperatorApiTest.php" - name: Run edge gateway API tests run: > docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc "cd /var/www/html && RUN_API_TESTS=1 API_TEST_BOOTSTRAP_SCHEMA=1 API_TEST_ALLOW_LIVE_DB=1 CONFIG_DB_TARGET=debug CONFIG_DB_HOST=mysql-debug CONFIG_DB_USER=\${CONFIG_DB_USER:-root} CONFIG_DB_PASSWORD=\${CONFIG_DB_PASSWORD:-debug_root_password} CONFIG_DB_DATABASE=\${CONFIG_DB_DATABASE:-nnks_db_debug} CONFIG_DB_PORT=3306 CONFIG_DB_DEBUG_HOST=mysql-debug CONFIG_DB_DEBUG_USER=\${CONFIG_DB_DEBUG_USER:-root} CONFIG_DB_DEBUG_PASSWORD=\${CONFIG_DB_DEBUG_PASSWORD:-debug_root_password} CONFIG_DB_DEBUG_DATABASE=\${CONFIG_DB_DEBUG_DATABASE:-nnks_db_debug} CONFIG_DB_DEBUG_PORT=3306 API_TEST_REQUEST_TIMEOUT=180 EDGE_GATEWAY_VIEW_CACHE_TTL=0 EDGE_BROKER_URL= vendor/bin/pest tests/Api/EdgeGatewayAgentApiTest.php tests/Api/EdgeGatewayBrokerApiTest.php tests/Api/EdgeGatewayOperatorApiTest.php --colors=always" - name: Run edge gateway integration tests run: > docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc "cd /var/www/html && RUN_INTEGRATION_TESTS=1 CONFIG_DB_TARGET=debug CONFIG_DB_HOST=mysql-debug CONFIG_DB_USER=\${CONFIG_DB_USER:-root} CONFIG_DB_PASSWORD=\${CONFIG_DB_PASSWORD:-debug_root_password} CONFIG_DB_DATABASE=\${CONFIG_DB_DATABASE:-nnks_db_debug} CONFIG_DB_PORT=3306 CONFIG_DB_DEBUG_HOST=mysql-debug CONFIG_DB_DEBUG_USER=\${CONFIG_DB_DEBUG_USER:-root} CONFIG_DB_DEBUG_PASSWORD=\${CONFIG_DB_DEBUG_PASSWORD:-debug_root_password} CONFIG_DB_DEBUG_DATABASE=\${CONFIG_DB_DEBUG_DATABASE:-nnks_db_debug} CONFIG_DB_DEBUG_PORT=3306 EDGE_BROKER_URL= vendor/bin/pest tests/Integration/EdgeGateway --colors=always" - name: Run edge gateway E2E smoke env: EDGE_GATEWAY_E2E_COPY_CONFIG: "true" EDGE_GATEWAY_E2E_SKIP_COMPOSE_UP: "true" run: node scripts/edge-gateway-e2e.mjs - name: Tear down local stack if: always() run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml down -v required-ci: name: Required CI runs-on: ubuntu-latest needs: [php, edge-agent, edge-broker, edge-gateway-backend] if: ${{ always() }} steps: - name: Verify required jobs succeeded env: PHP_RESULT: ${{ needs.php.result }} EDGE_AGENT_RESULT: ${{ needs.edge-agent.result }} EDGE_BROKER_RESULT: ${{ needs.edge-broker.result }} EDGE_GATEWAY_BACKEND_RESULT: ${{ needs.edge-gateway-backend.result }} run: | set -euo pipefail failed=0 for dependency in \ "php=${PHP_RESULT}" \ "edge-agent=${EDGE_AGENT_RESULT}" \ "edge-broker=${EDGE_BROKER_RESULT}" \ "edge-gateway-backend=${EDGE_GATEWAY_BACKEND_RESULT}" do name="${dependency%%=*}" result="${dependency#*=}" if [ "$result" != "success" ]; then echo "Required dependency ${name} completed with result: ${result:-missing}" >&2 failed=1 fi done test "$failed" -eq 0 release-manager-gate: name: Release Manager gate runs-on: [self-hosted, Linux, X64, pleno, backend] needs: [required-ci] if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && needs.required-ci.result == 'success' }} steps: - name: Record Release Manager API gate run: | set -euo pipefail test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1) response_file="$(mktemp)" http_code="$(curl --show-error --silent \ --connect-timeout 10 \ --retry 5 \ --retry-all-errors \ --retry-delay 15 \ --retry-max-time 300 \ -o "$response_file" \ -w '%{http_code}' \ -X POST "$RELEASE_MANAGER_GATE_URL" \ -H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \ -H "Content-Type: application/json" \ --data "{\"channel_slug\":\"stable\",\"app\":\"api\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"api_gateway\"]}")" response_body="$(cat "$response_file")" rm -f "$response_file" if [[ "$http_code" =~ ^2[0-9][0-9]$ ]]; then printf '%s\n' "$response_body" exit 0 fi printf '%s\n' "$response_body" echo "Release Manager gate failed with HTTP $http_code." >&2 exit 1 env: RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }} RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }} RELEASE_REPOSITORY: ${{ github.repository }} RELEASE_BRANCH: ${{ github.ref_name }} RELEASE_EXPECTED_COMMIT: ${{ github.sha }} RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}