langchain-ai/langchain

19 workflows · maturity 50% · 7 patterns · GitHub ↗

Security 40/100

Practices

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

Detected patterns

Security dimensions

permissions
25
security scan
0
supply chain
0
secret handling
15
harden runner
0

Workflows (19)

_compile_integration_test perms .github/workflows/_compile_integration_test.yml
Triggers
workflow_call
Runs on
ubuntu-latest
Jobs
build
Commands
  • uv sync --group test --group test_integration
  • uv run pytest -m compile tests/integration_tests
  • set -eu STATUS="$(git status)" echo "$STATUS" # grep will exit non-zero if the target message isn't found, # and `set -e` above will cause the step to fail. echo "$STATUS" | grep 'nothing to commit, working tree clean'
View raw YAML
# Validates that a package's integration tests compile without syntax or import errors.
#
# (If an integration test fails to compile, it won't run.)
#
# Called as part of check_diffs.yml workflow
#
# Runs pytest with compile marker to check syntax/imports.

name: "🔗 Compile Integration Tests"

on:
  workflow_call:
    inputs:
      working-directory:
        required: true
        type: string
        description: "From which folder this pipeline executes"
      python-version:
        required: true
        type: string
        description: "Python version to use"

permissions:
  contents: read

env:
  UV_FROZEN: "true"

jobs:
  build:
    defaults:
      run:
        working-directory: ${{ inputs.working-directory }}
    runs-on: ubuntu-latest
    timeout-minutes: 20
    name: "Python ${{ inputs.python-version }}"
    steps:
      - uses: actions/checkout@v6

      - name: "🐍 Set up Python ${{ inputs.python-version }} + UV"
        uses: "./.github/actions/uv_setup"
        with:
          python-version: ${{ inputs.python-version }}
          cache-suffix: compile-integration-tests-${{ inputs.working-directory }}
          working-directory: ${{ inputs.working-directory }}

      - name: "📦 Install Integration Dependencies"
        shell: bash
        run: uv sync --group test --group test_integration

      - name: "🔗 Check Integration Tests Compile"
        shell: bash
        run: uv run pytest -m compile tests/integration_tests

      - name: "🧹 Verify Clean Working Directory"
        shell: bash
        run: |
          set -eu

          STATUS="$(git status)"
          echo "$STATUS"

          # grep will exit non-zero if the target message isn't found,
          # and `set -e` above will cause the step to fail.
          echo "$STATUS" | grep 'nothing to commit, working tree clean'
_lint perms .github/workflows/_lint.yml
Triggers
workflow_call
Runs on
ubuntu-latest
Jobs
build
Commands
  • uv sync --group lint --group typing
  • make lint_package
  • uv sync --inexact --group test
  • uv sync --inexact --group test --group test_integration
  • make lint_tests
View raw YAML
# Runs linting.
#
# Uses the package's Makefile to run the checks, specifically the
# `lint_package` and `lint_tests` targets.
#
# Called as part of check_diffs.yml workflow.

name: "🧹 Linting"

on:
  workflow_call:
    inputs:
      working-directory:
        required: true
        type: string
        description: "From which folder this pipeline executes"
      python-version:
        required: true
        type: string
        description: "Python version to use"

permissions:
  contents: read

env:
  WORKDIR: ${{ inputs.working-directory == '' && '.' || inputs.working-directory }}

  # This env var allows us to get inline annotations when ruff has complaints.
  RUFF_OUTPUT_FORMAT: github

  UV_FROZEN: "true"

jobs:
  # Linting job - runs quality checks on package and test code
  build:
    name: "Python ${{ inputs.python-version }}"
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - name: "📋 Checkout Code"
        uses: actions/checkout@v6

      - name: "🐍 Set up Python ${{ inputs.python-version }} + UV"
        uses: "./.github/actions/uv_setup"
        with:
          python-version: ${{ inputs.python-version }}
          cache-suffix: lint-${{ inputs.working-directory }}
          working-directory: ${{ inputs.working-directory }}

      # - name: "🔒 Verify Lockfile is Up-to-Date"
      #   working-directory: ${{ inputs.working-directory }}
      #   run: |
      #     unset UV_FROZEN
      #     uv lock --check

      - name: "📦 Install Lint & Typing Dependencies"
        working-directory: ${{ inputs.working-directory }}
        run: |
          uv sync --group lint --group typing

      - name: "🔍 Analyze Package Code with Linters"
        working-directory: ${{ inputs.working-directory }}
        run: |
          make lint_package

      - name: "📦 Install Test Dependencies (non-partners)"
        # (For directories NOT starting with libs/partners/)
        if: ${{ ! startsWith(inputs.working-directory, 'libs/partners/') }}
        working-directory: ${{ inputs.working-directory }}
        run: |
          uv sync --inexact --group test
      - name: "📦 Install Test Dependencies"
        if: ${{ startsWith(inputs.working-directory, 'libs/partners/') }}
        working-directory: ${{ inputs.working-directory }}
        run: |
          uv sync --inexact --group test --group test_integration

      - name: "🔍 Analyze Test Code with Linters"
        working-directory: ${{ inputs.working-directory }}
        run: |
          make lint_tests
_refresh_model_profiles perms .github/workflows/_refresh_model_profiles.yml
Triggers
workflow_call
Runs on
ubuntu-latest
Jobs
refresh-profiles
Actions
astral-sh/setup-uv, actions/create-github-app-token, peter-evans/create-pull-request
Commands
  • if [ -n "${CLI_PATH}" ]; then resolved="${GITHUB_WORKSPACE}/${CLI_PATH}" if [ ! -d "${resolved}" ]; then echo "::error::cli-path '${CLI_PATH}' does not exist at ${resolved}" exit 1 fi echo "dir=${CLI_PATH}" >> "$GITHUB_OUTPUT" else echo "dir=_langchain-cli/libs/model-profiles" >> "$GITHUB_OUTPUT" fi
  • uv sync --frozen --no-group test --no-group dev --no-group lint
  • echo "${PROVIDERS_JSON}" | jq -e 'type == "array" and length > 0' > /dev/null || { echo "::error::providers input must be a non-empty JSON array" exit 1 } echo "${PROVIDERS_JSON}" | jq -e 'all(has("provider") and has("data_dir"))' > /dev/null || { echo "::error::every entry in providers must have 'provider' and 'data_dir' keys" exit 1 }
  • cli_dir="${GITHUB_WORKSPACE}/${{ steps.cli.outputs.dir }}" failed="" mapfile -t rows < <(echo "${PROVIDERS_JSON}" | jq -c '.[]') for row in "${rows[@]}"; do provider=$(echo "${row}" | jq -r '.provider') data_dir=$(echo "${row}" | jq -r '.data_dir') echo "--- Refreshing ${provider} -> ${data_dir} ---" if ! echo y | uv run --frozen --project "${cli_dir}" \ langchain-profiles refresh \ --provider "${provider}" \ --data-dir "${GITHUB_WORKSPACE}/${data_dir}"; then echo "::error::Failed to refresh provider: ${provider}" failed="${failed} ${provider}" fi done if [ -n "${failed}" ]; then echo "::error::The following providers failed:${failed}" exit 1 fi
  • if [ "${PR_OP}" = "created" ] || [ "${PR_OP}" = "updated" ]; then echo "### ✅ PR ${PR_OP}: ${PR_URL}" >> "$GITHUB_STEP_SUMMARY" elif [ -z "${PR_OP}" ] && [ "${JOB_STATUS}" = "success" ]; then echo "### ⏭️ Skipped: profiles already up to date" >> "$GITHUB_STEP_SUMMARY" elif [ "${JOB_STATUS}" = "failure" ]; then echo "### ❌ Job failed — check step logs for details" >> "$GITHUB_STEP_SUMMARY" fi
View raw YAML
# Reusable workflow: refreshes model profile data for any repo that uses the
# `langchain-profiles` CLI. Creates (or updates) a pull request with the
# resulting changes.
#
# Callers MUST set `permissions: { contents: write, pull-requests: write }` —
# reusable workflows cannot escalate the caller's token permissions.
#
# ── Example: external repo (langchain-google) ──────────────────────────
#
#   jobs:
#     refresh-profiles:
#       uses: langchain-ai/langchain/.github/workflows/_refresh_model_profiles.yml@master
#       with:
#         providers: >-
#           [
#             {"provider":"google",        "data_dir":"libs/genai/langchain_google_genai/data"},
#           ]
#       secrets:
#         MODEL_PROFILE_BOT_APP_ID:      ${{ secrets.MODEL_PROFILE_BOT_APP_ID }}
#         MODEL_PROFILE_BOT_PRIVATE_KEY: ${{ secrets.MODEL_PROFILE_BOT_PRIVATE_KEY }}

name: "Refresh Model Profiles (reusable)"

on:
  workflow_call:
    inputs:
      providers:
        description: >-
          JSON array of objects, each with `provider` (models.dev provider ID)
          and `data_dir` (path relative to repo root where `_profiles.py` and
          `profile_augmentations.toml` live).
        required: true
        type: string
      cli-path:
        description: >-
          Path (relative to workspace) to an existing `libs/model-profiles`
          checkout.  When set the workflow skips cloning the langchain repo and
          uses this directory for the CLI instead.  Useful when the caller IS
          the langchain monorepo.
        required: false
        type: string
        default: ""
      cli-ref:
        description: >-
          Git ref of langchain-ai/langchain to checkout for the CLI.
          Ignored when `cli-path` is set.
        required: false
        type: string
        default: master
      add-paths:
        description: "Glob for files to stage in the PR commit."
        required: false
        type: string
        default: "**/_profiles.py"
      pr-branch:
        description: "Branch name for the auto-created PR."
        required: false
        type: string
        default: bot/refresh-model-profiles
      pr-title:
        description: "PR / commit title."
        required: false
        type: string
        default: "chore(model-profiles): refresh model profile data"
      pr-body:
        description: "PR body."
        required: false
        type: string
        default: |
          Automated refresh of model profile data via `langchain-profiles refresh`.

          🤖 Generated by the `refresh_model_profiles` workflow.
      pr-labels:
        description: "Comma-separated labels to apply to the PR."
        required: false
        type: string
        default: bot
    secrets:
      MODEL_PROFILE_BOT_APP_ID:
        required: true
      MODEL_PROFILE_BOT_PRIVATE_KEY:
        required: true

permissions:
  contents: write
  pull-requests: write

jobs:
  refresh-profiles:
    name: refresh model profiles
    runs-on: ubuntu-latest
    steps:
      - name: "📋 Checkout"
        uses: actions/checkout@v6

      - name: "📋 Checkout langchain-profiles CLI"
        if: inputs.cli-path == ''
        uses: actions/checkout@v6
        with:
          repository: langchain-ai/langchain
          ref: ${{ inputs.cli-ref }}
          sparse-checkout: libs/model-profiles
          path: _langchain-cli

      - name: "🔧 Resolve CLI directory"
        id: cli
        env:
          CLI_PATH: ${{ inputs.cli-path }}
        run: |
          if [ -n "${CLI_PATH}" ]; then
            resolved="${GITHUB_WORKSPACE}/${CLI_PATH}"
            if [ ! -d "${resolved}" ]; then
              echo "::error::cli-path '${CLI_PATH}' does not exist at ${resolved}"
              exit 1
            fi
            echo "dir=${CLI_PATH}" >> "$GITHUB_OUTPUT"
          else
            echo "dir=_langchain-cli/libs/model-profiles" >> "$GITHUB_OUTPUT"
          fi

      - name: "🐍 Set up Python + uv"
        uses: astral-sh/setup-uv@0ca8f610542aa7f4acaf39e65cf4eb3c35091883 # v7
        with:
          version: "0.5.25"
          python-version: "3.12"
          enable-cache: true
          cache-dependency-glob: "**/model-profiles/uv.lock"

      - name: "📦 Install langchain-profiles CLI"
        working-directory: ${{ steps.cli.outputs.dir }}
        run: uv sync --frozen --no-group test --no-group dev --no-group lint

      - name: "✅ Validate providers input"
        env:
          PROVIDERS_JSON: ${{ inputs.providers }}
        run: |
          echo "${PROVIDERS_JSON}" | jq -e 'type == "array" and length > 0' > /dev/null || {
            echo "::error::providers input must be a non-empty JSON array"
            exit 1
          }
          echo "${PROVIDERS_JSON}" | jq -e 'all(has("provider") and has("data_dir"))' > /dev/null || {
            echo "::error::every entry in providers must have 'provider' and 'data_dir' keys"
            exit 1
          }

      - name: "🔄 Refresh profiles"
        env:
          PROVIDERS_JSON: ${{ inputs.providers }}
        run: |
          cli_dir="${GITHUB_WORKSPACE}/${{ steps.cli.outputs.dir }}"
          failed=""
          mapfile -t rows < <(echo "${PROVIDERS_JSON}" | jq -c '.[]')
          for row in "${rows[@]}"; do
            provider=$(echo "${row}" | jq -r '.provider')
            data_dir=$(echo "${row}" | jq -r '.data_dir')
            echo "--- Refreshing ${provider} -> ${data_dir} ---"
            if ! echo y | uv run --frozen --project "${cli_dir}" \
              langchain-profiles refresh \
              --provider "${provider}" \
              --data-dir "${GITHUB_WORKSPACE}/${data_dir}"; then
              echo "::error::Failed to refresh provider: ${provider}"
              failed="${failed} ${provider}"
            fi
          done
          if [ -n "${failed}" ]; then
            echo "::error::The following providers failed:${failed}"
            exit 1
          fi

      - name: "🔑 Generate GitHub App token"
        id: app-token
        uses: actions/create-github-app-token@v3
        with:
          app-id: ${{ secrets.MODEL_PROFILE_BOT_APP_ID }}
          private-key: ${{ secrets.MODEL_PROFILE_BOT_PRIVATE_KEY }}

      - name: "🔀 Create pull request"
        id: create-pr
        uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8
        with:
          token: ${{ steps.app-token.outputs.token }}
          branch: ${{ inputs.pr-branch }}
          commit-message: ${{ inputs.pr-title }}
          title: ${{ inputs.pr-title }}
          body: ${{ inputs.pr-body }}
          labels: ${{ inputs.pr-labels }}
          add-paths: ${{ inputs.add-paths }}

      - name: "📝 Summary"
        if: always()
        env:
          PR_OP: ${{ steps.create-pr.outputs.pull-request-operation }}
          PR_URL: ${{ steps.create-pr.outputs.pull-request-url }}
          JOB_STATUS: ${{ job.status }}
        run: |
          if [ "${PR_OP}" = "created" ] || [ "${PR_OP}" = "updated" ]; then
            echo "### ✅ PR ${PR_OP}: ${PR_URL}" >> "$GITHUB_STEP_SUMMARY"
          elif [ -z "${PR_OP}" ] && [ "${JOB_STATUS}" = "success" ]; then
            echo "### ⏭️ Skipped: profiles already up to date" >> "$GITHUB_STEP_SUMMARY"
          elif [ "${JOB_STATUS}" = "failure" ]; then
            echo "### ❌ Job failed — check step logs for details" >> "$GITHUB_STEP_SUMMARY"
          fi
