name: E2E Tests on: workflow_call: inputs: ref: description: 'Git ref to checkout (branch, tag, or SHA). Defaults to the triggering ref.' type: string required: false default: '' workflow_dispatch: concurrency: group: E2E Tests-${{ inputs.ref || github.ref }} cancel-in-progress: true permissions: contents: read actions: read jobs: suites: runs-on: ubuntu-latest timeout-minutes: 5 outputs: list: ${{ steps.suites.outputs.list }} steps: - name: Checkout repository uses: actions/checkout@v7 timeout-minutes: 1 with: ref: ${{ inputs.ref || github.ref }} persist-credentials: false - name: Build the suite matrix id: suites working-directory: e2e run: | list=$(bash ./scripts/ci-matrix.sh) echo "list=$list" >> "$GITHUB_OUTPUT" build-app: runs-on: ubuntu-latest timeout-minutes: 30 permissions: contents: read packages: write strategy: fail-fast: false matrix: variant: [plain, patched] name: build-app (${{ matrix.variant }}) steps: - name: Checkout repository uses: actions/checkout@v7 timeout-minutes: 1 with: ref: ${{ inputs.ref || github.ref }} persist-credentials: false - name: Get vault secrets uses: hashicorp/vault-action@v4 with: url: ${{ secrets.VAULT_HOST }} method: approle roleId: ${{ secrets.VAULT_ROLE_ID }} secretId: ${{ secrets.VAULT_SECRET_ID }} secrets: secrets/data/github packages_pat | PACKAGES_PAT # Nothing reverts these. The runner throws the checkout away. - name: Apply the e2e patches if: matrix.variant == 'patched' run: bash e2e/scripts/with-patches.sh - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 timeout-minutes: 5 - name: Login to GitHub Container Registry uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build the app image uses: docker/build-push-action@v7 timeout-minutes: 25 with: context: ${{ github.workspace }}/code file: ${{ github.workspace }}/code/Dockerfile provenance: false build-args: | PACKAGES_USERNAME=${{ github.repository_owner }} secrets: | packages_pat=${{ env.PACKAGES_PAT }} tags: cleanuparr-e2e-app:${{ matrix.variant }} outputs: type=docker,dest=/tmp/app-image.tar # The GHA cache evicted these layers once the repo passed its 10 GB quota. # A failed cache write must not fail the build. cache-from: type=registry,ref=ghcr.io/cleanuparr/cleanuparr-e2e-cache:${{ matrix.variant }} cache-to: type=registry,ref=ghcr.io/cleanuparr/cleanuparr-e2e-cache:${{ matrix.variant }},mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true # gzip -1 beats letting upload-artifact deflate a layer tar this size. - name: Compress the image run: gzip -1 /tmp/app-image.tar - name: Upload the image uses: actions/upload-artifact@v7 with: name: e2e-app-image-${{ matrix.variant }} path: /tmp/app-image.tar.gz retention-days: 1 compression-level: 0 e2e: needs: suites runs-on: ubuntu-latest timeout-minutes: 60 strategy: fail-fast: false matrix: suite: ${{ fromJSON(needs.suites.outputs.list) }} name: e2e (${{ matrix.suite.name }}) steps: - name: Checkout repository uses: actions/checkout@v7 timeout-minutes: 1 with: ref: ${{ inputs.ref || github.ref }} persist-credentials: false - name: Match container uid/gid to the runner user run: | echo "E2E_PUID=$(id -u)" >> "$GITHUB_ENV" echo "E2E_PGID=$(id -g)" >> "$GITHUB_ENV" - name: Start the stack working-directory: e2e run: make ${{ matrix.suite.stack-target }} - name: Setup Node.js uses: actions/setup-node@v7 with: node-version: 26 cache: 'npm' cache-dependency-path: e2e/package-lock.json - name: Install E2E dependencies working-directory: e2e run: npm ci - name: Resolve Playwright version id: playwright working-directory: e2e run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT" - name: Cache Playwright browsers uses: actions/cache@v6 with: path: ~/.cache/ms-playwright key: ${{ runner.os }}-playwright-${{ steps.playwright.outputs.version }} - name: Report runner disk space run: df -h / - name: Install Playwright browsers working-directory: e2e timeout-minutes: 10 env: DEBUG: pw:install run: npx playwright install --with-deps chromium - name: Wait for the app image timeout-minutes: 30 env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VARIANT: ${{ matrix.suite.image-variant }} RUN_JOBS: /repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/jobs RUN_ARTIFACTS: /repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts run: | set -euo pipefail artifact="e2e-app-image-$VARIANT" job="build-app ($VARIANT)" grace=12 # The artifact appears only once build-app uploads it. Watch that job too, # so a build failure ends this leg instead of stalling it until the timeout. while :; do # Assigned, not piped into grep: under pipefail a grep that exits on the # first match can SIGPIPE gh, making a found artifact read as not found. id=$(gh api "$RUN_ARTIFACTS" --paginate \ --jq ".artifacts[] | select(.name == \"$artifact\") | .id" || true) if [ -n "$id" ]; then break fi # A transient API error must not end the leg, so keep polling. # Under workflow_call the job is named " / build-app (...)". conclusion=$(gh api "$RUN_JOBS" --paginate \ --jq ".jobs[] | select(.name == \"$job\" or (.name | endswith(\" / $job\"))) | .conclusion" || true) case "$conclusion" in ''|null) ;; success) grace=$((grace - 1)) if [ "$grace" -le 0 ]; then echo "::error::$job succeeded but $artifact never appeared" exit 1 fi ;; *) echo "::error::$job concluded $conclusion, no image to run" exit 1 ;; esac sleep 10 done - name: Download the app image uses: actions/download-artifact@v8 with: name: e2e-app-image-${{ matrix.suite.image-variant }} path: /tmp - name: Load the app image run: gunzip -c /tmp/app-image.tar.gz | docker load - name: Start the app working-directory: e2e env: APP_IMAGE: cleanuparr-e2e-app:${{ matrix.suite.image-variant }} run: make up-app - name: Run E2E tests working-directory: e2e timeout-minutes: ${{ matrix.suite.budget-minutes }} run: npx playwright test ${{ matrix.suite.projects }} - name: Summarize test timings if: always() working-directory: e2e run: | if [ -f test-results/results.json ]; then bash scripts/ci-timings.sh test-results/results.json >> "$GITHUB_STEP_SUMMARY" fi - name: Check the leg budget if: always() working-directory: e2e env: LEG: ${{ matrix.suite.name }} BUDGET: ${{ matrix.suite.budget-minutes }} run: | if [ ! -f test-results/results.json ]; then exit 0 fi used=$(jq '.stats.duration / 60000 | floor' test-results/results.json) if [ "$used" -ge $((BUDGET * 4 / 5)) ]; then echo "::warning::$LEG used ${used}m of its ${BUDGET}m budget. Split it in e2e/scripts/ci-matrix.sh." fi - name: Dump service logs if: failure() working-directory: e2e run: | docker compose -f docker-compose.e2e.yml ps # A container can serve fine and still fail its check, so record what the check saw. docker compose -f docker-compose.e2e.yml ps -q | xargs -r docker inspect \ --format '{{.Name}} {{with .State.Health}}{{.Status}}{{range .Log}} [exit {{.ExitCode}}] {{.Output}}{{end}}{{end}}' docker compose -f docker-compose.e2e.yml logs --no-color --tail 500 - name: Upload test results uses: actions/upload-artifact@v7 if: always() with: name: e2e-test-results-${{ matrix.suite.name }} path: | e2e/playwright-report/ e2e/test-results/ retention-days: 7 - name: Stop services if: always() working-directory: e2e run: make down