pydantic/pydantic-ai

7 workflows · maturity 83% · 9 patterns · GitHub ↗

Security 14.29/100

Practices

✓ Matrix✓ Permissions○ Security scan✓ AI review✓ Cache✓ Concurrency○ Reusable workflows

Detected patterns

Security dimensions

permissions
14.3
security scan
0
supply chain
0
secret handling
0
harden runner
0

Workflows (7)

after-ci perms .github/workflows/after-ci.yml
Triggers
workflow_run
Runs on
ubuntu-latest, ubuntu-latest
Jobs
smokeshow, deploy-docs-preview
Actions
astral-sh/setup-uv, dawidd6/action-download-artifact, astral-sh/setup-uv, dawidd6/action-download-artifact, cloudflare/wrangler-action
Commands
  • uvx smokeshow upload coverage-html
  • echo "$GITHUB_EVENT_JSON"
  • npm install
  • uv run --no-project --with httpx .github/set_docs_pr_preview_url.py
View raw YAML
name: After CI

on:
  workflow_run:
    workflows: [CI]
    types: [completed]

permissions:
  statuses: write
  pull-requests: write

jobs:
  smokeshow:
    runs-on: ubuntu-latest

    steps:
      - uses: astral-sh/setup-uv@v5
        with:
          python-version: "3.12"

      - uses: dawidd6/action-download-artifact@v6
        with:
          workflow: ci.yml
          name: "(diff-)?coverage-html.*"
          name_is_regexp: true
          commit: ${{ github.event.workflow_run.head_sha }}
          allow_forks: true
          workflow_conclusion: completed
          if_no_artifact_found: warn

      - run: uvx smokeshow upload coverage-html
        if: hashFiles('coverage-html/*.html') != ''
        env:
          SMOKESHOW_GITHUB_STATUS_DESCRIPTION: Coverage {coverage-percentage}
          SMOKESHOW_GITHUB_COVERAGE_THRESHOLD: 95
          SMOKESHOW_GITHUB_CONTEXT: coverage
          SMOKESHOW_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          SMOKESHOW_GITHUB_PR_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
          SMOKESHOW_AUTH_KEY: ${{ secrets.SMOKESHOW_AUTH_KEY }}

  deploy-docs-preview:
    runs-on: ubuntu-latest
    if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.pull_requests[0] != null
    environment:
      name: deploy-docs-preview

    steps:
      - run: echo "$GITHUB_EVENT_JSON"
        env:
          GITHUB_EVENT_JSON: ${{ toJSON(github.event) }}

      - uses: actions/checkout@v6

      - uses: actions/setup-node@v4
      - run: npm install
        working-directory: docs-site

      - uses: astral-sh/setup-uv@v5
        with:
          python-version: "3.12"
          enable-cache: true
          cache-suffix: deploy-docs-preview

      - id: download-artifact
        uses: dawidd6/action-download-artifact@v6
        with:
          workflow: ci.yml
          name: site
          path: site
          commit: ${{ github.event.workflow_run.head_sha }}
          allow_forks: true
          workflow_conclusion: completed
          if_no_artifact_found: warn

      - uses: cloudflare/wrangler-action@v3
        id: deploy
        if: steps.download-artifact.outputs.found_artifact == 'true'
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          environment: previews
          workingDirectory: docs-site
          command: >
            deploy
            --var GIT_COMMIT_SHA:${{ github.event.workflow_run.head_sha }}
            --var GIT_BRANCH:${{ github.event.workflow_run.head_branch }}

      - name: Set preview URL
        run: uv run --no-project --with httpx .github/set_docs_pr_preview_url.py
        if: steps.deploy.outcome == 'success'
        env:
          DEPLOY_OUTPUT: ${{ steps.deploy.outputs.command-output }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          REPOSITORY: ${{ github.repository }}
          PULL_REQUEST_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }}
          REF: ${{ github.event.workflow_run.head_sha }}
at-claude AI .github/workflows/at-claude.yml
Triggers
issue_comment, pull_request_review_comment, issues, pull_request_review
Runs on
ubuntu-latest, ubuntu-latest, ubuntu-latest
Jobs
get-pr-info, at-claude, at-claude-fork
Actions
astral-sh/setup-uv, anthropics/claude-code-action, anthropics/claude-code-action
Commands
  • PR_NUMBER=${{ github.event.pull_request.number || github.event.issue.number }} PR_DATA=$(gh api repos/${{ github.repository }}/pulls/${PR_NUMBER}) echo "pr_head_repo=$(echo "$PR_DATA" | jq -r '.head.repo.full_name')" >> $GITHUB_OUTPUT echo "pr_head_ref=$(echo "$PR_DATA" | jq -r '.head.ref')" >> $GITHUB_OUTPUT echo "is_fork=$(echo "$PR_DATA" | jq -r '.head.repo.fork')" >> $GITHUB_OUTPUT
  • uv tool install pre-commit
  • make install
  • PR_NUMBER=${{ github.event.pull_request.number || github.event.issue.number }} CHANGED=$(gh pr diff "$PR_NUMBER" --name-only --repo ${{ github.repository }}) if echo "$CHANGED" | grep -qiE '(^|/)AGENTS\.md$|(^|/)CLAUDE\.md$|(^|/)\.claude/'; then echo "::error::PR modifies agent config files (AGENTS.md, CLAUDE.md, or .claude/). Skipping for security." exit 1 fi
View raw YAML
name: '@claude'

on:
  issue_comment:
    types: [created]
  pull_request_review_comment:
    types: [created]
  issues:
    types: [opened, assigned]
  pull_request_review:
    types: [submitted]

env:
  UV_PYTHON: 3.13
  UV_FROZEN: "1"