_release matrix perms .github/workflows/_release.yml
Triggers
workflow_call, workflow_dispatch
Runs on
ubuntu-latest, ubuntu-latest, ubuntu-latest, ubuntu-latest, ubuntu-latest, ubuntu-latest, ubuntu-latest, ubuntu-latest
Jobs
build, release-notes, test-pypi-publish, pre-release-checks, test-prior-published-packages-against-new-core, test-dependents, publish, mark-release
Matrix
package, package.name, package.path, package.repo, partner, python-version→ 3.11, 3.13, anthropic, deepagents, langchain-ai/deepagents, libs/deepagents
Actions
pypa/gh-action-pypi-publish, pypa/gh-action-pypi-publish, ncipollo/release-action
Commands
  • uv build
  • import os import tomllib with open("pyproject.toml", "rb") as f: data = tomllib.load(f) pkg_name = data["project"]["name"] version = data["project"]["version"] with open(os.environ["GITHUB_OUTPUT"], "a") as f: f.write(f"pkg-name={pkg_name}\n") f.write(f"version={version}\n")
  • # Handle regular versions and pre-release versions differently if [[ "$VERSION" == *"-"* ]]; then # This is a pre-release version (contains a hyphen) # Extract the base version without the pre-release suffix BASE_VERSION=${VERSION%%-*} # Look for the latest release of the same base version REGEX="^$PKG_NAME==$BASE_VERSION\$" PREV_TAG=$(git tag --sort=-creatordate | (grep -P "$REGEX" || true) | head -1) # If no exact base version match, look for the latest release of any kind if [ -z "$PREV_TAG" ]; then REGEX="^$PKG_NAME==\\d+\\.\\d+\\.\\d+\$" PREV_TAG=$(git tag --sort=-creatordate | (grep -P "$REGEX" || true) | head -1) fi else # Regular version handling PREV_TAG="$PKG_NAME==${VERSION%.*}.$(( ${VERSION##*.} - 1 ))"; [[ "${VERSION##*.}" -eq 0 ]] && PREV_TAG="" # backup case if releasing e.g. 0.3.0, looks up last release # note if last release (chronologically) was e.g. 0.1.47 it will get # that instead of the last 0.2 release if [ -z "$PREV_TAG" ]; then REGEX="^$PKG_NAME==\\d+\\.\\d+\\.\\d+\$" echo $REGEX PREV_TAG=$(git tag --sort=-creatordate | (grep -P $REGEX || true) | head -1) fi fi # if PREV_TAG is empty or came out to 0.0.0, let it be empty if [ -z "$PREV_TAG" ] || [ "$PREV_TAG" = "$PKG_NAME==0.0.0" ]; then echo "No previous tag found - first release" else # confirm prev-tag actually exists in git repo with git tag GIT_TAG_RESULT=$(git tag -l "$PREV_TAG") if [ -z "$GIT_TAG_RESULT" ]; then echo "Previous tag $PREV_TAG not found in git repo" exit 1 fi fi TAG="${PKG_NAME}==${VERSION}" if [ "$TAG" == "$PREV_TAG" ]; then echo "No new version to release" exit 1 fi echo tag="$TAG" >> $GITHUB_OUTPUT echo prev-tag="$PREV_TAG" >> $GITHUB_OUTPUT
  • PREAMBLE="Changes since $PREV_TAG" # if PREV_TAG is empty or 0.0.0, then we are releasing the first version if [ -z "$PREV_TAG" ] || [ "$PREV_TAG" = "$PKG_NAME==0.0.0" ]; then PREAMBLE="Initial release" PREV_TAG=$(git rev-list --max-parents=0 HEAD) fi { echo 'release-body<<EOF' echo $PREAMBLE echo git log --format="%s" "$PREV_TAG"..HEAD -- $WORKING_DIR echo EOF } >> "$GITHUB_OUTPUT"
  • uv venv VIRTUAL_ENV=.venv uv pip install dist/*.whl # Replace all dashes in the package name with underscores, # since that's how Python imports packages with dashes in the name. # also remove _official suffix IMPORT_NAME="$(echo "$PKG_NAME" | sed s/-/_/g | sed s/_official//g)" uv run python -c "import $IMPORT_NAME; print(dir($IMPORT_NAME))"
  • uv sync --group test
  • VIRTUAL_ENV=.venv uv pip install dist/*.whl
  • uv run python $GITHUB_WORKSPACE/.github/scripts/check_prerelease_dependencies.py pyproject.toml
View raw YAML
# Builds and publishes LangChain packages to PyPI.
#
# Manually triggered, though can be used as a reusable workflow (workflow_call).
#
# Handles version bumping, building, and publishing to PyPI with authentication.

name: "🚀 Package Release"
run-name: "Release ${{ inputs.working-directory }} ${{ inputs.release-version }}"
on:
  workflow_call:
    inputs:
      working-directory:
        required: true
        type: string
        description: "From which folder this pipeline executes"
  workflow_dispatch:
    inputs:
      working-directory:
        required: true
        type: string
        description: "From which folder this pipeline executes"
        default: "libs/langchain_v1"
      release-version:
        required: true
        type: string
        default: "0.1.0"
        description: "New version of package being released"
      dangerous-nonmaster-release:
        required: false
        type: boolean
        default: false
        description: "Release from a non-master branch (danger!) - Only use for hotfixes"

env:
  PYTHON_VERSION: "3.11"
  UV_FROZEN: "true"
  UV_NO_SYNC: "true"

permissions:
  contents: write # Required for creating GitHub releases

jobs:
  # Build the distribution package and extract version info
  # Runs in isolated environment with minimal permissions for security
  build:
    if: github.ref == 'refs/heads/master' || inputs.dangerous-nonmaster-release
    environment: Scheduled testing
    runs-on: ubuntu-latest
    permissions:
      contents: read

    outputs:
      pkg-name: ${{ steps.check-version.outputs.pkg-name }}
      version: ${{ steps.check-version.outputs.version }}

    steps:
      - uses: actions/checkout@v6

      - name: Set up Python + uv
        uses: "./.github/actions/uv_setup"
        with:
          python-version: ${{ env.PYTHON_VERSION }}

      # We want to keep this build stage *separate* from the release stage,
      # so that there's no sharing of permissions between them.
      # (Release stage has trusted publishing and GitHub repo contents write access,
      #
      # Otherwise, a malicious `build` step (e.g. via a compromised dependency)
      # could get access to our GitHub or PyPI credentials.
      #
      # Per the trusted publishing GitHub Action:
      # > It is strongly advised to separate jobs for building [...]
      # > from the publish job.
      # https://github.com/pypa/gh-action-pypi-publish#non-goals
      - name: Build project for distribution
        run: uv build
        working-directory: ${{ inputs.working-directory }}

      - name: Upload build
        uses: actions/upload-artifact@v7
        with:
          name: dist
          path: ${{ inputs.working-directory }}/dist/

      - name: Check version
        id: check-version
        shell: python
        working-directory: ${{ inputs.working-directory }}
        run: |
          import os
          import tomllib
          with open("pyproject.toml", "rb") as f:
              data = tomllib.load(f)
          pkg_name = data["project"]["name"]
          version = data["project"]["version"]
          with open(os.environ["GITHUB_OUTPUT"], "a") as f:
              f.write(f"pkg-name={pkg_name}\n")
              f.write(f"version={version}\n")
  release-notes:
    # release-notes must run before publishing because its check-tags step
    # validates version/tag state — do not remove this dependency.
    needs:
      - build
    runs-on: ubuntu-latest
    permissions:
      contents: read
    outputs:
      release-body: ${{ steps.generate-release-body.outputs.release-body }}
    steps:
      - uses: actions/checkout@v6
        with:
          repository: langchain-ai/langchain
          path: langchain
          sparse-checkout: | # this only grabs files for relevant dir
            ${{ inputs.working-directory }}
          ref: ${{ github.ref }} # this scopes to just ref'd branch
          fetch-depth: 0 # this fetches entire commit history
      - name: Check tags
        id: check-tags
        shell: bash
        working-directory: langchain/${{ inputs.working-directory }}
        env:
          PKG_NAME: ${{ needs.build.outputs.pkg-name }}
          VERSION: ${{ needs.build.outputs.version }}
        run: |
          # Handle regular versions and pre-release versions differently
          if [[ "$VERSION" == *"-"* ]]; then
            # This is a pre-release version (contains a hyphen)
            # Extract the base version without the pre-release suffix
            BASE_VERSION=${VERSION%%-*}
            # Look for the latest release of the same base version
            REGEX="^$PKG_NAME==$BASE_VERSION\$"
            PREV_TAG=$(git tag --sort=-creatordate | (grep -P "$REGEX" || true) | head -1)

            # If no exact base version match, look for the latest release of any kind
            if [ -z "$PREV_TAG" ]; then
              REGEX="^$PKG_NAME==\\d+\\.\\d+\\.\\d+\$"
              PREV_TAG=$(git tag --sort=-creatordate | (grep -P "$REGEX" || true) | head -1)
            fi
          else
            # Regular version handling
            PREV_TAG="$PKG_NAME==${VERSION%.*}.$(( ${VERSION##*.} - 1 ))"; [[ "${VERSION##*.}" -eq 0 ]] && PREV_TAG=""

            # backup case if releasing e.g. 0.3.0, looks up last release
            # note if last release (chronologically) was e.g. 0.1.47 it will get
            # that instead of the last 0.2 release
            if [ -z "$PREV_TAG" ]; then
              REGEX="^$PKG_NAME==\\d+\\.\\d+\\.\\d+\$"
              echo $REGEX
              PREV_TAG=$(git tag --sort=-creatordate | (grep -P $REGEX || true) | head -1)
            fi
          fi

          # if PREV_TAG is empty or came out to 0.0.0, let it be empty
          if [ -z "$PREV_TAG" ] || [ "$PREV_TAG" = "$PKG_NAME==0.0.0" ]; then
            echo "No previous tag found - first release"
          else
            # confirm prev-tag actually exists in git repo with git tag
            GIT_TAG_RESULT=$(git tag -l "$PREV_TAG")
            if [ -z "$GIT_TAG_RESULT" ]; then
              echo "Previous tag $PREV_TAG not found in git repo"
              exit 1
            fi
          fi


          TAG="${PKG_NAME}==${VERSION}"
          if [ "$TAG" == "$PREV_TAG" ]; then
            echo "No new version to release"
            exit 1
          fi
          echo tag="$TAG" >> $GITHUB_OUTPUT
          echo prev-tag="$PREV_TAG" >> $GITHUB_OUTPUT
      - name: Generate release body
        id: generate-release-body
        working-directory: langchain
        env:
          WORKING_DIR: ${{ inputs.working-directory }}
          PKG_NAME: ${{ needs.build.outputs.pkg-name }}
          TAG: ${{ steps.check-tags.outputs.tag }}
          PREV_TAG: ${{ steps.check-tags.outputs.prev-tag }}
        run: |
          PREAMBLE="Changes since $PREV_TAG"
          # if PREV_TAG is empty or 0.0.0, then we are releasing the first version
          if [ -z "$PREV_TAG" ] || [ "$PREV_TAG" = "$PKG_NAME==0.0.0" ]; then
            PREAMBLE="Initial release"
            PREV_TAG=$(git rev-list --max-parents=0 HEAD)
          fi
          {
            echo 'release-body<<EOF'
            echo $PREAMBLE
            echo
            git log --format="%s" "$PREV_TAG"..HEAD -- $WORKING_DIR
            echo EOF
          } >> "$GITHUB_OUTPUT"

  test-pypi-publish:
    # release-notes must run before publishing because its check-tags step
    # validates version/tag state — do not remove this dependency.
    needs:
      - build
      - release-notes
    runs-on: ubuntu-latest
    permissions:
      # This permission is used for trusted publishing:
      # https://blog.pypi.org/posts/2023-04-20-introducing-trusted-publishers/
      #
      # Trusted publishing has to also be configured on PyPI for each package:
      # https://docs.pypi.org/trusted-publishers/adding-a-publisher/
      id-token: write

    steps:
      - uses: actions/checkout@v6

      - uses: actions/download-artifact@v8
        with:
          name: dist
          path: ${{ inputs.working-directory }}/dist/

      - name: Publish to test PyPI
        uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # release/v1
        with:
          packages-dir: ${{ inputs.working-directory }}/dist/
          verbose: true
          print-hash: true
          repository-url: https://test.pypi.org/legacy/
          # We overwrite any existing distributions with the same name and version.
          # This is *only for CI use* and is *extremely dangerous* otherwise!
          # https://github.com/pypa/gh-action-pypi-publish#tolerating-release-package-file-duplicates
          skip-existing: true
          # Temp workaround since attestations are on by default as of gh-action-pypi-publish v1.11.0
          attestations: false

  pre-release-checks:
    needs:
      - build
      - release-notes
      - test-pypi-publish
    runs-on: ubuntu-latest
    permissions:
      contents: read
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@v6

      # We explicitly *don't* set up caching here. This ensures our tests are
      # maximally sensitive to catching breakage.
      #
      # For example, here's a way that caching can cause a falsely-passing test:
      # - Make the langchain package manifest no longer list a dependency package
      #   as a requirement. This means it won't be installed by `pip install`,
      #   and attempting to use it would cause a crash.
      # - That dependency used to be required, so it may have been cached.
      #   When restoring the venv packages from cache, that dependency gets included.
      # - Tests pass, because the dependency is present even though it wasn't specified.
      # - The package is published, and it breaks on the missing dependency when
      #   used in the real world.

      - name: Set up Python + uv
        uses: "./.github/actions/uv_setup"
        id: setup-python
        with:
          python-version: ${{ env.PYTHON_VERSION }}

      - uses: actions/download-artifact@v8
        with:
          name: dist
          path: ${{ inputs.working-directory }}/dist/

      - name: Import dist package
        shell: bash
        working-directory: ${{ inputs.working-directory }}
        env:
          PKG_NAME: ${{ needs.build.outputs.pkg-name }}
          VERSION: ${{ needs.build.outputs.version }}
        # Here we use:
        # - The default regular PyPI index as the *primary* index, meaning
        #   that it takes priority (https://pypi.org/simple)
        # - The test PyPI index as an extra index, so that any dependencies that
        #   are not found on test PyPI can be resolved and installed anyway.
        #   (https://test.pypi.org/simple). This will include the PKG_NAME==VERSION
        #   package because VERSION will not have been uploaded to regular PyPI yet.
        # - attempt install again after 5 seconds if it fails because there is
        #   sometimes a delay in availability on test pypi
        run: |
          uv venv
          VIRTUAL_ENV=.venv uv pip install dist/*.whl

          # Replace all dashes in the package name with underscores,
          # since that's how Python imports packages with dashes in the name.
          # also remove _official suffix
          IMPORT_NAME="$(echo "$PKG_NAME" | sed s/-/_/g | sed s/_official//g)"

          uv run python -c "import $IMPORT_NAME; print(dir($IMPORT_NAME))"

      - name: Import test dependencies
        run: uv sync --group test
        working-directory: ${{ inputs.working-directory }}

      # Overwrite the local version of the package with the built version
      - name: Import published package (again)
        working-directory: ${{ inputs.working-directory }}
        shell: bash
        env:
          PKG_NAME: ${{ needs.build.outputs.pkg-name }}
          VERSION: ${{ needs.build.outputs.version }}
        run: |
          VIRTUAL_ENV=.venv uv pip install dist/*.whl

      - name: Check for prerelease versions
        # Block release if any dependencies allow prerelease versions
        # (unless this is itself a prerelease version)
        working-directory: ${{ inputs.working-directory }}
        run: |
          uv run python $GITHUB_WORKSPACE/.github/scripts/check_prerelease_dependencies.py pyproject.toml

      - name: Run unit tests
        run: make tests
        working-directory: ${{ inputs.working-directory }}

      - name: Get minimum versions
        # Find the minimum published versions that satisfies the given constraints
        working-directory: ${{ inputs.working-directory }}
        id: min-version
        run: |
          VIRTUAL_ENV=.venv uv pip install packaging requests
          python_version="$(uv run python --version | awk '{print $2}')"
          min_versions="$(uv run python $GITHUB_WORKSPACE/.github/scripts/get_min_versions.py pyproject.toml release $python_version)"
          echo "min-versions=$min_versions" >> "$GITHUB_OUTPUT"
          echo "min-versions=$min_versions"

      - name: Run unit tests with minimum dependency versions
        if: ${{ steps.min-version.outputs.min-versions != '' }}
        env:
          MIN_VERSIONS: ${{ steps.min-version.outputs.min-versions }}
        run: |
          VIRTUAL_ENV=.venv uv pip install --force-reinstall --editable .
          VIRTUAL_ENV=.venv uv pip install --force-reinstall $MIN_VERSIONS
          make tests
        working-directory: ${{ inputs.working-directory }}

      - name: Import integration test dependencies
        run: uv sync --group test --group test_integration
        working-directory: ${{ inputs.working-directory }}

      - name: Run integration tests
        # Uses the Makefile's `integration_tests` target for the specified package
        if: ${{ startsWith(inputs.working-directory, 'libs/partners/') }}
        env:
          AI21_API_KEY: ${{ secrets.AI21_API_KEY }}
          GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
          TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
          AZURE_OPENAI_API_VERSION: ${{ secrets.AZURE_OPENAI_API_VERSION }}
          AZURE_OPENAI_API_BASE: ${{ secrets.AZURE_OPENAI_API_BASE }}
          AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }}
          AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_CHAT_DEPLOYMENT_NAME }}
          AZURE_OPENAI_LEGACY_CHAT_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_LEGACY_CHAT_DEPLOYMENT_NAME }}
          AZURE_OPENAI_LLM_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_LLM_DEPLOYMENT_NAME }}
          AZURE_OPENAI_EMBEDDINGS_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_EMBEDDINGS_DEPLOYMENT_NAME }}
          NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }}
          GOOGLE_SEARCH_API_KEY: ${{ secrets.GOOGLE_SEARCH_API_KEY }}
          GOOGLE_CSE_ID: ${{ secrets.GOOGLE_CSE_ID }}
          GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
          HUGGINGFACEHUB_API_TOKEN: ${{ secrets.HUGGINGFACEHUB_API_TOKEN }}
          EXA_API_KEY: ${{ secrets.EXA_API_KEY }}
          NOMIC_API_KEY: ${{ secrets.NOMIC_API_KEY }}
          WATSONX_APIKEY: ${{ secrets.WATSONX_APIKEY }}
          WATSONX_PROJECT_ID: ${{ secrets.WATSONX_PROJECT_ID }}
          ASTRA_DB_API_ENDPOINT: ${{ secrets.ASTRA_DB_API_ENDPOINT }}
          ASTRA_DB_APPLICATION_TOKEN: ${{ secrets.ASTRA_DB_APPLICATION_TOKEN }}
          ASTRA_DB_KEYSPACE: ${{ secrets.ASTRA_DB_KEYSPACE }}
          ES_URL: ${{ secrets.ES_URL }}
          ES_CLOUD_ID: ${{ secrets.ES_CLOUD_ID }}
          ES_API_KEY: ${{ secrets.ES_API_KEY }}
          MONGODB_ATLAS_URI: ${{ secrets.MONGODB_ATLAS_URI }}
          UPSTAGE_API_KEY: ${{ secrets.UPSTAGE_API_KEY }}
          FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
          XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
          DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
          PPLX_API_KEY: ${{ secrets.PPLX_API_KEY }}
          OLLAMA_API_KEY: ${{ secrets.OLLAMA_API_KEY }}
          OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
          LANGCHAIN_TESTS_USER_AGENT: ${{ secrets.LANGCHAIN_TESTS_USER_AGENT }}
        run: make integration_tests
        working-directory: ${{ inputs.working-directory }}

  # Test select published packages against new core
  # Done when code changes are made to langchain-core
  test-prior-published-packages-against-new-core:
    # Installs the new core with old partners: Installs the new unreleased core
    # alongside the previously published partner packages and runs integration tests
    needs:
      - build
      - release-notes
      - test-pypi-publish
      - pre-release-checks
    runs-on: ubuntu-latest
    permissions:
      contents: read
    if: false # temporarily skip
    strategy:
      matrix:
        partner: [anthropic]
      fail-fast: false # Continue testing other partners if one fails
    env:
      ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
      ANTHROPIC_FILES_API_IMAGE_ID: ${{ secrets.ANTHROPIC_FILES_API_IMAGE_ID }}
      ANTHROPIC_FILES_API_PDF_ID: ${{ secrets.ANTHROPIC_FILES_API_PDF_ID }}
      OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
      AZURE_OPENAI_API_VERSION: ${{ secrets.AZURE_OPENAI_API_VERSION }}
      AZURE_OPENAI_API_BASE: ${{ secrets.AZURE_OPENAI_API_BASE }}
      AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }}
      AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_CHAT_DEPLOYMENT_NAME }}
      AZURE_OPENAI_LEGACY_CHAT_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_LEGACY_CHAT_DEPLOYMENT_NAME }}
      AZURE_OPENAI_LLM_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_LLM_DEPLOYMENT_NAME }}
      AZURE_OPENAI_EMBEDDINGS_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_EMBEDDINGS_DEPLOYMENT_NAME }}
      LANGCHAIN_TESTS_USER_AGENT: ${{ secrets.LANGCHAIN_TESTS_USER_AGENT }}
    steps:
      - uses: actions/checkout@v6

      # We implement this conditional as Github Actions does not have good support
      # for conditionally needing steps. https://github.com/actions/runner/issues/491
      # TODO: this seems to be resolved upstream, so we can probably remove this workaround
      - name: Check if libs/core
        run: |
          if [ "${{ startsWith(inputs.working-directory, 'libs/core') }}" != "true" ]; then
            echo "Not in libs/core. Exiting successfully."
            exit 0
          fi

      - name: Set up Python + uv
        if: startsWith(inputs.working-directory, 'libs/core')
        uses: "./.github/actions/uv_setup"
        with:
          python-version: ${{ env.PYTHON_VERSION }}

      - uses: actions/download-artifact@v8
        if: startsWith(inputs.working-directory, 'libs/core')
        with:
          name: dist
          path: ${{ inputs.working-directory }}/dist/

      - name: Test against ${{ matrix.partner }}
        if: startsWith(inputs.working-directory, 'libs/core')
        run: |
          # Identify latest tag, excluding pre-releases
          LATEST_PACKAGE_TAG="$(
            git ls-remote --tags origin "langchain-${{ matrix.partner }}*" \
            | awk '{print $2}' \
            | sed 's|refs/tags/||' \
            | grep -E '[0-9]+\.[0-9]+\.[0-9]+$' \
            | sort -Vr \
            | head -n 1
          )"
          echo "Latest package tag: $LATEST_PACKAGE_TAG"

          # Shallow-fetch just that single tag
          git fetch --depth=1 origin tag "$LATEST_PACKAGE_TAG"

          # Checkout the latest package files
          rm -rf $GITHUB_WORKSPACE/libs/partners/${{ matrix.partner }}/*
          rm -rf $GITHUB_WORKSPACE/libs/standard-tests/*
          cd $GITHUB_WORKSPACE/libs/
          git checkout "$LATEST_PACKAGE_TAG" -- standard-tests/
          git checkout "$LATEST_PACKAGE_TAG" -- partners/${{ matrix.partner }}/
          cd partners/${{ matrix.partner }}

          # Print as a sanity check
          echo "Version number from pyproject.toml: "
          cat pyproject.toml | grep "version = "

          # Run tests
          uv sync --group test --group test_integration
          uv pip install ../../core/dist/*.whl
          make integration_tests

  # Test external packages that depend on langchain-core/langchain against the new release
  # Only runs for core and langchain_v1 releases to catch breaking changes before publish
  test-dependents:
    name: "🐍 Python ${{ matrix.python-version }}: ${{ matrix.package.path }}"
    needs:
      - build
      - release-notes
      - test-pypi-publish
      - pre-release-checks
    runs-on: ubuntu-latest
    permissions:
      contents: read
    # Only run for core or langchain_v1 releases
    if: startsWith(inputs.working-directory, 'libs/core') || startsWith(inputs.working-directory, 'libs/langchain_v1')
    strategy:
      fail-fast: false
      matrix:
        python-version: ["3.11", "3.13"]
        package:
          - name: deepagents
            repo: langchain-ai/deepagents
            path: libs/deepagents
    # No API keys needed for now - deepagents `make test` only runs unit tests

    steps:
      - uses: actions/checkout@v6
        with:
          path: langchain

      - uses: actions/checkout@v6
        with:
          repository: ${{ matrix.package.repo }}
          path: ${{ matrix.package.name }}

      - name: Set up Python + uv
        uses: "./langchain/.github/actions/uv_setup"
        with:
          python-version: ${{ matrix.python-version }}

      - uses: actions/download-artifact@v8
        with:
          name: dist
          path: dist/

      - name: Install ${{ matrix.package.name }} with local packages
        # External dependents don't have [tool.uv.sources] pointing to this repo,
        # so we install the package normally then override with the built wheel.
        run: |
          cd ${{ matrix.package.name }}/${{ matrix.package.path }}

          # Install the package with test dependencies
          uv sync --group test

          # Override with the built wheel from this release
          uv pip install $GITHUB_WORKSPACE/dist/*.whl

      - name: Run ${{ matrix.package.name }} tests
        run: |
          cd ${{ matrix.package.name }}/${{ matrix.package.path }}
          make test

  publish:
    # Publishes the package to PyPI
    needs:
      - build
      - release-notes
      - test-pypi-publish
      - pre-release-checks
      - test-dependents
      # - test-prior-published-packages-against-new-core
    # Run if all needed jobs succeeded or were skipped (test-dependents only runs for core/langchain_v1)
    if: ${{ !cancelled() && !failure() }}
    runs-on: ubuntu-latest
    permissions:
      # This permission is used for trusted publishing:
      # https://blog.pypi.org/posts/2023-04-20-introducing-trusted-publishers/
      #
      # Trusted publishing has to also be configured on PyPI for each package:
      # https://docs.pypi.org/trusted-publishers/adding-a-publisher/
      id-token: write

    defaults:
      run:
        working-directory: ${{ inputs.working-directory }}

    steps:
      - uses: actions/checkout@v6

      - name: Set up Python + uv
        uses: "./.github/actions/uv_setup"
        with:
          python-version: ${{ env.PYTHON_VERSION }}

      - uses: actions/download-artifact@v8
        with:
          name: dist
          path: ${{ inputs.working-directory }}/dist/

      - name: Publish package distributions to PyPI
        uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # release/v1
        with:
          packages-dir: ${{ inputs.working-directory }}/dist/
          verbose: true
          print-hash: true
          # Temp workaround since attestations are on by default as of gh-action-pypi-publish v1.11.0
          attestations: false

  mark-release:
    # Marks the GitHub release with the new version tag
    needs:
      - build
      - release-notes
      - test-pypi-publish
      - pre-release-checks
      - publish
    # Run if all needed jobs succeeded or were skipped (test-dependents only runs for core/langchain_v1)
    if: ${{ !cancelled() && !failure() }}
    runs-on: ubuntu-latest
    permissions:
      # This permission is needed by `ncipollo/release-action` to
      # create the GitHub release/tag
      contents: write

    defaults:
      run:
        working-directory: ${{ inputs.working-directory }}

    steps:
      - uses: actions/checkout@v6

      - name: Set up Python + uv
        uses: "./.github/actions/uv_setup"
        with:
          python-version: ${{ env.PYTHON_VERSION }}

      - uses: actions/download-artifact@v8
        with:
          name: dist
          path: ${{ inputs.working-directory }}/dist/

      - name: Create Tag
        uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1
        with:
          artifacts: "dist/*"
          token: ${{ secrets.GITHUB_TOKEN }}
          generateReleaseNotes: false
          tag: ${{needs.build.outputs.pkg-name}}==${{ needs.build.outputs.version }}
          body: ${{ needs.release-notes.outputs.release-body }}
          commit: ${{ github.sha }}
          makeLatest: ${{ needs.build.outputs.pkg-name == 'langchain-core'}}
_test perms .github/workflows/_test.yml
Triggers
workflow_call
Runs on
ubuntu-latest
Jobs
build
Commands
  • uv sync --group test --dev
  • make test PYTEST_EXTRA=-q
  • VIRTUAL_ENV=.venv uv pip install packaging tomli requests python_version="$(uv run python --version | awk '{print $2}')" min_versions="$(uv run python $GITHUB_WORKSPACE/.github/scripts/get_min_versions.py pyproject.toml pull_request $python_version)" echo "min-versions=$min_versions" >> "$GITHUB_OUTPUT" echo "min-versions=$min_versions"
  • VIRTUAL_ENV=.venv uv pip install $MIN_VERSIONS make tests PYTEST_EXTRA=-q
  • set -eu STATUS="$(git status)" echo "$STATUS" # grep will exit non-zero if the target message isn't found, # and `set -e` above will cause the step to fail. echo "$STATUS" | grep 'nothing to commit, working tree clean'
View raw YAML
# Runs unit tests with both current and minimum supported dependency versions
# to ensure compatibility across the supported range.

name: "🧪 Unit Testing"

on:
  workflow_call:
    inputs:
      working-directory:
        required: true
        type: string
        description: "From which folder this pipeline executes"
      python-version:
        required: true
        type: string
        description: "Python version to use"

permissions:
  contents: read

env:
  UV_FROZEN: "true"
  UV_NO_SYNC: "true"

jobs:
  # Main test job - runs unit tests with current deps, then retests with minimum versions
  build:
    defaults:
      run:
        working-directory: ${{ inputs.working-directory }}
    runs-on: ubuntu-latest
    timeout-minutes: 20
    name: "Python ${{ inputs.python-version }}"
    steps:
      - name: "📋 Checkout Code"
        uses: actions/checkout@v6

      - name: "🐍 Set up Python ${{ inputs.python-version }} + UV"
        uses: "./.github/actions/uv_setup"
        id: setup-python
        with:
          python-version: ${{ inputs.python-version }}
          cache-suffix: test-${{ inputs.working-directory }}
          working-directory: ${{ inputs.working-directory }}

      - name: "📦 Install Test Dependencies"
        shell: bash
        run: uv sync --group test --dev

      - name: "🧪 Run Core Unit Tests"
        shell: bash
        run: |
          make test PYTEST_EXTRA=-q

      - name: "🔍 Calculate Minimum Dependency Versions"
        working-directory: ${{ inputs.working-directory }}
        id: min-version
        shell: bash
        run: |
          VIRTUAL_ENV=.venv uv pip install packaging tomli requests
          python_version="$(uv run python --version | awk '{print $2}')"
          min_versions="$(uv run python $GITHUB_WORKSPACE/.github/scripts/get_min_versions.py pyproject.toml pull_request $python_version)"
          echo "min-versions=$min_versions" >> "$GITHUB_OUTPUT"
          echo "min-versions=$min_versions"

      - name: "🧪 Run Tests with Minimum Dependencies"
        if: ${{ steps.min-version.outputs.min-versions != '' }}
        env:
          MIN_VERSIONS: ${{ steps.min-version.outputs.min-versions }}
        run: |
          VIRTUAL_ENV=.venv uv pip install $MIN_VERSIONS
          make tests PYTEST_EXTRA=-q
        working-directory: ${{ inputs.working-directory }}

      - name: "🧹 Verify Clean Working Directory"
        shell: bash
        run: |
          set -eu

          STATUS="$(git status)"
          echo "$STATUS"

          # grep will exit non-zero if the target message isn't found,
          # and `set -e` above will cause the step to fail.
          echo "$STATUS" | grep 'nothing to commit, working tree clean'
_test_pydantic perms .github/workflows/_test_pydantic.yml
Triggers
workflow_call
Runs on
ubuntu-latest
Jobs
build
Commands
  • uv sync --group test
  • VIRTUAL_ENV=.venv uv pip install "pydantic~=$PYDANTIC_VERSION"
  • make test
  • set -eu STATUS="$(git status)" echo "$STATUS" # grep will exit non-zero if the target message isn't found, # and `set -e` above will cause the step to fail. echo "$STATUS" | grep 'nothing to commit, working tree clean'
View raw YAML
# Facilitate unit testing against different Pydantic versions for a provided package.

name: "🐍 Pydantic Version Testing"

on:
  workflow_call:
    inputs:
      working-directory:
        required: true
        type: string
        description: "From which folder this pipeline executes"
      python-version:
        required: false
        type: string
        description: "Python version to use"
        default: "3.12"
      pydantic-version:
        required: true
        type: string
        description: "Pydantic version to test."

permissions:
  contents: read

env:
  UV_FROZEN: "true"
  UV_NO_SYNC: "true"

jobs:
  build:
    defaults:
      run:
        working-directory: ${{ inputs.working-directory }}
    runs-on: ubuntu-latest
    timeout-minutes: 20
    name: "Pydantic ~=${{ inputs.pydantic-version }}"
    steps:
      - name: "📋 Checkout Code"
        uses: actions/checkout@v6

      - name: "🐍 Set up Python ${{ inputs.python-version }} + UV"
        uses: "./.github/actions/uv_setup"
        with:
          python-version: ${{ inputs.python-version }}
          cache-suffix: test-pydantic-${{ inputs.working-directory }}
          working-directory: ${{ inputs.working-directory }}

      - name: "📦 Install Test Dependencies"
        shell: bash
        run: uv sync --group test

      - name: "🔄 Install Specific Pydantic Version"
        shell: bash
        env:
          PYDANTIC_VERSION: ${{ inputs.pydantic-version }}
        run: VIRTUAL_ENV=.venv uv pip install "pydantic~=$PYDANTIC_VERSION"

      - name: "🧪 Run Core Tests"
        shell: bash
        run: |
          make test

      - name: "🧹 Verify Clean Working Directory"
        shell: bash
        run: |
          set -eu

          STATUS="$(git status)"
          echo "$STATUS"

          # grep will exit non-zero if the target message isn't found,
          # and `set -e` above will cause the step to fail.
          echo "$STATUS" | grep 'nothing to commit, working tree clean'
auto-label-by-package perms .github/workflows/auto-label-by-package.yml
Triggers
issues
Runs on
ubuntu-latest
Jobs
label-by-package
View raw YAML
name: Auto Label Issues by Package

on:
  issues:
    types: [opened, edited]

permissions:
  contents: read

jobs:
  label-by-package:
    permissions:
      issues: write
    runs-on: ubuntu-latest

    steps:
      - name: Sync package labels
        uses: actions/github-script@v8
        with:
          script: |
            const body = context.payload.issue.body || "";

            // Extract text under "### Package" (handles " (Required)" suffix and being last section)
            const match = body.match(/### Package[^\n]*\n([\s\S]*?)(?:\n###|$)/i);
            if (!match) return;

            const packageSection = match[1].trim();

            // Mapping table for package names to labels
            const mapping = {
              "langchain": "langchain",
              "langchain-openai": "openai",
              "langchain-anthropic": "anthropic",
              "langchain-classic": "langchain-classic",
              "langchain-core": "core",
              "langchain-model-profiles": "model-profiles",
              "langchain-tests": "standard-tests",
              "langchain-text-splitters": "text-splitters",
              "langchain-chroma": "chroma",
              "langchain-deepseek": "deepseek",
              "langchain-exa": "exa",
              "langchain-fireworks": "fireworks",
              "langchain-groq": "groq",
              "langchain-huggingface": "huggingface",
              "langchain-mistralai": "mistralai",
              "langchain-nomic": "nomic",
              "langchain-ollama": "ollama",
              "langchain-openrouter": "openrouter",
              "langchain-perplexity": "perplexity",
              "langchain-qdrant": "qdrant",
              "langchain-xai": "xai",
            };

            // All possible package labels we manage
            const allPackageLabels = Object.values(mapping);
            const selectedLabels = [];

            // Check if this is checkbox format (multiple selection)
            const checkboxMatches = packageSection.match(/- \[x\]\s+([^\n\r]+)/gi);
            if (checkboxMatches) {
              // Handle checkbox format
              for (const match of checkboxMatches) {
                const packageName = match.replace(/- \[x\]\s+/i, '').trim();
                const label = mapping[packageName];
                if (label && !selectedLabels.includes(label)) {
                  selectedLabels.push(label);
                }
              }
            } else {
              // Handle dropdown format (single selection)
              const label = mapping[packageSection];
              if (label) {
                selectedLabels.push(label);
              }
            }

            // Get current issue labels
            const issue = await github.rest.issues.get({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number
            });

            const currentLabels = issue.data.labels.map(label => label.name);
            const currentPackageLabels = currentLabels.filter(label => allPackageLabels.includes(label));

            // Determine labels to add and remove
            const labelsToAdd = selectedLabels.filter(label => !currentPackageLabels.includes(label));
            const labelsToRemove = currentPackageLabels.filter(label => !selectedLabels.includes(label));

            // Add new labels
            if (labelsToAdd.length > 0) {
              await github.rest.issues.addLabels({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                labels: labelsToAdd
              });
            }

            // Remove old labels
            for (const label of labelsToRemove) {
              await github.rest.issues.removeLabel({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                name: label
              });
            }
check_agents_sync perms .github/workflows/check_agents_sync.yml
Triggers
push, pull_request
Runs on
ubuntu-latest
Jobs
check-sync
Commands
  • if ! diff -q CLAUDE.md AGENTS.md > /dev/null 2>&1; then echo "❌ CLAUDE.md and AGENTS.md are out of sync!" echo "" echo "These files must contain identical content." echo "Differences:" echo "" diff --color=always CLAUDE.md AGENTS.md || true exit 1 fi echo "✅ CLAUDE.md and AGENTS.md are in sync"
View raw YAML
# Ensures CLAUDE.md and AGENTS.md stay synchronized.
#
# These files contain the same development guidelines but are named differently
# for compatibility with different AI coding assistants (Claude Code uses CLAUDE.md,
# other tools may use AGENTS.md).

name: "🔄 Check CLAUDE.md / AGENTS.md Sync"

on:
  push:
    branches: [master]
    paths:
      - "CLAUDE.md"
      - "AGENTS.md"
  pull_request:
    paths:
      - "CLAUDE.md"
      - "AGENTS.md"

permissions:
  contents: read

jobs:
  check-sync:
    name: "verify files are identical"
    runs-on: ubuntu-latest
    steps:
      - name: "📋 Checkout Code"
        uses: actions/checkout@v6

      - name: "🔍 Check CLAUDE.md and AGENTS.md are in sync"
        run: |
          if ! diff -q CLAUDE.md AGENTS.md > /dev/null 2>&1; then
            echo "❌ CLAUDE.md and AGENTS.md are out of sync!"
            echo ""
            echo "These files must contain identical content."
            echo "Differences:"
            echo ""
            diff --color=always CLAUDE.md AGENTS.md || true
            exit 1
          fi
          echo "✅ CLAUDE.md and AGENTS.md are in sync"
check_core_versions perms .github/workflows/check_core_versions.yml
Triggers
pull_request
Runs on
ubuntu-latest
Jobs
check_version_equality
Commands
  • # Check core versions CORE_PYPROJECT_VERSION=$(grep -Po '(?<=^version = ")[^"]*' libs/core/pyproject.toml) CORE_VERSION_PY_VERSION=$(grep -Po '(?<=^VERSION = ")[^"]*' libs/core/langchain_core/version.py) # Compare core versions if [ "$CORE_PYPROJECT_VERSION" != "$CORE_VERSION_PY_VERSION" ]; then echo "langchain-core versions in pyproject.toml and version.py do not match!" echo "pyproject.toml version: $CORE_PYPROJECT_VERSION" echo "version.py version: $CORE_VERSION_PY_VERSION" exit 1 else echo "Core versions match: $CORE_PYPROJECT_VERSION" fi # Check langchain_v1 versions LANGCHAIN_PYPROJECT_VERSION=$(grep -Po '(?<=^version = ")[^"]*' libs/langchain_v1/pyproject.toml) LANGCHAIN_INIT_PY_VERSION=$(grep -Po '(?<=^__version__ = ")[^"]*' libs/langchain_v1/langchain/__init__.py) # Compare langchain_v1 versions if [ "$LANGCHAIN_PYPROJECT_VERSION" != "$LANGCHAIN_INIT_PY_VERSION" ]; then echo "langchain_v1 versions in pyproject.toml and __init__.py do not match!" echo "pyproject.toml version: $LANGCHAIN_PYPROJECT_VERSION" echo "version.py version: $LANGCHAIN_INIT_PY_VERSION" exit 1 else echo "Langchain v1 versions match: $LANGCHAIN_PYPROJECT_VERSION" fi # Check langchain-anthropic versions ANTHROPIC_PYPROJECT_VERSION=$(grep -Po '(?<=^version = ")[^"]*' libs/partners/anthropic/pyproject.toml) ANTHROPIC_VERSION_PY_VERSION=$(grep -Po '(?<=^__version__ = ")[^"]*' libs/partners/anthropic/langchain_anthropic/_version.py) # Compare langchain-anthropic versions if [ "$ANTHROPIC_PYPROJECT_VERSION" != "$ANTHROPIC_VERSION_PY_VERSION" ]; then echo "langchain-anthropic versions in pyproject.toml and _version.py do not match!" echo "pyproject.toml version: $ANTHROPIC_PYPROJECT_VERSION" echo "_version.py version: $ANTHROPIC_VERSION_PY_VERSION" exit 1 else echo "Langchain-anthropic versions match: $ANTHROPIC_PYPROJECT_VERSION" fi
View raw YAML
# Ensures version numbers in pyproject.toml and version.py stay in sync.
#
# (Prevents releases with mismatched version numbers)

name: "🔍 Check Version Equality"

on:
  pull_request:
    paths:
      - "libs/core/pyproject.toml"
      - "libs/core/langchain_core/version.py"
      - "libs/partners/anthropic/pyproject.toml"
      - "libs/partners/anthropic/langchain_anthropic/_version.py"

permissions:
  contents: read

jobs:
  check_version_equality:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v6

      - name: "✅ Verify pyproject.toml & version.py Match"
        run: |
          # Check core versions
          CORE_PYPROJECT_VERSION=$(grep -Po '(?<=^version = ")[^"]*' libs/core/pyproject.toml)
          CORE_VERSION_PY_VERSION=$(grep -Po '(?<=^VERSION = ")[^"]*' libs/core/langchain_core/version.py)

          # Compare core versions
          if [ "$CORE_PYPROJECT_VERSION" != "$CORE_VERSION_PY_VERSION" ]; then
            echo "langchain-core versions in pyproject.toml and version.py do not match!"
            echo "pyproject.toml version: $CORE_PYPROJECT_VERSION"
            echo "version.py version: $CORE_VERSION_PY_VERSION"
            exit 1
          else
            echo "Core versions match: $CORE_PYPROJECT_VERSION"
          fi

          # Check langchain_v1 versions
          LANGCHAIN_PYPROJECT_VERSION=$(grep -Po '(?<=^version = ")[^"]*' libs/langchain_v1/pyproject.toml)
          LANGCHAIN_INIT_PY_VERSION=$(grep -Po '(?<=^__version__ = ")[^"]*' libs/langchain_v1/langchain/__init__.py)

          # Compare langchain_v1 versions
          if [ "$LANGCHAIN_PYPROJECT_VERSION" != "$LANGCHAIN_INIT_PY_VERSION" ]; then
            echo "langchain_v1 versions in pyproject.toml and __init__.py do not match!"
            echo "pyproject.toml version: $LANGCHAIN_PYPROJECT_VERSION"
            echo "version.py version: $LANGCHAIN_INIT_PY_VERSION"
            exit 1
          else
            echo "Langchain v1 versions match: $LANGCHAIN_PYPROJECT_VERSION"
          fi

          # Check langchain-anthropic versions
          ANTHROPIC_PYPROJECT_VERSION=$(grep -Po '(?<=^version = ")[^"]*' libs/partners/anthropic/pyproject.toml)
          ANTHROPIC_VERSION_PY_VERSION=$(grep -Po '(?<=^__version__ = ")[^"]*' libs/partners/anthropic/langchain_anthropic/_version.py)

          # Compare langchain-anthropic versions
          if [ "$ANTHROPIC_PYPROJECT_VERSION" != "$ANTHROPIC_VERSION_PY_VERSION" ]; then
            echo "langchain-anthropic versions in pyproject.toml and _version.py do not match!"
            echo "pyproject.toml version: $ANTHROPIC_PYPROJECT_VERSION"
            echo "_version.py version: $ANTHROPIC_VERSION_PY_VERSION"
            exit 1
          else
            echo "Langchain-anthropic versions match: $ANTHROPIC_PYPROJECT_VERSION"
          fi
check_diffs matrix perms .github/workflows/check_diffs.yml
Triggers
push, pull_request, merge_group
Runs on
ubuntu-latest, ubuntu-latest, ubuntu-latest
Jobs
build, lint, test, test-pydantic, compile-integration-tests, extended-tests, ci_success
Matrix
job-configs→ ${{ fromJson(needs.build.outputs.compile-integration-tests) }}, ${{ fromJson(needs.build.outputs.extended-tests) }}, ${{ fromJson(needs.build.outputs.lint) }}, ${{ fromJson(needs.build.outputs.test) }}, ${{ fromJson(needs.build.outputs.test-pydantic) }}
Actions
Ana06/get-changed-files
Commands
  • python -m pip install packaging requests python .github/scripts/check_diff.py ${{ steps.files.outputs.all }} >> $GITHUB_OUTPUT
  • echo "Running extended tests, installing dependencies with uv..." uv venv uv sync --group test VIRTUAL_ENV=.venv uv pip install -r extended_testing_deps.txt VIRTUAL_ENV=.venv make extended_tests
  • set -eu STATUS="$(git status)" echo "$STATUS" # grep will exit non-zero if the target message isn't found, # and `set -e` above will cause the step to fail. echo "$STATUS" | grep 'nothing to commit, working tree clean'
  • echo $JOBS_JSON echo $RESULTS_JSON echo "Exiting with $EXIT_CODE" exit $EXIT_CODE
View raw YAML
# Primary CI workflow.
#
# Only runs against packages that have changed files.
#
# Runs:
# - Linting (_lint.yml)
# - Unit Tests (_test.yml)
# - Pydantic compatibility tests (_test_pydantic.yml)
# - Integration test compilation checks (_compile_integration_test.yml)
# - Extended test suites that require additional dependencies
#
# Reports status to GitHub checks and PR status.

name: "🔧 CI"

on:
  push:
    branches: [master]
  pull_request:
  merge_group:

# Optimizes CI performance by canceling redundant workflow runs
# If another push to the same PR or branch happens while this workflow is still running,
# cancel the earlier run in favor of the next run.
#
# There's no point in testing an outdated version of the code. GitHub only allows
# a limited number of job runners to be active at the same time, so it's better to
# cancel pointless jobs early so that more useful jobs can run sooner.
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

env:
  UV_FROZEN: "true"
  UV_NO_SYNC: "true"

jobs:
  # This job analyzes which files changed and creates a dynamic test matrix
  # to only run tests/lints for the affected packages, improving CI efficiency
  build:
    name: "Detect Changes & Set Matrix"
    runs-on: ubuntu-latest
    if: ${{ !contains(github.event.pull_request.labels.*.name, 'ci-ignore') }}
    steps:
      - name: "📋 Checkout Code"
        uses: actions/checkout@v6
      - name: "🐍 Setup Python 3.11"
        uses: actions/setup-python@v6
        with:
          python-version: "3.11"
      - name: "📂 Get Changed Files"
        id: files
        uses: Ana06/get-changed-files@25f79e676e7ea1868813e21465014798211fad8c # v2.3.0
      - name: "🔍 Analyze Changed Files & Generate Build Matrix"
        id: set-matrix
        run: |
          python -m pip install packaging requests
          python .github/scripts/check_diff.py ${{ steps.files.outputs.all }} >> $GITHUB_OUTPUT
    outputs:
      lint: ${{ steps.set-matrix.outputs.lint }}
      test: ${{ steps.set-matrix.outputs.test }}
      extended-tests: ${{ steps.set-matrix.outputs.extended-tests }}
      compile-integration-tests: ${{ steps.set-matrix.outputs.compile-integration-tests }}
      dependencies: ${{ steps.set-matrix.outputs.dependencies }}
      test-pydantic: ${{ steps.set-matrix.outputs.test-pydantic }}
  # Run linting only on packages that have changed files
  lint:
    needs: [build]
    if: ${{ needs.build.outputs.lint != '[]' }}
    strategy:
      matrix:
        job-configs: ${{ fromJson(needs.build.outputs.lint) }}
      fail-fast: false
    uses: ./.github/workflows/_lint.yml
    with:
      working-directory: ${{ matrix.job-configs.working-directory }}
      python-version: ${{ matrix.job-configs.python-version }}
    secrets: inherit

  # Run unit tests only on packages that have changed files
  test:
    needs: [build]
    if: ${{ needs.build.outputs.test != '[]' }}
    strategy:
      matrix:
        job-configs: ${{ fromJson(needs.build.outputs.test) }}
      fail-fast: false
    uses: ./.github/workflows/_test.yml
    with:
      working-directory: ${{ matrix.job-configs.working-directory }}
      python-version: ${{ matrix.job-configs.python-version }}
    secrets: inherit

  # Test compatibility with different Pydantic versions for affected packages
  test-pydantic:
    needs: [build]
    if: ${{ needs.build.outputs.test-pydantic != '[]' }}
    strategy:
      matrix:
        job-configs: ${{ fromJson(needs.build.outputs.test-pydantic) }}
      fail-fast: false
    uses: ./.github/workflows/_test_pydantic.yml
    with:
      working-directory: ${{ matrix.job-configs.working-directory }}
      pydantic-version: ${{ matrix.job-configs.pydantic-version }}
    secrets: inherit

  # Verify integration tests compile without actually running them (faster feedback)
  compile-integration-tests:
    name: "Compile Integration Tests"
    needs: [build]
    if: ${{ needs.build.outputs.compile-integration-tests != '[]' }}
    strategy:
      matrix:
        job-configs: ${{ fromJson(needs.build.outputs.compile-integration-tests) }}
      fail-fast: false
    uses: ./.github/workflows/_compile_integration_test.yml
    with:
      working-directory: ${{ matrix.job-configs.working-directory }}
      python-version: ${{ matrix.job-configs.python-version }}
    secrets: inherit

  # Run extended test suites that require additional dependencies
  extended-tests:
    name: "Extended Tests"
    needs: [build]
    if: ${{ needs.build.outputs.extended-tests != '[]' }}
    strategy:
      matrix:
        # note different variable for extended test dirs
        job-configs: ${{ fromJson(needs.build.outputs.extended-tests) }}
      fail-fast: false
    runs-on: ubuntu-latest
    timeout-minutes: 20
    defaults:
      run:
        working-directory: ${{ matrix.job-configs.working-directory }}
    steps:
      - uses: actions/checkout@v6

      - name: "🐍 Set up Python ${{ matrix.job-configs.python-version }} + UV"
        uses: "./.github/actions/uv_setup"
        with:
          python-version: ${{ matrix.job-configs.python-version }}
          cache-suffix: extended-tests-${{ matrix.job-configs.working-directory }}
          working-directory: ${{ matrix.job-configs.working-directory }}

      - name: "📦 Install Dependencies & Run Extended Tests"
        shell: bash
        run: |
          echo "Running extended tests, installing dependencies with uv..."
          uv venv
          uv sync --group test
          VIRTUAL_ENV=.venv uv pip install -r extended_testing_deps.txt
          VIRTUAL_ENV=.venv make extended_tests

      - name: "🧹 Verify Clean Working Directory"
        shell: bash
        run: |
          set -eu

          STATUS="$(git status)"
          echo "$STATUS"

          # grep will exit non-zero if the target message isn't found,
          # and `set -e` above will cause the step to fail.
          echo "$STATUS" | grep 'nothing to commit, working tree clean'

  # Final status check - ensures all required jobs passed before allowing merge
  ci_success:
    name: "✅ CI Success"
    needs:
      [
        build,
        lint,
        test,
        compile-integration-tests,
        extended-tests,
        test-pydantic,
      ]
    if: |
      always()
    runs-on: ubuntu-latest
    env:
      JOBS_JSON: ${{ toJSON(needs) }}
      RESULTS_JSON: ${{ toJSON(needs.*.result) }}
      EXIT_CODE: ${{!contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') && '0' || '1'}}
    steps:
      - name: "🎉 All Checks Passed"
        run: |
          echo $JOBS_JSON
          echo $RESULTS_JSON
          echo "Exiting with $EXIT_CODE"
          exit $EXIT_CODE
codspeed matrix perms .github/workflows/codspeed.yml
Triggers
push, pull_request
Runs on
ubuntu-latest, ubuntu-latest
Jobs
build, benchmarks
Matrix
job-configs→ ${{ fromJson(needs.build.outputs.codspeed) }}
Actions
Ana06/get-changed-files, astral-sh/setup-uv, CodSpeedHQ/action
Commands
  • python -m pip install packaging requests python .github/scripts/check_diff.py ${{ steps.files.outputs.all }} >> $GITHUB_OUTPUT
  • uv sync --group test
View raw YAML
# CodSpeed performance benchmarks.
#
# Runs benchmarks on changed packages and uploads results to CodSpeed.
# Separated from the main CI workflow so that push-to-master baseline runs
# are never cancelled by subsequent merges (cancel-in-progress is only
# enabled for pull_request events).

name: "⚡ CodSpeed"

on:
  push:
    branches: [master]
  pull_request:

# On PRs, cancel stale runs when new commits are pushed.
# On push-to-master, never cancel — these runs populate CodSpeed baselines.
concurrency:
  group: ${{ github.workflow }}-${{ github.event_name == 'push' && github.sha || github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

permissions:
  contents: read

env:
  UV_FROZEN: "true"
  UV_NO_SYNC: "true"

jobs:
  build:
    name: "Detect Changes"
    runs-on: ubuntu-latest
    if: ${{ !contains(github.event.pull_request.labels.*.name, 'codspeed-ignore') }}
    steps:
      - name: "📋 Checkout Code"
        uses: actions/checkout@v6
      - name: "🐍 Setup Python 3.11"
        uses: actions/setup-python@v6
        with:
          python-version: "3.11"
      - name: "📂 Get Changed Files"
        id: files
        uses: Ana06/get-changed-files@25f79e676e7ea1868813e21465014798211fad8c # v2.3.0
      - name: "🔍 Analyze Changed Files"
        id: set-matrix
        run: |
          python -m pip install packaging requests
          python .github/scripts/check_diff.py ${{ steps.files.outputs.all }} >> $GITHUB_OUTPUT
    outputs:
      codspeed: ${{ steps.set-matrix.outputs.codspeed }}

  benchmarks:
    name: "⚡ CodSpeed Benchmarks"
    needs: [build]
    if: ${{ needs.build.outputs.codspeed != '[]' }}
    runs-on: ubuntu-latest
    strategy:
      matrix:
        job-configs: ${{ fromJson(needs.build.outputs.codspeed) }}
      fail-fast: false
    steps:
      - uses: actions/checkout@v6

      - name: "📦 Install UV Package Manager"
        uses: astral-sh/setup-uv@0ca8f610542aa7f4acaf39e65cf4eb3c35091883 # v7
        with:
          # Pinned to 3.13.11 to work around CodSpeed walltime segfault on 3.13.12+
          # See: https://github.com/CodSpeedHQ/pytest-codspeed/issues/106
          python-version: "3.13.11"

      - name: "📦 Install Test Dependencies"
        run: uv sync --group test
        working-directory: ${{ matrix.job-configs.working-directory }}

      - name: "⚡ Run Benchmarks: ${{ matrix.job-configs.working-directory }}"
        uses: CodSpeedHQ/action@a50965600eafa04edcd6717761f55b77e52aafbd # v4
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          ANTHROPIC_FILES_API_IMAGE_ID: ${{ secrets.ANTHROPIC_FILES_API_IMAGE_ID }}
          ANTHROPIC_FILES_API_PDF_ID: ${{ secrets.ANTHROPIC_FILES_API_PDF_ID }}
          AZURE_OPENAI_API_VERSION: ${{ secrets.AZURE_OPENAI_API_VERSION }}
          AZURE_OPENAI_API_BASE: ${{ secrets.AZURE_OPENAI_API_BASE }}
          AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }}
          AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_CHAT_DEPLOYMENT_NAME }}
          AZURE_OPENAI_LEGACY_CHAT_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_LEGACY_CHAT_DEPLOYMENT_NAME }}
          AZURE_OPENAI_LLM_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_LLM_DEPLOYMENT_NAME }}
          AZURE_OPENAI_EMBEDDINGS_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_EMBEDDINGS_DEPLOYMENT_NAME }}
          COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }}
          DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
          EXA_API_KEY: ${{ secrets.EXA_API_KEY }}
          FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
          GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
          HUGGINGFACEHUB_API_TOKEN: ${{ secrets.HUGGINGFACEHUB_API_TOKEN }}
          MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
          NOMIC_API_KEY: ${{ secrets.NOMIC_API_KEY }}
          OLLAMA_API_KEY: ${{ secrets.OLLAMA_API_KEY }}
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
          OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
          PPLX_API_KEY: ${{ secrets.PPLX_API_KEY }}
          XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
        with:
          token: ${{ secrets.CODSPEED_TOKEN }}
          run: |
            cd ${{ matrix.job-configs.working-directory }}
            if [ "${{ matrix.job-configs.working-directory }}" = "libs/core" ]; then
              uv run --no-sync pytest ./tests/benchmarks --codspeed
            else
              uv run --no-sync pytest ./tests/unit_tests/ -m benchmark --codspeed
            fi
          mode: ${{ matrix.job-configs.codspeed-mode }}
integration_tests matrix perms .github/workflows/integration_tests.yml
Triggers
workflow_dispatch, schedule
Runs on
ubuntu-latest, ubuntu-latest, ubuntu-latest
Jobs
compute-matrix, integration-tests, test-dependents
Matrix
package, package.name, package.path, package.repo, python-version, working-directory→ ${{ fromJSON(needs.compute-matrix.outputs.matrix).python-version }}, ${{ fromJSON(needs.compute-matrix.outputs.matrix).working-directory }}, ${{ fromJSON(needs.compute-matrix.outputs.python-version-min-3-11) }}, deepagents, langchain-ai/deepagents, libs/deepagents
Actions
google-github-actions/auth, aws-actions/configure-aws-credentials
Commands
  • # echo "matrix=..." where matrix is a json formatted str with keys python-version and working-directory # python-version should default to 3.10 and 3.13, but is overridden to [PYTHON_VERSION_FORCE] if set # working-directory should default to DEFAULT_LIBS, but is overridden to [WORKING_DIRECTORY_FORCE] if set python_version='["3.10", "3.13"]' python_version_min_3_11='["3.11", "3.13"]' working_directory="$DEFAULT_LIBS" if [ -n "$PYTHON_VERSION_FORCE" ]; then python_version="[\"$PYTHON_VERSION_FORCE\"]" # Bound forced version to >= 3.11 for packages requiring it if [ "$(echo "$PYTHON_VERSION_FORCE >= 3.11" | bc -l)" -eq 1 ]; then python_version_min_3_11="[\"$PYTHON_VERSION_FORCE\"]" else python_version_min_3_11='["3.11"]' fi fi if [ -n "$WORKING_DIRECTORY_FORCE" ]; then working_directory="[\"$WORKING_DIRECTORY_FORCE\"]" fi matrix="{\"python-version\": $python_version, \"working-directory\": $working_directory}" echo $matrix echo "matrix=$matrix" >> $GITHUB_OUTPUT echo "python-version-min-3-11=$python_version_min_3_11" >> $GITHUB_OUTPUT
  • rm -rf \ langchain/libs/partners/google-genai \ langchain/libs/partners/google-vertexai mv langchain-google/libs/genai langchain/libs/partners/google-genai mv langchain-google/libs/vertexai langchain/libs/partners/google-vertexai mv langchain-aws/libs/aws langchain/libs/partners/aws
  • echo "Running scheduled tests, installing dependencies with uv..." cd langchain/${{ matrix.working-directory }} uv sync --group test --group test_integration
  • cd langchain/${{ matrix.working-directory }} make integration_tests
  • rm -rf \ langchain/libs/partners/google-genai \ langchain/libs/partners/google-vertexai \ langchain/libs/partners/aws
  • set -eu STATUS="$(git status)" echo "$STATUS" # grep will exit non-zero if the target message isn't found, # and `set -e` above will cause the step to fail. echo "$STATUS" | grep 'nothing to commit, working tree clean'
  • cd ${{ matrix.package.name }}/${{ matrix.package.path }} # Install the package with test dependencies uv sync --group test # Override langchain packages with local versions uv pip install \ -e $GITHUB_WORKSPACE/langchain/libs/core \ -e $GITHUB_WORKSPACE/langchain/libs/langchain_v1
  • cd ${{ matrix.package.name }}/${{ matrix.package.path }} make test
View raw YAML
# Routine integration tests against partner libraries with live API credentials.
#
# Uses `make integration_tests` within each library being tested.
#
# Runs daily with the option to trigger manually.

name: "⏰ Integration Tests"
run-name: "Run Integration Tests - ${{ inputs.working-directory-force || 'all libs' }} (Python ${{ inputs.python-version-force || '3.10, 3.13' }})"

on:
  workflow_dispatch:
    inputs:
      working-directory-force:
        type: string
        description: "From which folder this pipeline executes - defaults to all in matrix - example value: libs/partners/anthropic"
      python-version-force:
        type: string
        description: "Python version to use - defaults to 3.10 and 3.13 in matrix - example value: 3.11"
  schedule:
    - cron: "0 13 * * *" # Runs daily at 1PM UTC (9AM EDT/6AM PDT)

permissions:
  contents: read

env:
  UV_FROZEN: "true"
  DEFAULT_LIBS: >-
    ["libs/partners/openai",
    "libs/partners/anthropic",
    "libs/partners/fireworks",
    "libs/partners/groq",
    "libs/partners/mistralai",
    "libs/partners/xai",
    "libs/partners/google-vertexai",
    "libs/partners/google-genai",
    "libs/partners/aws"]

jobs:
  # Generate dynamic test matrix based on input parameters or defaults
  # Only runs on the main repo (for scheduled runs) or when manually triggered
  compute-matrix:
    # Defend against forks running scheduled jobs, but allow manual runs from forks
    if: github.repository_owner == 'langchain-ai' || github.event_name != 'schedule'

    runs-on: ubuntu-latest
    name: "📋 Compute Test Matrix"
    outputs:
      matrix: ${{ steps.set-matrix.outputs.matrix }}
      python-version-min-3-11: ${{ steps.set-matrix.outputs.python-version-min-3-11 }}
    steps:
      - name: "🔢 Generate Python & Library Matrix"
        id: set-matrix
        env:
          DEFAULT_LIBS: ${{ env.DEFAULT_LIBS }}
          WORKING_DIRECTORY_FORCE: ${{ github.event.inputs.working-directory-force || '' }}
          PYTHON_VERSION_FORCE: ${{ github.event.inputs.python-version-force || '' }}
        run: |
          # echo "matrix=..." where matrix is a json formatted str with keys python-version and working-directory
          # python-version should default to 3.10 and 3.13, but is overridden to [PYTHON_VERSION_FORCE] if set
          # working-directory should default to DEFAULT_LIBS, but is overridden to [WORKING_DIRECTORY_FORCE] if set
          python_version='["3.10", "3.13"]'
          python_version_min_3_11='["3.11", "3.13"]'
          working_directory="$DEFAULT_LIBS"
          if [ -n "$PYTHON_VERSION_FORCE" ]; then
            python_version="[\"$PYTHON_VERSION_FORCE\"]"
            # Bound forced version to >= 3.11 for packages requiring it
            if [ "$(echo "$PYTHON_VERSION_FORCE >= 3.11" | bc -l)" -eq 1 ]; then
              python_version_min_3_11="[\"$PYTHON_VERSION_FORCE\"]"
            else
              python_version_min_3_11='["3.11"]'
            fi
          fi
          if [ -n "$WORKING_DIRECTORY_FORCE" ]; then
            working_directory="[\"$WORKING_DIRECTORY_FORCE\"]"
          fi
          matrix="{\"python-version\": $python_version, \"working-directory\": $working_directory}"
          echo $matrix
          echo "matrix=$matrix" >> $GITHUB_OUTPUT
          echo "python-version-min-3-11=$python_version_min_3_11" >> $GITHUB_OUTPUT

  # Run integration tests against partner libraries with live API credentials
  integration-tests:
    if: github.repository_owner == 'langchain-ai' || github.event_name != 'schedule'
    name: "🐍 Python ${{ matrix.python-version }}: ${{ matrix.working-directory }}"
    runs-on: ubuntu-latest
    needs: [compute-matrix]
    timeout-minutes: 30
    strategy:
      fail-fast: false
      matrix:
        python-version: ${{ fromJSON(needs.compute-matrix.outputs.matrix).python-version }}
        working-directory: ${{ fromJSON(needs.compute-matrix.outputs.matrix).working-directory }}

    steps:
      - uses: actions/checkout@v6
        with:
          path: langchain

      # These libraries exist outside of the monorepo and need to be checked out separately
      - uses: actions/checkout@v6
        with:
          repository: langchain-ai/langchain-google
          path: langchain-google
      - name: "🔐 Authenticate to Google Cloud"
        id: "auth"
        uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3
        with:
          credentials_json: "${{ secrets.GOOGLE_CREDENTIALS }}"
      - uses: actions/checkout@v6
        with:
          repository: langchain-ai/langchain-aws
          path: langchain-aws
      - name: "🔐 Configure AWS Credentials"
        uses: aws-actions/configure-aws-credentials@fb7eb401298e393da51cdcb2feb1ed0183619014 # v6
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ secrets.AWS_REGION }}
      - name: "📦 Organize External Libraries"
        run: |
          rm -rf \
            langchain/libs/partners/google-genai \
            langchain/libs/partners/google-vertexai
          mv langchain-google/libs/genai langchain/libs/partners/google-genai
          mv langchain-google/libs/vertexai langchain/libs/partners/google-vertexai
          mv langchain-aws/libs/aws langchain/libs/partners/aws

      - name: "🐍 Set up Python ${{ matrix.python-version }} + UV"
        uses: "./langchain/.github/actions/uv_setup"
        with:
          python-version: ${{ matrix.python-version }}

      - name: "📦 Install Dependencies"
        # Partner packages use [tool.uv.sources] in their pyproject.toml to resolve
        # langchain-core/langchain to local editable installs, so `uv sync` automatically
        # tests against the versions from the current branch (not published releases).

        # TODO: external google/aws don't have local resolution since they live in
        # separate repos, so they pull `core`/`langchain_v1` from PyPI. We should update
        # their dev groups to use git source dependencies pointing to the current
        # branch's latest commit SHA to fully test against local langchain changes.
        run: |
          echo "Running scheduled tests, installing dependencies with uv..."
          cd langchain/${{ matrix.working-directory }}
          uv sync --group test --group test_integration

      - name: "🚀 Run Integration Tests"
        # WARNING: All secrets below are available to every matrix job regardless of
        # which package is being tested. This is intentional for simplicity, but means
        # any test file could technically access any key. Only use for trusted code.
        env:
          LANGCHAIN_TESTS_USER_AGENT: ${{ secrets.LANGCHAIN_TESTS_USER_AGENT }}

          AI21_API_KEY: ${{ secrets.AI21_API_KEY }}
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          ANTHROPIC_FILES_API_IMAGE_ID: ${{ secrets.ANTHROPIC_FILES_API_IMAGE_ID }}
          ANTHROPIC_FILES_API_PDF_ID: ${{ secrets.ANTHROPIC_FILES_API_PDF_ID }}
          ASTRA_DB_API_ENDPOINT: ${{ secrets.ASTRA_DB_API_ENDPOINT }}
          ASTRA_DB_APPLICATION_TOKEN: ${{ secrets.ASTRA_DB_APPLICATION_TOKEN }}
          ASTRA_DB_KEYSPACE: ${{ secrets.ASTRA_DB_KEYSPACE }}
          AZURE_OPENAI_API_VERSION: ${{ secrets.AZURE_OPENAI_API_VERSION }}
          AZURE_OPENAI_API_BASE: ${{ secrets.AZURE_OPENAI_API_BASE }}
          AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }}
          AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_CHAT_DEPLOYMENT_NAME }}
          AZURE_OPENAI_LEGACY_CHAT_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_LEGACY_CHAT_DEPLOYMENT_NAME }}
          AZURE_OPENAI_LLM_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_LLM_DEPLOYMENT_NAME }}
          AZURE_OPENAI_EMBEDDINGS_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_EMBEDDINGS_DEPLOYMENT_NAME }}
          COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }}
          DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
          ES_URL: ${{ secrets.ES_URL }}
          ES_CLOUD_ID: ${{ secrets.ES_CLOUD_ID }}
          ES_API_KEY: ${{ secrets.ES_API_KEY }}
          EXA_API_KEY: ${{ secrets.EXA_API_KEY }}
          FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
          GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
          GOOGLE_SEARCH_API_KEY: ${{ secrets.GOOGLE_SEARCH_API_KEY }}
          GOOGLE_CSE_ID: ${{ secrets.GOOGLE_CSE_ID }}
          GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
          HUGGINGFACEHUB_API_TOKEN: ${{ secrets.HUGGINGFACEHUB_API_TOKEN }}
          MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
          MONGODB_ATLAS_URI: ${{ secrets.MONGODB_ATLAS_URI }}
          NOMIC_API_KEY: ${{ secrets.NOMIC_API_KEY }}
          NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }}
          OLLAMA_API_KEY: ${{ secrets.OLLAMA_API_KEY }}
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
          OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
          PPLX_API_KEY: ${{ secrets.PPLX_API_KEY }}
          TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
          UPSTAGE_API_KEY: ${{ secrets.UPSTAGE_API_KEY }}
          WATSONX_APIKEY: ${{ secrets.WATSONX_APIKEY }}
          WATSONX_PROJECT_ID: ${{ secrets.WATSONX_PROJECT_ID }}
          XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
        run: |
          cd langchain/${{ matrix.working-directory }}
          make integration_tests

      - name: "🧹 Clean up External Libraries"
        # Clean up external libraries to avoid affecting the following git status check
        run: |
          rm -rf \
            langchain/libs/partners/google-genai \
            langchain/libs/partners/google-vertexai \
            langchain/libs/partners/aws

      - name: "🧹 Verify Clean Working Directory"
        working-directory: langchain
        run: |
          set -eu

          STATUS="$(git status)"
          echo "$STATUS"

          # grep will exit non-zero if the target message isn't found,
          # and `set -e` above will cause the step to fail.
          echo "$STATUS" | grep 'nothing to commit, working tree clean'

  # Test dependent packages against local packages to catch breaking changes
  test-dependents:
    # Defend against forks running scheduled jobs, but allow manual runs from forks
    if: github.repository_owner == 'langchain-ai' || github.event_name != 'schedule'

    name: "🐍 Python ${{ matrix.python-version }}: ${{ matrix.package.path }}"
    runs-on: ubuntu-latest
    needs: [compute-matrix]
    timeout-minutes: 30
    strategy:
      fail-fast: false
      matrix:
        # deepagents requires Python >= 3.11, use bounded version from compute-matrix
        python-version: ${{ fromJSON(needs.compute-matrix.outputs.python-version-min-3-11) }}
        package:
          - name: deepagents
            repo: langchain-ai/deepagents
            path: libs/deepagents

    steps:
      - uses: actions/checkout@v6
        with:
          path: langchain

      - uses: actions/checkout@v6
        with:
          repository: ${{ matrix.package.repo }}
          path: ${{ matrix.package.name }}

      - name: "🐍 Set up Python ${{ matrix.python-version }} + UV"
        uses: "./langchain/.github/actions/uv_setup"
        with:
          python-version: ${{ matrix.python-version }}

      - name: "📦 Install ${{ matrix.package.name }} with Local"
        # Unlike partner packages (which use [tool.uv.sources] for local resolution),
        # external dependents live in separate repos and need explicit overrides to
        # test against the langchain versions from the current branch, as their
        # pyproject.toml files point to released versions.
        run: |
          cd ${{ matrix.package.name }}/${{ matrix.package.path }}

          # Install the package with test dependencies
          uv sync --group test

          # Override langchain packages with local versions
          uv pip install \
            -e $GITHUB_WORKSPACE/langchain/libs/core \
            -e $GITHUB_WORKSPACE/langchain/libs/langchain_v1

      # No API keys needed for now - deepagents `make test` only runs unit tests
      - name: "🚀 Run ${{ matrix.package.name }} Tests"
        run: |
          cd ${{ matrix.package.name }}/${{ matrix.package.path }}
          make test
pr_labeler perms .github/workflows/pr_labeler.yml
Triggers
pull_request_target
Runs on
ubuntu-latest
Jobs
label
Actions
actions/create-github-app-token
Commands
  • if [ -z "${{ steps.app-token.outputs.token }}" ]; then echo "::error::GitHub App token generation failed — cannot classify contributor" exit 1 fi
View raw YAML
# Unified PR labeler — applies size, file-based, title-based, and
# contributor classification labels in a single sequential workflow.
#
# Consolidates pr_labeler_file.yml, pr_labeler_title.yml,
# pr_size_labeler.yml, and PR-handling from tag-external-contributions.yml
# into one workflow to eliminate race conditions from concurrent label
# mutations. tag-external-issues.yml remains active for issue-only
# labeling. Backfill lives in pr_labeler_backfill.yml.
#
# Config and shared logic live in .github/scripts/pr-labeler-config.json
# and .github/scripts/pr-labeler.js — update those when adding partners.
#
# Setup Requirements:
# 1. Create a GitHub App with permissions:
#    - Repository: Pull requests (write)
#    - Repository: Issues (write)
#    - Organization: Members (read)
# 2. Install the app on your organization and this repository
# 3. Add these repository secrets:
#    - ORG_MEMBERSHIP_APP_ID: Your app's ID
#    - ORG_MEMBERSHIP_APP_PRIVATE_KEY: Your app's private key
#
# The GitHub App token is required to check private organization membership
# and to propagate label events to downstream workflows.

name: "🏷️ PR Labeler"

on:
  # Safe since we're not checking out or running the PR's code.
  # NEVER CHECK OUT UNTRUSTED CODE FROM A PR's HEAD IN A pull_request_target JOB.
  # Doing so would allow attackers to execute arbitrary code in the context of your repository.
  pull_request_target:
    types: [opened, synchronize, reopened, edited]

permissions:
  contents: read

concurrency:
  # Separate opened events so external/tier labels are never lost to cancellation
  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}-${{ github.event.action == 'opened' && 'opened' || 'update' }}
  cancel-in-progress: ${{ github.event.action != 'opened' }}

jobs:
  label:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write
      issues: write

    steps:
      # Checks out the BASE branch (safe for pull_request_target — never
      # the PR head). Needed to load .github/scripts/pr-labeler*.
      - uses: actions/checkout@v6

      - name: Generate GitHub App token
        if: github.event.action == 'opened'
        id: app-token
        uses: actions/create-github-app-token@v3
        with:
          app-id: ${{ secrets.ORG_MEMBERSHIP_APP_ID }}
          private-key: ${{ secrets.ORG_MEMBERSHIP_APP_PRIVATE_KEY }}

      - name: Verify App token
        if: github.event.action == 'opened'
        run: |
          if [ -z "${{ steps.app-token.outputs.token }}" ]; then
            echo "::error::GitHub App token generation failed — cannot classify contributor"
            exit 1
          fi

      - name: Check org membership
        if: github.event.action == 'opened'
        id: check-membership
        uses: actions/github-script@v8
        with:
          github-token: ${{ steps.app-token.outputs.token }}
          script: |
            const { owner, repo } = context.repo;
            const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo, core);

            const author = context.payload.sender.login;
            const { isExternal } = await h.checkMembership(
              author, context.payload.sender.type,
            );
            core.setOutput('is-external', isExternal ? 'true' : 'false');

      - name: Apply PR labels
        uses: actions/github-script@v8
        env:
          IS_EXTERNAL: ${{ steps.check-membership.outputs.is-external }}
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const { owner, repo } = context.repo;
            const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo, core);

            const pr = context.payload.pull_request;
            if (!pr) return;
            const prNumber = pr.number;
            const action = context.payload.action;

            const toAdd = new Set();
            const toRemove = new Set();

            const currentLabels = (await github.paginate(
              github.rest.issues.listLabelsOnIssue,
              { owner, repo, issue_number: prNumber, per_page: 100 },
            )).map(l => l.name ?? '');

            // ── Size + file labels (skip on 'edited' — files unchanged) ──
            if (action !== 'edited') {
              for (const sl of h.sizeLabels) await h.ensureLabel(sl);

              const files = await github.paginate(github.rest.pulls.listFiles, {
                owner, repo, pull_number: prNumber, per_page: 100,
              });

              const { totalChanged, sizeLabel } = h.computeSize(files);
              toAdd.add(sizeLabel);
              for (const sl of h.sizeLabels) {
                if (currentLabels.includes(sl) && sl !== sizeLabel) toRemove.add(sl);
              }
              console.log(`Size: ${totalChanged} changed lines → ${sizeLabel}`);

              for (const label of h.matchFileLabels(files)) {
                toAdd.add(label);
              }
            }

            // ── Title-based labels ──
            const { labels: titleLabels, typeLabel } = h.matchTitleLabels(pr.title || '');
            for (const label of titleLabels) toAdd.add(label);

            // Remove stale type labels only when a type was detected
            if (typeLabel) {
              for (const tl of h.allTypeLabels) {
                if (currentLabels.includes(tl) && !titleLabels.has(tl)) toRemove.add(tl);
              }
            }

            // ── Internal label (only on open, non-external contributors) ──
            // IS_EXTERNAL is empty string on non-opened events (step didn't
            // run), so this guard is only true for opened + internal.
            if (action === 'opened' && process.env.IS_EXTERNAL === 'false') {
              toAdd.add('internal');
            }

            // ── Apply changes ──
            // Ensure all labels we're about to add exist (addLabels returns
            // 422 if any label in the batch is missing, which would prevent
            // ALL labels from being applied).
            for (const name of toAdd) {
              await h.ensureLabel(name);
            }

            for (const name of toRemove) {
              if (toAdd.has(name)) continue;
              try {
                await github.rest.issues.removeLabel({
                  owner, repo, issue_number: prNumber, name,
                });
              } catch (e) {
                if (e.status !== 404) throw e;
              }
            }

            const addList = [...toAdd];
            if (addList.length > 0) {
              await github.rest.issues.addLabels({
                owner, repo, issue_number: prNumber, labels: addList,
              });
            }

            const removed = [...toRemove].filter(r => !toAdd.has(r));
            console.log(`PR #${prNumber}: +[${addList.join(', ')}] -[${removed.join(', ')}]`);

      # Apply tier label BEFORE the external label so that
      # "trusted-contributor" is already present when the "external" labeled
      # event fires and triggers require_issue_link.yml.
      - name: Apply contributor tier label
        if: github.event.action == 'opened' && steps.check-membership.outputs.is-external == 'true'
        uses: actions/github-script@v8
        with:
          github-token: ${{ steps.app-token.outputs.token }}
          script: |
            const { owner, repo } = context.repo;
            const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo, core);

            const pr = context.payload.pull_request;
            await h.applyTierLabel(pr.number, pr.user.login);

      - name: Add external label
        if: github.event.action == 'opened' && steps.check-membership.outputs.is-external == 'true'
        uses: actions/github-script@v8
        with:
          # Use App token so the "labeled" event propagates to downstream
          # workflows (e.g. require_issue_link.yml). Events created by the
          # default GITHUB_TOKEN do not trigger additional workflow runs.
          github-token: ${{ steps.app-token.outputs.token }}
          script: |
            const { owner, repo } = context.repo;
            const prNumber = context.payload.pull_request.number;

            const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo, core);

            await h.ensureLabel('external');
            await github.rest.issues.addLabels({
              owner, repo,
              issue_number: prNumber,
              labels: ['external'],
            });
            console.log(`Added 'external' label to PR #${prNumber}`);
pr_labeler_backfill perms .github/workflows/pr_labeler_backfill.yml
Triggers
workflow_dispatch
Runs on
ubuntu-latest
Jobs
backfill
Actions
actions/create-github-app-token
View raw YAML
# Backfill PR labels on all open PRs.
#
# Manual-only workflow that applies the same labels as pr_labeler.yml
# (size, file, title, contributor classification) to existing open PRs.
# Reuses shared logic from .github/scripts/pr-labeler.js.

name: "🏷️ PR Labeler Backfill"

on:
  workflow_dispatch:
    inputs:
      max_items:
        description: "Maximum number of open PRs to process"
        default: "100"
        type: string

permissions:
  contents: read

jobs:
  backfill:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write
      issues: write

    steps:
      - uses: actions/checkout@v6

      - name: Generate GitHub App token
        id: app-token
        uses: actions/create-github-app-token@v3
        with:
          app-id: ${{ secrets.ORG_MEMBERSHIP_APP_ID }}
          private-key: ${{ secrets.ORG_MEMBERSHIP_APP_PRIVATE_KEY }}

      - name: Backfill labels on open PRs
        uses: actions/github-script@v8
        with:
          github-token: ${{ steps.app-token.outputs.token }}
          script: |
            const { owner, repo } = context.repo;
            const rawMax = '${{ inputs.max_items }}';
            const maxItems = parseInt(rawMax, 10);
            if (isNaN(maxItems) || maxItems <= 0) {
              core.setFailed(`Invalid max_items: "${rawMax}" — must be a positive integer`);
              return;
            }

            const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo, core);

            for (const name of [...h.sizeLabels, ...h.tierLabels]) {
              await h.ensureLabel(name);
            }

            const contributorCache = new Map();
            const fileRules = h.buildFileRules();

            const prs = await github.paginate(github.rest.pulls.list, {
              owner, repo, state: 'open', per_page: 100,
            });

            let processed = 0;
            let failures = 0;
            for (const pr of prs) {
              if (processed >= maxItems) break;
              try {
                const author = pr.user.login;
                const info = await h.getContributorInfo(contributorCache, author, pr.user.type);
                const labels = new Set();

                labels.add(info.isExternal ? 'external' : 'internal');
                if (info.isExternal && info.mergedCount != null && info.mergedCount >= h.trustedThreshold) {
                  labels.add('trusted-contributor');
                } else if (info.isExternal && info.mergedCount === 0) {
                  labels.add('new-contributor');
                }

                // Size + file labels
                const files = await github.paginate(github.rest.pulls.listFiles, {
                  owner, repo, pull_number: pr.number, per_page: 100,
                });
                const { sizeLabel } = h.computeSize(files);
                labels.add(sizeLabel);

                for (const label of h.matchFileLabels(files, fileRules)) {
                  labels.add(label);
                }

                // Title labels
                const { labels: titleLabels } = h.matchTitleLabels(pr.title ?? '');
                for (const tl of titleLabels) labels.add(tl);

                // Ensure all labels exist before batch add
                for (const name of labels) {
                  await h.ensureLabel(name);
                }

                // Remove stale managed labels
                const currentLabels = (await github.paginate(
                  github.rest.issues.listLabelsOnIssue,
                  { owner, repo, issue_number: pr.number, per_page: 100 },
                )).map(l => l.name ?? '');

                const managed = [...h.sizeLabels, ...h.tierLabels, ...h.allTypeLabels];
                for (const name of currentLabels) {
                  if (managed.includes(name) && !labels.has(name)) {
                    try {
                      await github.rest.issues.removeLabel({
                        owner, repo, issue_number: pr.number, name,
                      });
                    } catch (e) {
                      if (e.status !== 404) throw e;
                    }
                  }
                }

                await github.rest.issues.addLabels({
                  owner, repo, issue_number: pr.number, labels: [...labels],
                });
                console.log(`PR #${pr.number} (${author}): ${[...labels].join(', ')}`);
                processed++;
              } catch (e) {
                failures++;
                core.warning(`Failed to process PR #${pr.number}: ${e.message}`);
              }
            }

            console.log(`\nBackfill complete. Processed ${processed} PRs, ${failures} failures. ${contributorCache.size} unique authors.`);
pr_lint perms .github/workflows/pr_lint.yml
Triggers
pull_request
Runs on
ubuntu-latest
Jobs
lint-pr-title
Actions
amannn/action-semantic-pull-request
Commands
  • if [[ "$PR_TITLE" =~ ^[a-z]+\(\)[!]?: ]]; then echo "::error::PR title has empty scope parentheses: '$PR_TITLE'" echo "Either remove the parentheses or provide a scope (e.g., 'fix(core): ...')." exit 1 fi
View raw YAML
# PR title linting.
#
# FORMAT (Conventional Commits 1.0.0):
#
#   <type>[optional scope]: <description>
#   [optional body]
#   [optional footer(s)]
#
# Examples:
#     feat(core): add multi‐tenant support
#     fix(langchain): resolve error
#     docs: update API usage examples
#     docs(openai): update API usage examples
#
# Allowed Types:
#   * feat       — a new feature (MINOR)
#   * fix        — a bug fix (PATCH)
#   * docs       — documentation only changes
#   * style      — formatting, linting, etc.; no code change or typing refactors
#   * refactor   — code change that neither fixes a bug nor adds a feature
#   * perf       — code change that improves performance
#   * test       — adding tests or correcting existing
#   * build      — changes that affect the build system/external dependencies
#   * ci         — continuous integration/configuration changes
#   * chore      — other changes that don't modify source or test files
#   * revert     — reverts a previous commit
#   * release    — prepare a new release
#   * hotfix     — urgent fix
#
# Allowed Scope(s) (optional):
#   core, langchain, langchain-classic, model-profiles,
#   standard-tests, text-splitters, docs, anthropic, chroma, deepseek, exa,
#   fireworks, groq, huggingface, mistralai, nomic, ollama, openai,
#   perplexity, qdrant, xai, infra, deps, partners
#
# Multiple scopes can be used by separating them with a comma. For example:
#
#   feat(core,langchain): add multi‐tenant support to core and langchain
#
# Note: PRs touching the langchain package should use the 'langchain' scope. It is not
#   acceptable to omit the scope for changes to the langchain package, despite it being
#   the main package & name of the repo.
#
# Rules:
#   1. The 'Type' must start with a lowercase letter.
#   2. Breaking changes: append "!" after type/scope (e.g., feat!: drop x support)
#   3. When releasing (updating the pyproject.toml and uv.lock), the commit message
#      should be: `release(scope): x.y.z` (e.g., `release(core): 1.2.0` with no
#      body, footer, or preceeding/proceeding text).
#
# Enforces Conventional Commits format for pull request titles to maintain a clear and
# machine-readable change history.

name: "🏷️ PR Title Lint"

permissions:
  pull-requests: read

on:
  pull_request:
    types: [opened, edited, synchronize]

jobs:
  # Validates that PR title follows Conventional Commits 1.0.0 specification
  lint-pr-title:
    name: "validate format"
    runs-on: ubuntu-latest
    steps:
      - name: "🚫 Reject empty scope"
        env:
          PR_TITLE: ${{ github.event.pull_request.title }}
        run: |
          if [[ "$PR_TITLE" =~ ^[a-z]+\(\)[!]?: ]]; then
            echo "::error::PR title has empty scope parentheses: '$PR_TITLE'"
            echo "Either remove the parentheses or provide a scope (e.g., 'fix(core): ...')."
            exit 1
          fi
      - name: "✅ Validate Conventional Commits Format"
        uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          types: |
            feat
            fix
            docs
            style
            refactor
            perf
            test
            build
            ci
            chore
            revert
            release
            hotfix
          scopes: |
            core
            langchain
            langchain-classic
            model-profiles
            standard-tests
            text-splitters
            docs
            anthropic
            chroma
            deepseek
            exa
            fireworks
            groq
            huggingface
            mistralai
            nomic
            ollama
            openai
            openrouter
            perplexity
            qdrant
            xai
            infra
            deps
            partners
          requireScope: false
          disallowScopes: |
            release
            [A-Z]+
          ignoreLabels: |
            ignore-lint-pr-title
refresh_model_profiles perms .github/workflows/refresh_model_profiles.yml
Triggers
schedule, workflow_dispatch
Runs on
Jobs
refresh-profiles
View raw YAML
# Refreshes model profile data for all in-monorepo partner integrations by
# pulling the latest metadata from models.dev via the `langchain-profiles` CLI.
#
# Creates a pull request with any changes. Runs daily and can be triggered
# manually from the Actions UI. Uses a fixed branch so each run supersedes
# any stale PR from a previous run.

name: "🔄 Refresh Model Profiles"

on:
  schedule:
    - cron: "0 8 * * *" # daily at 08:00 UTC
  workflow_dispatch:

permissions:
  contents: write
  pull-requests: write

jobs:
  refresh-profiles:
    uses: ./.github/workflows/_refresh_model_profiles.yml
    with:
      providers: >-
        [
          {"provider":"anthropic",    "data_dir":"libs/partners/anthropic/langchain_anthropic/data"},
          {"provider":"deepseek",     "data_dir":"libs/partners/deepseek/langchain_deepseek/data"},
          {"provider":"fireworks-ai", "data_dir":"libs/partners/fireworks/langchain_fireworks/data"},
          {"provider":"groq",         "data_dir":"libs/partners/groq/langchain_groq/data"},
          {"provider":"huggingface",  "data_dir":"libs/partners/huggingface/langchain_huggingface/data"},
          {"provider":"mistral",      "data_dir":"libs/partners/mistralai/langchain_mistralai/data"},
          {"provider":"openai",       "data_dir":"libs/partners/openai/langchain_openai/data"},
          {"provider":"openrouter",   "data_dir":"libs/partners/openrouter/langchain_openrouter/data"},
          {"provider":"perplexity",   "data_dir":"libs/partners/perplexity/langchain_perplexity/data"},
          {"provider":"xai",          "data_dir":"libs/partners/xai/langchain_xai/data"}
        ]
      cli-path: libs/model-profiles
      add-paths: libs/partners/**/data/_profiles.py
      pr-body: |
        Automated refresh of model profile data for all in-monorepo partner
        integrations via `langchain-profiles refresh`.

        🤖 Generated by the `refresh_model_profiles` workflow.
    secrets:
      MODEL_PROFILE_BOT_APP_ID: ${{ secrets.MODEL_PROFILE_BOT_APP_ID }}
      MODEL_PROFILE_BOT_PRIVATE_KEY: ${{ secrets.MODEL_PROFILE_BOT_PRIVATE_KEY }}
