diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index fef2033..8fc1f4a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -21,6 +21,27 @@ permissions: contents: read jobs: + # Detect whether a pull request touches LocalStack. The live integration job + # installs the AWS CLI, pulls the LocalStack image, and starts a real + # container against an external auth token -- weight that only pays off when + # LocalStack code or its CI wiring changes. Only runs on pull_request; on + # push/tag events localstack-integration runs unconditionally. + changes: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + outputs: + localstack: ${{ steps.filter.outputs.localstack }} + steps: + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + id: filter + with: + filters: | + localstack: + - 'pydantic_ai_harness/localstack/**' + - 'tests/localstack/**' + - 'integration_tests/localstack/**' + - '.github/workflows/main.yml' + lint: runs-on: ubuntu-latest steps: @@ -109,12 +130,7 @@ jobs: # The lock validation must be off here: this job deviates from the lock # on purpose, and under UV_LOCKED=1 the lowest-direct resolution fails # because uv refuses to write the lowest-resolved lockfile it produces. - # greenlet 3.5.3 was published without Linux wheels, which makes uv's - # install phase fail on Ubuntu runners after resolution succeeds. - - run: > - uv sync --resolution lowest-direct - --exclude-newer-package greenlet=2026-06-25T00:00:00Z - --group dev --all-extras + - run: uv sync --resolution lowest-direct --group dev --all-extras env: UV_LOCKED: '0' - run: uv run --no-sync pytest @@ -154,6 +170,11 @@ jobs: - run: uv run --no-sync pytest localstack-integration: + needs: [changes] + # Skip on pull requests that leave LocalStack untouched; always run on + # push/tag events so releases exercise the live path. `always()` lets this + # evaluate even though `changes` is skipped on non-pull_request events. + if: ${{ always() && (github.event_name != 'pull_request' || needs.changes.outputs.localstack == 'true') }} runs-on: ubuntu-latest name: LocalStack integration tests # The auth token secret lives in this environment; binding the job to it also @@ -216,11 +237,15 @@ jobs: check: if: always() - needs: [lint, test, test-floor, test-latest, coverage, localstack-integration] + needs: [changes, lint, test, test-floor, test-latest, coverage, localstack-integration] runs-on: ubuntu-latest steps: - uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2 with: + # localstack-integration is skipped on pull requests that don't touch + # LocalStack, and `changes` is skipped on push/tag events. Both still + # vote (block the merge) when they actually run and fail. + allowed-skips: changes, localstack-integration jobs: ${{ toJSON(needs) }} release: diff --git a/tests/localstack/test_ci_workflow.py b/tests/localstack/test_ci_workflow.py index f8542f0..9f487d5 100644 --- a/tests/localstack/test_ci_workflow.py +++ b/tests/localstack/test_ci_workflow.py @@ -38,10 +38,23 @@ def test_localstack_ci_gates_the_aggregate_check() -> None: # The aggregate `check` job must depend on localstack-integration so a live # test failure blocks merges rather than passing silently. - needs = next(line for line in lines if line.strip().startswith('needs: [lint, test')) + needs = next(line for line in lines if line.strip().startswith('needs: [') and 'coverage' in line) assert 'localstack-integration' in needs +def test_localstack_integration_is_scoped_to_localstack_changes() -> None: + lines = _workflow_lines() + + # On a pull request the live job runs only when LocalStack paths change, so + # unrelated PRs don't pull the image, install the AWS CLI, or spend the token. + assert any('needs.changes.outputs.localstack' in line for line in lines) + assert any("- 'pydantic_ai_harness/localstack/**'" in line for line in lines) + + # A skipped live job must not fail the required aggregate check; a job that + # actually runs and fails still votes (see allowed-skips semantics). + assert any('allowed-skips: changes, localstack-integration' in line for line in lines) + + def test_localstack_ci_scopes_the_auth_token_to_the_test_step() -> None: lines = _workflow_lines()