name: CI on: push: branches: - main tags: - '*' pull_request: ~ workflow_dispatch: ~ concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true permissions: contents: read jobs: build: name: Build Dev Image runs-on: ubuntu-latest timeout-minutes: 15 if: "!startsWith(github.ref, 'refs/tags/')" permissions: contents: read steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false - name: Lint Dockerfile uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 - name: Build Docker images uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7 with: pull: true files: | compose.yaml compose.override.yaml set: | *.cache-from=type=gha,scope=${{github.ref}}-devbuild *.cache-from=type=gha,scope=refs/heads/main-devbuild *.cache-to=type=gha,scope=${{github.ref}}-devbuild,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }} quality: name: Code Quality runs-on: ubuntu-latest timeout-minutes: 20 needs: build if: "!startsWith(github.ref, 'refs/tags/')" permissions: contents: read steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 - name: Load Docker images uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7 with: load: true files: | compose.yaml compose.override.yaml set: | *.cache-from=type=gha,scope=${{github.ref}}-devbuild - name: Start services run: docker compose up php database --wait --no-build - name: Warm up dev cache run: docker compose exec -T php bin/console cache:warmup --env=dev - name: Lint Twig templates id: twig_lint continue-on-error: true run: docker compose exec -T php bin/console lint:twig --format=github templates - name: Coding Style id: cs continue-on-error: true run: docker compose exec -T php vendor/bin/php-cs-fixer check --diff --show-progress=none - name: Twig Coding Style id: twig_cs continue-on-error: true run: docker compose exec -T php vendor/bin/twig-cs-fixer check - name: Static Analysis (PHPStan) id: phpstan continue-on-error: true run: docker compose exec -T php vendor/bin/phpstan analyse --no-progress --no-ansi --error-format=github - name: Rector id: rector continue-on-error: true run: docker compose exec -T php vendor/bin/rector process --dry-run --no-progress-bar --output-format=github - name: Check HTTP reachability run: curl -v --fail-with-body http://localhost - name: Assert all checks passed if: always() run: | failed=0 check() { local name="$1" outcome="$2" if [[ "$outcome" == "failure" ]]; then echo "::error::$name failed" failed=1 fi } check "Twig Lint" "${{ steps.twig_lint.outcome }}" check "Coding Style" "${{ steps.cs.outcome }}" check "Twig Coding Style" "${{ steps.twig_cs.outcome }}" check "PHPStan" "${{ steps.phpstan.outcome }}" check "Rector" "${{ steps.rector.outcome }}" exit $failed tests: name: Tests runs-on: ubuntu-latest timeout-minutes: 20 needs: build if: "!startsWith(github.ref, 'refs/tags/')" permissions: checks: write pull-requests: write contents: read steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 - name: Load Docker images uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7 with: load: true files: | compose.yaml compose.override.yaml set: | *.cache-from=type=gha,scope=${{github.ref}}-devbuild - name: Start services run: docker compose up php database --wait --no-build - name: Create test database run: docker compose exec -T php bin/console -e test doctrine:database:create - name: Run migrations run: docker compose exec -T php bin/console -e test doctrine:migrations:migrate --no-interaction - name: Load fixtures run: docker compose exec -T php bin/console -e test doctrine:fixtures:load --no-interaction --group=test - name: Run PHPUnit run: docker compose exec -T php vendor/bin/phpunit --log-junit var/phpunit/junit.xml - name: Publish PHPUnit test results if: always() uses: mikepenz/action-junit-report@d9f48fc87bc235f7e214acf696ca5abc0a986f16 # v6 with: report_paths: var/phpunit/junit.xml check_name: PHPUnit - name: Doctrine Schema Validator run: docker compose exec -T php bin/console -e test doctrine:schema:validate verify-prior-run: name: Verify Prior CI Run runs-on: ubuntu-latest timeout-minutes: 20 if: startsWith(github.ref, 'refs/tags/') permissions: actions: read steps: - name: Wait for and verify successful CI run on this commit env: GH_TOKEN: ${{ github.token }} run: | max_attempts=30 attempt=0 while [[ $attempt -lt $max_attempts ]]; do attempt=$((attempt + 1)) success_count=$(gh api \ "repos/${{ github.repository }}/actions/workflows/ci.yml/runs?head_sha=${{ github.sha }}&status=success&per_page=5" \ --jq "[.workflow_runs[] | select(.id != ${{ github.run_id }})] | length") if [[ "$success_count" -gt 0 ]]; then echo "Found $success_count prior successful CI run(s) for ${{ github.sha }}." exit 0 fi in_progress_count=$(gh api \ "repos/${{ github.repository }}/actions/workflows/ci.yml/runs?head_sha=${{ github.sha }}&per_page=10" \ --jq "[.workflow_runs[] | select(.id != ${{ github.run_id }}) | select(.status == \"in_progress\" or .status == \"queued\" or .status == \"waiting\" or .status == \"requested\" or .status == \"pending\")] | length") if [[ "$in_progress_count" -gt 0 ]]; then echo "CI still in progress (attempt $attempt/$max_attempts), waiting 30s..." sleep 30 else echo "::error::No prior successful CI run found for ${{ github.sha }}. Only tag commits that have passed CI on main." exit 1 fi done echo "::error::Timed out waiting for CI run to complete for ${{ github.sha }}." exit 1 build-deploy: name: Build and Deploy permissions: contents: read packages: write environment: name: ${{ startsWith(github.ref, 'refs/tags/') && 'production' || 'acceptance' }} url: ${{ vars.URL }} needs: [quality, tests, verify-prior-run] runs-on: ubuntu-latest timeout-minutes: 15 if: >- always() && !cancelled() && !failure() && ((github.ref == 'refs/heads/main' && false) || startsWith(github.ref, 'refs/tags/')) steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: persist-credentials: false - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 - name: Log in to GitHub Container Registry uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata id: meta run: | REPO_LOWER=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]') if [[ "${{ github.ref }}" == refs/tags/* ]]; then TAG="${GITHUB_REF#refs/tags/}" SENTRY_VERSION="${TAG#v}" { echo "tag=$TAG" echo "sentry_version=$SENTRY_VERSION" echo "full_name=ghcr.io/${REPO_LOWER}:$TAG" } >> "$GITHUB_OUTPUT" else SHORT_SHA=$(git rev-parse --short HEAD) { echo "tag=$SHORT_SHA" echo "sentry_version=$SHORT_SHA" echo "full_name=ghcr.io/${REPO_LOWER}:$SHORT_SHA" } >> "$GITHUB_OUTPUT" fi - name: Build and Push Docker images uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7 with: pull: true push: true files: | compose.yaml compose.build.yaml set: | *.cache-from=type=gha,scope=${{github.ref}} *.cache-from=type=gha,scope=refs/heads/main *.cache-to=type=gha,scope=${{github.ref}},mode=max *.tags=${{ steps.meta.outputs.full_name }} - name: Create Sentry release uses: getsentry/action-release@ff07929a6537bac57790c3451cf4d364aca38528 # v3 env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} with: release: ${{steps.meta.outputs.sentry_version}} environment: ${{ startsWith(github.ref, 'refs/tags/') && 'production' || 'acceptance' }} - name: Trigger Portainer Deployment shell: bash env: PORTAINER_WEBHOOK: ${{secrets.PORTAINER_WEBHOOK}} IMAGE_TAG: ${{steps.meta.outputs.tag}} SENTRY_RELEASE: ${{steps.meta.outputs.sentry_version}} run: | curl -v -X POST "${PORTAINER_WEBHOOK}?IMAGE_TAG=${IMAGE_TAG}&SENTRY_RELEASE=${SENTRY_RELEASE}" --fail-with-body