require_issue_link perms .github/workflows/require_issue_link.yml
Triggers
pull_request_target
Runs on
ubuntu-latest
Jobs
check-issue-link
View raw YAML
# Require external PRs to reference an approved issue (e.g. Fixes #NNN) and
# the PR author to be assigned to that issue. On failure the PR is
# labeled "missing-issue-link", commented on, and closed.
#
# Maintainer override: an org member can reopen the PR or remove
# "missing-issue-link" — both add "bypass-issue-check" and reopen.
#
# Dependency: pr_labeler.yml must apply the "external" label first. This
# workflow does NOT trigger on "opened" (new PRs have no labels yet, so the
# gate would always skip).

name: Require Issue Link

on:
  pull_request_target:
    # NEVER CHECK OUT UNTRUSTED CODE FROM A PR's HEAD IN A pull_request_target JOB.
    # Doing so would allow attackers to execute arbitrary code in the context of your repository.
    types: [edited, reopened, labeled, unlabeled]

# ──────────────────────────────────────────────────────────────────────────────
# Enforcement gate: set to 'true' to activate the issue link requirement.
# When 'false', the workflow still runs the check logic (useful for dry-run
# visibility) but will NOT label, comment, close, or fail PRs.
# ──────────────────────────────────────────────────────────────────────────────
env:
  ENFORCE_ISSUE_LINK: "true"