jobs:
  get-pr-info:
    if: |
      (
        (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
        (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
        (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
        (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
      ) && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'),
        github.event.comment.author_association ||
        github.event.review.author_association ||
        github.event.issue.author_association)
    runs-on: ubuntu-latest
    outputs:
      is_fork: ${{ steps.pr-info.outputs.is_fork }}
      pr_head_repo: ${{ steps.pr-info.outputs.pr_head_repo }}
      pr_head_ref: ${{ steps.pr-info.outputs.pr_head_ref }}
    steps:
      - name: Get PR info
        if: github.event.issue.pull_request || github.event.pull_request
        id: pr-info
        run: |
          PR_NUMBER=${{ github.event.pull_request.number || github.event.issue.number }}
          PR_DATA=$(gh api repos/${{ github.repository }}/pulls/${PR_NUMBER})
          echo "pr_head_repo=$(echo "$PR_DATA" | jq -r '.head.repo.full_name')" >> $GITHUB_OUTPUT
          echo "pr_head_ref=$(echo "$PR_DATA" | jq -r '.head.ref')" >> $GITHUB_OUTPUT
          echo "is_fork=$(echo "$PR_DATA" | jq -r '.head.repo.fork')" >> $GITHUB_OUTPUT
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

  at-claude:
    needs: get-pr-info
    if: needs.get-pr-info.outputs.is_fork != 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 60
    permissions:
      contents: write
      pull-requests: write
      issues: write
      id-token: write
      actions: read
    steps:
      - name: Checkout repository
        uses: actions/checkout@v6
        with:
          fetch-depth: 1

      - uses: astral-sh/setup-uv@v5
        with:
          enable-cache: true
          cache-suffix: claude-code

      - run: uv tool install pre-commit

      - run: make install

      - uses: anthropics/claude-code-action@v1
        with:
          anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
          additional_permissions: |
            actions: read
          claude_args: |
            --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr checks:*),Bash(gh pr list:*),Bash(gh pr create:*),Bash(gh issue view:*),Bash(gh issue list:*),Bash(gh run view:*),Bash(gh run list:*),Bash(git log:*),Bash(git diff:*),Bash(git grep:*),Bash(git show:*),Bash(git status:*),Bash(git add:*),Bash(git checkout:*),Bash(git commit:*),Bash(git push:*),Bash(rg:*),Bash(ls:*),Bash(tree:*),Bash(grep:*),Bash(uv run:*),Bash(make:*)"

  at-claude-fork:
    needs: get-pr-info
    if: needs.get-pr-info.outputs.is_fork == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 60
    permissions:
      contents: write
      pull-requests: write
      issues: write
      id-token: write
      actions: read
    steps:
      - name: Checkout fork repository
        uses: actions/checkout@v6
        with:
          repository: ${{ needs.get-pr-info.outputs.pr_head_repo }}
          ref: ${{ needs.get-pr-info.outputs.pr_head_ref }}
          fetch-depth: 1

      - name: Check for modified config files
        run: |
          PR_NUMBER=${{ github.event.pull_request.number || github.event.issue.number }}
          CHANGED=$(gh pr diff "$PR_NUMBER" --name-only --repo ${{ github.repository }})
          if echo "$CHANGED" | grep -qiE '(^|/)AGENTS\.md$|(^|/)CLAUDE\.md$|(^|/)\.claude/'; then
            echo "::error::PR modifies agent config files (AGENTS.md, CLAUDE.md, or .claude/). Skipping for security."
            exit 1
          fi
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      - uses: anthropics/claude-code-action@v1
        with:
          anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
          additional_permissions: |
            actions: read
          claude_args: |
            --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr checks:*),Bash(gh pr list:*),Bash(gh issue view:*),Bash(gh issue list:*),Bash(gh run view:*),Bash(gh run list:*),Bash(git log:*),Bash(git diff:*),Bash(git grep:*),Bash(git show:*),Bash(git status:*),Bash(rg:*),Bash(ls:*),Bash(tree:*),Bash(grep:*)"
bots AI .github/workflows/bots.yml
Triggers
pull_request_target
Runs on
ubuntu-latest, ubuntu-latest, ubuntu-latest, ubuntu-latest
Jobs
size-label, category-classify, category-apply, review
Actions
anthropics/claude-code-action, anthropics/claude-code-action
Commands
  • # Fetch file changes for this PR FILES=$(gh api repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files --paginate | jq -s 'add') # Calculate lines by category (excluding uv.lock and cassettes) CODE=$(echo "$FILES" | jq '[.[] | select( (.filename | (startswith("tests/") or startswith("docs/") or endswith(".md")) | not) and (.filename != "uv.lock") and (.filename | contains("/cassettes/") | not) ) | .additions + .deletions] | add // 0') DOCS=$(echo "$FILES" | jq '[.[] | select( (.filename | (startswith("docs/") or endswith(".md"))) and (.filename != "uv.lock") and (.filename | contains("/cassettes/") | not) ) | .additions + .deletions] | add // 0') TESTS=$(echo "$FILES" | jq '[.[] | select( (.filename | startswith("tests/")) and (.filename | contains("/cassettes/") | not) and (.filename | endswith(".md") | not) ) | .additions + .deletions] | add // 0') # Calculate weighted score: code + 50% docs + 50% tests SCORE=$((CODE + DOCS / 2 + TESTS / 2)) echo "Code: $CODE, Docs: $DOCS, Tests: $TESTS" echo "Weighted score: $SCORE" # Determine size label based on cutoffs if [ $SCORE -le 100 ]; then SIZE="size: S" elif [ $SCORE -le 500 ]; then SIZE="size: M" elif [ $SCORE -le 1500 ]; then SIZE="size: L" else SIZE="size: XL" fi echo "Size: $SIZE" # Remove any existing size labels (except the one we're setting) via API for label in "size: S" "size: M" "size: L" "size: XL"; do if [ "$label" != "$SIZE" ]; then gh api "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/labels/${label}" --method DELETE 2>/dev/null || true fi done # Add the new size label via API gh api "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/labels" --method POST -f "labels[]=$SIZE" echo "Set label: $SIZE (score: $SCORE)"
  • CHANGED=$(gh pr diff ${{ github.event.pull_request.number }} --name-only --repo ${{ github.repository }}) if echo "$CHANGED" | grep -qiE '(^|/)AGENTS\.md$|(^|/)CLAUDE\.md$|(^|/)\.claude/'; then echo "::error::PR modifies agent config files (AGENTS.md, CLAUDE.md, or .claude/). Skipping auto-labeling for security." exit 1 fi
  • LABELS=$(gh pr view ${{ github.event.pull_request.number }} --repo ${{ github.repository }} --json labels --jq '.labels[].name') CATEGORY_LABELS=("bug" "feature" "docs" "chore" "dependency") for label in "${CATEGORY_LABELS[@]}"; do if echo "$LABELS" | grep -q "^${label}$"; then echo "has_label=true" >> $GITHUB_OUTPUT echo "PR already has category label: $label" exit 0 fi done echo "has_label=false" >> $GITHUB_OUTPUT
  • CATEGORY=$(echo "$STRUCTURED_OUTPUT" | jq -r .category | tr -d '[:space:]') ALLOWED="bug feature docs chore dependency" if ! echo "$ALLOWED" | grep -Fqw "$CATEGORY"; then echo "::error::Invalid category '$CATEGORY' from Claude — must be one of: $ALLOWED" exit 1 fi echo "category=$CATEGORY" >> $GITHUB_OUTPUT echo "Classified as: $CATEGORY"
  • CATEGORY="${{ needs.category-classify.outputs.category }}" ALLOWED="bug feature docs chore dependency" if ! echo "$ALLOWED" | grep -Fqw "$CATEGORY"; then echo "::error::Invalid category '$CATEGORY' — must be one of: $ALLOWED" exit 1 fi gh api "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/labels" \ --method POST -f "labels[]=$CATEGORY" echo "Applied label: $CATEGORY"
  • CHANGED=$(gh pr diff ${{ github.event.pull_request.number }} --name-only --repo ${{ github.repository }}) if echo "$CHANGED" | grep -qiE '(^|/)AGENTS\.md$|(^|/)CLAUDE\.md$|(^|/)\.claude/'; then echo "::error::PR modifies agent config files (AGENTS.md, CLAUDE.md, or .claude/). Skipping auto-review for security." exit 1 fi
  • LABELS=$(gh pr view ${{ github.event.pull_request.number }} --repo ${{ github.repository }} --json labels --jq '.labels[].name') echo "Labels: $LABELS" CATEGORY=$(echo "$LABELS" | grep -E '^(bug|feature|docs|chore|dependency)$' | head -1) SIZE=$(echo "$LABELS" | grep -E '^size: ' | head -1) # Default to Opus for large/complex PRs MODEL="claude-opus-4-6[1m]" # Disabled for now because Sonnet reviews have been disappointing # # Use Sonnet for docs, dependency, chore, or small/medium PRs # if [ "$CATEGORY" = "docs" ] || [ "$CATEGORY" = "dependency" ] || [ "$CATEGORY" = "chore" ] || [ "$SIZE" = "size: S" ] || [ "$SIZE" = "size: M" ]; then # MODEL="claude-sonnet-4-5[1m]" # fi echo "model=$MODEL" >> $GITHUB_OUTPUT echo "Selected model: $MODEL (category: $CATEGORY, size: $SIZE)"
  • # Use the script from the base repo (main branch), not the fork gh api "repos/${REPO}/contents/scripts/gather-review-context.sh?ref=${BASE_REF}" --jq .content | base64 -d > /tmp/gather-review-context.sh bash /tmp/gather-review-context.sh "$PR_NUMBER" "$REPO"
View raw YAML
name: PR Bots

on:
  pull_request_target:
    types: [opened, synchronize, labeled]

concurrency:
  group: pr-bots-${{ github.event.pull_request.number }}
  cancel-in-progress: ${{ github.event.action == 'synchronize' }}

jobs:
  size-label:
    name: Size Label
    if: github.event.action != 'labeled'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      issues: write
      pull-requests: write
    steps:
      - name: Calculate PR size and set label
        run: |
          # Fetch file changes for this PR
          FILES=$(gh api repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files --paginate | jq -s 'add')

          # Calculate lines by category (excluding uv.lock and cassettes)
          CODE=$(echo "$FILES" | jq '[.[] | select(
            (.filename | (startswith("tests/") or startswith("docs/") or endswith(".md")) | not) and
            (.filename != "uv.lock") and
            (.filename | contains("/cassettes/") | not)
          ) | .additions + .deletions] | add // 0')

          DOCS=$(echo "$FILES" | jq '[.[] | select(
            (.filename | (startswith("docs/") or endswith(".md"))) and
            (.filename != "uv.lock") and
            (.filename | contains("/cassettes/") | not)
          ) | .additions + .deletions] | add // 0')

          TESTS=$(echo "$FILES" | jq '[.[] | select(
            (.filename | startswith("tests/")) and
            (.filename | contains("/cassettes/") | not) and
            (.filename | endswith(".md") | not)
          ) | .additions + .deletions] | add // 0')

          # Calculate weighted score: code + 50% docs + 50% tests
          SCORE=$((CODE + DOCS / 2 + TESTS / 2))

          echo "Code: $CODE, Docs: $DOCS, Tests: $TESTS"
          echo "Weighted score: $SCORE"

          # Determine size label based on cutoffs
          if [ $SCORE -le 100 ]; then
            SIZE="size: S"
          elif [ $SCORE -le 500 ]; then
            SIZE="size: M"
          elif [ $SCORE -le 1500 ]; then
            SIZE="size: L"
          else
            SIZE="size: XL"
          fi

          echo "Size: $SIZE"

          # Remove any existing size labels (except the one we're setting) via API
          for label in "size: S" "size: M" "size: L" "size: XL"; do
            if [ "$label" != "$SIZE" ]; then
              gh api "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/labels/${label}" --method DELETE 2>/dev/null || true
            fi
          done

          # Add the new size label via API
          gh api "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/labels" --method POST -f "labels[]=$SIZE"

          echo "Set label: $SIZE (score: $SCORE)"
        env:
          GH_TOKEN: ${{ github.token }}

  # Security: The classify job runs the LLM with READ-ONLY permissions and no label API access.
  # The LLM's output is validated against an allowlist before the apply job takes any write action.
  # This prevents prompt injection from adding arbitrary labels (e.g. 'auto-review' to trigger the review job).
  category-classify:
    name: Category Classify
    if: github.event.action != 'labeled'
    runs-on: ubuntu-latest
    timeout-minutes: 10
    permissions:
      contents: read
      pull-requests: read
    outputs:
      category: ${{ steps.extract.outputs.category }}
      skip: ${{ steps.check-label.outputs.has_label }}
    steps:
      - name: Check for modified config files
        if: github.event.pull_request.head.repo.fork
        run: |
          CHANGED=$(gh pr diff ${{ github.event.pull_request.number }} --name-only --repo ${{ github.repository }})
          if echo "$CHANGED" | grep -qiE '(^|/)AGENTS\.md$|(^|/)CLAUDE\.md$|(^|/)\.claude/'; then
            echo "::error::PR modifies agent config files (AGENTS.md, CLAUDE.md, or .claude/). Skipping auto-labeling for security."
            exit 1
          fi
        env:
          GH_TOKEN: ${{ github.token }}

      - name: Check if category label already exists
        id: check-label
        run: |
          LABELS=$(gh pr view ${{ github.event.pull_request.number }} --repo ${{ github.repository }} --json labels --jq '.labels[].name')
          CATEGORY_LABELS=("bug" "feature" "docs" "chore" "dependency")

          for label in "${CATEGORY_LABELS[@]}"; do
            if echo "$LABELS" | grep -q "^${label}$"; then
              echo "has_label=true" >> $GITHUB_OUTPUT
              echo "PR already has category label: $label"
              exit 0
            fi
          done

          echo "has_label=false" >> $GITHUB_OUTPUT
        env:
          GH_TOKEN: ${{ github.token }}

      - name: Checkout repository
        if: steps.check-label.outputs.has_label == 'false'
        uses: actions/checkout@v6

      - name: Classify PR with Claude Code
        if: steps.check-label.outputs.has_label == 'false'
        id: classify
        uses: anthropics/claude-code-action@v1
        env:
          ANTHROPIC_BASE_URL: ${{ secrets.CLAUDE_CODE_BASE_URL }}
        with:
          anthropic_api_key: ${{ secrets.CLAUDE_CODE_API_KEY || secrets.ANTHROPIC_API_KEY }}
          github_token: ${{ github.token }}
          allowed_non_write_users: "*"
          claude_args: |
            --allowedTools "Bash(gh pr view:*),Bash(gh pr diff:*)"
            --json-schema '{"type":"object","properties":{"category":{"type":"string"}},"required":["category"]}'
          prompt: |
            Classify PR #${{ github.event.pull_request.number }} in ${{ github.repository }}.

            Run `gh pr view ${{ github.event.pull_request.number }} --repo ${{ github.repository }}` for the title and description.
            Run `gh pr diff ${{ github.event.pull_request.number }} --repo ${{ github.repository }} --name-only` for the changed files.

            Categories:
            - bug: Fixes broken behavior
            - feature: New functionality
            - docs: Documentation-only (no code changes)
            - chore: CI, refactoring, dev dependencies, tests-only
            - dependency: Production dependency updates

            When both code and docs change, prefer `feature` or `bug` over `docs`.
            If pyproject.toml files changed, run `gh pr diff ${{ github.event.pull_request.number }} --repo ${{ github.repository }}` to see the full diff and distinguish production deps (`dependency`) from dev-only deps in `[dependency-groups]` (`chore`).

            Return your classification as JSON with a "category" field set to one of: bug, feature, docs, chore, dependency.

      - name: Extract and validate category
        if: steps.check-label.outputs.has_label == 'false'
        id: extract
        run: |
          CATEGORY=$(echo "$STRUCTURED_OUTPUT" | jq -r .category | tr -d '[:space:]')
          ALLOWED="bug feature docs chore dependency"
          if ! echo "$ALLOWED" | grep -Fqw "$CATEGORY"; then
            echo "::error::Invalid category '$CATEGORY' from Claude — must be one of: $ALLOWED"
            exit 1
          fi
          echo "category=$CATEGORY" >> $GITHUB_OUTPUT
          echo "Classified as: $CATEGORY"
        env:
          STRUCTURED_OUTPUT: ${{ steps.classify.outputs.structured_output }}

  category-apply:
    name: Category Apply
    needs: category-classify
    if: needs.category-classify.outputs.skip != 'true' && needs.category-classify.outputs.category != ''
    runs-on: ubuntu-latest
    permissions:
      issues: write
      pull-requests: write
    steps:
      - name: Validate and apply category label
        run: |
          CATEGORY="${{ needs.category-classify.outputs.category }}"
          ALLOWED="bug feature docs chore dependency"

          if ! echo "$ALLOWED" | grep -Fqw "$CATEGORY"; then
            echo "::error::Invalid category '$CATEGORY' — must be one of: $ALLOWED"
            exit 1
          fi

          gh api "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/labels" \
            --method POST -f "labels[]=$CATEGORY"
          echo "Applied label: $CATEGORY"
        env:
          GH_TOKEN: ${{ github.token }}

  review:
    name: Review
    needs: [size-label, category-apply]
    if: >-
      !failure() && !cancelled() &&
      github.event.action == 'labeled' && github.event.label.name == 'auto-review'
    runs-on: ubuntu-latest
    timeout-minutes: 60
    permissions:
      contents: read
      issues: write
      pull-requests: write
      actions: read
    steps:
      - uses: actions/checkout@v6
        with:
          repository: ${{ github.event.pull_request.head.repo.full_name }}
          ref: ${{ github.event.pull_request.head.ref }}
          fetch-depth: 0

      - name: Check for modified config files
        if: github.event.pull_request.head.repo.fork
        run: |
          CHANGED=$(gh pr diff ${{ github.event.pull_request.number }} --name-only --repo ${{ github.repository }})
          if echo "$CHANGED" | grep -qiE '(^|/)AGENTS\.md$|(^|/)CLAUDE\.md$|(^|/)\.claude/'; then
            echo "::error::PR modifies agent config files (AGENTS.md, CLAUDE.md, or .claude/). Skipping auto-review for security."
            exit 1
          fi
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      - name: Select review model
        id: model
        run: |
          LABELS=$(gh pr view ${{ github.event.pull_request.number }} --repo ${{ github.repository }} --json labels --jq '.labels[].name')
          echo "Labels: $LABELS"

          CATEGORY=$(echo "$LABELS" | grep -E '^(bug|feature|docs|chore|dependency)$' | head -1)
          SIZE=$(echo "$LABELS" | grep -E '^size: ' | head -1)

          # Default to Opus for large/complex PRs
          MODEL="claude-opus-4-6[1m]"

          # Disabled for now because Sonnet reviews have been disappointing
          # # Use Sonnet for docs, dependency, chore, or small/medium PRs
          # if [ "$CATEGORY" = "docs" ] || [ "$CATEGORY" = "dependency" ] || [ "$CATEGORY" = "chore" ] || [ "$SIZE" = "size: S" ] || [ "$SIZE" = "size: M" ]; then
          #   MODEL="claude-sonnet-4-5[1m]"
          # fi

          echo "model=$MODEL" >> $GITHUB_OUTPUT
          echo "Selected model: $MODEL (category: $CATEGORY, size: $SIZE)"
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      - name: Gather PR context
        run: |
          # Use the script from the base repo (main branch), not the fork
          gh api "repos/${REPO}/contents/scripts/gather-review-context.sh?ref=${BASE_REF}" --jq .content | base64 -d > /tmp/gather-review-context.sh
          bash /tmp/gather-review-context.sh "$PR_NUMBER" "$REPO"
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          REPO: ${{ github.repository }}
          BASE_REF: ${{ github.event.pull_request.base.ref }}
          PR_NUMBER: ${{ github.event.pull_request.number }}

      - uses: anthropics/claude-code-action@v1
        env:
          ANTHROPIC_BASE_URL: ${{ secrets.CLAUDE_CODE_BASE_URL }}
        with:
          anthropic_api_key: ${{ secrets.CLAUDE_CODE_API_KEY || secrets.ANTHROPIC_API_KEY }}
          github_token: ${{ secrets.GITHUB_TOKEN }}
          allowed_non_write_users: "*"
          display_report: 'true'
          additional_permissions: |
            actions: read
          claude_args: |
            --model ${{ steps.model.outputs.model }}
            --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr checks:*),Bash(gh pr list:*),Bash(gh issue view:*),Bash(gh issue list:*),Bash(gh run view:*),Bash(gh run list:*),Bash(gh api repos/${{ github.repository }}/pulls/comments/:*),Bash(gh api repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/comments:*),Bash(gh api repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/reviews:*),Bash(gh api repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments:*),Bash(git log:*),Bash(git diff:*),Bash(git grep:*),Bash(git show:*),Bash(git status:*),Bash(jq:*),Bash(cat:*),Bash(rg:*),Bash(ls:*),Bash(tree:*),Bash(grep:*),WebSearch,WebFetch"

          prompt: |
            REPO: ${{ github.repository }}
            PR NUMBER: ${{ github.event.pull_request.number }}
            PR AUTHOR: ${{ github.event.pull_request.user.login }} (${{ github.event.pull_request.author_association }})

            Review this pull request. The PR branch is already checked out in the current working directory and the `CLAUDE.md` at the root (symlinked to `AGENTS.md`) is already loaded in your system prompt.

            # What to look for

            - If the PR should not have been created yet (e.g. no issue, insufficiently defined scope, not ready for implementation, duplicate of existing open PR), just leave a comment informing the user and maintainer of this and don't bother doing a thorough review.
            - Any change that does not align with the project's standards, philosophy, or requirements for every contribution as stated in `AGENTS.md` (symlinked from `CLAUDE.md`).
            - Any change that does not match maintainer guidance in the issue or earlier PR comments on what an acceptable solution would look like.
            - Any change or design decision or tradeoff (in both behavior and API) that needs explicit consideration, discussion, or maintainer awareness and approval.
            - Any line of code that violates the concrete guidelines/rules laid out in the relevant `AGENTS.md` file(s): the top-level guidelines apply to all changes, while directory-specific guidelines affect only the changes in that directory.
            - Anything else that the responsibilities you are assigned in `AGENTS.md` suggest that you should be calling out: use your best judgment.

            Generally, the priority in terms of "crucial to get right" and "what to focus on first in a new PR" is public API > concepts and behavior > documentation > tests > code style.
            If the PR has high level problems that will likely require significant changes at lower levels, hold off on looking for or commenting on lower level problems until the higher level problems are addressed, so that the PR author (and your context window) don't get overwhelmed.

            Note that while another agent (Devin) is responsible for thoroughly reviewing the implementation for bugs, security issues, and edge cases,
            _you_ are responsible for catching every violation of the repository's standards and guidelines listed in the `AGENTS.md` and `agent_docs/*.md` files.
            Do not focus exclusively on high-level concerns: by the time the author has addressed every comment you've left over multiple rounds of review, the PR should be ready to merge.

            # Gathering context

            Before doing anything else, read ALL of the following pre-gathered context files in a single parallel tool call:
            - `.github/.review-context/pr-details.json` — PR title, body, author, branch info, labels, state, review decision, timestamps
            - `.github/.review-context/pr-comments.txt` — existing top-level PR comments
            - `.github/.review-context/review-comments.txt` — existing inline review comments (resolved+outdated threads and threads predating the last auto-review are collapsed to one-liners with comment IDs so you can fetch full details if needed)
            - `.github/.review-context/related-issues.txt` — linked issues and their comments
            - `.github/.review-context/changed-files.txt` — changed files with per-file addition/deletion counts (tab-separated; non-generated files include a third column with the path to their per-file diff)
            - `.github/.review-context/agents-md.txt` — directory-specific `AGENTS.md` files for changed directories
            - `agent_docs/index.md` - repo-wide coding guidelines

            The diff is split into per-file diffs under `.github/.review-context/diff/` (excluding `uv.lock` and cassettes), that you can read on demand and in parallel.
            The diffs include function-level context (`git diff -W`), so you can see the full function/method being modified without needing to read the source file separately.
            Each commentable line in the diff is prefixed with its source line number: `NL:<number>` for new or context lines, `OL:<number>` for deleted lines.
            For newly added files, the diff contains the complete file contents — do not re-read these from disk.
            The diff file paths are listed in the third column of `changed-files.txt`.

            The pre-gathered diffs are the source of truth for what this PR changes. Do not re-fetch diffs or file lists using `gh pr diff` or `gh api`.
            When you need code context beyond what the diffs provide, use the `Read` tool on the checked-out source files.

            Use the `gh` CLI only when you need additional information not already in these files (e.g. to read other referenced PRs or issues, check CI status, or read files excluded from the gathered diff).
            Use specific `gh` subcommands (`gh pr view`, `gh issue view`, `gh run view`, etc.) rather than `gh api` for most queries.
            `gh api` is scoped to comment and review endpoints on this PR only:
            - `gh api repos/${{ github.repository }}/pulls/comments/<id>` — individual review comment by ID
            - `gh api repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/comments` — list review comments
            - `gh api repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/reviews` — list reviews
            - `gh api repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments` — list issue comments

            Be careful about loading large diffs if you're unlikely to need them yet, like massive test files when there's plenty of more interesting code to comment on first, as you don't want to blow your context window too early.
            You will usually want to read all the "core implementation" and docs diffs in one go, though, so you have the full context of the PR as you identify problems, instead of going file by file.

            # Posting comments

            While gathering context and learning about the PR, keep track of problems/points of discussion as you find them, and wait to post comments until the end,
            as the comments you write will be better, less duplicative and more focused on the changes that really matter if you have the full set of problems as context.

            For each identified issue that is determined to be worth a new comment, use `mcp__github_inline_comment__create_inline_comment` to attach the feedback to a specific line of code.
              - Only lines with an `NL:` or `OL:` prefix in the diff are commentable. For OL lines, use `side: LEFT`.
              - Include the reasoning, but don't quote specific rules from the `AGENTS.md` files.
              - Include a concrete suggestion if appropriate (but to not use ` ```suggestion ` blocks as they can render incorrectly when the line numbers are off)
              - Include a ping to the maintainer (`@DouweM`) on any change that requires maintainer input before the PR author can move forward.
              - If the same issue shows up in multiple places, post a comment on each instance but have later comments refer to the first comment using a link.
            - Use `gh pr comment` only for important feedback that doesn't relate to a specific line or file, not for a summary of feedback you've already posted inline.

            Your comments should be:
            - actionable: they should request a change, flag a concern that needs discussion, and/or suggest an improvement; don't comment on positive aspects of the PR like "excellent design choices".
            - concise and to the point: don't use unnecessary emojis, lists, or subheadings, but do link to code if appropriate; 1 to 3 paragraphs are pretty much always enough.
            - friendly without being sycophantic: use the tone and language of a helpful and encouraging project maintainer, but no need to compliment the author on positive aspects of the PR or point out changes that are good.
            - non-repetitive: don't repeat things pointed out in earlier review comments, unless it looks like they'll be forgotten if you don't point them out; e.g. when they're marked as resolved/outdated but the problem persists without a satisfactory resolution (like a maintainer comment saying the comment does not need to be addressed).

            You are meant to be helpful to the contributor and the maintainer, so your comments should never add noise to the conversation:
            - Do not post a final summary comment; inline comments are sufficient.
            - Do not comment on lines that do not need improvement, maintainer awareness, or discussion; comments pointing out a good choice are just noise.
            - Do not post multiple comments for the same exact issue unless it shows up in different places.

            It bears repeating that you are the first line of defense against low-quality contributions and maintainer headaches, and you have a big role in ensuring that every contribution to this project meets or exceeds the high standards that the Pydantic brand is known and loved for.

      - name: Remove auto-review label
        if: always()
        run: gh api "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/labels/auto-review" --method DELETE 2>/dev/null || true
        env:
          GH_TOKEN: ${{ github.token }}
ci matrix perms .github/workflows/ci.yml
Triggers
push, pull_request
Runs on
ubuntu-latest, ubuntu-latest, ubuntu-latest, ${{ !contains(github.event.pull_request.labels.*.name, 'ci:slow') && matrix.install.name == 'all-extras' && ( github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:fast') ) && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}, ${{ !contains(github.event.pull_request.labels.*.name, 'ci:slow') && ( github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:fast') ) && 'depot-ubuntu-24.04-4' || 'ubuntu-latest' }}, ubuntu-latest, ubuntu-latest, ubuntu-latest, ubuntu-latest, ubuntu-latest, ubuntu-latest, ubuntu-latest
Jobs
lint, mypy, docs, test, test-lowest-versions, test-examples, coverage, check, deploy-docs, deploy-docs-preview, release, send-tweet
Matrix
install, install.command, install.name, python-version→ , --all-extras --no-extra outlines-vllm-offline, --package pydantic-ai-slim, 3.10, 3.11, 3.12, 3.13, 3.14, all-extras, pydantic-ai-slim, standard
Actions
astral-sh/setup-uv, pre-commit/action, astral-sh/setup-uv, astral-sh/setup-uv, astral-sh/setup-uv, astral-sh/setup-uv, astral-sh/setup-uv, astral-sh/setup-uv, re-actors/alls-green, astral-sh/setup-uv, cloudflare/wrangler-action, astral-sh/setup-uv, cloudflare/wrangler-action, astral-sh/setup-uv, pypa/gh-action-pypi-publish
Commands
  • uv sync --all-extras --no-extra outlines-vllm-offline --all-packages --group lint
  • uv build --all-packages
  • ls -lh dist/
  • uv sync --no-dev --group lint
  • make typecheck-mypy
  • uv sync --group docs
  • make docs
  • tree -sh site
View raw YAML
name: CI

on:
  push:
    branches:
      - main
    tags:
      - "**"
  pull_request: {}

env:
  COLUMNS: 150
  UV_PYTHON: 3.12
  UV_FROZEN: "1"

permissions:
  contents: read

jobs:
  lint:
    name: lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6

      - uses: astral-sh/setup-uv@v5
        with:
          python-version: "3.13"
          enable-cache: true
          cache-suffix: lint

      - name: Install dependencies
        run: uv sync --all-extras --no-extra outlines-vllm-offline --all-packages --group lint

      - uses: pre-commit/action@v3.0.0
        with:
          extra_args: --all-files --verbose
        env:
          SKIP: no-commit-to-branch

      - run: uv build --all-packages
      - run: ls -lh dist/

  # mypy and lint are a bit slower than other jobs, so we run them separately
  mypy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6

      - uses: astral-sh/setup-uv@v5
        with:
          enable-cache: true
          cache-suffix: mypy

      - name: Install dependencies
        run: uv sync --no-dev --group lint

      - run: make typecheck-mypy

  docs:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6

      - uses: astral-sh/setup-uv@v5
        with:
          enable-cache: true
          cache-suffix: docs

      - run: uv sync --group docs

      - run: make docs

      - run: tree -sh site
      - uses: actions/setup-node@v4
      - run: npm install
        working-directory: docs-site
      - run: npm run typecheck
        working-directory: docs-site

      - name: Store docs
        uses: actions/upload-artifact@v4
        with:
          name: site
          path: site

      # check all docs images are tinified, You'll need an API key from https://tinify.com/ to fix this if it fails
      - run: uvx tinicly docs --check

  test:
    name: test on ${{ matrix.python-version }} (${{ matrix.install.name }})
    # Use Depot 4-core runners for all-extras (the slowest variant) when run by maintainers or opted in via 'ci:fast' label
    # Use 'ci:slow' label to force standard GitHub runners (e.g. during Depot outages)
    runs-on: >-
      ${{
        !contains(github.event.pull_request.labels.*.name, 'ci:slow')
        && matrix.install.name == 'all-extras'
        && (
          github.event.pull_request.head.repo.full_name == github.repository
          || contains(github.event.pull_request.labels.*.name, 'ci:fast')
        )
        && 'depot-ubuntu-24.04-4'
        || 'ubuntu-latest'
      }}
    timeout-minutes: 20
    strategy:
      fail-fast: false
      matrix:
        python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
        install:
          - name: pydantic-ai-slim
            command: "--package pydantic-ai-slim"
          - name: standard
            command: ""
          - name: all-extras
            command: "--all-extras --no-extra outlines-vllm-offline"
    env:
      CI: true
      COVERAGE_PROCESS_START: ./pyproject.toml
    steps:
      - uses: actions/checkout@v6

      - uses: astral-sh/setup-uv@v5
        with:
          python-version: ${{ matrix.python-version }}
          enable-cache: true
          cache-suffix: ${{ matrix.install.name }}

      - run: mkdir .coverage

      - run: uv sync --only-dev

      - name: cache HuggingFace models
        uses: actions/cache@v4
        with:
          path: ~/.cache/huggingface
          key: hf-${{ runner.os }}-${{ hashFiles('**/uv.lock') }}
          restore-keys: |
            hf-${{ runner.os }}-

      - run: uv run ${{ matrix.install.command }} coverage run -m pytest --durations=100 -n auto --dist=loadgroup
        env:
          COVERAGE_FILE: .coverage/.coverage.${{ matrix.python-version }}-${{ matrix.install.name }}

      - name: store coverage files
        uses: actions/upload-artifact@v4
        with:
          name: coverage-${{ matrix.python-version }}-${{ matrix.install.name }}
          path: .coverage
          include-hidden-files: true

  test-lowest-versions:
    name: test on ${{ matrix.python-version }} (lowest-versions)
    # Use Depot 4-core runners for maintainers or opted in via 'ci:fast' label
    # Use 'ci:slow' label to force standard GitHub runners (e.g. during Depot outages)
    runs-on: >-
      ${{
        !contains(github.event.pull_request.labels.*.name, 'ci:slow')
        && (
          github.event.pull_request.head.repo.full_name == github.repository
          || contains(github.event.pull_request.labels.*.name, 'ci:fast')
        )
        && 'depot-ubuntu-24.04-4'
        || 'ubuntu-latest'
      }}
    timeout-minutes: 20
    strategy:
      fail-fast: false
      matrix:
        python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
    env:
      CI: true
      COVERAGE_PROCESS_START: ./pyproject.toml
    steps:
      - uses: actions/checkout@v6

      - uses: astral-sh/setup-uv@v5
        with:
          python-version: ${{ matrix.python-version }}
          enable-cache: true
          cache-suffix: lowest-versions

      - run: mkdir .coverage

      - run: uv sync --group dev

      - name: cache HuggingFace models
        uses: actions/cache@v4
        with:
          path: ~/.cache/huggingface
          key: hf-${{ runner.os }}-${{ hashFiles('**/uv.lock') }}
          restore-keys: |
            hf-${{ runner.os }}-

      - run: unset UV_FROZEN

      - run: uv run --all-extras --no-extra outlines-vllm-offline --resolution lowest-direct coverage run -m pytest --durations=100 -n auto --dist=loadgroup
        env:
          COVERAGE_FILE: .coverage/.coverage.${{matrix.python-version}}-lowest-versions

      - name: store coverage files
        uses: actions/upload-artifact@v4
        with:
          name: coverage-${{ matrix.python-version }}-lowest-versions
          path: .coverage
          include-hidden-files: true

  test-examples:
    name: test examples on ${{ matrix.python-version }}
    runs-on: ubuntu-latest
    timeout-minutes: 10
    strategy:
      fail-fast: false
      matrix:
        python-version: ["3.11", "3.12", "3.13", "3.14"]
    env:
      CI: true
    steps:
      - uses: actions/checkout@v6

      - uses: astral-sh/setup-uv@v5
        with:
          python-version: ${{ matrix.python-version }}
          enable-cache: true
          cache-suffix: examples

      - name: cache HuggingFace models
        uses: actions/cache@v4
        with:
          path: ~/.cache/huggingface
          key: hf-${{ runner.os }}-${{ hashFiles('**/uv.lock') }}
          restore-keys: |
            hf-${{ runner.os }}-

      - run: uv run --all-extras --no-extra outlines-vllm-offline python tests/import_examples.py

  coverage:
    runs-on: ubuntu-latest
    needs: [test, test-lowest-versions]
    steps:
      - uses: actions/checkout@v6
        with:
          # needed for diff-cover
          fetch-depth: 0

      - name: get coverage files
        uses: actions/download-artifact@v4
        with:
          merge-multiple: true
          path: .coverage

      - uses: astral-sh/setup-uv@v5
        with:
          enable-cache: true
          cache-suffix: dev

      - run: uv sync --group dev
      - run: uv run coverage combine
      - run: uv run coverage report

      - run: uv run strict-no-cover
        env:
          COVERAGE_FILE: .coverage/.coverage

      - run: uv run coverage html --show-contexts --title "Pydantic AI coverage for ${{ github.sha }}"
      - uses: actions/upload-artifact@v4
        with:
          name: coverage-html
          path: htmlcov
          include-hidden-files: true

  # https://github.com/marketplace/actions/alls-green#why used for branch protection checks
  check:
    if: always()
    needs:
      - lint
      - mypy
      - docs
      - test
      - test-lowest-versions
      - test-examples
      - coverage
    runs-on: ubuntu-latest

    steps:
      - name: Decide whether the needed jobs succeeded or failed
        uses: re-actors/alls-green@release/v1
        with:
          jobs: ${{ toJSON(needs) }}

  deploy-docs:
    needs: [check]
    if: success() && startsWith(github.ref, 'refs/tags/')
    runs-on: ubuntu-latest
    environment:
      name: deploy-docs
      url: https://ai.pydantic.dev

    steps:
      - uses: actions/checkout@v6

      - uses: actions/setup-node@v4
      - run: npm install
        working-directory: docs-site

      - uses: astral-sh/setup-uv@v5
        with:
          enable-cache: true
          cache-suffix: docs-upload

      - uses: actions/download-artifact@v4
        with:
          name: site
          path: site

      - uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          workingDirectory: docs-site
          command: >
            deploy
            --var GIT_COMMIT_SHA:${{ github.sha }}
            --var GIT_BRANCH:main

      - run: uv sync --group docs-upload
      - run: uv run python docs/.hooks/algolia.py upload
        env:
          ALGOLIA_WRITE_API_KEY: ${{ secrets.ALGOLIA_WRITE_API_KEY }}

  deploy-docs-preview:
    needs: [check]
    if: success() && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment:
      name: deploy-docs-preview

    permissions:
      deployments: write
      statuses: write

    steps:
      - uses: actions/checkout@v6

      - uses: actions/setup-node@v4
      - run: npm install
        working-directory: docs-site

      - uses: astral-sh/setup-uv@v5
        with:
          enable-cache: true
          cache-suffix: deploy-docs-preview

      - uses: actions/download-artifact@v4
        with:
          name: site
          path: site

      - uses: cloudflare/wrangler-action@v3
        id: deploy
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          environment: previews
          workingDirectory: docs-site
          command: >
            deploy
            --var GIT_COMMIT_SHA:${{ github.sha }}
            --var GIT_BRANCH:main

      - name: Set preview URL
        run: uv run --no-project --with httpx .github/set_docs_main_preview_url.py
        env:
          DEPLOY_OUTPUT: ${{ steps.deploy.outputs.command-output }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          REPOSITORY: ${{ github.repository }}
          REF: ${{ github.sha }}

  # TODO(Marcelo): We need to split this into two jobs: `build` and `release`.
  release:
    needs: [check]
    if: success() && startsWith(github.ref, 'refs/tags/')
    runs-on: ubuntu-latest

    environment:
      name: release
      url: https://pypi.org/project/pydantic-ai/${{ steps.inspect_package.outputs.version }}

    permissions:
      id-token: write

    outputs:
      package-version: ${{ steps.inspect_package.outputs.version }}

    steps:
      - uses: actions/checkout@v6

      - uses: astral-sh/setup-uv@v5
        with:
          enable-cache: true
          cache-suffix: release

      - run: uv build --all-packages

      - name: Inspect package version
        id: inspect_package
        run: |
          uv tool install --with uv-dynamic-versioning hatchling
          version=$(uvx hatchling version)
          echo "version=$version" >> "$GITHUB_OUTPUT"

      - name: Publish to PyPI
        uses: pypa/gh-action-pypi-publish@release/v1
        with:
          skip-existing: true

  send-tweet:
    name: Send tweet
    needs: [release]
    if: needs.release.result == 'success'
    runs-on: ubuntu-latest

    steps:
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - name: Install dependencies
        run: pip install tweepy==4.14.0
      - name: Send tweet
        shell: python
        run: |
          import os
          import tweepy

          client = tweepy.Client(
              access_token=os.getenv("TWITTER_ACCESS_TOKEN"),
              access_token_secret=os.getenv("TWITTER_ACCESS_TOKEN_SECRET"),
              consumer_key=os.getenv("TWITTER_CONSUMER_KEY"),
              consumer_secret=os.getenv("TWITTER_CONSUMER_SECRET"),
          )
          version = os.getenv("VERSION").strip('"')
          tweet = os.getenv("TWEET").format(version=version)
          client.create_tweet(text=tweet)
        env:
          VERSION: ${{ needs.release.outputs.package-version }}
          TWEET: |
            Pydantic AI version {version} is out! 🎉

            https://github.com/pydantic/pydantic-ai/releases/tag/v{version}
          TWITTER_CONSUMER_KEY: ${{ secrets.TWITTER_CONSUMER_KEY }}
          TWITTER_CONSUMER_SECRET: ${{ secrets.TWITTER_CONSUMER_SECRET }}
          TWITTER_ACCESS_TOKEN: ${{ secrets.TWITTER_ACCESS_TOKEN }}
          TWITTER_ACCESS_TOKEN_SECRET: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }}
manually-deploy-docs .github/workflows/manually-deploy-docs.yml
Triggers
workflow_dispatch
Runs on
ubuntu-latest, ubuntu-latest
Jobs
build-docs-manual, deploy-docs-manual
Actions
astral-sh/setup-uv, astral-sh/setup-uv, cloudflare/wrangler-action
Commands
  • uv sync --group docs
  • make docs
  • tree -sh site
  • npm install
  • npm run typecheck
  • npm install
  • uv sync --group docs-upload
  • uv run python docs/.hooks/algolia.py upload
View raw YAML
name: Manual Docs Deploy

on:
  workflow_dispatch:

jobs:
  build-docs-manual:
    # Note: this should match the `docs` job in ci.yml
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6

      - uses: astral-sh/setup-uv@v5
        with:
          enable-cache: true
          cache-suffix: docs

      - run: uv sync --group docs

      - run: make docs

      - run: tree -sh site
      - uses: actions/setup-node@v4
      - run: npm install
        working-directory: docs-site
      - run: npm run typecheck
        working-directory: docs-site

      - name: Store docs
        uses: actions/upload-artifact@v4
        with:
          name: site
          path: site

  deploy-docs-manual:
    # Note: this should match the `deploy-docs` job in ci.yml
    needs: [build-docs-manual]
    runs-on: ubuntu-latest
    environment:
      name: deploy-docs
      url: https://ai.pydantic.dev

    steps:
      - uses: actions/checkout@v6

      - uses: actions/setup-node@v4
      - run: npm install
        working-directory: docs-site

      - uses: astral-sh/setup-uv@v5
        with:
          enable-cache: true
          cache-suffix: docs-upload

      - uses: actions/download-artifact@v4
        with:
          name: site
          path: site

      - uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          workingDirectory: docs-site
          command: >
            deploy
            --var GIT_COMMIT_SHA:${{ github.sha }}
            --var GIT_BRANCH:main

      - run: uv sync --group docs-upload
      - run: uv run python docs/.hooks/algolia.py upload
        env:
          ALGOLIA_WRITE_API_KEY: ${{ secrets.ALGOLIA_WRITE_API_KEY }}
pr-guard perms .github/workflows/pr-guard.yml
Triggers
pull_request_target
Runs on
ubuntu-latest
Jobs
guard
Commands
  • set -euo pipefail PR_NUMBER=${{ github.event.pull_request.number }} PR_AUTHOR="${{ github.event.pull_request.user.login }}" AUTHOR_ASSOCIATION="${{ github.event.pull_request.author_association }}" PR_BODY=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.body // ""') echo "PR #${PR_NUMBER} by ${PR_AUTHOR} (${AUTHOR_ASSOCIATION})" # Skip draft PRs — authors may create drafts to save progress if [ "${{ github.event.pull_request.draft }}" = "true" ]; then echo "PR is a draft, skipping checks." exit 0 fi # Maintainer bypass if [[ "$AUTHOR_ASSOCIATION" == "MEMBER" || "$AUTHOR_ASSOCIATION" == "OWNER" || "$AUTHOR_ASSOCIATION" == "COLLABORATOR" ]]; then echo "Author is a maintainer/collaborator, skipping checks." exit 0 fi # Parse closing keywords from PR body # GitHub recognizes: close, closes, closed, fix, fixes, fixed, resolve, resolves, resolved # Case-insensitive, with optional leading - or * ISSUE_NUMBERS=$(printf '%s\n' "$PR_BODY" | grep -ioE '(^|\s|[-*]\s*)(close[sd]?|fix(es|ed)?|resolve[sd]?)\s+#[0-9]+' | grep -oE '[0-9]+' | sort -u || true) if [ -z "$ISSUE_NUMBERS" ]; then echo "No closing keywords found in PR body." exit 0 fi echo "Found linked issues: $ISSUE_NUMBERS" # Helper: attempt to assign a user to an issue. # GitHub silently ignores assignees who lack push access (returns 201 but doesn't add them). # We check assignability first via GET /repos/{owner}/{repo}/assignees/{user} (204 = yes, 404 = no). try_assign() { local issue_num="$1" local user="$2" if gh api "repos/${REPO}/assignees/${user}" > /dev/null 2>&1; then gh api "repos/${REPO}/issues/${issue_num}/assignees" --method POST -f "assignees[]=${user}" > /dev/null echo "Assigned ${user} to issue #${issue_num}." return 0 else echo "Cannot assign ${user} to issue #${issue_num} (user is not assignable to this repo)." return 1 fi } # Fetch all open PRs once upfront (the list endpoint includes body and labels). # Done outside the loop to avoid repeated paginated calls when multiple issues are referenced. ALL_OPEN_PRS=$(gh api "repos/${REPO}/pulls?state=open&per_page=100" --paginate) || ALL_OPEN_PRS="[]" FOUND_OPEN_ISSUE="false" FOUND_CLOSED_ISSUE="false" for ISSUE_NUM in $ISSUE_NUMBERS; do echo "" echo "--- Checking issue #${ISSUE_NUM} ---" # Fetch issue details, skip if not found (gh api exits non-zero on 404) if ! ISSUE_JSON=$(gh api "repos/${REPO}/issues/${ISSUE_NUM}" 2>/dev/null); then echo "Issue #${ISSUE_NUM} not found, skipping." continue fi # Skip if this is actually a pull request IS_PR=$(echo "$ISSUE_JSON" | jq 'has("pull_request")') if [ "$IS_PR" = "true" ]; then echo "#${ISSUE_NUM} is a pull request, not an issue. Skipping." continue fi # Skip closed issues — duplicate check only applies to open issues ISSUE_STATE=$(echo "$ISSUE_JSON" | jq -r '.state') if [ "$ISSUE_STATE" != "open" ]; then echo "Issue #${ISSUE_NUM} is ${ISSUE_STATE}, skipping." FOUND_CLOSED_ISSUE="true" continue fi FOUND_OPEN_ISSUE="true" # Check for duplicate/blocking PRs first — this must run regardless of assignment outcome. # Look for any other open PR (excluding this one) that references this issue. echo "Checking for existing PRs targeting issue #${ISSUE_NUM}..." BLOCKING_PRS="" FOUND_STALE_PR="false" MATCHING_PRS=$(echo "$ALL_OPEN_PRS" | jq -c "[.[] | select(.number != ${PR_NUMBER} and (.body // \"\" | test(\"(?i)\\\\b(close[sd]?|fix(es|ed)?|resolve[sd]?)\\\\s+#${ISSUE_NUM}(\\\\s|$|[^0-9])\")))] | .[]") while IFS= read -r PR_JSON; do [ -z "$PR_JSON" ] && continue EXISTING_PR_NUM=$(echo "$PR_JSON" | jq -r '.number') EXISTING_PR_AUTHOR=$(echo "$PR_JSON" | jq -r '.user.login') echo "Found PR #${EXISTING_PR_NUM} by ${EXISTING_PR_AUTHOR} that references issue #${ISSUE_NUM}." # Check if the existing PR has the Stale label HAS_STALE=$(echo "$PR_JSON" | jq '[.labels[].name] | any(. == "Stale")') if [ "$HAS_STALE" = "true" ]; then echo "PR #${EXISTING_PR_NUM} is stale. Allowing new PR to supersede." FOUND_STALE_PR="true" continue fi # Collect all non-stale blocking PRs if [ -z "$BLOCKING_PRS" ]; then BLOCKING_PRS="#${EXISTING_PR_NUM}" else BLOCKING_PRS="${BLOCKING_PRS}, #${EXISTING_PR_NUM}" fi done <<< "$MATCHING_PRS" if [ -n "$BLOCKING_PRS" ]; then echo "Closing PR #${PR_NUMBER} — issue #${ISSUE_NUM} already has active PRs: ${BLOCKING_PRS}" COMMENT=$(printf '%s\n\n%s\n\n%s' \ "Thanks for your interest in this issue! However, there are already open PRs addressing issue #${ISSUE_NUM}: ${BLOCKING_PRS}." \ "To avoid duplicate efforts, this PR has been closed. If you'd like to contribute, you can review the existing PRs or share your thoughts on [issue #${ISSUE_NUM}](https://github.com/${REPO}/issues/${ISSUE_NUM})." \ "If you believe the existing PRs are inactive, please comment on the issue and a maintainer can reassess.") gh pr close "$PR_NUMBER" --repo "$REPO" gh pr comment "$PR_NUMBER" --repo "$REPO" --body "$COMMENT" || true exit 0 fi if [ "$FOUND_STALE_PR" = "true" ]; then echo "All existing PRs for issue #${ISSUE_NUM} are stale. Allowing new PR." fi # Now handle assignment (best-effort, does not gate duplicate check above) ASSIGNEES=$(echo "$ISSUE_JSON" | jq -r '[.assignees[].login] | join(",")') echo "Current assignees: ${ASSIGNEES:-none}" if [ -z "$ASSIGNEES" ]; then # No assignee — attempt to assign PR author (may fail if user lacks push access) try_assign "$ISSUE_NUM" "$PR_AUTHOR" || true elif echo ",$ASSIGNEES," | grep -q ",${PR_AUTHOR},"; then echo "Issue #${ISSUE_NUM} is already assigned to ${PR_AUTHOR}." else echo "Issue #${ISSUE_NUM} is assigned to someone else. Leaving assignment as-is for maintainers to review." fi done # If we found closed issues but no open ones, close the PR if [ "$FOUND_CLOSED_ISSUE" = "true" ] && [ "$FOUND_OPEN_ISSUE" = "false" ]; then echo "All referenced issues are closed. Closing PR." gh pr close "$PR_NUMBER" --repo "$REPO" gh pr comment "$PR_NUMBER" --repo "$REPO" --body "All issues referenced by this PR are already closed. If you believe an issue should be reopened, please comment on it first." || true fi
View raw YAML
name: PR Guard

on:
  pull_request_target:
    types: [opened, ready_for_review, edited]

permissions:
  contents: read
  issues: write
  pull-requests: write

concurrency:
  group: pr-guard-${{ github.event.pull_request.number }}

jobs:
  guard:
    name: Guard
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - name: Check linked issues and guard against duplicates
        run: |
          set -euo pipefail

          PR_NUMBER=${{ github.event.pull_request.number }}
          PR_AUTHOR="${{ github.event.pull_request.user.login }}"
          AUTHOR_ASSOCIATION="${{ github.event.pull_request.author_association }}"
          PR_BODY=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.body // ""')

          echo "PR #${PR_NUMBER} by ${PR_AUTHOR} (${AUTHOR_ASSOCIATION})"

          # Skip draft PRs — authors may create drafts to save progress
          if [ "${{ github.event.pull_request.draft }}" = "true" ]; then
            echo "PR is a draft, skipping checks."
            exit 0
          fi

          # Maintainer bypass
          if [[ "$AUTHOR_ASSOCIATION" == "MEMBER" || "$AUTHOR_ASSOCIATION" == "OWNER" || "$AUTHOR_ASSOCIATION" == "COLLABORATOR" ]]; then
            echo "Author is a maintainer/collaborator, skipping checks."
            exit 0
          fi

          # Parse closing keywords from PR body
          # GitHub recognizes: close, closes, closed, fix, fixes, fixed, resolve, resolves, resolved
          # Case-insensitive, with optional leading - or *
          ISSUE_NUMBERS=$(printf '%s\n' "$PR_BODY" | grep -ioE '(^|\s|[-*]\s*)(close[sd]?|fix(es|ed)?|resolve[sd]?)\s+#[0-9]+' | grep -oE '[0-9]+' | sort -u || true)

          if [ -z "$ISSUE_NUMBERS" ]; then
            echo "No closing keywords found in PR body."
            exit 0
          fi

          echo "Found linked issues: $ISSUE_NUMBERS"

          # Helper: attempt to assign a user to an issue.
          # GitHub silently ignores assignees who lack push access (returns 201 but doesn't add them).
          # We check assignability first via GET /repos/{owner}/{repo}/assignees/{user} (204 = yes, 404 = no).
          try_assign() {
            local issue_num="$1"
            local user="$2"
            if gh api "repos/${REPO}/assignees/${user}" > /dev/null 2>&1; then
              gh api "repos/${REPO}/issues/${issue_num}/assignees" --method POST -f "assignees[]=${user}" > /dev/null
              echo "Assigned ${user} to issue #${issue_num}."
              return 0
            else
              echo "Cannot assign ${user} to issue #${issue_num} (user is not assignable to this repo)."
              return 1
            fi
          }

          # Fetch all open PRs once upfront (the list endpoint includes body and labels).
          # Done outside the loop to avoid repeated paginated calls when multiple issues are referenced.
          ALL_OPEN_PRS=$(gh api "repos/${REPO}/pulls?state=open&per_page=100" --paginate) || ALL_OPEN_PRS="[]"

          FOUND_OPEN_ISSUE="false"
          FOUND_CLOSED_ISSUE="false"
          for ISSUE_NUM in $ISSUE_NUMBERS; do
            echo ""
            echo "--- Checking issue #${ISSUE_NUM} ---"

            # Fetch issue details, skip if not found (gh api exits non-zero on 404)
            if ! ISSUE_JSON=$(gh api "repos/${REPO}/issues/${ISSUE_NUM}" 2>/dev/null); then
              echo "Issue #${ISSUE_NUM} not found, skipping."
              continue
            fi

            # Skip if this is actually a pull request
            IS_PR=$(echo "$ISSUE_JSON" | jq 'has("pull_request")')
            if [ "$IS_PR" = "true" ]; then
              echo "#${ISSUE_NUM} is a pull request, not an issue. Skipping."
              continue
            fi

            # Skip closed issues — duplicate check only applies to open issues
            ISSUE_STATE=$(echo "$ISSUE_JSON" | jq -r '.state')
            if [ "$ISSUE_STATE" != "open" ]; then
              echo "Issue #${ISSUE_NUM} is ${ISSUE_STATE}, skipping."
              FOUND_CLOSED_ISSUE="true"
              continue
            fi

            FOUND_OPEN_ISSUE="true"

            # Check for duplicate/blocking PRs first — this must run regardless of assignment outcome.
            # Look for any other open PR (excluding this one) that references this issue.
            echo "Checking for existing PRs targeting issue #${ISSUE_NUM}..."

            BLOCKING_PRS=""
            FOUND_STALE_PR="false"
            MATCHING_PRS=$(echo "$ALL_OPEN_PRS" | jq -c "[.[] | select(.number != ${PR_NUMBER} and (.body // \"\" | test(\"(?i)\\\\b(close[sd]?|fix(es|ed)?|resolve[sd]?)\\\\s+#${ISSUE_NUM}(\\\\s|$|[^0-9])\")))] | .[]")

            while IFS= read -r PR_JSON; do
              [ -z "$PR_JSON" ] && continue
              EXISTING_PR_NUM=$(echo "$PR_JSON" | jq -r '.number')
              EXISTING_PR_AUTHOR=$(echo "$PR_JSON" | jq -r '.user.login')
              echo "Found PR #${EXISTING_PR_NUM} by ${EXISTING_PR_AUTHOR} that references issue #${ISSUE_NUM}."

              # Check if the existing PR has the Stale label
              HAS_STALE=$(echo "$PR_JSON" | jq '[.labels[].name] | any(. == "Stale")')
              if [ "$HAS_STALE" = "true" ]; then
                echo "PR #${EXISTING_PR_NUM} is stale. Allowing new PR to supersede."
                FOUND_STALE_PR="true"
                continue
              fi

              # Collect all non-stale blocking PRs
              if [ -z "$BLOCKING_PRS" ]; then
                BLOCKING_PRS="#${EXISTING_PR_NUM}"
              else
                BLOCKING_PRS="${BLOCKING_PRS}, #${EXISTING_PR_NUM}"
              fi
            done <<< "$MATCHING_PRS"

            if [ -n "$BLOCKING_PRS" ]; then
              echo "Closing PR #${PR_NUMBER} — issue #${ISSUE_NUM} already has active PRs: ${BLOCKING_PRS}"

              COMMENT=$(printf '%s\n\n%s\n\n%s' \
                "Thanks for your interest in this issue! However, there are already open PRs addressing issue #${ISSUE_NUM}: ${BLOCKING_PRS}." \
                "To avoid duplicate efforts, this PR has been closed. If you'd like to contribute, you can review the existing PRs or share your thoughts on [issue #${ISSUE_NUM}](https://github.com/${REPO}/issues/${ISSUE_NUM})." \
                "If you believe the existing PRs are inactive, please comment on the issue and a maintainer can reassess.")
              gh pr close "$PR_NUMBER" --repo "$REPO"
              gh pr comment "$PR_NUMBER" --repo "$REPO" --body "$COMMENT" || true
              exit 0
            fi

            if [ "$FOUND_STALE_PR" = "true" ]; then
              echo "All existing PRs for issue #${ISSUE_NUM} are stale. Allowing new PR."
            fi

            # Now handle assignment (best-effort, does not gate duplicate check above)
            ASSIGNEES=$(echo "$ISSUE_JSON" | jq -r '[.assignees[].login] | join(",")')
            echo "Current assignees: ${ASSIGNEES:-none}"

            if [ -z "$ASSIGNEES" ]; then
              # No assignee — attempt to assign PR author (may fail if user lacks push access)
              try_assign "$ISSUE_NUM" "$PR_AUTHOR" || true
            elif echo ",$ASSIGNEES," | grep -q ",${PR_AUTHOR},"; then
              echo "Issue #${ISSUE_NUM} is already assigned to ${PR_AUTHOR}."
            else
              echo "Issue #${ISSUE_NUM} is assigned to someone else. Leaving assignment as-is for maintainers to review."
            fi
          done

          # If we found closed issues but no open ones, close the PR
          if [ "$FOUND_CLOSED_ISSUE" = "true" ] && [ "$FOUND_OPEN_ISSUE" = "false" ]; then
            echo "All referenced issues are closed. Closing PR."
            gh pr close "$PR_NUMBER" --repo "$REPO"
            gh pr comment "$PR_NUMBER" --repo "$REPO" --body "All issues referenced by this PR are already closed. If you believe an issue should be reopened, please comment on it first." || true
          fi
        env:
          GH_TOKEN: ${{ github.token }}
          REPO: ${{ github.repository }}
stale perms .github/workflows/stale.yml
Triggers
schedule
Runs on
ubuntu-latest
Jobs
stale
Actions
actions/stale
View raw YAML
name: 'Close stale issues and PRs'
on:
  schedule:
    - cron: '0 14 * * *'

permissions:
  issues: write
  pull-requests: write

jobs:
  stale:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/stale@v9
        with:

          any-of-issue-labels: 'question,more info'
          days-before-issue-stale: 7
          days-before-issue-close: 3
          stale-issue-message: 'This issue is stale, and will be closed in 3 days if no reply is received.'
          close-issue-message: 'Closing this issue as it has been inactive for 10 days.'

          any-of-pr-labels: 'awaiting author revision'
          days-before-pr-stale: 14
          days-before-pr-close: 7
          stale-pr-message: 'This PR is stale, and will be closed in 7 days if no reply is received.'
          close-pr-message: 'Closing this PR as it has been inactive for 21 days.'