name: CI/CD Pipeline on: push: branches: [ main, experimental, dev ] tags: [ 'v*.*.*' ] pull_request: branches: [ main ] workflow_dispatch: inputs: skip_tests: description: 'Skip tests' required: false default: 'false' type: boolean image_tag: description: 'Custom tag for Docker image' required: false default: 'latest' type: string jobs: # ========================================== # TESTING STAGE # ========================================== unit-tests: name: Unit Tests (Python ${{ matrix.python-version }}) runs-on: ubuntu-latest if: ${{ !inputs.skip_tests }} strategy: fail-fast: false matrix: python-version: ['3.9', '3.10', '3.11'] steps: - name: Checkout code uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Cache pip dependencies uses: actions/cache@v3 with: path: ~/.cache/pip key: ${{ runner.os }}-py${{ matrix.python-version }}-pip-${{ hashFiles('requirements.txt', 'requirements-test.txt') }} restore-keys: | ${{ runner.os }}-py${{ matrix.python-version }}-pip- - name: Install dependencies run: | python -m pip install --upgrade pip setuptools wheel pip install -r requirements.txt pip install -r requirements-test.txt - name: Create test configuration run: | mkdir -p embed logs cat > config.ini << EOF [Pterodactyl] PanelURL = https://panel.example.com ClientAPIKey = ptlc_test_client_key_123456789 ApplicationAPIKey = ptla_test_app_key_987654321 [Discord] Token = test_discord_token_placeholder AllowedGuildID = 123456789 EOF - name: Run unit tests with coverage run: | pytest test_pterodisbot.py \ -v \ --tb=short \ --cov=pterodisbot \ --cov=server_metrics_graphs \ --cov-report=xml \ --cov-report=term \ --cov-report=html \ --junitxml=test-results-${{ matrix.python-version }}.xml - name: Upload coverage to artifacts uses: actions/upload-artifact@v3 with: name: coverage-report-py${{ matrix.python-version }} path: | coverage.xml htmlcov/ test-results-${{ matrix.python-version }}.xml code-quality: name: Code Quality & Linting runs-on: ubuntu-latest if: ${{ !inputs.skip_tests }} steps: - name: Checkout code uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.11' - name: Install linting tools run: | python -m pip install --upgrade pip pip install flake8 pylint black isort mypy - name: Run flake8 run: | flake8 pterodisbot.py server_metrics_graphs.py \ --max-line-length=120 \ --ignore=E501,W503,E203 \ --exclude=venv,__pycache__,build,dist \ --statistics \ --output-file=flake8-report.txt continue-on-error: true - name: Run pylint run: | pylint pterodisbot.py server_metrics_graphs.py \ --disable=C0111,C0103,R0913,R0914,R0915,W0718 \ --max-line-length=120 \ --output-format=text \ --reports=y > pylint-report.txt || true continue-on-error: true - name: Check code formatting with black run: | black --check --line-length=120 --diff pterodisbot.py server_metrics_graphs.py | tee black-report.txt continue-on-error: true - name: Check import ordering run: | isort --check-only --profile black --line-length=120 pterodisbot.py server_metrics_graphs.py continue-on-error: true - name: Type checking with mypy run: | mypy pterodisbot.py server_metrics_graphs.py --ignore-missing-imports > mypy-report.txt || true continue-on-error: true - name: Upload linting reports uses: actions/upload-artifact@v3 with: name: code-quality-reports path: | flake8-report.txt pylint-report.txt black-report.txt mypy-report.txt security-scan: name: Security Scanning runs-on: ubuntu-latest if: ${{ !inputs.skip_tests }} steps: - name: Checkout code uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.11' - name: Install security tools run: | python -m pip install --upgrade pip pip install bandit safety pip-audit - name: Run bandit security scan run: | bandit -r . \ -f json \ -o bandit-report.json \ -ll \ --exclude ./venv,./test_*.py,./tests continue-on-error: true - name: Run safety dependency check run: | pip install -r requirements.txt safety check --json --output safety-report.json || true continue-on-error: true - name: Run pip-audit run: | pip-audit --desc --format json --output pip-audit-report.json || true continue-on-error: true - name: Upload security reports uses: actions/upload-artifact@v3 with: name: security-reports path: | bandit-report.json safety-report.json pip-audit-report.json integration-tests: name: Integration Tests runs-on: ubuntu-latest needs: [unit-tests] if: ${{ !inputs.skip_tests }} steps: - name: Checkout code uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.11' - name: Cache dependencies uses: actions/cache@v3 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-integration-${{ hashFiles('requirements.txt') }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt pip install -r requirements-test.txt - name: Create test configuration run: | mkdir -p embed logs cat > config.ini << EOF [Pterodactyl] PanelURL = https://panel.example.com ClientAPIKey = ptlc_test_client_key_123456789 ApplicationAPIKey = ptla_test_app_key_987654321 [Discord] Token = test_discord_token_placeholder AllowedGuildID = 123456789 EOF - name: Run integration tests run: | pytest test_pterodisbot.py::TestIntegration \ -v \ --tb=short \ --timeout=60 # ========================================== # BUILD STAGE # ========================================== docker-build: name: Build Docker Image runs-on: ubuntu-latest needs: [unit-tests, code-quality, security-scan] if: | always() && (needs.unit-tests.result == 'success' || inputs.skip_tests) && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') steps: - name: Checkout code uses: actions/checkout@v3 - name: Set up QEMU uses: docker/setup-qemu-action@v2 with: platforms: arm64 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 with: platforms: linux/amd64,linux/arm64 driver-opts: | image=moby/buildkit:latest - name: Log in to registry uses: docker/login-action@v2 with: registry: ${{ vars.REGISTRY }} username: ${{ secrets.REGISTRY_USERNAME }} password: ${{ secrets.REGISTRY_PASSWORD }} - name: Generate Docker image tags id: tags run: | IMAGE_NAME="${{ vars.REGISTRY }}/${{ github.repository_owner }}/${{ vars.IMAGE_NAME }}" if [ -n "${{ github.event.inputs.image_tag }}" ]; then PRIMARY_TAG="${{ github.event.inputs.image_tag }}" elif [[ ${{ github.ref }} == refs/tags/v* ]]; then PRIMARY_TAG="${GITHUB_REF#refs/tags/}" elif [[ ${{ github.ref }} == refs/heads/main ]]; then PRIMARY_TAG="latest" elif [[ ${{ github.ref }} == refs/heads/experimental ]]; then PRIMARY_TAG="experimental" elif [[ ${{ github.ref }} == refs/heads/dev ]]; then PRIMARY_TAG="dev" else PRIMARY_TAG="latest" fi TAGS="$IMAGE_NAME:$PRIMARY_TAG,$IMAGE_NAME:${{ github.sha }}" if [[ ${{ github.ref }} == refs/tags/v* ]]; then MAJOR_MINOR_TAG=$(echo "$PRIMARY_TAG" | sed -E 's/^v([0-9]+\.[0-9]+)\.[0-9]+.*$/v\1/') if [[ "$MAJOR_MINOR_TAG" != "$PRIMARY_TAG" ]]; then TAGS="$TAGS,$IMAGE_NAME:$MAJOR_MINOR_TAG" fi MAJOR_TAG=$(echo "$PRIMARY_TAG" | sed -E 's/^v([0-9]+)\.[0-9]+\.[0-9]+.*$/v\1/') if [[ "$MAJOR_TAG" != "$PRIMARY_TAG" ]]; then TAGS="$TAGS,$IMAGE_NAME:$MAJOR_TAG" fi fi echo "tags=$TAGS" >> $GITHUB_OUTPUT echo "Generated tags: $TAGS" - name: Build and push multi-arch image uses: docker/build-push-action@v4 with: context: . platforms: linux/amd64,linux/arm64 push: true cache-from: type=registry,ref=${{ vars.REGISTRY }}/${{ github.repository_owner }}/${{ vars.IMAGE_NAME }}:cache cache-to: type=registry,ref=${{ vars.REGISTRY }}/${{ github.repository_owner }}/${{ vars.IMAGE_NAME }}:cache,mode=max tags: ${{ steps.tags.outputs.tags }} labels: | org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} org.opencontainers.image.revision=${{ github.sha }} org.opencontainers.image.created=${{ github.event.head_commit.timestamp }} # ========================================== # REPORTING STAGE # ========================================== test-report: name: Generate Test Report runs-on: ubuntu-latest needs: [unit-tests, code-quality, security-scan, integration-tests] if: always() && !inputs.skip_tests steps: - name: Download all artifacts uses: actions/download-artifact@v3 - name: Generate test summary run: | echo "## ๐Ÿงช Test Results Summary" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "### Job Status:" >> $GITHUB_STEP_SUMMARY echo "- โœ… Unit Tests: \`${{ needs.unit-tests.result }}\`" >> $GITHUB_STEP_SUMMARY echo "- ๐ŸŽจ Code Quality: \`${{ needs.code-quality.result }}\`" >> $GITHUB_STEP_SUMMARY echo "- ๐Ÿ”’ Security Scan: \`${{ needs.security-scan.result }}\`" >> $GITHUB_STEP_SUMMARY echo "- ๐Ÿ”— Integration Tests: \`${{ needs.integration-tests.result }}\`" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "### Artifacts Generated:" >> $GITHUB_STEP_SUMMARY echo "- Coverage reports (HTML & XML)" >> $GITHUB_STEP_SUMMARY echo "- Code quality reports (flake8, pylint, black)" >> $GITHUB_STEP_SUMMARY echo "- Security scan reports (bandit, safety)" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "**Commit:** \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY echo "**Branch:** \`${{ github.ref_name }}\`" >> $GITHUB_STEP_SUMMARY echo "**Triggered by:** ${{ github.actor }}" >> $GITHUB_STEP_SUMMARY final-status: name: CI/CD Pipeline Status runs-on: ubuntu-latest needs: [test-report, docker-build] if: always() steps: - name: Check pipeline status run: | echo "## ๐Ÿš€ CI/CD Pipeline Complete" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY if [[ "${{ needs.docker-build.result }}" == "success" ]]; then echo "โœ… **Docker image built and pushed successfully**" >> $GITHUB_STEP_SUMMARY elif [[ "${{ needs.docker-build.result }}" == "skipped" ]]; then echo "โญ๏ธ **Docker build skipped**" >> $GITHUB_STEP_SUMMARY else echo "โŒ **Docker build failed**" >> $GITHUB_STEP_SUMMARY fi echo "" >> $GITHUB_STEP_SUMMARY echo "**Pipeline run:** ${{ github.run_number }}" >> $GITHUB_STEP_SUMMARY echo "**Workflow:** ${{ github.workflow }}" >> $GITHUB_STEP_SUMMARY - name: Fail if critical jobs failed if: | (needs.unit-tests.result == 'failure' && !inputs.skip_tests) || needs.docker-build.result == 'failure' run: exit 1