permissions:
  contents: read

jobs:
  check-issue-link:
    # Run when the "external" label is added, on edit/reopen if already labeled,
    # or when "missing-issue-link" is removed (triggers maintainer override check).
    # Skip entirely when the PR already carries "trusted-contributor" or
    # "bypass-issue-check".
    if: >-
      !contains(github.event.pull_request.labels.*.name, 'trusted-contributor') &&
      !contains(github.event.pull_request.labels.*.name, 'bypass-issue-check') &&
      (
        (github.event.action == 'labeled' && github.event.label.name == 'external') ||
        (github.event.action == 'unlabeled' && github.event.label.name == 'missing-issue-link' && contains(github.event.pull_request.labels.*.name, 'external')) ||
        (github.event.action != 'labeled' && github.event.action != 'unlabeled' && contains(github.event.pull_request.labels.*.name, 'external'))
      )
    runs-on: ubuntu-latest
    permissions:
      actions: write
      pull-requests: write

    steps:
      - name: Check for issue link and assignee
        id: check-link
        uses: actions/github-script@v8
        with:
          script: |
            const { owner, repo } = context.repo;
            const prNumber = context.payload.pull_request.number;
            const action = context.payload.action;

            // ── Helper: ensure a label exists, then add it to the PR ────────
            async function ensureAndAddLabel(labelName, color) {
              try {
                await github.rest.issues.getLabel({ owner, repo, name: labelName });
              } catch (e) {
                if (e.status !== 404) throw e;
                try {
                  await github.rest.issues.createLabel({ owner, repo, name: labelName, color });
                } catch (createErr) {
                  // 422 = label was created by a concurrent run between our
                  // GET and POST — safe to ignore.
                  if (createErr.status !== 422) throw createErr;
                }
              }
              await github.rest.issues.addLabels({
                owner, repo, issue_number: prNumber, labels: [labelName],
              });
            }

            // ── Helper: check if the user who triggered this event (reopened
            // the PR / removed the label) has write+ access on the repo ───
            // Uses the repo collaborator permission endpoint instead of the
            // org membership endpoint. The org endpoint requires the caller
            // to be an org member, which GITHUB_TOKEN (an app installation
            // token) never is — so it always returns 403.
            async function senderIsOrgMember() {
              const sender = context.payload.sender?.login;
              if (!sender) {
                throw new Error('Event has no sender — cannot check permissions');
              }
              try {
                const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
                  owner, repo, username: sender,
                });
                const perm = data.permission;
                if (['admin', 'maintain', 'write'].includes(perm)) {
                  console.log(`${sender} has ${perm} permission — treating as maintainer`);
                  return { isMember: true, login: sender };
                }
                console.log(`${sender} has ${perm} permission — not a maintainer`);
                return { isMember: false, login: sender };
              } catch (e) {
                if (e.status === 404) {
                  console.log(`Cannot check permissions for ${sender} — treating as non-maintainer`);
                  return { isMember: false, login: sender };
                }
                const status = e.status ?? 'unknown';
                throw new Error(
                  `Permission check failed for ${sender} (HTTP ${status}): ${e.message}`,
                );
              }
            }

            // ── Helper: apply maintainer bypass (shared by both override paths) ──
            async function applyMaintainerBypass(reason) {
              console.log(reason);

              // Remove missing-issue-link if present
              try {
                await github.rest.issues.removeLabel({
                  owner, repo, issue_number: prNumber, name: 'missing-issue-link',
                });
              } catch (e) {
                if (e.status !== 404) throw e;
              }

              // Reopen before adding bypass label — a failed reopen is more
              // actionable than a closed PR with a bypass label stuck on it.
              if (context.payload.pull_request.state === 'closed') {
                try {
                  await github.rest.pulls.update({
                    owner, repo, pull_number: prNumber, state: 'open',
                  });
                  console.log(`Reopened PR #${prNumber}`);
                } catch (e) {
                  // 422 if head branch deleted; 403 if permissions insufficient.
                  // Bypass labels still apply — maintainer can reopen manually.
                  core.warning(
                    `Could not reopen PR #${prNumber} (HTTP ${e.status ?? 'unknown'}): ${e.message}. ` +
                    `Bypass labels were applied — a maintainer may need to reopen manually.`,
                  );
                }
              }

              // Add bypass-issue-check so future triggers skip enforcement
              await ensureAndAddLabel('bypass-issue-check', '0e8a16');

              core.setOutput('has-link', 'true');
              core.setOutput('is-assigned', 'true');
            }

            // ── Maintainer override: removed "missing-issue-link" label ─────
            if (action === 'unlabeled') {
              const { isMember, login } = await senderIsOrgMember();
              if (isMember) {
                await applyMaintainerBypass(
                  `Maintainer ${login} removed missing-issue-link from PR #${prNumber} — bypassing enforcement`,
                );
                return;
              }
              // Non-member removed the label — re-add it defensively and
              // set failure outputs so downstream steps (comment, close) fire.
              // NOTE: addLabels fires a "labeled" event, but the job-level gate
              // only matches labeled events for "external", so no re-trigger.
              console.log(`Non-member ${login} removed missing-issue-link — re-adding`);
              try {
                await ensureAndAddLabel('missing-issue-link', 'b76e79');
              } catch (e) {
                core.warning(
                  `Failed to re-add missing-issue-link (HTTP ${e.status ?? 'unknown'}): ${e.message}. ` +
                  `Downstream step will retry.`,
                );
              }
              core.setOutput('has-link', 'false');
              core.setOutput('is-assigned', 'false');
              return;
            }

            // ── Maintainer override: reopened PR with "missing-issue-link" ──
            const prLabels = context.payload.pull_request.labels.map(l => l.name);
            if (action === 'reopened' && prLabels.includes('missing-issue-link')) {
              const { isMember, login } = await senderIsOrgMember();
              if (isMember) {
                await applyMaintainerBypass(
                  `Maintainer ${login} reopened PR #${prNumber} — bypassing enforcement`,
                );
                return;
              }
              console.log(`Non-member ${login} reopened PR — proceeding with check`);
            }

            // ── Fetch live labels (race guard) ──────────────────────────────
            const { data: liveLabels } = await github.rest.issues.listLabelsOnIssue({
              owner, repo, issue_number: prNumber,
            });
            const liveNames = liveLabels.map(l => l.name);
            if (liveNames.includes('trusted-contributor') || liveNames.includes('bypass-issue-check')) {
              console.log('PR has trusted-contributor or bypass-issue-check label — bypassing');
              core.setOutput('has-link', 'true');
              core.setOutput('is-assigned', 'true');
              return;
            }

            const body = context.payload.pull_request.body || '';
            const pattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*#(\d+)/gi;
            const matches = [...body.matchAll(pattern)];

            if (matches.length === 0) {
              console.log('No issue link found in PR body');
              core.setOutput('has-link', 'false');
              core.setOutput('is-assigned', 'false');
              return;
            }

            const issues = matches.map(m => `#${m[1]}`).join(', ');
            console.log(`Found issue link(s): ${issues}`);
            core.setOutput('has-link', 'true');

            // Check whether the PR author is assigned to at least one linked issue
            const prAuthor = context.payload.pull_request.user.login;
            const MAX_ISSUES = 5;
            const allIssueNumbers = [...new Set(matches.map(m => parseInt(m[1], 10)))];
            const issueNumbers = allIssueNumbers.slice(0, MAX_ISSUES);
            if (allIssueNumbers.length > MAX_ISSUES) {
              core.warning(
                `PR references ${allIssueNumbers.length} issues — only checking the first ${MAX_ISSUES}`,
              );
            }

            let assignedToAny = false;
            for (const num of issueNumbers) {
              try {
                const { data: issue } = await github.rest.issues.get({
                  owner, repo, issue_number: num,
                });
                const assignees = issue.assignees.map(a => a.login.toLowerCase());
                if (assignees.includes(prAuthor.toLowerCase())) {
                  console.log(`PR author "${prAuthor}" is assigned to #${num}`);
                  assignedToAny = true;
                  break;
                } else {
                  console.log(`PR author "${prAuthor}" is NOT assigned to #${num} (assignees: ${assignees.join(', ') || 'none'})`);
                }
              } catch (error) {
                if (error.status === 404) {
                  console.log(`Issue #${num} not found — skipping`);
                } else {
                  // Non-404 errors (rate limit, server error) must not be
                  // silently skipped — they could cause false enforcement
                  // (closing a legitimate PR whose assignment can't be verified).
                  throw new Error(
                    `Cannot verify assignee for issue #${num} (${error.status}): ${error.message}`,
                  );
                }
              }
            }

            core.setOutput('is-assigned', assignedToAny ? 'true' : 'false');

      - name: Add missing-issue-link label
        if: >-
          env.ENFORCE_ISSUE_LINK == 'true' &&
          (steps.check-link.outputs.has-link != 'true' || steps.check-link.outputs.is-assigned != 'true')
        uses: actions/github-script@v8
        with:
          script: |
            const { owner, repo } = context.repo;
            const prNumber = context.payload.pull_request.number;
            const labelName = 'missing-issue-link';

            // Ensure the label exists (no checkout/shared helper available)
            try {
              await github.rest.issues.getLabel({ owner, repo, name: labelName });
            } catch (e) {
              if (e.status !== 404) throw e;
              try {
                await github.rest.issues.createLabel({
                  owner, repo, name: labelName, color: 'b76e79',
                });
              } catch (createErr) {
                if (createErr.status !== 422) throw createErr;
              }
            }

            await github.rest.issues.addLabels({
              owner, repo, issue_number: prNumber, labels: [labelName],
            });

      - name: Remove missing-issue-link label and reopen PR
        if: >-
          env.ENFORCE_ISSUE_LINK == 'true' &&
          steps.check-link.outputs.has-link == 'true' && steps.check-link.outputs.is-assigned == 'true'
        uses: actions/github-script@v8
        with:
          script: |
            const { owner, repo } = context.repo;
            const prNumber = context.payload.pull_request.number;
            try {
              await github.rest.issues.removeLabel({
                owner, repo, issue_number: prNumber, name: 'missing-issue-link',
              });
            } catch (error) {
              if (error.status !== 404) throw error;
            }

            // Reopen if this workflow previously closed the PR. We check the
            // event payload labels (not live labels) because we already removed
            // missing-issue-link above; the payload still reflects pre-step state.
            const labels = context.payload.pull_request.labels.map(l => l.name);
            if (context.payload.pull_request.state === 'closed' && labels.includes('missing-issue-link')) {
              await github.rest.pulls.update({
                owner,
                repo,
                pull_number: prNumber,
                state: 'open',
              });
              console.log(`Reopened PR #${prNumber}`);
            }

      - name: Post comment, close PR, and fail
        if: >-
          env.ENFORCE_ISSUE_LINK == 'true' &&
          (steps.check-link.outputs.has-link != 'true' || steps.check-link.outputs.is-assigned != 'true')
        uses: actions/github-script@v8
        with:
          script: |
            const { owner, repo } = context.repo;
            const prNumber = context.payload.pull_request.number;
            const hasLink = '${{ steps.check-link.outputs.has-link }}' === 'true';
            const isAssigned = '${{ steps.check-link.outputs.is-assigned }}' === 'true';
            const marker = '<!-- require-issue-link -->';

            let lines;
            if (!hasLink) {
              lines = [
                marker,
                '**This PR has been automatically closed** because it does not link to an approved issue.',
                '',
                'All external contributions must reference an approved issue or discussion. Please:',
                '1. Find or [open an issue](https://github.com/' + owner + '/' + repo + '/issues/new/choose) describing the change',
                '2. Wait for a maintainer to approve and assign you',
                '3. Add `Fixes #<issue_number>`, `Closes #<issue_number>`, or `Resolves #<issue_number>` to your PR description and the PR will be reopened automatically',
                '',
                '*Maintainers: reopen this PR or remove the `missing-issue-link` label to bypass this check.*',
              ];
            } else {
              lines = [
                marker,
                '**This PR has been automatically closed** because you are not assigned to the linked issue.',
                '',
                'External contributors must be assigned to an issue before opening a PR for it. Please:',
                '1. Comment on the linked issue to request assignment from a maintainer',
                '2. Once assigned, edit your PR description and the PR will be reopened automatically',
                '',
                '*Maintainers: reopen this PR or remove the `missing-issue-link` label to bypass this check.*',
              ];
            }

            const body = lines.join('\n');

            // Deduplicate: check for existing comment with the marker
            const comments = await github.paginate(
              github.rest.issues.listComments,
              { owner, repo, issue_number: prNumber, per_page: 100 },
            );
            const existing = comments.find(c => c.body && c.body.includes(marker));

            if (!existing) {
              await github.rest.issues.createComment({
                owner,
                repo,
                issue_number: prNumber,
                body,
              });
              console.log('Posted requirement comment');
            } else if (existing.body !== body) {
              await github.rest.issues.updateComment({
                owner,
                repo,
                comment_id: existing.id,
                body,
              });
              console.log('Updated existing comment with new message');
            } else {
              console.log('Comment already exists — skipping');
            }

            // Close the PR
            if (context.payload.pull_request.state === 'open') {
              await github.rest.pulls.update({
                owner,
                repo,
                pull_number: prNumber,
                state: 'closed',
              });
              console.log(`Closed PR #${prNumber}`);
            }

            // Cancel all other in-progress and queued workflow runs for this PR
            const headSha = context.payload.pull_request.head.sha;
            for (const status of ['in_progress', 'queued']) {
              const runs = await github.paginate(
                github.rest.actions.listWorkflowRunsForRepo,
                { owner, repo, head_sha: headSha, status, per_page: 100 },
              );
              for (const run of runs) {
                if (run.id === context.runId) continue;
                try {
                  await github.rest.actions.cancelWorkflowRun({
                    owner, repo, run_id: run.id,
                  });
                  console.log(`Cancelled ${status} run ${run.id} (${run.name})`);
                } catch (err) {
                  console.log(`Could not cancel run ${run.id}: ${err.message}`);
                }
              }
            }

            const reason = !hasLink
              ? 'PR must reference an issue using auto-close keywords (e.g., "Fixes #123").'
              : 'PR author must be assigned to the linked issue.';
            core.setFailed(reason);
tag-external-issues perms .github/workflows/tag-external-issues.yml
Triggers
issues, workflow_dispatch
Runs on
ubuntu-latest, ubuntu-latest
Jobs
tag-external, backfill
Actions
actions/create-github-app-token, actions/create-github-app-token
View raw YAML
# Automatically tag issues as "external" or "internal" based on whether
# the author is a member of the langchain-ai GitHub organization, and
# apply contributor tier labels to external contributors based on their
# merged PR history.
#
# NOTE: PR labeling (including external/internal, tier, size, file, and
# title labels) is handled by pr_labeler.yml. This workflow handles
# issues only.
#
# Config (trustedThreshold, labelColor) is read from
# .github/scripts/pr-labeler-config.json to stay in sync with
# pr_labeler.yml.
#
# Setup Requirements:
# 1. Create a GitHub App with permissions:
#    - Repository: Issues (write)
#    - Organization: Members (read)
# 2. Install the app on your organization and this repository
# 3. Add these repository secrets:
#    - ORG_MEMBERSHIP_APP_ID: Your app's ID
#    - ORG_MEMBERSHIP_APP_PRIVATE_KEY: Your app's private key
#
# The GitHub App token is required to check private organization membership.
# Without it, the workflow will fail.

name: Tag External Issues

on:
  issues:
    types: [opened]
  workflow_dispatch:
    inputs:
      max_items:
        description: "Maximum number of open issues to process"
        default: "100"
        type: string

permissions:
  contents: read

concurrency:
  group: ${{ github.workflow }}-${{ github.event.issue.number || github.run_id }}
  cancel-in-progress: true

jobs:
  tag-external:
    if: github.event_name != 'workflow_dispatch'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      issues: write

    steps:
      - uses: actions/checkout@v6

      - name: Generate GitHub App token
        id: app-token
        uses: actions/create-github-app-token@v3
        with:
          app-id: ${{ secrets.ORG_MEMBERSHIP_APP_ID }}
          private-key: ${{ secrets.ORG_MEMBERSHIP_APP_PRIVATE_KEY }}

      - name: Check if contributor is external
        if: steps.app-token.outcome == 'success'
        id: check-membership
        uses: actions/github-script@v8
        with:
          github-token: ${{ steps.app-token.outputs.token }}
          script: |
            const { owner, repo } = context.repo;
            const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo, core);

            const author = context.payload.sender.login;
            const { isExternal } = await h.checkMembership(
              author, context.payload.sender.type,
            );
            core.setOutput('is-external', isExternal ? 'true' : 'false');

      - name: Apply contributor tier label
        if: steps.check-membership.outputs.is-external == 'true'
        uses: actions/github-script@v8
        with:
          # GITHUB_TOKEN is fine here — no downstream workflow chains
          # off tier labels on issues (unlike PRs where App token is
          # needed for require_issue_link.yml).
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const { owner, repo } = context.repo;
            const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo, core);

            const issue = context.payload.issue;
            // new-contributor is only meaningful on PRs, not issues
            await h.applyTierLabel(issue.number, issue.user.login, { skipNewContributor: true });

      - name: Add external/internal label
        if: steps.check-membership.outputs.is-external != ''
        uses: actions/github-script@v8
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const { owner, repo } = context.repo;
            const issue_number = context.payload.issue.number;

            const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo, core);

            const label = '${{ steps.check-membership.outputs.is-external }}' === 'true'
              ? 'external' : 'internal';
            await h.ensureLabel(label);
            await github.rest.issues.addLabels({
              owner, repo, issue_number, labels: [label],
            });
            console.log(`Added '${label}' label to issue #${issue_number}`);

  backfill:
    if: github.event_name == 'workflow_dispatch'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      issues: write

    steps:
      - uses: actions/checkout@v6

      - name: Generate GitHub App token
        id: app-token
        uses: actions/create-github-app-token@v3
        with:
          app-id: ${{ secrets.ORG_MEMBERSHIP_APP_ID }}
          private-key: ${{ secrets.ORG_MEMBERSHIP_APP_PRIVATE_KEY }}

      - name: Backfill labels on open issues
        uses: actions/github-script@v8
        with:
          github-token: ${{ steps.app-token.outputs.token }}
          script: |
            const { owner, repo } = context.repo;
            const rawMax = '${{ inputs.max_items }}';
            const maxItems = parseInt(rawMax, 10);
            if (isNaN(maxItems) || maxItems <= 0) {
              core.setFailed(`Invalid max_items: "${rawMax}" — must be a positive integer`);
              return;
            }

            const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo, core);

            const tierLabels = ['trusted-contributor'];
            for (const name of tierLabels) {
              await h.ensureLabel(name);
            }

            const contributorCache = new Map();

            const issues = await github.paginate(github.rest.issues.listForRepo, {
              owner, repo, state: 'open', per_page: 100,
            });

            let processed = 0;
            let failures = 0;
            for (const issue of issues) {
              if (processed >= maxItems) break;
              if (issue.pull_request) continue;

              try {
                const author = issue.user.login;
                const info = await h.getContributorInfo(contributorCache, author, issue.user.type);

                const labels = [info.isExternal ? 'external' : 'internal'];
                if (info.isExternal && info.mergedCount != null && info.mergedCount >= h.trustedThreshold) {
                  labels.push('trusted-contributor');
                }

                // Ensure all labels exist before batch add
                for (const name of labels) {
                  await h.ensureLabel(name);
                }

                // Remove stale tier labels
                const currentLabels = (await github.paginate(
                  github.rest.issues.listLabelsOnIssue,
                  { owner, repo, issue_number: issue.number, per_page: 100 },
                )).map(l => l.name ?? '');
                for (const name of currentLabels) {
                  if (tierLabels.includes(name) && !labels.includes(name)) {
                    try {
                      await github.rest.issues.removeLabel({
                        owner, repo, issue_number: issue.number, name,
                      });
                    } catch (e) {
                      if (e.status !== 404) throw e;
                    }
                  }
                }

                await github.rest.issues.addLabels({
                  owner, repo, issue_number: issue.number, labels,
                });
                console.log(`Issue #${issue.number} (${author}): ${labels.join(', ')}`);
                processed++;
              } catch (e) {
                failures++;
                core.warning(`Failed to process issue #${issue.number}: ${e.message}`);
              }
            }

            console.log(`\nBackfill complete. Processed ${processed} issues, ${failures} failures. ${contributorCache.size} unique authors.`);
v03_api_doc_build perms .github/workflows/v03_api_doc_build.yml
Triggers
workflow_dispatch
Runs on
ubuntu-latest
Jobs
build
Actions
mikefarah/yq, EndBug/add-and-commit
Commands
  • # Get unique repositories REPOS=$(echo "$REPOS_UNSORTED" | sort -u) # Checkout each unique repository for repo in $REPOS; do # Validate repository format (allow any org with proper format) if [[ ! "$repo" =~ ^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$ ]]; then echo "Error: Invalid repository format: $repo" exit 1 fi REPO_NAME=$(echo $repo | cut -d'/' -f2) # Additional validation for repo name if [[ ! "$REPO_NAME" =~ ^[a-zA-Z0-9_.-]+$ ]]; then echo "Error: Invalid repository name: $REPO_NAME" exit 1 fi echo "Checking out $repo to $REPO_NAME" # Special handling for langchain-tavily: checkout by commit hash if [[ "$REPO_NAME" == "langchain-tavily" ]]; then git clone https://github.com/$repo.git $REPO_NAME cd $REPO_NAME git checkout f3515654724a9e87bdfe2c2f509d6cdde646e563 cd .. else git clone --depth 1 --branch v0.3 https://github.com/$repo.git $REPO_NAME fi done
  • python -m pip install -U uv python -m uv pip install --upgrade --no-cache-dir pip setuptools pyyaml
  • python langchain/.github/scripts/prep_api_docs_build.py
  • rm -rf langchain-api-docs-html/api_reference_build/html
  • # Install all partner packages in editable mode with overrides python -m uv pip install $(ls ./libs/partners | grep -v azure-ai | xargs -I {} echo "./libs/partners/{}") --overrides ./docs/vercel_overrides.txt --prerelease=allow # Install langchain-azure-ai with tools extra python -m uv pip install "./libs/partners/azure-ai[tools]" --overrides ./docs/vercel_overrides.txt --prerelease=allow # Install core langchain and other main packages python -m uv pip install libs/core libs/langchain libs/text-splitters libs/community libs/experimental libs/standard-tests # Install Sphinx and related packages for building docs python -m uv pip install -r docs/api_reference/requirements.txt
  • git config --local user.email "actions@github.com" git config --local user.name "Github Actions"
  • # Generate the API reference RST files python docs/api_reference/create_api_rst.py # Build the HTML documentation using Sphinx # -T: show full traceback on exception # -E: don't use cached environment (force rebuild, ignore cached doctrees) # -b html: build HTML docs (vs PDS, etc.) # -d: path for the cached environment (parsed document trees / doctrees) # - Separate from output dir for faster incremental builds # -c: path to conf.py # -j auto: parallel build using all available CPU cores python -m sphinx -T -E -b html -d ../langchain-api-docs-html/_build/doctrees -c docs/api_reference docs/api_reference ../langchain-api-docs-html/api_reference_build/html -j auto # Post-process the generated HTML python docs/api_reference/scripts/custom_formatter.py ../langchain-api-docs-html/api_reference_build/html # Default index page is blank so we copy in the actual home page. cp ../langchain-api-docs-html/api_reference_build/html/{reference,index}.html # Removes Sphinx's intermediate build artifacts after the build is complete. rm -rf ../langchain-api-docs-html/_build/
View raw YAML
# Build the API reference documentation for v0.3 branch.
#
# Manual trigger only.
#
# Built HTML pushed to langchain-ai/langchain-api-docs-html.
#
# Looks for langchain-ai org repos in packages.yml and checks them out.
# Calls prep_api_docs_build.py.

name: "📚 API Docs (v0.3)"
run-name: "Build & Deploy API Reference (v0.3)"

on:
  workflow_dispatch:

permissions:
  contents: read

env:
  PYTHON_VERSION: "3.11"

jobs:
  build:
    if: github.repository == 'langchain-ai/langchain' || github.event_name != 'schedule'
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v6
        with:
          ref: v0.3
          path: langchain

      - uses: actions/checkout@v6
        with:
          repository: langchain-ai/langchain-api-docs-html
          path: langchain-api-docs-html
          token: ${{ secrets.TOKEN_GITHUB_API_DOCS_HTML }}

      - name: "📋 Extract Repository List with yq"
        id: get-unsorted-repos
        uses: mikefarah/yq@88a31ae8c6b34aad77d2efdecc146113cb3315d0 # master
        with:
          cmd: |
            # Extract repos from packages.yml that are in the langchain-ai org
            # (excluding 'langchain' itself)
            yq '
              .packages[]
              | select(
                  (
                    (.repo | test("^langchain-ai/"))
                    and
                    (.repo != "langchain-ai/langchain")
                  )
                  or
                  (.include_in_api_ref // false)
                )
              | .repo
            ' langchain/libs/packages.yml

      - name: "📋 Parse YAML & Checkout Repositories"
        env:
          REPOS_UNSORTED: ${{ steps.get-unsorted-repos.outputs.result }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          # Get unique repositories
          REPOS=$(echo "$REPOS_UNSORTED" | sort -u)
          # Checkout each unique repository
          for repo in $REPOS; do
            # Validate repository format (allow any org with proper format)
            if [[ ! "$repo" =~ ^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$ ]]; then
              echo "Error: Invalid repository format: $repo"
              exit 1
            fi

            REPO_NAME=$(echo $repo | cut -d'/' -f2)

            # Additional validation for repo name
            if [[ ! "$REPO_NAME" =~ ^[a-zA-Z0-9_.-]+$ ]]; then
              echo "Error: Invalid repository name: $REPO_NAME"
              exit 1
            fi
            echo "Checking out $repo to $REPO_NAME"

            # Special handling for langchain-tavily: checkout by commit hash
            if [[ "$REPO_NAME" == "langchain-tavily" ]]; then
              git clone https://github.com/$repo.git $REPO_NAME
              cd $REPO_NAME
              git checkout f3515654724a9e87bdfe2c2f509d6cdde646e563
              cd ..
            else
              git clone --depth 1 --branch v0.3 https://github.com/$repo.git $REPO_NAME
            fi
          done

      - name: "🐍 Setup Python ${{ env.PYTHON_VERSION }}"
        uses: actions/setup-python@v6
        id: setup-python
        with:
          python-version: ${{ env.PYTHON_VERSION }}

      - name: "📦 Install Initial Python Dependencies using uv"
        working-directory: langchain
        run: |
          python -m pip install -U uv
          python -m uv pip install --upgrade --no-cache-dir pip setuptools pyyaml

      - name: "📦 Organize Library Directories"
        # Places cloned partner packages into libs/partners structure
        run: python langchain/.github/scripts/prep_api_docs_build.py

      - name: "🧹 Clear Prior Build"
        run:
          # Remove artifacts from prior docs build
          rm -rf langchain-api-docs-html/api_reference_build/html

      - name: "📦 Install Documentation Dependencies using uv"
        working-directory: langchain
        run: |
          # Install all partner packages in editable mode with overrides
          python -m uv pip install $(ls ./libs/partners | grep -v azure-ai | xargs -I {} echo "./libs/partners/{}") --overrides ./docs/vercel_overrides.txt --prerelease=allow

          # Install langchain-azure-ai with tools extra
          python -m uv pip install "./libs/partners/azure-ai[tools]" --overrides ./docs/vercel_overrides.txt --prerelease=allow

          # Install core langchain and other main packages
          python -m uv pip install libs/core libs/langchain libs/text-splitters libs/community libs/experimental libs/standard-tests

          # Install Sphinx and related packages for building docs
          python -m uv pip install -r docs/api_reference/requirements.txt

      - name: "🔧 Configure Git Settings"
        working-directory: langchain
        run: |
          git config --local user.email "actions@github.com"
          git config --local user.name "Github Actions"

      - name: "📚 Build API Documentation"
        working-directory: langchain
        run: |
          # Generate the API reference RST files
          python docs/api_reference/create_api_rst.py

          # Build the HTML documentation using Sphinx
          # -T: show full traceback on exception
          # -E: don't use cached environment (force rebuild, ignore cached doctrees)
          # -b html: build HTML docs (vs PDS, etc.)
          # -d: path for the cached environment (parsed document trees / doctrees)
          #     - Separate from output dir for faster incremental builds
          # -c: path to conf.py
          # -j auto: parallel build using all available CPU cores
          python -m sphinx -T -E -b html -d ../langchain-api-docs-html/_build/doctrees -c docs/api_reference docs/api_reference ../langchain-api-docs-html/api_reference_build/html -j auto

          # Post-process the generated HTML
          python docs/api_reference/scripts/custom_formatter.py ../langchain-api-docs-html/api_reference_build/html

          # Default index page is blank so we copy in the actual home page.
          cp ../langchain-api-docs-html/api_reference_build/html/{reference,index}.html

          # Removes Sphinx's intermediate build artifacts after the build is complete.
          rm -rf ../langchain-api-docs-html/_build/

      # Commit and push changes to langchain-api-docs-html repo
      - uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9
        with:
          cwd: langchain-api-docs-html
          message: "Update API docs build from v0.3 branch"