chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 11:58:32 +08:00
commit 7406cfee43
3019 changed files with 494026 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
/.github/ @ccurme @eyurtsev @mdrxy
/libs/core/ @eyurtsev
/libs/partners/ @ccurme @mdrxy
+153
View File
@@ -0,0 +1,153 @@
name: "\U0001F41B Bug Report"
description: Report a bug in LangChain. To report a security issue, please instead use the security option (below). For questions, please use the LangChain forum (below).
labels: ["bug"]
type: bug
body:
- type: markdown
attributes:
value: |
> **All contributions must be in English.** See the [language policy](https://docs.langchain.com/oss/python/contributing/overview#language-policy).
Thank you for taking the time to file a bug report.
For usage questions, feature requests and general design questions, please use the [LangChain Forum](https://forum.langchain.com/).
Check these before submitting to see if your issue has already been reported, fixed or if there's another way to solve your problem:
* [Documentation](https://docs.langchain.com/oss/python/langchain/overview),
* [API Reference Documentation](https://reference.langchain.com/python/),
* [LangChain ChatBot](https://chat.langchain.com/)
* [GitHub search](https://github.com/langchain-ai/langchain),
* [LangChain Forum](https://forum.langchain.com/),
- type: checkboxes
id: checks
attributes:
label: Submission checklist
description: Please confirm and check all the following options.
options:
- label: This is a bug, not a usage question.
required: true
- label: I added a clear and descriptive title that summarizes this issue.
required: true
- label: I used the GitHub search to find a similar question and didn't find it.
required: true
- label: I am sure that this is a bug in LangChain rather than my code.
required: true
- label: The bug is not resolved by updating to the latest stable version of LangChain (or the specific integration package).
required: true
- label: This is not related to the langchain-community package.
required: true
- label: I posted a self-contained, minimal, reproducible example. A maintainer can copy it and run it AS IS.
required: true
- type: checkboxes
id: package
attributes:
label: Package (Required)
description: |
Which `langchain` package(s) is this bug related to? Select at least one.
Note that if the package you are reporting for is not listed here, it is not in this repository (e.g. `langchain-google-genai` is in [`langchain-ai/langchain-google`](https://github.com/langchain-ai/langchain-google/)).
Please report issues for other packages to their respective repositories.
options:
- label: langchain
- label: langchain-openai
- label: langchain-anthropic
- label: langchain-classic
- label: langchain-core
- label: langchain-model-profiles
- label: langchain-tests
- label: langchain-text-splitters
- label: langchain-chroma
- label: langchain-deepseek
- label: langchain-exa
- label: langchain-fireworks
- label: langchain-groq
- label: langchain-huggingface
- label: langchain-mistralai
- label: langchain-nomic
- label: langchain-ollama
- label: langchain-openrouter
- label: langchain-perplexity
- label: langchain-qdrant
- label: langchain-xai
- label: Other / not sure / general
- type: textarea
id: related
validations:
required: false
attributes:
label: Related Issues / PRs
description: |
If this bug is related to any existing issues or pull requests, please link them here.
placeholder: |
* e.g. #123, #456
- type: textarea
id: reproduction
validations:
required: true
attributes:
label: Reproduction Steps / Example Code (Python)
description: |
Please add a self-contained, [minimal, reproducible, example](https://stackoverflow.com/help/minimal-reproducible-example) with your use case.
If a maintainer can copy it, run it, and see it right away, there's a much higher chance that you'll be able to get help.
**Important!**
* Avoid screenshots, as they are hard to read and (more importantly) don't allow others to copy-and-paste your code.
* Reduce your code to the minimum required to reproduce the issue if possible.
(This will be automatically formatted into code, so no need for backticks.)
render: python
placeholder: |
from langchain_core.runnables import RunnableLambda
def bad_code(inputs) -> int:
raise NotImplementedError('For demo purpose')
chain = RunnableLambda(bad_code)
chain.invoke('Hello!')
- type: textarea
attributes:
label: Error Message and Stack Trace (if applicable)
description: |
If you are reporting an error, please copy and paste the full error message and
stack trace.
(This will be automatically formatted into code, so no need for backticks.)
render: shell
- type: textarea
id: description
attributes:
label: Description
description: |
What is the problem, question, or error?
Write a short description telling what you are doing, what you expect to happen, and what is currently happening.
placeholder: |
* I'm trying to use the `langchain` library to do X.
* I expect to see Y.
* Instead, it does Z.
validations:
required: true
- type: textarea
id: system-info
attributes:
label: System Info
description: |
Please share your system info with us.
Run the following command in your terminal and paste the output here:
`python -m langchain_core.sys_info`
or if you have an existing python interpreter running:
```python
from langchain_core import sys_info
sys_info.print_sys_info()
```
placeholder: |
python -m langchain_core.sys_info
validations:
required: true
+15
View File
@@ -0,0 +1,15 @@
blank_issues_enabled: false
version: 2.1
contact_links:
- name: 💬 LangChain Forum
url: https://forum.langchain.com/
about: General community discussions and support
- name: 📚 LangChain Documentation
url: https://docs.langchain.com/oss/python/langchain/overview
about: View the official LangChain documentation
- name: 📚 API Reference Documentation
url: https://reference.langchain.com/python/
about: View the official LangChain API reference documentation
- name: 📚 Documentation issue
url: https://github.com/langchain-ai/docs/issues/new?template=01-langchain.yml
about: Report an issue related to the LangChain documentation
+155
View File
@@ -0,0 +1,155 @@
name: "✨ Feature Request"
description: Request a new feature or enhancement for LangChain. For questions, please use the LangChain forum (below).
labels: ["feature request"]
type: feature
body:
- type: markdown
attributes:
value: |
> **All contributions must be in English.** See the [language policy](https://docs.langchain.com/oss/python/contributing/overview#language-policy).
Thank you for taking the time to request a new feature.
Use this to request NEW FEATURES or ENHANCEMENTS in LangChain. For bug reports, please use the bug report template. For usage questions and general design questions, please use the [LangChain Forum](https://forum.langchain.com/).
Relevant links to check before filing a feature request to see if your request has already been made or
if there's another way to achieve what you want:
* [Documentation](https://docs.langchain.com/oss/python/langchain/overview),
* [API Reference Documentation](https://reference.langchain.com/python/),
* [LangChain ChatBot](https://chat.langchain.com/)
* [GitHub search](https://github.com/langchain-ai/langchain),
* [LangChain Forum](https://forum.langchain.com/),
**Note:** Do not begin work on a PR unless explicitly assigned to this issue by a maintainer.
- type: checkboxes
id: checks
attributes:
label: Submission checklist
description: Please confirm and check all the following options.
options:
- label: This is a feature request, not a bug report or usage question.
required: true
- label: I added a clear and descriptive title that summarizes the feature request.
required: true
- label: I used the GitHub search to find a similar feature request and didn't find it.
required: true
- label: I checked the LangChain documentation and API reference to see if this feature already exists.
required: true
- label: This is not related to the langchain-community package.
required: true
- type: checkboxes
id: package
attributes:
label: Package (Required)
description: |
Which `langchain` package(s) is this request related to? Select at least one.
Note that if the package you are requesting for is not listed here, it is not in this repository (e.g. `langchain-google-genai` is in `langchain-ai/langchain`).
Please submit feature requests for other packages to their respective repositories.
options:
- label: langchain
- label: langchain-openai
- label: langchain-anthropic
- label: langchain-classic
- label: langchain-core
- label: langchain-model-profiles
- label: langchain-tests
- label: langchain-text-splitters
- label: langchain-chroma
- label: langchain-deepseek
- label: langchain-exa
- label: langchain-fireworks
- label: langchain-groq
- label: langchain-huggingface
- label: langchain-mistralai
- label: langchain-nomic
- label: langchain-ollama
- label: langchain-openrouter
- label: langchain-perplexity
- label: langchain-qdrant
- label: langchain-xai
- label: Other / not sure / general
- type: textarea
id: feature-description
validations:
required: true
attributes:
label: Feature Description
description: |
Please provide a clear and concise description of the feature you would like to see added to LangChain.
What specific functionality are you requesting? Be as detailed as possible.
placeholder: |
I would like LangChain to support...
This feature would allow users to...
- type: textarea
id: use-case
validations:
required: true
attributes:
label: Use Case
description: |
Describe the specific use case or problem this feature would solve.
Why do you need this feature? What problem does it solve for you or other users?
placeholder: |
I'm trying to build an application that...
Currently, I have to work around this by...
This feature would help me/users to...
- type: textarea
id: proposed-solution
validations:
required: false
attributes:
label: Proposed Solution
description: |
If you have ideas about how this feature could be implemented, please describe them here.
This is optional but can be helpful for maintainers to understand your vision.
placeholder: |
I think this could be implemented by...
The API could look like...
```python
# Example of how the feature might work
```
- type: textarea
id: alternatives
validations:
required: false
attributes:
label: Alternatives Considered
description: |
Have you considered any alternative solutions or workarounds?
What other approaches have you tried or considered?
placeholder: |
I've tried using...
Alternative approaches I considered:
1. ...
2. ...
But these don't work because...
- type: textarea
id: additional-context
validations:
required: false
attributes:
label: Additional Context
description: |
Add any other context, screenshots, examples, or references that would help explain your feature request.
placeholder: |
Related issues: #...
Similar features in other libraries:
- ...
Additional context or examples:
- ...
+49
View File
@@ -0,0 +1,49 @@
name: 🔒 Privileged
description: You are a LangChain maintainer, or was asked directly by a maintainer to create an issue here. If not, check the other options.
body:
- type: markdown
attributes:
value: |
If you are not a LangChain maintainer, employee, or were not asked directly by a maintainer to create an issue, then please start the conversation on the [LangChain Forum](https://forum.langchain.com/) instead.
- type: checkboxes
id: privileged
attributes:
label: Privileged issue
description: Confirm that you are allowed to create an issue here.
options:
- label: I am a LangChain maintainer, or was asked directly by a LangChain maintainer to create an issue here.
required: true
- type: textarea
id: content
attributes:
label: Issue Content
description: Add the content of the issue here.
- type: checkboxes
id: package
attributes:
label: Package (Required)
description: |
Please select package(s) that this issue is related to.
options:
- label: langchain
- label: langchain-openai
- label: langchain-anthropic
- label: langchain-classic
- label: langchain-core
- label: langchain-model-profiles
- label: langchain-tests
- label: langchain-text-splitters
- label: langchain-chroma
- label: langchain-deepseek
- label: langchain-exa
- label: langchain-fireworks
- label: langchain-groq
- label: langchain-huggingface
- label: langchain-mistralai
- label: langchain-nomic
- label: langchain-ollama
- label: langchain-openrouter
- label: langchain-perplexity
- label: langchain-qdrant
- label: langchain-xai
- label: Other / not sure / general
+120
View File
@@ -0,0 +1,120 @@
name: "📋 Task"
description: Create a task for project management and tracking by LangChain maintainers. If you are not a maintainer, please use other templates or the forum.
labels: ["task"]
type: task
body:
- type: markdown
attributes:
value: |
Thanks for creating a task to help organize LangChain development.
This template is for **maintainer tasks** such as project management, development planning, refactoring, documentation updates, and other organizational work.
If you are not a LangChain maintainer or were not asked directly by a maintainer to create a task, then please start the conversation on the [LangChain Forum](https://forum.langchain.com/) instead or use the appropriate bug report or feature request templates on the previous page.
- type: checkboxes
id: maintainer
attributes:
label: Maintainer task
description: Confirm that you are allowed to create a task here.
options:
- label: I am a LangChain maintainer, or was asked directly by a LangChain maintainer to create a task here.
required: true
- type: textarea
id: task-description
attributes:
label: Task Description
description: |
Provide a clear and detailed description of the task.
What needs to be done? Be specific about the scope and requirements.
placeholder: |
This task involves...
The goal is to...
Specific requirements:
- ...
- ...
validations:
required: true
- type: textarea
id: acceptance-criteria
attributes:
label: Acceptance Criteria
description: |
Define the criteria that must be met for this task to be considered complete.
What are the specific deliverables or outcomes expected?
placeholder: |
This task will be complete when:
- [ ] ...
- [ ] ...
- [ ] ...
validations:
required: true
- type: textarea
id: context
attributes:
label: Context and Background
description: |
Provide any relevant context, background information, or links to related issues/PRs.
Why is this task needed? What problem does it solve?
placeholder: |
Background:
- ...
Related issues/PRs:
- #...
Additional context:
- ...
validations:
required: false
- type: textarea
id: dependencies
attributes:
label: Dependencies
description: |
List any dependencies or blockers for this task.
Are there other tasks, issues, or external factors that need to be completed first?
placeholder: |
This task depends on:
- [ ] Issue #...
- [ ] PR #...
- [ ] External dependency: ...
Blocked by:
- ...
validations:
required: false
- type: checkboxes
id: package
attributes:
label: Package (Required)
description: |
Please select package(s) that this task is related to.
options:
- label: langchain
- label: langchain-openai
- label: langchain-anthropic
- label: langchain-classic
- label: langchain-core
- label: langchain-model-profiles
- label: langchain-tests
- label: langchain-text-splitters
- label: langchain-chroma
- label: langchain-deepseek
- label: langchain-exa
- label: langchain-fireworks
- label: langchain-groq
- label: langchain-huggingface
- label: langchain-mistralai
- label: langchain-nomic
- label: langchain-ollama
- label: langchain-openrouter
- label: langchain-perplexity
- label: langchain-qdrant
- label: langchain-xai
- label: Other / not sure / general
+48
View File
@@ -0,0 +1,48 @@
Fixes #
---
<!-- Keep the `Fixes #xx` keyword at the very top and update the issue number — this auto-closes the issue on merge. Replace this comment with a 1-2 sentence description of your change. No `# Summary` header; the description is the summary. -->
Read the full contributing guidelines: https://docs.langchain.com/oss/python/contributing/overview
> **All contributions must be in English.** See the [language policy](https://docs.langchain.com/oss/python/contributing/overview#language-policy).
If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED!
Thank you for contributing to LangChain! Follow these steps to have your pull request considered as ready for review.
1. PR title: Should follow the format: TYPE(SCOPE): DESCRIPTION
- Examples:
- fix(anthropic): resolve flag parsing error
- feat(core): add multi-tenant support
- test(openai): update API usage tests
- Allowed TYPE and SCOPE values: https://github.com/langchain-ai/langchain/blob/master/.github/workflows/pr_lint.yml#L15-L33
2. PR description:
- Write 1-2 sentences that make the change easy to understand: who benefits, what problem they had, and how this solves it. Prefer a simple user story over a long summary.
- The `Fixes #xx` line at the top is **required** for external contributions — update the issue number and keep the keyword. This links your PR to the approved issue and auto-closes it on merge.
- If there are any breaking changes, please clearly describe them.
- If this PR depends on another PR being merged first, please include "Depends on #PR_NUMBER" in the description.
## Release note
<!-- Required for net new features or behavior-changing bugfixes. State the user-visible change in release-note-ready language. Omit this section for chores, refactors, or test-only changes. -->
3. Run `make format`, `make lint` and `make test` from the root of the package(s) you've modified.
- We will not consider a PR unless these three are passing in CI.
4. How did you verify your code works?
Additional guidelines:
- All external PRs must link to an issue or discussion where a solution has been approved by a maintainer, and you must be assigned to that issue. PRs without prior approval will be closed.
- PRs should not touch more than one package unless absolutely necessary.
- Do not update the `uv.lock` files or add dependencies to `pyproject.toml` files (even optional ones) unless you have explicit permission to do so by a maintainer.
## Social handles (optional)
<!-- If you'd like a shoutout on release, add your socials below -->
Twitter: @
LinkedIn: https://linkedin.com/in/
+39
View File
@@ -0,0 +1,39 @@
# Helper to set up Python and uv with caching
name: uv-install
description: Set up Python and uv with caching
inputs:
python-version:
description: Python version, supporting MAJOR.MINOR only
required: true
enable-cache:
description: Enable caching for uv dependencies
required: false
default: "true"
cache-suffix:
description: Custom cache key suffix for cache invalidation
required: false
default: ""
working-directory:
description: Working directory for cache glob scoping
required: false
default: "**"
env:
UV_VERSION: "0.11.26"
runs:
using: composite
steps:
- name: Install uv and set the python version
uses: astral-sh/setup-uv@0ca8f610542aa7f4acaf39e65cf4eb3c35091883 # v7
with:
version: ${{ env.UV_VERSION }}
python-version: ${{ inputs.python-version }}
enable-cache: ${{ inputs.enable-cache }}
cache-dependency-glob: |
${{ inputs.working-directory }}/pyproject.toml
${{ inputs.working-directory }}/uv.lock
${{ inputs.working-directory }}/requirements*.txt
cache-suffix: ${{ inputs.cache-suffix }}
+119
View File
@@ -0,0 +1,119 @@
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
# and
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
groups:
minor-and-patch:
patterns:
- "*"
update-types:
- "minor"
- "patch"
major:
patterns:
- "*"
update-types:
- "major"
- package-ecosystem: "uv"
directories:
- "/libs/core/"
- "/libs/langchain/"
- "/libs/langchain_v1/"
schedule:
interval: "monthly"
versioning-strategy: increase
ignore:
- dependency-name: "langchain-core"
- dependency-name: "langchain"
- dependency-name: "langchain-classic"
- dependency-name: "langchain-text-splitters"
- dependency-name: "langchain-tests"
- dependency-name: "langchain-model-profiles"
groups:
minor-and-patch:
patterns:
- "*"
update-types:
- "minor"
- "patch"
major:
patterns:
- "*"
update-types:
- "major"
- package-ecosystem: "uv"
directories:
- "/libs/partners/anthropic/"
- "/libs/partners/chroma/"
- "/libs/partners/deepseek/"
- "/libs/partners/exa/"
- "/libs/partners/fireworks/"
- "/libs/partners/groq/"
- "/libs/partners/huggingface/"
- "/libs/partners/mistralai/"
- "/libs/partners/nomic/"
- "/libs/partners/ollama/"
- "/libs/partners/openai/"
- "/libs/partners/openrouter/"
- "/libs/partners/perplexity/"
- "/libs/partners/qdrant/"
- "/libs/partners/xai/"
schedule:
interval: "monthly"
versioning-strategy: increase
ignore:
- dependency-name: "langchain-core"
- dependency-name: "langchain"
- dependency-name: "langchain-classic"
- dependency-name: "langchain-text-splitters"
- dependency-name: "langchain-tests"
- dependency-name: "langchain-model-profiles"
groups:
minor-and-patch:
patterns:
- "*"
update-types:
- "minor"
- "patch"
major:
patterns:
- "*"
update-types:
- "major"
- package-ecosystem: "uv"
directories:
- "/libs/text-splitters/"
- "/libs/standard-tests/"
- "/libs/model-profiles/"
schedule:
interval: "monthly"
versioning-strategy: increase
ignore:
- dependency-name: "langchain-core"
- dependency-name: "langchain"
- dependency-name: "langchain-classic"
- dependency-name: "langchain-text-splitters"
- dependency-name: "langchain-tests"
- dependency-name: "langchain-model-profiles"
groups:
minor-and-patch:
patterns:
- "*"
update-types:
- "minor"
- "patch"
major:
patterns:
- "*"
update-types:
- "major"
+6
View File
@@ -0,0 +1,6 @@
<svg width="472" height="100" viewBox="0 0 472 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="100" height="100" rx="20" fill="#161F34"/>
<path d="M54.2612 54.2583L63.1942 45.3253C67.8979 40.6215 67.8979 32.9952 63.1942 28.2914C58.4904 23.5877 50.8641 23.5877 46.1603 28.2914L37.2273 37.2244" stroke="#7FC8FF" stroke-width="12.0389"/>
<path d="M45.7427 45.7412L36.8098 54.6742C32.106 59.3779 32.106 67.0042 36.8098 71.708C41.5135 76.4118 49.1398 76.4118 53.8436 71.708L62.7766 62.775" stroke="#7FC8FF" stroke-width="12.0389"/>
<path d="M142.427 70.248V65.748H153.227V32.748H142.427V28.248H158.147V65.748H168.947V70.248H142.427ZM189.174 70.608C182.454 70.608 177.894 67.248 177.894 61.668C177.894 55.548 182.154 52.128 190.194 52.128H199.194V50.028C199.194 46.068 196.374 43.668 191.574 43.668C187.254 43.668 184.374 45.708 183.774 48.828H178.854C179.574 42.828 184.434 39.288 191.814 39.288C199.614 39.288 204.114 43.188 204.114 50.328V63.708C204.114 65.328 204.714 65.748 206.094 65.748H207.654V70.248H204.954C200.874 70.248 199.494 68.508 199.434 65.508C197.514 68.268 194.454 70.608 189.174 70.608ZM189.534 66.408C195.654 66.408 199.194 62.868 199.194 57.768V56.268H189.714C185.334 56.268 182.874 57.888 182.874 61.368C182.874 64.368 185.454 66.408 189.534 66.408ZM216.601 70.248V39.648H220.861L221.521 43.788C223.321 41.448 226.321 39.288 231.121 39.288C237.601 39.288 243.001 42.948 243.001 52.848V70.248H238.081V53.148C238.081 47.028 235.201 43.788 230.281 43.788C224.941 43.788 221.521 47.928 221.521 53.988V70.248H216.601ZM266.348 82.608C258.548 82.608 253.088 78.948 252.308 72.228H257.348C258.188 76.068 261.608 78.228 266.708 78.228C273.128 78.228 276.608 75.228 276.608 68.568V64.968C274.568 68.448 271.268 70.608 266.108 70.608C257.648 70.608 251.408 64.908 251.408 54.948C251.408 45.588 257.648 39.288 266.108 39.288C271.268 39.288 274.688 41.508 276.608 44.928L277.268 39.648H281.528V68.748C281.528 77.568 276.848 82.608 266.348 82.608ZM266.588 66.228C272.588 66.228 276.668 61.608 276.668 55.068C276.668 48.348 272.588 43.668 266.588 43.668C260.528 43.668 256.448 48.288 256.448 54.948C256.448 61.608 260.528 66.228 266.588 66.228ZM304.875 70.608C295.935 70.608 290.055 64.548 290.055 55.008C290.055 45.648 296.115 39.288 304.995 39.288C312.495 39.288 317.235 43.488 318.495 50.208H313.335C312.435 46.128 309.435 43.668 304.935 43.668C299.055 43.668 295.095 48.348 295.095 55.008C295.095 61.668 299.055 66.228 304.935 66.228C309.315 66.228 312.315 63.708 313.275 59.808H318.495C317.295 66.408 312.315 70.608 304.875 70.608ZM328.042 70.248V28.248H332.962V43.788C335.242 40.968 338.782 39.288 342.742 39.288C350.422 39.288 354.802 44.388 354.802 53.208V70.248H349.882V53.508C349.882 47.268 347.002 43.788 341.902 43.788C336.442 43.788 332.962 48.108 332.962 54.948V70.248H328.042ZM375.209 70.608C368.489 70.608 363.929 67.248 363.929 61.668C363.929 55.548 368.189 52.128 376.229 52.128H385.229V50.028C385.229 46.068 382.409 43.668 377.609 43.668C373.289 43.668 370.409 45.708 369.809 48.828H364.889C365.609 42.828 370.469 39.288 377.849 39.288C385.649 39.288 390.149 43.188 390.149 50.328V63.708C390.149 65.328 390.749 65.748 392.129 65.748H393.689V70.248H390.989C386.909 70.248 385.529 68.508 385.469 65.508C383.549 68.268 380.489 70.608 375.209 70.608ZM375.569 66.408C381.689 66.408 385.229 62.868 385.229 57.768V56.268H375.749C371.369 56.268 368.909 57.888 368.909 61.368C368.909 64.368 371.489 66.408 375.569 66.408ZM403.476 70.248V65.748H414.276V44.148H403.476V39.648H419.196V65.748H429.996V70.248H403.476ZM416.796 34.248C414.576 34.248 412.836 32.568 412.836 30.288C412.836 28.068 414.576 26.388 416.796 26.388C419.016 26.388 420.756 28.068 420.756 30.288C420.756 32.568 419.016 34.248 416.796 34.248ZM439.843 70.248V39.648H444.103L444.763 43.788C446.563 41.448 449.563 39.288 454.363 39.288C460.843 39.288 466.243 42.948 466.243 52.848V70.248H461.323V53.148C461.323 47.028 458.443 43.788 453.523 43.788C448.183 43.788 444.763 47.928 444.763 53.988V70.248H439.843Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg width="472" height="100" viewBox="0 0 472 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="100" height="100" rx="20" fill="#161F34"/>
<path d="M54.2612 54.2583L63.1942 45.3253C67.8979 40.6215 67.8979 32.9952 63.1942 28.2914C58.4904 23.5877 50.8641 23.5877 46.1603 28.2914L37.2273 37.2244" stroke="#7FC8FF" stroke-width="12.0389"/>
<path d="M45.7427 45.7411L36.8098 54.6741C32.106 59.3779 32.106 67.0042 36.8098 71.7079C41.5135 76.4117 49.1398 76.4117 53.8436 71.7079L62.7766 62.775" stroke="#7FC8FF" stroke-width="12.0389"/>
<path d="M142.427 70.248V65.748H153.227V32.748H142.427V28.248H158.147V65.748H168.947V70.248H142.427ZM189.174 70.608C182.454 70.608 177.894 67.248 177.894 61.668C177.894 55.548 182.154 52.128 190.194 52.128H199.194V50.028C199.194 46.068 196.374 43.668 191.574 43.668C187.254 43.668 184.374 45.708 183.774 48.828H178.854C179.574 42.828 184.434 39.288 191.814 39.288C199.614 39.288 204.114 43.188 204.114 50.328V63.708C204.114 65.328 204.714 65.748 206.094 65.748H207.654V70.248H204.954C200.874 70.248 199.494 68.508 199.434 65.508C197.514 68.268 194.454 70.608 189.174 70.608ZM189.534 66.408C195.654 66.408 199.194 62.868 199.194 57.768V56.268H189.714C185.334 56.268 182.874 57.888 182.874 61.368C182.874 64.368 185.454 66.408 189.534 66.408ZM216.601 70.248V39.648H220.861L221.521 43.788C223.321 41.448 226.321 39.288 231.121 39.288C237.601 39.288 243.001 42.948 243.001 52.848V70.248H238.081V53.148C238.081 47.028 235.201 43.788 230.281 43.788C224.941 43.788 221.521 47.928 221.521 53.988V70.248H216.601ZM266.348 82.608C258.548 82.608 253.088 78.948 252.308 72.228H257.348C258.188 76.068 261.608 78.228 266.708 78.228C273.128 78.228 276.608 75.228 276.608 68.568V64.968C274.568 68.448 271.268 70.608 266.108 70.608C257.648 70.608 251.408 64.908 251.408 54.948C251.408 45.588 257.648 39.288 266.108 39.288C271.268 39.288 274.688 41.508 276.608 44.928L277.268 39.648H281.528V68.748C281.528 77.568 276.848 82.608 266.348 82.608ZM266.588 66.228C272.588 66.228 276.668 61.608 276.668 55.068C276.668 48.348 272.588 43.668 266.588 43.668C260.528 43.668 256.448 48.288 256.448 54.948C256.448 61.608 260.528 66.228 266.588 66.228ZM304.875 70.608C295.935 70.608 290.055 64.548 290.055 55.008C290.055 45.648 296.115 39.288 304.995 39.288C312.495 39.288 317.235 43.488 318.495 50.208H313.335C312.435 46.128 309.435 43.668 304.935 43.668C299.055 43.668 295.095 48.348 295.095 55.008C295.095 61.668 299.055 66.228 304.935 66.228C309.315 66.228 312.315 63.708 313.275 59.808H318.495C317.295 66.408 312.315 70.608 304.875 70.608ZM328.042 70.248V28.248H332.962V43.788C335.242 40.968 338.782 39.288 342.742 39.288C350.422 39.288 354.802 44.388 354.802 53.208V70.248H349.882V53.508C349.882 47.268 347.002 43.788 341.902 43.788C336.442 43.788 332.962 48.108 332.962 54.948V70.248H328.042ZM375.209 70.608C368.489 70.608 363.929 67.248 363.929 61.668C363.929 55.548 368.189 52.128 376.229 52.128H385.229V50.028C385.229 46.068 382.409 43.668 377.609 43.668C373.289 43.668 370.409 45.708 369.809 48.828H364.889C365.609 42.828 370.469 39.288 377.849 39.288C385.649 39.288 390.149 43.188 390.149 50.328V63.708C390.149 65.328 390.749 65.748 392.129 65.748H393.689V70.248H390.989C386.909 70.248 385.529 68.508 385.469 65.508C383.549 68.268 380.489 70.608 375.209 70.608ZM375.569 66.408C381.689 66.408 385.229 62.868 385.229 57.768V56.268H375.749C371.369 56.268 368.909 57.888 368.909 61.368C368.909 64.368 371.489 66.408 375.569 66.408ZM403.476 70.248V65.748H414.276V44.148H403.476V39.648H419.196V65.748H429.996V70.248H403.476ZM416.796 34.248C414.576 34.248 412.836 32.568 412.836 30.288C412.836 28.068 414.576 26.388 416.796 26.388C419.016 26.388 420.756 28.068 420.756 30.288C420.756 32.568 419.016 34.248 416.796 34.248ZM439.843 70.248V39.648H444.103L444.763 43.788C446.563 41.448 449.563 39.288 454.363 39.288C460.843 39.288 466.243 42.948 466.243 52.848V70.248H461.323V53.148C461.323 47.028 458.443 43.788 453.523 43.788C448.183 43.788 444.763 47.928 444.763 53.988V70.248H439.843Z" fill="#161F34"/>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

+398
View File
@@ -0,0 +1,398 @@
"""Analyze git diffs to determine which directories need to be tested.
Intelligently determines which LangChain packages and directories need to be tested,
linted, or built based on the changes. Handles dependency relationships between
packages, maps file changes to appropriate CI job configurations, and outputs JSON
configurations for GitHub Actions.
- Maps changed files to affected package directories (libs/core, libs/partners/*, etc.)
- Builds dependency graph to include dependent packages when core components change
- Generates test matrix configurations with appropriate Python versions
- Handles special cases for Pydantic version testing and performance benchmarks
Used as part of the check_diffs workflow.
"""
import glob
import json
import os
import sys
from collections import defaultdict
from pathlib import Path
from typing import Dict, List, Set
import tomllib
from get_min_versions import get_min_version_from_toml
from packaging.requirements import Requirement
LANGCHAIN_DIRS = [
"libs/core",
"libs/text-splitters",
"libs/langchain",
"libs/langchain_v1",
"libs/model-profiles",
]
# Packages with VCR cassette-backed integration tests.
# These get a playback-only CI check to catch stale cassettes.
VCR_PACKAGES = {
"libs/partners/openai",
}
# When set to True, we are ignoring core dependents
# in order to be able to get CI to pass for each individual
# package that depends on core
# e.g. if you touch core, we don't then add textsplitters/etc to CI
IGNORE_CORE_DEPENDENTS = False
# Ignored partners are removed from dependents but still run if directly edited
IGNORED_PARTNERS = [
# remove huggingface from dependents because of CI instability
# specifically in huggingface jobs
"huggingface",
]
def all_package_dirs() -> Set[str]:
return {
"/".join(path.split("/")[:-1]).lstrip("./")
for path in glob.glob("./libs/**/pyproject.toml", recursive=True)
if "libs/standard-tests" not in path
}
def dependents_graph() -> dict:
"""Construct a mapping of package -> dependents
Done such that we can run tests on all dependents of a package when a change is made.
"""
dependents = defaultdict(set)
for path in glob.glob("./libs/**/pyproject.toml", recursive=True):
if "template" in path:
continue
# load regular and test deps from pyproject.toml
with open(path, "rb") as f:
pyproject = tomllib.load(f)
pkg_dir = "libs" + "/".join(path.split("libs")[1].split("/")[:-1])
for dep in [
*pyproject["project"]["dependencies"],
*pyproject["dependency-groups"]["test"],
]:
requirement = Requirement(dep)
package_name = requirement.name
if "langchain" in dep:
dependents[package_name].add(pkg_dir)
continue
# load extended deps from extended_testing_deps.txt
package_path = Path(path).parent
extended_requirement_path = package_path / "extended_testing_deps.txt"
if extended_requirement_path.exists():
with open(extended_requirement_path, "r") as f:
extended_deps = f.read().splitlines()
for depline in extended_deps:
if depline.startswith("-e "):
# editable dependency
assert depline.startswith("-e ../partners/"), (
"Extended test deps should only editable install partner packages"
)
partner = depline.split("partners/")[1]
dep = f"langchain-{partner}"
else:
dep = depline.split("==")[0]
if "langchain" in dep:
dependents[dep].add(pkg_dir)
for k in dependents:
for partner in IGNORED_PARTNERS:
if f"libs/partners/{partner}" in dependents[k]:
dependents[k].remove(f"libs/partners/{partner}")
return dependents
def add_dependents(dirs_to_eval: Set[str], dependents: dict) -> List[str]:
updated = set()
for dir_ in dirs_to_eval:
# handle core manually because it has so many dependents
if "core" in dir_:
updated.add(dir_)
continue
pkg = "langchain-" + dir_.split("/")[-1]
updated.update(dependents[pkg])
updated.add(dir_)
return list(updated)
def _get_configs_for_single_dir(job: str, dir_: str) -> List[Dict[str, str]]:
if job == "test-pydantic":
return _get_pydantic_test_configs(dir_)
if job == "codspeed":
# CPU simulation (<1% variance, Valgrind-based) is the default.
# Partners with heavy SDK inits use walltime instead to keep CI fast.
CODSPEED_WALLTIME_DIRS = {
"libs/core",
"libs/partners/fireworks", # ~328s under simulation
"libs/partners/openai", # 6 benchmarks, ~6 min under simulation
}
mode = "walltime" if dir_ in CODSPEED_WALLTIME_DIRS else "simulation"
return [
{
"working-directory": dir_,
"python-version": "3.13",
"codspeed-mode": mode,
}
]
if dir_ == "libs/core":
py_versions = ["3.10", "3.11", "3.12", "3.13", "3.14"]
else:
py_versions = ["3.10", "3.14"]
return [{"working-directory": dir_, "python-version": py_v} for py_v in py_versions]
def _get_pydantic_test_configs(
dir_: str, *, python_version: str = "3.12"
) -> List[Dict[str, str]]:
with open("./libs/core/uv.lock", "rb") as f:
core_uv_lock_data = tomllib.load(f)
for package in core_uv_lock_data["package"]:
if package["name"] == "pydantic":
core_max_pydantic_minor = package["version"].split(".")[1]
break
with open(f"./{dir_}/uv.lock", "rb") as f:
dir_uv_lock_data = tomllib.load(f)
for package in dir_uv_lock_data["package"]:
if package["name"] == "pydantic":
dir_max_pydantic_minor = package["version"].split(".")[1]
break
core_min_pydantic_version = get_min_version_from_toml(
"./libs/core/pyproject.toml", "release", python_version, include=["pydantic"]
)["pydantic"]
core_min_pydantic_minor = (
core_min_pydantic_version.split(".")[1]
if "." in core_min_pydantic_version
else "0"
)
dir_min_pydantic_version = get_min_version_from_toml(
f"./{dir_}/pyproject.toml", "release", python_version, include=["pydantic"]
).get("pydantic", "0.0.0")
dir_min_pydantic_minor = (
dir_min_pydantic_version.split(".")[1]
if "." in dir_min_pydantic_version
else "0"
)
max_pydantic_minor = min(
int(dir_max_pydantic_minor),
int(core_max_pydantic_minor),
)
min_pydantic_minor = max(
int(dir_min_pydantic_minor),
int(core_min_pydantic_minor),
)
configs = [
{
"working-directory": dir_,
"pydantic-version": f"2.{v}.0",
"python-version": python_version,
}
for v in range(min_pydantic_minor, max_pydantic_minor + 1)
]
return configs
def _get_configs_for_multi_dirs(
job: str, dirs_to_run: Dict[str, Set[str]], dependents: dict
) -> List[Dict[str, str]]:
if job == "lint":
dirs = add_dependents(
dirs_to_run["lint"] | dirs_to_run["test"] | dirs_to_run["extended-test"],
dependents,
)
elif job in ["test", "compile-integration-tests", "dependencies", "test-pydantic"]:
dirs = add_dependents(
dirs_to_run["test"] | dirs_to_run["extended-test"], dependents
)
elif job == "extended-tests":
dirs = list(dirs_to_run["extended-test"])
elif job == "codspeed":
dirs = list(dirs_to_run["codspeed"])
elif job == "vcr-tests":
# Only run VCR tests for packages that have cassettes and are affected
all_affected = set(
add_dependents(
dirs_to_run["test"] | dirs_to_run["extended-test"], dependents
)
)
dirs = [d for d in VCR_PACKAGES if d in all_affected]
else:
raise ValueError(f"Unknown job: {job}")
return [
config for dir_ in dirs for config in _get_configs_for_single_dir(job, dir_)
]
def _get_changed_files(args: list[str]) -> list[str]:
"""Parse changed files from command-line arguments.
Args:
args: Either a legacy list of filename arguments or a single JSON array
produced by `Ana06/get-changed-files` with `format: json`.
Returns:
List of changed files.
Raises:
ValueError: If a single argument looks like JSON but is not a string array.
"""
if len(args) != 1:
return args
value = args[0].strip()
if not value.startswith("[") or not value.endswith("]"):
return args
try:
parsed = json.loads(value)
except json.JSONDecodeError as e:
msg = "Expected changed files JSON to be a list of strings."
raise ValueError(msg) from e
if not isinstance(parsed, list) or not all(
isinstance(file, str) for file in parsed
):
msg = "Expected changed files JSON to be a list of strings."
raise ValueError(msg)
return parsed
if __name__ == "__main__":
files = _get_changed_files(sys.argv[1:])
dirs_to_run: Dict[str, set] = {
"lint": set(),
"test": set(),
"extended-test": set(),
"codspeed": set(),
}
docs_edited = False
if len(files) >= 300:
# max diff length is 300 files - there are likely files missing
dirs_to_run["lint"] = all_package_dirs()
dirs_to_run["test"] = all_package_dirs()
dirs_to_run["extended-test"] = set(LANGCHAIN_DIRS)
for file in files:
if any(
file.startswith(dir_)
for dir_ in (
".github/workflows",
".github/tools",
".github/actions",
".github/scripts/check_diff.py",
)
):
# Infrastructure changes (workflows, actions, CI scripts) trigger tests on
# all core packages as a safety measure. This ensures that changes to CI/CD
# infrastructure don't inadvertently break package testing, even if the change
# appears unrelated (e.g., documentation build workflows). This is intentionally
# conservative to catch unexpected side effects from workflow modifications.
#
# Example: A PR modifying .github/workflows/api_doc_build.yml will trigger
# lint/test jobs for libs/core, libs/text-splitters, libs/langchain, and
# libs/langchain_v1, even though the workflow may only affect documentation.
dirs_to_run["extended-test"].update(LANGCHAIN_DIRS)
if file.startswith("libs/core"):
dirs_to_run["codspeed"].add("libs/core")
if file.startswith("libs/langchain_v1"):
dirs_to_run["codspeed"].add("libs/langchain_v1")
if any(file.startswith(dir_) for dir_ in LANGCHAIN_DIRS):
# add that dir and all dirs after in LANGCHAIN_DIRS
# for extended testing
found = False
for dir_ in LANGCHAIN_DIRS:
if dir_ == "libs/core" and IGNORE_CORE_DEPENDENTS:
dirs_to_run["extended-test"].add(dir_)
continue
if file.startswith(dir_):
found = True
if found:
dirs_to_run["extended-test"].add(dir_)
elif file.startswith("libs/standard-tests"):
# TODO: update to include all packages that rely on standard-tests (all partner packages)
# Note: won't run on external repo partners
dirs_to_run["lint"].add("libs/standard-tests")
dirs_to_run["test"].add("libs/standard-tests")
dirs_to_run["test"].add("libs/partners/mistralai")
dirs_to_run["test"].add("libs/partners/openai")
dirs_to_run["test"].add("libs/partners/anthropic")
dirs_to_run["test"].add("libs/partners/fireworks")
dirs_to_run["test"].add("libs/partners/groq")
elif file.startswith("libs/partners"):
partner_dir = file.split("/")[2]
if os.path.isdir(f"libs/partners/{partner_dir}") and [
filename
for filename in os.listdir(f"libs/partners/{partner_dir}")
if not filename.startswith(".")
] != ["README.md"]:
dirs_to_run["test"].add(f"libs/partners/{partner_dir}")
# Only add to codspeed if the partner has benchmarks and is not ignored
if (
partner_dir not in IGNORED_PARTNERS
and os.path.isdir(
f"libs/partners/{partner_dir}/tests/benchmarks"
)
):
dirs_to_run["codspeed"].add(f"libs/partners/{partner_dir}")
# Skip if the directory was deleted or is just a tombstone readme
elif file.startswith("libs/"):
# Check if this is a root-level file in libs/ (e.g., libs/README.md)
file_parts = file.split("/")
if len(file_parts) == 2:
# Root-level file in libs/, skip it (no tests needed)
continue
raise ValueError(
f"Unknown lib: {file}. check_diff.py likely needs "
"an update for this new library!"
)
elif file in [
"pyproject.toml",
"uv.lock",
]: # root uv files
docs_edited = True
dependents = dependents_graph()
# we now have dirs_by_job
# todo: clean this up
map_job_to_configs = {
job: _get_configs_for_multi_dirs(job, dirs_to_run, dependents)
for job in [
"lint",
"test",
"extended-tests",
"compile-integration-tests",
"dependencies",
"test-pydantic",
"codspeed",
"vcr-tests",
]
}
for key, value in map_job_to_configs.items():
json_output = json.dumps(value)
print(f"{key}={json_output}")
+118
View File
@@ -0,0 +1,118 @@
"""Check that optional extras stay in sync with required dependencies.
When a package appears in both [project.dependencies] and
[project.optional-dependencies], we ensure their version constraints match.
This prevents silent version drift (e.g. bumping a required dep but
forgetting the corresponding extra).
"""
import sys
import tomllib
from pathlib import Path
from re import compile as re_compile
# Matches the package name at the start of a PEP 508 dependency string.
# Stops at the first non-name character; downstream code is responsible for
# stripping extras (`[...]`) and env markers (`; ...`) from the remainder.
_NAME_RE = re_compile(r"^([A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?)")
def _normalize(name: str) -> str:
"""Normalize a package name for equality comparison.
Lowercases and maps `-` and `.` to `_`. Looser than PEP 503
(which uses `-` and collapses runs), but sufficient for matching the
same package across two PEP 508 strings.
Returns:
Lowercased, underscore-normalized package name.
"""
return name.lower().replace("-", "_").replace(".", "_")
def _parse_dep(dep: str) -> tuple[str, str]:
"""Return `(normalized_name, version_spec)` from a PEP 508 string.
Strips extras (`pkg[async]`), environment markers (`; python_version ...`),
URL specifiers (`pkg @ git+...`), and whitespace so the returned
`version_spec` is directly comparable between a required and optional dep.
Returns:
Tuple of normalized package name and bare version specifier.
Raises:
ValueError: If the dependency string cannot be parsed.
"""
match = _NAME_RE.match(dep)
if not match:
msg = f"Cannot parse dependency: {dep!r}"
raise ValueError(msg)
name = match.group(1)
rest = dep[match.end() :].strip()
if rest.startswith("["):
close = rest.find("]")
if close == -1:
msg = f"Unclosed extras bracket in dependency: {dep!r}"
raise ValueError(msg)
rest = rest[close + 1 :].strip()
if ";" in rest:
rest = rest.split(";", 1)[0].strip()
# URL specifiers have no comparable version; treat as unconstrained.
if rest.startswith("@"):
rest = ""
rest = " ".join(rest.split())
return _normalize(name), rest
def main(pyproject_path: Path) -> int:
"""Check extras sync and return `0` on pass, `1` on mismatch or parse error."""
with pyproject_path.open("rb") as f:
data = tomllib.load(f)
required: dict[str, str] = {}
for dep in data.get("project", {}).get("dependencies", []):
try:
name, spec = _parse_dep(dep)
except ValueError as e:
print(f"::error file={pyproject_path}::{e}")
return 1
required[name] = spec
optional = data.get("project", {}).get("optional-dependencies", {})
if not optional:
return 0
mismatches: list[str] = []
for group, deps in optional.items():
for dep in deps:
try:
name, spec = _parse_dep(dep)
except ValueError as e:
print(f"::error file={pyproject_path}::{e}")
return 1
if name in required and spec != required[name]:
mismatches.append(
f" [{group}] {name}: extra has '{spec}' "
f"but required dep has '{required[name]}'"
)
if mismatches:
print(f"Extra / required dependency version mismatch in {pyproject_path}:")
print("\n".join(mismatches))
print(
"\nUpdate the optional extras in [project.optional-dependencies] "
"to match [project.dependencies]."
)
return 1
print(f"All extras in {pyproject_path} are in sync with required dependencies.")
return 0
if __name__ == "__main__":
path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("pyproject.toml")
raise SystemExit(main(path))
@@ -0,0 +1,36 @@
"""Check that no dependencies allow prereleases unless we're releasing a prerelease."""
import sys
import tomllib
if __name__ == "__main__":
# Get the TOML file path from the command line argument
toml_file = sys.argv[1]
with open(toml_file, "rb") as file:
toml_data = tomllib.load(file)
# See if we're releasing an rc or dev version
version = toml_data["project"]["version"]
releasing_rc = "rc" in version or "dev" in version
# If not, iterate through dependencies and make sure none allow prereleases
if not releasing_rc:
dependencies = toml_data["project"]["dependencies"]
for dep_version in dependencies:
dep_version_string = (
dep_version["version"] if isinstance(dep_version, dict) else dep_version
)
if "rc" in dep_version_string:
raise ValueError(
f"Dependency {dep_version} has a prerelease version. Please remove this."
)
if isinstance(dep_version, dict) and dep_version.get(
"allow-prereleases", False
):
raise ValueError(
f"Dependency {dep_version} has allow-prereleases set to true. Please remove this."
)
+215
View File
@@ -0,0 +1,215 @@
"""Get minimum versions of dependencies from a pyproject.toml file."""
import sys
from collections import defaultdict
if sys.version_info >= (3, 11):
import tomllib
else:
# For Python 3.10 and below, which doesnt have stdlib tomllib
import tomli as tomllib
import re
from typing import List
import requests
from packaging.requirements import Requirement
from packaging.specifiers import SpecifierSet
from packaging.version import Version, parse
MIN_VERSION_LIBS = [
"langchain-core",
"langchain",
"langchain-text-splitters",
"numpy",
"SQLAlchemy",
]
# some libs only get checked on release because of simultaneous changes in
# multiple libs
SKIP_IF_PULL_REQUEST = [
"langchain-core",
"langchain-text-splitters",
"langchain",
]
def get_pypi_versions(package_name: str) -> List[str]:
"""Fetch all available versions for a package from PyPI.
Args:
package_name: Name of the package
Returns:
List of all available versions
Raises:
requests.exceptions.RequestException: If PyPI API request fails
KeyError: If package not found or response format unexpected
"""
pypi_url = f"https://pypi.org/pypi/{package_name}/json"
response = requests.get(pypi_url, timeout=10.0)
response.raise_for_status()
return list(response.json()["releases"].keys())
def get_minimum_version(package_name: str, spec_string: str) -> str | None:
"""Find the minimum published version that satisfies the given constraints.
Args:
package_name: Name of the package
spec_string: Version specification string (e.g., ">=0.2.43,<0.4.0,!=0.3.0")
Returns:
Minimum compatible version or None if no compatible version found
"""
# Rewrite occurrences of ^0.0.z to 0.0.z (can be anywhere in constraint string)
spec_string = re.sub(r"\^0\.0\.(\d+)", r"0.0.\1", spec_string)
# Rewrite occurrences of ^0.y.z to >=0.y.z,<0.y+1 (can be anywhere in constraint string)
for y in range(1, 10):
spec_string = re.sub(
rf"\^0\.{y}\.(\d+)", rf">=0.{y}.\1,<0.{y + 1}", spec_string
)
# Rewrite occurrences of ^x.y.z to >=x.y.z,<x+1.0.0 (can be anywhere in constraint string)
for x in range(1, 10):
spec_string = re.sub(
rf"\^{x}\.(\d+)\.(\d+)", rf">={x}.\1.\2,<{x + 1}", spec_string
)
spec_set = SpecifierSet(spec_string)
all_versions = get_pypi_versions(package_name)
valid_versions = []
for version_str in all_versions:
try:
version = parse(version_str)
if spec_set.contains(version):
valid_versions.append(version)
except ValueError:
continue
return str(min(valid_versions)) if valid_versions else None
def _check_python_version_from_requirement(
requirement: Requirement, python_version: str
) -> bool:
if not requirement.marker:
return True
else:
marker_str = str(requirement.marker)
if "python_version" in marker_str or "python_full_version" in marker_str:
python_version_str = "".join(
char
for char in marker_str
if char.isdigit() or char in (".", "<", ">", "=", ",")
)
return check_python_version(python_version, python_version_str)
return True
def get_min_version_from_toml(
toml_path: str,
versions_for: str,
python_version: str,
*,
include: list | None = None,
):
# Parse the TOML file
with open(toml_path, "rb") as file:
toml_data = tomllib.load(file)
dependencies = defaultdict(list)
for dep in toml_data["project"]["dependencies"]:
requirement = Requirement(dep)
dependencies[requirement.name].append(requirement)
# Initialize a dictionary to store the minimum versions
min_versions = {}
# Iterate over the libs in MIN_VERSION_LIBS
for lib in set(MIN_VERSION_LIBS + (include or [])):
if versions_for == "pull_request" and lib in SKIP_IF_PULL_REQUEST:
# some libs only get checked on release because of simultaneous
# changes in multiple libs
continue
# Check if the lib is present in the dependencies
if lib in dependencies:
if include and lib not in include:
continue
requirements = dependencies[lib]
for requirement in requirements:
if _check_python_version_from_requirement(requirement, python_version):
version_string = str(requirement.specifier)
break
# Use parse_version to get the minimum supported version from version_string
min_version = get_minimum_version(lib, version_string)
# Store the minimum version in the min_versions dictionary
min_versions[lib] = min_version
return min_versions
def check_python_version(version_string, constraint_string):
"""Check if the given Python version matches the given constraints.
Args:
version_string: A string representing the Python version (e.g. "3.8.5").
constraint_string: A string representing the package's Python version
constraints (e.g. ">=3.6, <4.0").
Returns:
True if the version matches the constraints
"""
# Rewrite occurrences of ^0.0.z to 0.0.z (can be anywhere in constraint string)
constraint_string = re.sub(r"\^0\.0\.(\d+)", r"0.0.\1", constraint_string)
# Rewrite occurrences of ^0.y.z to >=0.y.z,<0.y+1.0 (can be anywhere in constraint string)
for y in range(1, 10):
constraint_string = re.sub(
rf"\^0\.{y}\.(\d+)", rf">=0.{y}.\1,<0.{y + 1}.0", constraint_string
)
# Rewrite occurrences of ^x.y.z to >=x.y.z,<x+1.0.0 (can be anywhere in constraint string)
for x in range(1, 10):
constraint_string = re.sub(
rf"\^{x}\.0\.(\d+)", rf">={x}.0.\1,<{x + 1}.0.0", constraint_string
)
try:
version = Version(version_string)
constraints = SpecifierSet(constraint_string)
return version in constraints
except Exception as e:
print(f"Error: {e}")
return False
if __name__ == "__main__":
# Get the TOML file path from the command line argument
toml_file = sys.argv[1]
versions_for = sys.argv[2]
python_version = sys.argv[3]
assert versions_for in ["release", "pull_request"]
# Call the function to get the minimum versions
min_versions = get_min_version_from_toml(toml_file, versions_for, python_version)
# A `None` value means no *published* version on PyPI satisfies the declared
# constraint, e.g. a `release(...)` PR bumped a minimum pin to a version that
# has not shipped yet. Emitting `pkg==None` would be passed verbatim to
# `uv pip install` in the release workflow's minimum-version test step,
# producing a cryptic install failure, so fail loudly here instead.
unresolved = [lib for lib, version in min_versions.items() if version is None]
if unresolved:
print(
"ERROR: no published version on PyPI satisfies the declared constraint "
f"for: {', '.join(sorted(unresolved))}. A release likely pinned a "
"dependency to a version that is not yet published. Release the "
"dependency first, or relax the pin.",
file=sys.stderr,
)
sys.exit(1)
print(" ".join([f"{lib}=={version}" for lib, version in min_versions.items()]))
+84
View File
@@ -0,0 +1,84 @@
{
"trustedThreshold": 5,
"labelColor": "b76e79",
"sizeThresholds": [
{ "label": "size: XS", "max": 50 },
{ "label": "size: S", "max": 200 },
{ "label": "size: M", "max": 500 },
{ "label": "size: L", "max": 1000 },
{ "label": "size: XL" }
],
"excludedFiles": ["uv.lock"],
"excludedPaths": ["docs/"],
"typeToLabel": {
"feat": "feature",
"fix": "fix",
"docs": "documentation",
"style": "linting",
"refactor": "refactor",
"perf": "performance",
"test": "tests",
"build": "infra",
"ci": "infra",
"chore": "infra",
"revert": "revert",
"release": "release",
"hotfix": "hotfix",
"breaking": "breaking"
},
"scopeToLabel": {
"core": "core",
"langchain": "langchain",
"langchain-classic": "langchain-classic",
"model-profiles": "model-profiles",
"standard-tests": "standard-tests",
"text-splitters": "text-splitters",
"anthropic": "anthropic",
"chroma": "chroma",
"deepseek": "deepseek",
"exa": "exa",
"fireworks": "fireworks",
"groq": "groq",
"huggingface": "huggingface",
"mistralai": "mistralai",
"nomic": "nomic",
"ollama": "ollama",
"openai": "openai",
"openrouter": "openrouter",
"perplexity": "perplexity",
"qdrant": "qdrant",
"xai": "xai",
"deps": "dependencies",
"docs": "documentation",
"infra": "infra"
},
"fileRules": [
{ "label": "core", "prefix": "libs/core/", "skipExcludedFiles": true },
{ "label": "langchain-classic", "prefix": "libs/langchain/", "skipExcludedFiles": true },
{ "label": "langchain", "prefix": "libs/langchain_v1/", "skipExcludedFiles": true },
{ "label": "standard-tests", "prefix": "libs/standard-tests/", "skipExcludedFiles": true },
{ "label": "model-profiles", "prefix": "libs/model-profiles/", "skipExcludedFiles": true },
{ "label": "text-splitters", "prefix": "libs/text-splitters/", "skipExcludedFiles": true },
{ "label": "integration", "prefix": "libs/partners/", "skipExcludedFiles": true },
{ "label": "anthropic", "prefix": "libs/partners/anthropic/", "skipExcludedFiles": true },
{ "label": "chroma", "prefix": "libs/partners/chroma/", "skipExcludedFiles": true },
{ "label": "deepseek", "prefix": "libs/partners/deepseek/", "skipExcludedFiles": true },
{ "label": "exa", "prefix": "libs/partners/exa/", "skipExcludedFiles": true },
{ "label": "fireworks", "prefix": "libs/partners/fireworks/", "skipExcludedFiles": true },
{ "label": "groq", "prefix": "libs/partners/groq/", "skipExcludedFiles": true },
{ "label": "huggingface", "prefix": "libs/partners/huggingface/", "skipExcludedFiles": true },
{ "label": "mistralai", "prefix": "libs/partners/mistralai/", "skipExcludedFiles": true },
{ "label": "nomic", "prefix": "libs/partners/nomic/", "skipExcludedFiles": true },
{ "label": "ollama", "prefix": "libs/partners/ollama/", "skipExcludedFiles": true },
{ "label": "openai", "prefix": "libs/partners/openai/", "skipExcludedFiles": true },
{ "label": "openrouter", "prefix": "libs/partners/openrouter/", "skipExcludedFiles": true },
{ "label": "perplexity", "prefix": "libs/partners/perplexity/", "skipExcludedFiles": true },
{ "label": "qdrant", "prefix": "libs/partners/qdrant/", "skipExcludedFiles": true },
{ "label": "xai", "prefix": "libs/partners/xai/", "skipExcludedFiles": true },
{ "label": "github_actions", "prefix": ".github/workflows/" },
{ "label": "github_actions", "prefix": ".github/actions/" },
{ "label": "dependencies", "suffix": "pyproject.toml" },
{ "label": "dependencies", "exact": "uv.lock" },
{ "label": "dependencies", "pattern": "(?:^|/)requirements[^/]*\\.txt$" }
]
}
+278
View File
@@ -0,0 +1,278 @@
// Shared helpers for pr_labeler.yml and tag-external-issues.yml.
//
// Usage from actions/github-script (requires actions/checkout first):
// const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo, core);
const fs = require('fs');
const path = require('path');
function loadConfig() {
const configPath = path.join(__dirname, 'pr-labeler-config.json');
let raw;
try {
raw = fs.readFileSync(configPath, 'utf8');
} catch (e) {
throw new Error(`Failed to read ${configPath}: ${e.message}`);
}
let config;
try {
config = JSON.parse(raw);
} catch (e) {
throw new Error(`Failed to parse pr-labeler-config.json: ${e.message}`);
}
const required = [
'labelColor', 'sizeThresholds', 'fileRules',
'typeToLabel', 'scopeToLabel', 'trustedThreshold',
'excludedFiles', 'excludedPaths',
];
const missing = required.filter(k => !(k in config));
if (missing.length > 0) {
throw new Error(`pr-labeler-config.json missing required keys: ${missing.join(', ')}`);
}
return config;
}
function init(github, owner, repo, config, core) {
if (!core) {
throw new Error('init() requires a `core` parameter (e.g., from actions/github-script)');
}
const {
trustedThreshold,
labelColor,
sizeThresholds,
scopeToLabel,
typeToLabel,
fileRules: fileRulesDef,
excludedFiles,
excludedPaths,
} = config;
const sizeLabels = sizeThresholds.map(t => t.label);
const allTypeLabels = [...new Set(Object.values(typeToLabel))];
const tierLabels = ['new-contributor', 'trusted-contributor'];
// ── Label management ──────────────────────────────────────────────
async function ensureLabel(name, color = labelColor) {
try {
await github.rest.issues.getLabel({ owner, repo, name });
} catch (e) {
if (e.status !== 404) throw e;
try {
await github.rest.issues.createLabel({ owner, repo, name, color });
} catch (createErr) {
// 422 = label created by a concurrent run between our get and create
if (createErr.status !== 422) throw createErr;
core.info(`Label "${name}" creation returned 422 (likely already exists)`);
}
}
}
// ── Size calculation ──────────────────────────────────────────────
function getSizeLabel(totalChanged) {
for (const t of sizeThresholds) {
if (t.max != null && totalChanged < t.max) return t.label;
}
// Last entry has no max — it's the catch-all
return sizeThresholds[sizeThresholds.length - 1].label;
}
function computeSize(files) {
const excluded = new Set(excludedFiles);
const totalChanged = files.reduce((sum, f) => {
const p = f.filename ?? '';
const base = p.split('/').pop();
if (excluded.has(base)) return sum;
for (const prefix of excludedPaths) {
if (p.startsWith(prefix)) return sum;
}
return sum + (f.additions ?? 0) + (f.deletions ?? 0);
}, 0);
return { totalChanged, sizeLabel: getSizeLabel(totalChanged) };
}
// ── File-based labels ─────────────────────────────────────────────
function buildFileRules() {
return fileRulesDef.map((rule, i) => {
let test;
if (rule.prefix) test = p => p.startsWith(rule.prefix);
else if (rule.suffix) test = p => p.endsWith(rule.suffix);
else if (rule.exact) test = p => p === rule.exact;
else if (rule.pattern) {
const re = new RegExp(rule.pattern);
test = p => re.test(p);
} else {
throw new Error(
`fileRules[${i}] (label: "${rule.label}") has no recognized matcher ` +
`(expected one of: prefix, suffix, exact, pattern)`
);
}
return { label: rule.label, test, skipExcluded: !!rule.skipExcludedFiles };
});
}
function matchFileLabels(files, fileRules) {
const rules = fileRules || buildFileRules();
const excluded = new Set(excludedFiles);
const labels = new Set();
for (const rule of rules) {
// skipExcluded: ignore files whose basename is in the top-level
// "excludedFiles" list (e.g. uv.lock) so lockfile-only changes
// don't trigger package labels.
const candidates = rule.skipExcluded
? files.filter(f => !excluded.has((f.filename ?? '').split('/').pop()))
: files;
if (candidates.some(f => rule.test(f.filename ?? ''))) {
labels.add(rule.label);
}
}
return labels;
}
// ── Title-based labels ────────────────────────────────────────────
function matchTitleLabels(title) {
const labels = new Set();
const m = (title ?? '').match(/^(\w+)(?:\(([^)]+)\))?(!)?:/);
if (!m) return { labels, type: null, typeLabel: null, scopes: [], breaking: false };
const type = m[1].toLowerCase();
const scopeStr = m[2] ?? '';
const breaking = !!m[3];
const typeLabel = typeToLabel[type] || null;
if (typeLabel) labels.add(typeLabel);
if (breaking) labels.add('breaking');
const scopes = scopeStr.split(',').map(s => s.trim()).filter(Boolean);
for (const scope of scopes) {
const sl = scopeToLabel[scope];
if (sl) labels.add(sl);
}
return { labels, type, typeLabel, scopes, breaking };
}
// ── Org membership ────────────────────────────────────────────────
async function checkMembership(author, userType) {
if (userType === 'Bot') {
console.log(`${author} is a Bot — treating as internal`);
return { isExternal: false };
}
try {
const membership = await github.rest.orgs.getMembershipForUser({
org: 'langchain-ai',
username: author,
});
const isExternal = membership.data.state !== 'active';
console.log(
isExternal
? `${author} has pending membership — treating as external`
: `${author} is an active member of langchain-ai`,
);
return { isExternal };
} catch (e) {
if (e.status === 404) {
console.log(`${author} is not a member of langchain-ai`);
return { isExternal: true };
}
// Non-404 errors (rate limit, auth failure, server error) must not
// silently default to external — rethrow to fail the step.
throw new Error(
`Membership check failed for ${author} (${e.status}): ${e.message}`,
);
}
}
// ── Contributor analysis ──────────────────────────────────────────
async function getContributorInfo(contributorCache, author, userType) {
if (contributorCache.has(author)) return contributorCache.get(author);
const { isExternal } = await checkMembership(author, userType);
let mergedCount = null;
if (isExternal) {
try {
const result = await github.rest.search.issuesAndPullRequests({
q: `repo:${owner}/${repo} is:pr is:merged author:"${author}"`,
per_page: 1,
});
mergedCount = result?.data?.total_count ?? null;
} catch (e) {
if (e?.status !== 422) throw e;
core.warning(`Search failed for ${author}; skipping tier.`);
}
}
const info = { isExternal, mergedCount };
contributorCache.set(author, info);
return info;
}
// ── Tier label resolution ───────────────────────────────────────────
async function applyTierLabel(issueNumber, author, { skipNewContributor = false } = {}) {
let mergedCount;
try {
const result = await github.rest.search.issuesAndPullRequests({
q: `repo:${owner}/${repo} is:pr is:merged author:"${author}"`,
per_page: 1,
});
mergedCount = result?.data?.total_count;
} catch (error) {
if (error?.status !== 422) throw error;
core.warning(`Search failed for ${author}; skipping tier label.`);
return;
}
if (mergedCount == null) {
core.warning(`Search response missing total_count for ${author}; skipping tier label.`);
return;
}
let tierLabel = null;
if (mergedCount >= trustedThreshold) tierLabel = 'trusted-contributor';
else if (mergedCount === 0 && !skipNewContributor) tierLabel = 'new-contributor';
if (tierLabel) {
await ensureLabel(tierLabel);
await github.rest.issues.addLabels({
owner, repo, issue_number: issueNumber, labels: [tierLabel],
});
console.log(`Applied '${tierLabel}' to #${issueNumber} (${mergedCount} merged PRs)`);
} else {
console.log(`No tier label for ${author} (${mergedCount} merged PRs)`);
}
return tierLabel;
}
return {
ensureLabel,
getSizeLabel,
computeSize,
buildFileRules,
matchFileLabels,
matchTitleLabels,
allTypeLabels,
checkMembership,
getContributorInfo,
applyTierLabel,
sizeLabels,
tierLabels,
trustedThreshold,
labelColor,
};
}
function loadAndInit(github, owner, repo, core) {
const config = loadConfig();
return { config, h: init(github, owner, repo, config, core) };
}
module.exports = { loadConfig, init, loadAndInit };
+66
View File
@@ -0,0 +1,66 @@
"""Verify _release.yml dropdown options match actual package directories.
Dropdown options are short names (e.g. `openai`, `core`). The workflow's
`EFFECTIVE_WORKING_DIR` expression re-adds the `libs/` prefix for top-level
packages and `libs/partners/` for everything else. This test reconstructs the
full path for each short name and compares against packages on disk.
"""
from pathlib import Path
import yaml
REPO_ROOT = Path(__file__).resolve().parents[2]
# Keep in sync with the non-partner allowlist in `EFFECTIVE_WORKING_DIR`
# in `.github/workflows/_release.yml`.
TOP_LEVEL_PACKAGES = frozenset(
{"core", "langchain", "langchain_v1", "text-splitters", "standard-tests", "model-profiles"}
)
def _get_release_options() -> list[str]:
workflow = REPO_ROOT / ".github" / "workflows" / "_release.yml"
with open(workflow) as f:
data = yaml.safe_load(f)
try:
# PyYAML (YAML 1.1) parses the bare key `on` as boolean True
return data[True]["workflow_dispatch"]["inputs"]["working-directory"]["options"]
except (KeyError, TypeError) as e:
msg = f"Could not find workflow_dispatch options in {workflow}: {e}"
raise AssertionError(msg) from e
def _expand_option(option: str) -> str:
if option in TOP_LEVEL_PACKAGES:
return f"libs/{option}"
return f"libs/partners/{option}"
def _get_package_dirs() -> set[str]:
libs = REPO_ROOT / "libs"
dirs: set[str] = set()
# Top-level packages (libs/core, libs/langchain, etc.)
for p in libs.iterdir():
if p.is_dir() and (p / "pyproject.toml").exists():
dirs.add(f"libs/{p.name}")
# Partner packages (libs/partners/*)
partners = libs / "partners"
if partners.exists():
for p in partners.iterdir():
if p.is_dir() and (p / "pyproject.toml").exists():
dirs.add(f"libs/partners/{p.name}")
return dirs
def test_release_options_match_packages() -> None:
options = {_expand_option(o) for o in _get_release_options()}
packages = _get_package_dirs()
missing_from_dropdown = packages - options
extra_in_dropdown = options - packages
assert not missing_from_dropdown, (
f"Packages on disk missing from _release.yml dropdown: {missing_from_dropdown}"
)
assert not extra_in_dropdown, (
f"Dropdown options with no matching package directory: {extra_in_dropdown}"
)
+756
View File
@@ -0,0 +1,756 @@
#!/usr/bin/env python3
#
# git-restore-mtime - Change mtime of files based on commit date of last change
#
# Copyright (C) 2012 Rodrigo Silva (MestreLion) <linux@rodrigosilva.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. See <http://www.gnu.org/licenses/gpl.html>
#
# Source: https://github.com/MestreLion/git-tools
# Version: July 13, 2023 (commit hash 5f832e72453e035fccae9d63a5056918d64476a2)
"""
Change the modification time (mtime) of files in work tree, based on the
date of the most recent commit that modified the file, including renames.
Ignores untracked files and uncommitted deletions, additions and renames, and
by default modifications too.
---
Useful prior to generating release tarballs, so each file is archived with a
date that is similar to the date when the file was actually last modified,
assuming the actual modification date and its commit date are close.
"""
# TODO:
# - Add -z on git whatchanged/ls-files, so we don't deal with filename decoding
# - When Python is bumped to 3.7, use text instead of universal_newlines on subprocess
# - Update "Statistics for some large projects" with modern hardware and repositories.
# - Create a README.md for git-restore-mtime alone. It deserves extensive documentation
# - Move Statistics there
# - See git-extras as a good example on project structure and documentation
# FIXME:
# - When current dir is outside the worktree, e.g. using --work-tree, `git ls-files`
# assume any relative pathspecs are to worktree root, not the current dir. As such,
# relative pathspecs may not work.
# - Renames are tricky:
# - R100 should not change mtime, but original name is not on filelist. Should
# track renames until a valid (A, M) mtime found and then set on current name.
# - Should set mtime for both current and original directories.
# - Check mode changes with unchanged blobs?
# - Check file (A, D) for the directory mtime is not sufficient:
# - Renames also change dir mtime, unless rename was on a parent dir
# - If most recent change of all files in a dir was a Modification (M),
# dir might not be touched at all.
# - Dirs containing only subdirectories but no direct files will also
# not be touched. They're files' [grand]parent dir, but never their dirname().
# - Some solutions:
# - After files done, perform some dir processing for missing dirs, finding latest
# file (A, D, R)
# - Simple approach: dir mtime is the most recent child (dir or file) mtime
# - Use a virtual concept of "created at most at" to fill missing info, bubble up
# to parents and grandparents
# - When handling [grand]parent dirs, stay inside <pathspec>
# - Better handling of merge commits. `-m` is plain *wrong*. `-c/--cc` is perfect, but
# painfully slow. First pass without merge commits is not accurate. Maybe add a new
# `--accurate` mode for `--cc`?
if __name__ != "__main__":
raise ImportError("{} should not be used as a module.".format(__name__))
import argparse
import datetime
import logging
import os.path
import shlex
import signal
import subprocess
import sys
import time
__version__ = "2022.12+dev"
# Update symlinks only if the platform supports not following them
UPDATE_SYMLINKS = bool(os.utime in getattr(os, "supports_follow_symlinks", []))
# Call os.path.normpath() only if not in a POSIX platform (Windows)
NORMALIZE_PATHS = os.path.sep != "/"
# How many files to process in each batch when re-trying merge commits
STEPMISSING = 100
# (Extra) keywords for the os.utime() call performed by touch()
UTIME_KWS = {} if not UPDATE_SYMLINKS else {"follow_symlinks": False}
# Command-line interface ######################################################
def parse_args():
parser = argparse.ArgumentParser(description=__doc__.split("\n---")[0])
group = parser.add_mutually_exclusive_group()
group.add_argument(
"--quiet",
"-q",
dest="loglevel",
action="store_const",
const=logging.WARNING,
default=logging.INFO,
help="Suppress informative messages and summary statistics.",
)
group.add_argument(
"--verbose",
"-v",
action="count",
help="""
Print additional information for each processed file.
Specify twice to further increase verbosity.
""",
)
parser.add_argument(
"--cwd",
"-C",
metavar="DIRECTORY",
help="""
Run as if %(prog)s was started in directory %(metavar)s.
This affects how --work-tree, --git-dir and PATHSPEC arguments are handled.
See 'man 1 git' or 'git --help' for more information.
""",
)
parser.add_argument(
"--git-dir",
dest="gitdir",
metavar="GITDIR",
help="""
Path to the git repository, by default auto-discovered by searching
the current directory and its parents for a .git/ subdirectory.
""",
)
parser.add_argument(
"--work-tree",
dest="workdir",
metavar="WORKTREE",
help="""
Path to the work tree root, by default the parent of GITDIR if it's
automatically discovered, or the current directory if GITDIR is set.
""",
)
parser.add_argument(
"--force",
"-f",
default=False,
action="store_true",
help="""
Force updating files with uncommitted modifications.
Untracked files and uncommitted deletions, renames and additions are
always ignored.
""",
)
parser.add_argument(
"--merge",
"-m",
default=False,
action="store_true",
help="""
Include merge commits.
Leads to more recent times and more files per commit, thus with the same
time, which may or may not be what you want.
Including merge commits may lead to fewer commits being evaluated as files
are found sooner, which can improve performance, sometimes substantially.
But as merge commits are usually huge, processing them may also take longer.
By default, merge commits are only used for files missing from regular commits.
""",
)
parser.add_argument(
"--first-parent",
default=False,
action="store_true",
help="""
Consider only the first parent, the "main branch", when evaluating merge commits.
Only effective when merge commits are processed, either when --merge is
used or when finding missing files after the first regular log search.
See --skip-missing.
""",
)
parser.add_argument(
"--skip-missing",
"-s",
dest="missing",
default=True,
action="store_false",
help="""
Do not try to find missing files.
If merge commits were not evaluated with --merge and some files were
not found in regular commits, by default %(prog)s searches for these
files again in the merge commits.
This option disables this retry, so files found only in merge commits
will not have their timestamp updated.
""",
)
parser.add_argument(
"--no-directories",
"-D",
dest="dirs",
default=True,
action="store_false",
help="""
Do not update directory timestamps.
By default, use the time of its most recently created, renamed or deleted file.
Note that just modifying a file will NOT update its directory time.
""",
)
parser.add_argument(
"--test",
"-t",
default=False,
action="store_true",
help="Test run: do not actually update any file timestamp.",
)
parser.add_argument(
"--commit-time",
"-c",
dest="commit_time",
default=False,
action="store_true",
help="Use commit time instead of author time.",
)
parser.add_argument(
"--oldest-time",
"-o",
dest="reverse_order",
default=False,
action="store_true",
help="""
Update times based on the oldest, instead of the most recent commit of a file.
This reverses the order in which the git log is processed to emulate a
file "creation" date. Note this will be inaccurate for files deleted and
re-created at later dates.
""",
)
parser.add_argument(
"--skip-older-than",
metavar="SECONDS",
type=int,
help="""
Ignore files that are currently older than %(metavar)s.
Useful in workflows that assume such files already have a correct timestamp,
as it may improve performance by processing fewer files.
""",
)
parser.add_argument(
"--skip-older-than-commit",
"-N",
default=False,
action="store_true",
help="""
Ignore files older than the timestamp it would be updated to.
Such files may be considered "original", likely in the author's repository.
""",
)
parser.add_argument(
"--unique-times",
default=False,
action="store_true",
help="""
Set the microseconds to a unique value per commit.
Allows telling apart changes that would otherwise have identical timestamps,
as git's time accuracy is in seconds.
""",
)
parser.add_argument(
"pathspec",
nargs="*",
metavar="PATHSPEC",
help="""
Only modify paths matching %(metavar)s, relative to current directory.
By default, update all but untracked files and submodules.
""",
)
parser.add_argument(
"--version",
"-V",
action="version",
version="%(prog)s version {version}".format(version=get_version()),
)
args_ = parser.parse_args()
if args_.verbose:
args_.loglevel = max(logging.TRACE, logging.DEBUG // args_.verbose)
args_.debug = args_.loglevel <= logging.DEBUG
return args_
def get_version(version=__version__):
if not version.endswith("+dev"):
return version
try:
cwd = os.path.dirname(os.path.realpath(__file__))
return Git(cwd=cwd, errors=False).describe().lstrip("v")
except Git.Error:
return "-".join((version, "unknown"))
# Helper functions ############################################################
def setup_logging():
"""Add TRACE logging level and corresponding method, return the root logger"""
logging.TRACE = TRACE = logging.DEBUG // 2
logging.Logger.trace = lambda _, m, *a, **k: _.log(TRACE, m, *a, **k)
return logging.getLogger()
def normalize(path):
r"""Normalize paths from git, handling non-ASCII characters.
Git stores paths as UTF-8 normalization form C.
If path contains non-ASCII or non-printable characters, git outputs the UTF-8
in octal-escaped notation, escaping double-quotes and backslashes, and then
double-quoting the whole path.
https://git-scm.com/docs/git-config#Documentation/git-config.txt-corequotePath
This function reverts this encoding, so:
normalize(r'"Back\\slash_double\"quote_a\303\247a\303\255"') =>
r'Back\slash_double"quote_açaí')
Paths with invalid UTF-8 encoding, such as single 0x80-0xFF bytes (e.g, from
Latin1/Windows-1251 encoding) are decoded using surrogate escape, the same
method used by Python for filesystem paths. So 0xE6 ("æ" in Latin1, r'\\346'
from Git) is decoded as "\udce6". See https://peps.python.org/pep-0383/ and
https://vstinner.github.io/painful-history-python-filesystem-encoding.html
Also see notes on `windows/non-ascii-paths.txt` about path encodings on
non-UTF-8 platforms and filesystems.
"""
if path and path[0] == '"':
# Python 2: path = path[1:-1].decode("string-escape")
# Python 3: https://stackoverflow.com/a/46650050/624066
path = (
path[1:-1] # Remove enclosing double quotes
.encode("latin1") # Convert to bytes, required by 'unicode-escape'
.decode("unicode-escape") # Perform the actual octal-escaping decode
.encode("latin1") # 1:1 mapping to bytes, UTF-8 encoded
.decode("utf8", "surrogateescape")
) # Decode from UTF-8
if NORMALIZE_PATHS:
# Make sure the slash matches the OS; for Windows we need a backslash
path = os.path.normpath(path)
return path
def dummy(*_args, **_kwargs):
"""No-op function used in dry-run tests"""
def touch(path, mtime):
"""The actual mtime update"""
os.utime(path, (mtime, mtime), **UTIME_KWS)
def touch_ns(path, mtime_ns):
"""The actual mtime update, using nanoseconds for unique timestamps"""
os.utime(path, None, ns=(mtime_ns, mtime_ns), **UTIME_KWS)
def isodate(secs: int):
# time.localtime() accepts floats, but discards fractional part
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(secs))
def isodate_ns(ns: int):
# for integers fromtimestamp() is equivalent and ~16% slower than isodate()
return datetime.datetime.fromtimestamp(ns / 1000000000).isoformat(sep=" ")
def get_mtime_ns(secs: int, idx: int):
# Time resolution for filesystems and functions:
# ext-4 and other POSIX filesystems: 1 nanosecond
# NTFS (Windows default): 100 nanoseconds
# datetime.datetime() (due to 64-bit float epoch): 1 microsecond
us = idx % 1000000 # 10**6
return 1000 * (1000000 * secs + us)
def get_mtime_path(path):
return os.path.getmtime(path)
# Git class and parse_log(), the heart of the script ##########################
class Git:
def __init__(self, workdir=None, gitdir=None, cwd=None, errors=True):
self.gitcmd = ["git"]
self.errors = errors
self._proc = None
if workdir:
self.gitcmd.extend(("--work-tree", workdir))
if gitdir:
self.gitcmd.extend(("--git-dir", gitdir))
if cwd:
self.gitcmd.extend(("-C", cwd))
self.workdir, self.gitdir = self._get_repo_dirs()
def ls_files(self, paths: list = None):
return (normalize(_) for _ in self._run("ls-files --full-name", paths))
def ls_dirty(self, force=False):
return (
normalize(_[3:].split(" -> ", 1)[-1])
for _ in self._run("status --porcelain")
if _[:2] != "??" and (not force or (_[0] in ("R", "A") or _[1] == "D"))
)
def log(
self,
merge=False,
first_parent=False,
commit_time=False,
reverse_order=False,
paths: list = None,
):
cmd = "whatchanged --pretty={}".format("%ct" if commit_time else "%at")
if merge:
cmd += " -m"
if first_parent:
cmd += " --first-parent"
if reverse_order:
cmd += " --reverse"
return self._run(cmd, paths)
def describe(self):
return self._run("describe --tags", check=True)[0]
def terminate(self):
if self._proc is None:
return
try:
self._proc.terminate()
except OSError:
# Avoid errors on OpenBSD
pass
def _get_repo_dirs(self):
return (
os.path.normpath(_)
for _ in self._run(
"rev-parse --show-toplevel --absolute-git-dir", check=True
)
)
def _run(self, cmdstr: str, paths: list = None, output=True, check=False):
cmdlist = self.gitcmd + shlex.split(cmdstr)
if paths:
cmdlist.append("--")
cmdlist.extend(paths)
popen_args = dict(universal_newlines=True, encoding="utf8")
if not self.errors:
popen_args["stderr"] = subprocess.DEVNULL
log.trace("Executing: %s", " ".join(cmdlist))
if not output:
return subprocess.call(cmdlist, **popen_args)
if check:
try:
stdout: str = subprocess.check_output(cmdlist, **popen_args)
return stdout.splitlines()
except subprocess.CalledProcessError as e:
raise self.Error(e.returncode, e.cmd, e.output, e.stderr)
self._proc = subprocess.Popen(cmdlist, stdout=subprocess.PIPE, **popen_args)
return (_.rstrip() for _ in self._proc.stdout)
def __del__(self):
self.terminate()
class Error(subprocess.CalledProcessError):
"""Error from git executable"""
def parse_log(filelist, dirlist, stats, git, merge=False, filterlist=None):
mtime = 0
datestr = isodate(0)
for line in git.log(
merge, args.first_parent, args.commit_time, args.reverse_order, filterlist
):
stats["loglines"] += 1
# Blank line between Date and list of files
if not line:
continue
# Date line
if line[0] != ":": # Faster than `not line.startswith(':')`
stats["commits"] += 1
mtime = int(line)
if args.unique_times:
mtime = get_mtime_ns(mtime, stats["commits"])
if args.debug:
datestr = isodate(mtime)
continue
# File line: three tokens if it describes a renaming, otherwise two
tokens = line.split("\t")
# Possible statuses:
# M: Modified (content changed)
# A: Added (created)
# D: Deleted
# T: Type changed: to/from regular file, symlinks, submodules
# R099: Renamed (moved), with % of unchanged content. 100 = pure rename
# Not possible in log: C=Copied, U=Unmerged, X=Unknown, B=pairing Broken
status = tokens[0].split(" ")[-1]
file = tokens[-1]
# Handles non-ASCII chars and OS path separator
file = normalize(file)
def do_file():
if args.skip_older_than_commit and get_mtime_path(file) <= mtime:
stats["skip"] += 1
return
if args.debug:
log.debug(
"%d\t%d\t%d\t%s\t%s",
stats["loglines"],
stats["commits"],
stats["files"],
datestr,
file,
)
try:
touch(os.path.join(git.workdir, file), mtime)
stats["touches"] += 1
except Exception as e:
log.error("ERROR: %s: %s", e, file)
stats["errors"] += 1
def do_dir():
if args.debug:
log.debug(
"%d\t%d\t-\t%s\t%s",
stats["loglines"],
stats["commits"],
datestr,
"{}/".format(dirname or "."),
)
try:
touch(os.path.join(git.workdir, dirname), mtime)
stats["dirtouches"] += 1
except Exception as e:
log.error("ERROR: %s: %s", e, dirname)
stats["direrrors"] += 1
if file in filelist:
stats["files"] -= 1
filelist.remove(file)
do_file()
if args.dirs and status in ("A", "D"):
dirname = os.path.dirname(file)
if dirname in dirlist:
dirlist.remove(dirname)
do_dir()
# All files done?
if not stats["files"]:
git.terminate()
return
# Main Logic ##################################################################
def main():
start = time.time() # yes, Wall time. CPU time is not realistic for users.
stats = {
_: 0
for _ in (
"loglines",
"commits",
"touches",
"skip",
"errors",
"dirtouches",
"direrrors",
)
}
logging.basicConfig(level=args.loglevel, format="%(message)s")
log.trace("Arguments: %s", args)
# First things first: Where and Who are we?
if args.cwd:
log.debug("Changing directory: %s", args.cwd)
try:
os.chdir(args.cwd)
except OSError as e:
log.critical(e)
return e.errno
# Using both os.chdir() and `git -C` is redundant, but might prevent side effects
# `git -C` alone could be enough if we make sure that:
# - all paths, including args.pathspec, are processed by git: ls-files, rev-parse
# - touch() / os.utime() path argument is always prepended with git.workdir
try:
git = Git(workdir=args.workdir, gitdir=args.gitdir, cwd=args.cwd)
except Git.Error as e:
# Not in a git repository, and git already informed user on stderr. So we just...
return e.returncode
# Get the files managed by git and build file list to be processed
if UPDATE_SYMLINKS and not args.skip_older_than:
filelist = set(git.ls_files(args.pathspec))
else:
filelist = set()
for path in git.ls_files(args.pathspec):
fullpath = os.path.join(git.workdir, path)
# Symlink (to file, to dir or broken - git handles the same way)
if not UPDATE_SYMLINKS and os.path.islink(fullpath):
log.warning(
"WARNING: Skipping symlink, no OS support for updates: %s", path
)
continue
# skip files which are older than given threshold
if (
args.skip_older_than
and start - get_mtime_path(fullpath) > args.skip_older_than
):
continue
# Always add files relative to worktree root
filelist.add(path)
# If --force, silently ignore uncommitted deletions (not in the filesystem)
# and renames / additions (will not be found in log anyway)
if args.force:
filelist -= set(git.ls_dirty(force=True))
# Otherwise, ignore any dirty files
else:
dirty = set(git.ls_dirty())
if dirty:
log.warning(
"WARNING: Modified files in the working directory were ignored."
"\nTo include such files, commit your changes or use --force."
)
filelist -= dirty
# Build dir list to be processed
dirlist = set(os.path.dirname(_) for _ in filelist) if args.dirs else set()
stats["totalfiles"] = stats["files"] = len(filelist)
log.info("{0:,} files to be processed in work dir".format(stats["totalfiles"]))
if not filelist:
# Nothing to do. Exit silently and without errors, just like git does
return
# Process the log until all files are 'touched'
log.debug("Line #\tLog #\tF.Left\tModification Time\tFile Name")
parse_log(filelist, dirlist, stats, git, args.merge, args.pathspec)
# Missing files
if filelist:
# Try to find them in merge logs, if not done already
# (usually HUGE, thus MUCH slower!)
if args.missing and not args.merge:
filterlist = list(filelist)
missing = len(filterlist)
log.info(
"{0:,} files not found in log, trying merge commits".format(missing)
)
for i in range(0, missing, STEPMISSING):
parse_log(
filelist,
dirlist,
stats,
git,
merge=True,
filterlist=filterlist[i : i + STEPMISSING],
)
# Still missing some?
for file in filelist:
log.warning("WARNING: not found in the log: %s", file)
# Final statistics
# Suggestion: use git-log --before=mtime to brag about skipped log entries
def log_info(msg, *a, width=13):
ifmt = "{:%d,}" % (width,) # not using 'n' for consistency with ffmt
ffmt = "{:%d,.2f}" % (width,)
# %-formatting lacks a thousand separator, must pre-render with .format()
log.info(msg.replace("%d", ifmt).replace("%f", ffmt).format(*a))
log_info(
"Statistics:\n%f seconds\n%d log lines processed\n%d commits evaluated",
time.time() - start,
stats["loglines"],
stats["commits"],
)
if args.dirs:
if stats["direrrors"]:
log_info("%d directory update errors", stats["direrrors"])
log_info("%d directories updated", stats["dirtouches"])
if stats["touches"] != stats["totalfiles"]:
log_info("%d files", stats["totalfiles"])
if stats["skip"]:
log_info("%d files skipped", stats["skip"])
if stats["files"]:
log_info("%d files missing", stats["files"])
if stats["errors"]:
log_info("%d file update errors", stats["errors"])
log_info("%d files updated", stats["touches"])
if args.test:
log.info("TEST RUN - No files modified!")
# Keep only essential, global assignments here. Any other logic must be in main()
log = setup_logging()
args = parse_args()
# Set the actual touch() and other functions based on command-line arguments
if args.unique_times:
touch = touch_ns
isodate = isodate_ns
# Make sure this is always set last to ensure --test behaves as intended
if args.test:
touch = dummy
# UI done, it's showtime!
try:
sys.exit(main())
except KeyboardInterrupt:
log.info("\nAborting")
signal.signal(signal.SIGINT, signal.SIG_DFL)
os.kill(os.getpid(), signal.SIGINT)
@@ -0,0 +1,65 @@
# 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # 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'
+81
View File
@@ -0,0 +1,81 @@
# 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # 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
@@ -0,0 +1,228 @@
# 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_CLIENT_ID: ${{ secrets.MODEL_PROFILE_BOT_CLIENT_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](https://github.com/langchain-ai/langchain/blob/master/.github/workflows/refresh_model_profiles.yml).
pr-labels:
description: "Comma-separated labels to apply to the PR."
required: false
type: string
default: bot
secrets:
MODEL_PROFILE_BOT_CLIENT_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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
- name: "📋 Checkout langchain-profiles CLI"
if: inputs.cli-path == ''
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # 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: "📝 Build PR body with change summary"
id: pr-body
env:
PROVIDERS_JSON: ${{ inputs.providers }}
PR_BODY: ${{ inputs.pr-body }}
run: |
# The refresh step modified the working tree without committing, so
# comparing against HEAD yields exactly the refresh's changes.
cli_dir="${GITHUB_WORKSPACE}/${{ steps.cli.outputs.dir }}"
body_file="${RUNNER_TEMP}/pr_body.md"
printf '%s\n\n' "${PR_BODY}" > "${body_file}"
# `summarize` builds the whole summary in memory and prints it once,
# so a failure exits non-zero before any stdout reaches the append —
# the body keeps only the static note, never a half-written summary.
if ! uv run --frozen --project "${cli_dir}" \
langchain-profiles summarize \
--providers "${PROVIDERS_JSON}" \
--base-ref HEAD \
--repo-root "${GITHUB_WORKSPACE}" >> "${body_file}"; then
echo "::warning::Could not generate change summary; see job log."
# Surface the degradation in the PR body too: the warning above only
# lands in the Actions log, which a PR reviewer won't see.
printf '\n> [!NOTE]\n> Automated change summary unavailable — see the workflow run log.\n' >> "${body_file}"
fi
echo "path=${body_file}" >> "$GITHUB_OUTPUT"
- name: "🔑 Generate GitHub App token"
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
with:
client-id: ${{ secrets.MODEL_PROFILE_BOT_CLIENT_ID }}
private-key: ${{ secrets.MODEL_PROFILE_BOT_PRIVATE_KEY }}
- name: "🔀 Create pull request"
id: create-pr
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8
with:
token: ${{ steps.app-token.outputs.token }}
branch: ${{ inputs.pr-branch }}
commit-message: ${{ inputs.pr-title }}
title: ${{ inputs.pr-title }}
body-path: ${{ steps.pr-body.outputs.path }}
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
+914
View File
@@ -0,0 +1,914 @@
# 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 title resolves dropdown values to the published package name (e.g.
# `core` -> `langchain-core`, `openai` -> `langchain-openai`). Falls back to
# the raw input for override and `workflow_call` cases, which already pass
# a full path. Three dropdown values don't follow `langchain-{name}`:
# `langchain` -> `langchain-classic`, `langchain_v1` -> `langchain`,
# `standard-tests` -> `langchain-tests`.
run-name: >-
Release ${{ inputs.working-directory-override ||
(startsWith(inputs.working-directory, 'libs/') && inputs.working-directory) ||
(inputs.working-directory == 'langchain' && 'langchain-classic') ||
(inputs.working-directory == 'langchain_v1' && 'langchain') ||
(inputs.working-directory == 'standard-tests' && 'langchain-tests') ||
format('langchain-{0}', inputs.working-directory) }} ${{
inputs.release-version }}
on:
workflow_call:
inputs:
working-directory:
required: true
type: string
description: "From which folder this pipeline executes"
release-version:
required: false
type: string
default: ""
description: "Expected package version. If provided, must match pyproject.toml."
allow-prereleases:
required: false
type: boolean
default: false
description: "Pass `--prerelease=allow` to wheel-install steps so
transitive prerelease deps (e.g. langgraph-checkpoint>=4.1.0a3 pulled
in by an alpha langgraph) resolve. Use only when the release itself
is a prerelease and at least one dep is also a prerelease."
# `workflow_call` callers must pass an exact lowercase value: `none` or a
# partner name from the `test-prior-published-packages-against-new-core`
# matrix (or `all`). Unrecognized values fail safe (the check still runs).
# Keep this list in sync with that matrix and the `workflow_dispatch`
# `options` below.
skip-prior-published-package-checks:
required: false
type: string
default: "none"
description: "Prior published partner check to skip for core releases:
none, anthropic, openai, or all."
workflow_dispatch:
inputs:
working-directory:
required: true
type: choice
description: "From which folder this pipeline executes"
default: "langchain_v1"
# Short names only — `EFFECTIVE_WORKING_DIR` below re-adds the `libs/`
# or `libs/partners/` prefix. When adding a new option, also update the
# non-partner allowlist in `EFFECTIVE_WORKING_DIR` if it isn't a partner
# package (partners are the default branch).
options:
- core
- langchain
- langchain_v1
- text-splitters
- standard-tests
- model-profiles
- anthropic
- chroma
- deepseek
- exa
- fireworks
- groq
- huggingface
- mistralai
- nomic
- ollama
- openai
- openrouter
- perplexity
- qdrant
- xai
working-directory-override:
required: false
type: string
description: "Manual override — takes precedence over dropdown (e.g.
libs/partners/partner-xyz)"
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"
allow-prereleases:
required: false
type: boolean
default: false
description: "Pass `--prerelease=allow` to wheel-install steps so
transitive prerelease deps (e.g. langgraph-checkpoint>=4.1.0a3 pulled
in by an alpha langgraph) resolve. Use only when the release itself
is a prerelease and at least one dep is also a prerelease."
skip-prior-published-package-checks:
required: false
type: choice
default: none
description: "Prior published partner check to skip for core releases"
options:
- none
- anthropic
- openai
- all
env:
PYTHON_VERSION: "3.11"
UV_FROZEN: "true"
UV_NO_SYNC: "true"
# Resolves to a full path. Accepts either:
# - `working-directory-override` as a full path (e.g. `libs/partners/partner-xyz`)
# - `working-directory` as a full path (from `workflow_call` callers)
# - `working-directory` as a short dropdown name (from `workflow_dispatch`)
EFFECTIVE_WORKING_DIR: >-
${{
inputs.working-directory-override
|| (startsWith(inputs.working-directory, 'libs/') && inputs.working-directory)
|| (contains(fromJSON('["core","langchain","langchain_v1","text-splitters","standard-tests","model-profiles"]'), inputs.working-directory) && format('libs/{0}', inputs.working-directory))
|| format('libs/partners/{0}', inputs.working-directory)
}}
permissions:
contents: read # Job-level overrides grant write only where needed (mark-release)
jobs:
# Build the distribution package and extract version info
# Runs in isolated environment with minimal permissions for security
build:
name: 📦 Build distribution
if: github.repository_owner == 'langchain-ai' && (github.ref == 'refs/heads/master' || inputs.dangerous-nonmaster-release)
environment: Release
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
- name: Set up Python + uv
uses: "./.github/actions/uv_setup"
with:
python-version: ${{ env.PYTHON_VERSION }}
enable-cache: "false"
- name: Summarize release bypasses
if: >-
inputs.dangerous-nonmaster-release || inputs.allow-prereleases ||
inputs.skip-prior-published-package-checks != 'none'
env:
ALLOW_PRERELEASES: ${{ inputs.allow-prereleases }}
DANGEROUS_NONMASTER_RELEASE: ${{ inputs.dangerous-nonmaster-release }}
SKIP_PRIOR_PUBLISHED_PACKAGE_CHECKS: ${{ inputs.skip-prior-published-package-checks }}
run: |
echo "::warning::Release bypass input(s) enabled. See job summary."
{
echo "## ⚠️ Release bypasses enabled"
echo
echo "One or more release safety bypasses were selected for this run:"
echo
if [ "$DANGEROUS_NONMASTER_RELEASE" = "true" ]; then
echo "- \`dangerous-nonmaster-release\`: release jobs may run from a non-\`master\` ref."
fi
if [ "$ALLOW_PRERELEASES" = "true" ]; then
echo "- \`allow-prereleases\`: install checks use \`--prerelease=allow\`."
fi
if [ -n "$SKIP_PRIOR_PUBLISHED_PACKAGE_CHECKS" ] && [ "$SKIP_PRIOR_PUBLISHED_PACKAGE_CHECKS" != "none" ]; then
echo "- \`skip-prior-published-package-checks\`: \`$SKIP_PRIOR_PUBLISHED_PACKAGE_CHECKS\`."
fi
} >> "$GITHUB_STEP_SUMMARY"
- name: Check version
id: check-version
shell: python
working-directory: ${{ env.EFFECTIVE_WORKING_DIR }}
env:
RELEASE_VERSION_INPUT: ${{ inputs.release-version }}
run: |
import os
import re
import sys
import tomllib
import urllib.error
import urllib.request
with open("pyproject.toml", "rb") as f:
data = tomllib.load(f)
pkg_name = data["project"]["name"]
version = data["project"]["version"]
requested_version = os.environ.get("RELEASE_VERSION_INPUT", "").strip()
def normalize(v):
# Lightweight PEP 440 comparison key: lowercase and drop the `-`,
# `_`, or `.` separators that precede a pre/post/dev segment so that
# e.g. `0.1.0-rc1` and `0.1.0rc1` compare equal. Full canonicalization
# lives in `packaging`, which isn't installed in this bare release step.
return re.sub(r"[-_.]+(?=[a-z])", "", v.lower())
if requested_version and normalize(requested_version) != normalize(version):
print(
f"::error::Requested release version {requested_version!r} does "
f"not match {pkg_name} pyproject.toml version {version!r}."
)
sys.exit(1)
# Query the per-version endpoint so PyPI applies PEP 440 normalization
# (e.g. `0.1.0-rc1` and `0.1.0rc1` resolve to the same release): HTTP 200
# means the version is already published, 404 means it's available
# (including the first-ever release of a new package). Only the status
# code is used, so a malicious or malformed response body can't mislead us.
url = f"https://pypi.org/pypi/{pkg_name}/{version}/json"
try:
with urllib.request.urlopen(url, timeout=10):
already_published = True
except urllib.error.HTTPError as err:
if err.code == 404:
already_published = False
else:
# Fail closed: an unexpected status means we can't verify.
print(
f"::error::PyPI returned HTTP {err.code} checking whether "
f"{pkg_name}=={version} exists; cannot verify, aborting."
)
sys.exit(1)
except urllib.error.URLError as err:
# Fail closed: if PyPI is unreachable we must not assume the version
# is free, or we risk re-publishing an existing release.
print(
f"::error::Could not reach PyPI to verify {pkg_name}=={version} "
f"({err.reason}); cannot verify, aborting."
)
sys.exit(1)
if already_published:
print(f"::error::{pkg_name}=={version} already exists on PyPI.")
sys.exit(1)
with open(os.environ["GITHUB_OUTPUT"], "a") as f:
f.write(f"pkg-name={pkg_name}\n")
f.write(f"version={version}\n")
# 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,
# which the build stage must not have access to.)
#
# 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: ${{ env.EFFECTIVE_WORKING_DIR }}
- name: Upload build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: dist
path: ${{ env.EFFECTIVE_WORKING_DIR }}/dist/
release-notes:
name: 📝 Generate 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
with:
repository: langchain-ai/langchain
path: langchain
sparse-checkout: | # this only grabs files for relevant dir
${{ env.EFFECTIVE_WORKING_DIR }}
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/${{ env.EFFECTIVE_WORKING_DIR }}
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: ${{ env.EFFECTIVE_WORKING_DIR }}
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"
pre-release-checks:
name: ✅ Pre-release checks
needs:
- build
- release-notes
environment: Release
runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 20
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # 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 }}
enable-cache: "false"
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: dist
path: ${{ env.EFFECTIVE_WORKING_DIR }}/dist/
- name: Import dist package
shell: bash
working-directory: ${{ env.EFFECTIVE_WORKING_DIR }}
env:
PKG_NAME: ${{ needs.build.outputs.pkg-name }}
VERSION: ${{ needs.build.outputs.version }}
PRERELEASE_FLAG: ${{ inputs.allow-prereleases && '--prerelease=allow' || '' }}
# Install directly from the locally-built wheel (no index resolution needed).
# `PRERELEASE_FLAG` is empty by default; opt-in via the `allow-prereleases`
# workflow input lets transitive prerelease deps resolve during alpha
# release cycles. Stable-release safety is still enforced by the
# `Check for prerelease versions` step below.
run: |
uv venv
VIRTUAL_ENV=.venv uv pip install $PRERELEASE_FLAG 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: ${{ env.EFFECTIVE_WORKING_DIR }}
# Overwrite the local version of the package with the built version
- name: Import published package (again)
working-directory: ${{ env.EFFECTIVE_WORKING_DIR }}
shell: bash
env:
PKG_NAME: ${{ needs.build.outputs.pkg-name }}
VERSION: ${{ needs.build.outputs.version }}
PRERELEASE_FLAG: ${{ inputs.allow-prereleases && '--prerelease=allow' || '' }}
run: |
VIRTUAL_ENV=.venv uv pip install $PRERELEASE_FLAG dist/*.whl
- name: Check for prerelease versions
# Block release if any dependencies allow prerelease versions
# (unless this is itself a prerelease version)
working-directory: ${{ env.EFFECTIVE_WORKING_DIR }}
run: |
uv run python $GITHUB_WORKSPACE/.github/scripts/check_prerelease_dependencies.py pyproject.toml
- name: Run unit tests
run: make tests
working-directory: ${{ env.EFFECTIVE_WORKING_DIR }}
- name: Get minimum versions
# Find the minimum published versions that satisfies the given constraints
working-directory: ${{ env.EFFECTIVE_WORKING_DIR }}
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 }}
PRERELEASE_FLAG: ${{ inputs.allow-prereleases && '--prerelease=allow' || '' }}
run: |
VIRTUAL_ENV=.venv uv pip install $PRERELEASE_FLAG --force-reinstall --editable .
VIRTUAL_ENV=.venv uv pip install $PRERELEASE_FLAG --force-reinstall $MIN_VERSIONS
make tests PYTEST_EXTRA="-q -k 'not test_serdes'"
working-directory: ${{ env.EFFECTIVE_WORKING_DIR }}
- name: Import integration test dependencies
run: uv sync --group test --group test_integration
working-directory: ${{ env.EFFECTIVE_WORKING_DIR }}
- name: Run integration tests
# Uses the Makefile's `integration_tests` target for the specified package
if: ${{ startsWith(env.EFFECTIVE_WORKING_DIR, '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: ${{ env.EFFECTIVE_WORKING_DIR }}
test-pypi-publish:
name: 🧪 Publish to TestPyPI
# release-notes must run before publishing because its check-tags step
# validates version/tag state — do not remove this dependency.
needs:
- build
- release-notes
- pre-release-checks
environment: Release
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: dist
path: ${{ env.EFFECTIVE_WORKING_DIR }}/dist/
- name: Publish to test PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
with:
packages-dir: ${{ env.EFFECTIVE_WORKING_DIR }}/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
# Test select published packages against new core
# Done when code changes are made to langchain-core
test-prior-published-packages-against-new-core:
name: 🔄 Test prior partners against new core
# Installs the new core with old partners: Installs the new unreleased core
# alongside the previously published partner packages and runs unit and integration tests
needs:
- build
- release-notes
- test-pypi-publish
- pre-release-checks
environment: Release
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
matrix:
# When adding a partner, also update the `skip-prior-published-package-checks`
# input (the `workflow_dispatch` `options` list and the `workflow_call`
# description) so the per-partner skip remains selectable.
partner: [ anthropic, openai ]
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # 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(env.EFFECTIVE_WORKING_DIR, 'libs/core') }}" != "true" ]; then
echo "Not in libs/core. Exiting successfully."
exit 0
fi
- name: Set up Python + uv
if: startsWith(env.EFFECTIVE_WORKING_DIR, 'libs/core')
uses: "./.github/actions/uv_setup"
with:
python-version: ${{ env.PYTHON_VERSION }}
enable-cache: "false"
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
if: startsWith(env.EFFECTIVE_WORKING_DIR, 'libs/core')
with:
name: dist
path: ${{ env.EFFECTIVE_WORKING_DIR }}/dist/
- name: Skip prior published ${{ matrix.partner }} check
if: >-
startsWith(env.EFFECTIVE_WORKING_DIR, 'libs/core') &&
(inputs.skip-prior-published-package-checks == matrix.partner ||
inputs.skip-prior-published-package-checks == 'all')
run: |
echo "Skipping prior published ${{ matrix.partner }} check as requested."
- name: Test against ${{ matrix.partner }}
if: >-
startsWith(env.EFFECTIVE_WORKING_DIR, 'libs/core') &&
inputs.skip-prior-published-package-checks != matrix.partner &&
inputs.skip-prior-published-package-checks != 'all'
env:
PARTNER: ${{ matrix.partner }}
PRERELEASE_FLAG: ${{ inputs.allow-prereleases && '--prerelease=allow' || '' }}
run: |
PACKAGE_NAME="langchain-$PARTNER"
# Identify the latest non-yanked published package release, excluding pre-releases.
# Fail closed (matching the `Check version` step) so a PyPI outage or a
# missing release aborts with a clear message rather than an empty version.
LATEST_PACKAGE_VERSION="$(PACKAGE_NAME="$PACKAGE_NAME" python - <<'PY'
import json
import os
import re
import sys
import urllib.error
import urllib.request
package_name = os.environ["PACKAGE_NAME"]
url = f"https://pypi.org/pypi/{package_name}/json"
try:
with urllib.request.urlopen(url, timeout=10) as response:
data = json.load(response)
except urllib.error.HTTPError as err:
print(
f"::error::PyPI returned HTTP {err.code} listing {package_name} "
f"releases; cannot determine latest version, aborting.",
file=sys.stderr,
)
sys.exit(1)
except urllib.error.URLError as err:
print(
f"::error::Could not reach PyPI to list {package_name} releases "
f"({err.reason}); cannot determine latest version, aborting.",
file=sys.stderr,
)
sys.exit(1)
versions: list[tuple[int, int, int, str]] = []
for version, files in data["releases"].items():
if not re.fullmatch(r"\d+\.\d+\.\d+", version):
continue
if not files or all(file.get("yanked", False) for file in files):
continue
versions.append((*map(int, version.split(".")), version))
if not versions:
print(f"::error::No non-yanked final releases found for {package_name}", file=sys.stderr)
sys.exit(1)
print(max(versions)[3])
PY
)"
# Belt-and-suspenders: a bare assignment masks the heredoc's exit status
# in some shells, so guard explicitly rather than relying on `set -e`.
if [ -z "$LATEST_PACKAGE_VERSION" ]; then
echo "::error::Could not determine latest published $PACKAGE_NAME version; aborting."
exit 1
fi
LATEST_PACKAGE_TAG="$PACKAGE_NAME==$LATEST_PACKAGE_VERSION"
echo "Latest non-yanked package tag: $LATEST_PACKAGE_TAG"
# Ensure the PyPI release maps to a source tag before running tests.
git ls-remote --exit-code --tags origin "refs/tags/$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/$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/$PARTNER/"
cd "partners/$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 $PRERELEASE_FLAG ../../core/dist/*.whl
make test
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: "🐍 Test dependent: ${{ matrix.package.path }} (Python ${{
matrix.python-version }})"
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.
# Job-level 'if' does not support env context, so EFFECTIVE_WORKING_DIR is
# unavailable; must use inputs directly and match both forms: short dropdown
# names (workflow_dispatch, e.g. 'core') and full 'libs/' paths
# (workflow_call / working-directory-override).
if: >-
contains(fromJSON('["core","langchain_v1"]'),
inputs.working-directory-override || inputs.working-directory) ||
startsWith(inputs.working-directory-override || inputs.working-directory,
'libs/core') || startsWith(inputs.working-directory-override ||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
with:
path: langchain
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # 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 }}
enable-cache: "false"
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # 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.
env:
PRERELEASE_FLAG: ${{ inputs.allow-prereleases && '--prerelease=allow' || '' }}
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 $PRERELEASE_FLAG $GITHUB_WORKSPACE/dist/*.whl
- name: Run ${{ matrix.package.name }} tests
run: |
cd ${{ matrix.package.name }}/${{ matrix.package.path }}
make test
publish:
name: 🚀 Publish to PyPI
# 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 and
# test-prior-published-packages-against-new-core only run for core/langchain_v1)
if: ${{ !cancelled() && !failure() }}
environment: Release
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: ${{ env.EFFECTIVE_WORKING_DIR }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
- name: Set up Python + uv
uses: "./.github/actions/uv_setup"
with:
python-version: ${{ env.PYTHON_VERSION }}
enable-cache: "false"
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: dist
path: ${{ env.EFFECTIVE_WORKING_DIR }}/dist/
- name: Publish package distributions to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
with:
packages-dir: ${{ env.EFFECTIVE_WORKING_DIR }}/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:
name: 🏷️ Tag GitHub 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
if: ${{ !cancelled() && !failure() }}
environment: Release
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: ${{ env.EFFECTIVE_WORKING_DIR }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
- name: Set up Python + uv
uses: "./.github/actions/uv_setup"
with:
python-version: ${{ env.PYTHON_VERSION }}
enable-cache: "false"
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: dist
path: ${{ env.EFFECTIVE_WORKING_DIR }}/dist/
- name: Create Tag
uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1
with:
# JS actions ignore `defaults.run.working-directory`, so this glob is
# resolved from the repo root. Point it at the package's `dist/`
# (where `download-artifact` placed the wheels) instead of a bare
# `dist/*`, which never matched and attached no assets to releases.
artifacts: "${{ env.EFFECTIVE_WORKING_DIR }}/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'}}
+85
View File
@@ -0,0 +1,85 @@
# 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # 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'
+73
View File
@@ -0,0 +1,73 @@
# 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # 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'
+66
View File
@@ -0,0 +1,66 @@
# Runs VCR cassette-backed integration tests in playback-only mode.
#
# No API keys needed — catches stale cassettes caused by test input
# changes without re-recording.
#
# Called as part of check_diffs.yml workflow.
name: "📼 VCR Cassette 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
- name: "🐍 Set up Python ${{ inputs.python-version }} + UV"
uses: "./.github/actions/uv_setup"
with:
python-version: ${{ inputs.python-version }}
cache-suffix: test-vcr-${{ inputs.working-directory }}
working-directory: ${{ inputs.working-directory }}
- name: "📦 Install Test Dependencies"
shell: bash
run: uv sync --group test
- name: "📼 Run VCR Cassette Tests (playback-only)"
shell: bash
env:
OPENAI_API_KEY: sk-fake
run: make test_vcr
- 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'
+116
View File
@@ -0,0 +1,116 @@
name: Auto Label Issues by Package
on:
issues:
types: [opened, edited]
permissions:
contents: read
jobs:
label-by-package:
if: github.repository_owner == 'langchain-ai'
permissions:
issues: write
runs-on: ubuntu-latest
steps:
- name: Sync package labels
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const body = context.payload.issue.body || "";
// Extract text under "## Package" or "### Package" (handles " (Required)" suffix and being last section)
const match = body.match(/#{2,3} Package[^\n]*\n([\s\S]*?)(?:\n#{2,3} |$)/i);
if (!match) {
core.setFailed(
`Could not find "## Package" section in issue #${context.issue.number} body. ` +
`The issue template may have changed — update the regex in this workflow.`
);
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
});
}
+146
View File
@@ -0,0 +1,146 @@
# Block PRs whose head ref is `main` (or `master`) from a fork. This topology
# (`<fork>:master -> langchain-ai/langchain:master`) lets contributors click
# "Update branch" on the PR, producing a `Merge branch 'master' into master`
# commit on the source side that — under admin merge override — can land
# directly on `master` as a 2-parent merge commit, bypassing the repo's
# squash-only policy and polluting the changelog.
#
# `pull_request_target` is required so the job receives a token scoped to
# write PR labels/comments on fork PRs (the standard `pull_request` token is
# read-only for forks). This also means the job MUST NOT check out PR code —
# see the inline warning in the trigger block below.
#
# Maintainer bypass: add the `bypass-fork-main-check` label to the PR.
name: Block fork main PRs
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: [opened, reopened, synchronize, labeled, unlabeled]
permissions:
contents: read
jobs:
guard:
if: >-
github.repository_owner == 'langchain-ai' &&
github.event.pull_request.head.repo.fork == true &&
(github.event.pull_request.head.ref == 'main' || github.event.pull_request.head.ref == 'master') &&
!contains(github.event.pull_request.labels.*.name, 'bypass-fork-main-check')
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Close PR and post guidance
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { owner, repo } = context.repo;
const prNumber = context.payload.pull_request.number;
const headRef = context.payload.pull_request.head.ref;
const marker = '<!-- block-fork-main -->';
// Ensure the warning label exists and apply it
const labelName = 'fork-main-head';
try {
await github.rest.issues.getLabel({ owner, repo, name: labelName });
} catch (e) {
if (e.status !== 404) {
throw new Error(`getLabel(${labelName}) failed: ${e.message}`);
}
try {
await github.rest.issues.createLabel({
owner, repo, name: labelName, color: 'b76e79',
});
} catch (createErr) {
// A 422 with code `already_exists` means a race created the
// label between getLabel and createLabel — safe to ignore.
// Any other 422 (bad color, name too long) indicates a real
// bug introduced by editing this step, so rethrow.
const alreadyExists =
createErr.status === 422 &&
Array.isArray(createErr.errors) &&
createErr.errors.some(e => e.code === 'already_exists');
if (!alreadyExists) throw createErr;
}
}
await github.rest.issues.addLabels({
owner, repo, issue_number: prNumber, labels: [labelName],
});
const defaultBranch = context.payload.repository.default_branch;
const lines = [
marker,
`**This PR has been automatically closed** because its head branch is \`${headRef}\` on a fork.`,
'',
'PRs opened from a fork\'s `main` (or `master`) branch can produce a `Merge branch \'main\' into main` commit on the source side. Under an admin merge override that commit can land directly on this repo\'s default branch, bypassing the squash-only policy and polluting the changelog.',
'',
'To fix:',
`1. Sync your fork's \`${defaultBranch}\` first (\`git fetch upstream && git switch ${defaultBranch} && git merge --ff-only upstream/${defaultBranch}\`)`,
'2. Create a feature branch: `git switch -c feat/my-change`',
'3. Push it: `git push -u origin feat/my-change`',
`4. Open a new PR from \`feat/my-change\` → \`langchain-ai/langchain:${defaultBranch}\``,
'',
'*Maintainers: add the `bypass-fork-main-check` label to override.*',
];
const body = lines.join('\n');
// Dedup: update existing marker comment instead of stacking.
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,
});
} else if (existing.body !== body) {
await github.rest.issues.updateComment({
owner, repo, comment_id: existing.id, body,
});
}
if (context.payload.pull_request.state === 'open') {
await github.rest.pulls.update({
owner, repo, pull_number: prNumber, state: 'closed',
});
}
// Cancel still-queued/in-progress checks on this PR head.
// Best-effort: new runs may still queue after this loop (e.g., other
// pull_request triggers fanning out). The PR is already closed above,
// so leftover runs are wasted compute, not a correctness issue.
// We track the cancel ratio so a wholesale failure (token-scope
// regression making EVERY cancel return 403) is surfaced rather
// than silently producing N warnings + green job.
const headSha = context.payload.pull_request.head.sha;
let attempted = 0;
let cancelled = 0;
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;
attempted++;
try {
await github.rest.actions.cancelWorkflowRun({
owner, repo, run_id: run.id,
});
cancelled++;
} catch (err) {
core.warning(`Could not cancel run ${run.id}: ${err.message}`);
}
}
}
if (attempted > 0 && cancelled === 0) {
core.warning(`Attempted to cancel ${attempted} run(s) on head ${headSha} but none succeeded — check token scope.`);
}
core.setFailed(`PR head ref is \`${headRef}\` on a fork — open from a feature branch instead.`);
+205
View File
@@ -0,0 +1,205 @@
# Monthly bump of the uv pin in `.github/actions/uv_setup/action.yml`.
#
# We pin uv (rather than letting setup-uv resolve latest) because
# `releases.astral.sh` lags GitHub Releases on new uv versions, causing CI
# to flap on fresh-release days. This workflow keeps the pin fresh without
# exposing that race.
#
# Dependabot's `github-actions` ecosystem only updates `uses:` SHA pins, not
# the `UV_VERSION` env value the action passes to `astral-sh/setup-uv`, so we
# open the PR ourselves. Idempotent: if a PR for the target version already
# exists, the workflow exits without creating a duplicate.
name: "Bump uv pin"
on:
schedule:
- cron: "0 9 1 * *"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: bump-uv-pin
cancel-in-progress: false
jobs:
bump:
if: github.repository_owner == 'langchain-ai'
name: "Open PR if uv has a newer release"
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
- name: Resolve current and latest uv versions
id: versions
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
action_file=".github/actions/uv_setup/action.yml"
current=$(grep -oE 'UV_VERSION: "[0-9]+\.[0-9]+\.[0-9]+"' "$action_file" \
| sed -E 's/UV_VERSION: "([^"]+)"/\1/' | head -n1)
latest=$(gh api repos/astral-sh/uv/releases/latest --jq .tag_name)
semver='^[0-9]+\.[0-9]+\.[0-9]+$'
if [[ ! "$current" =~ $semver ]]; then
echo "::error::Could not parse current uv pin from $action_file (got '$current')"
exit 1
fi
if [[ ! "$latest" =~ $semver ]]; then
echo "::error::Unexpected uv tag from GitHub API (got '$latest')"
exit 1
fi
echo "current=$current" >> "$GITHUB_OUTPUT"
echo "latest=$latest" >> "$GITHUB_OUTPUT"
echo "branch=chore/bump-uv-$latest" >> "$GITHUB_OUTPUT"
echo "Current pin: $current"
echo "Latest uv: $latest"
- name: Log if already up to date
# The actual skip is implemented by the `if:` guards on every
# subsequent step; this step only emits a log line so the run
# history shows why no PR was opened.
if: steps.versions.outputs.current == steps.versions.outputs.latest
run: echo "uv pin already at ${{ steps.versions.outputs.latest }}; nothing to do."
- name: Skip if PR already open for this version
id: existing
if: steps.versions.outputs.current != steps.versions.outputs.latest
env:
GH_TOKEN: ${{ github.token }}
BRANCH: ${{ steps.versions.outputs.branch }}
run: |
set -euo pipefail
count=$(gh pr list --head "$BRANCH" --state open --json number --jq 'length')
echo "count=$count" >> "$GITHUB_OUTPUT"
if [ "$count" -gt 0 ]; then
echo "Open PR already exists for $BRANCH; skipping."
fi
- name: Wait for astral mirror to replicate
id: mirror
if: steps.versions.outputs.current != steps.versions.outputs.latest && steps.existing.outputs.count == '0'
env:
LATEST: ${{ steps.versions.outputs.latest }}
run: |
set -euo pipefail
# The mirror can lag GitHub Releases. If it hasn't replicated yet,
# defer the bump rather than landing a pin that races the mirror
# on every CI run. We probe several arches because partial
# replication (linux ready, macOS/aarch64 not) would still race
# CI on other runners.
assets=(
"uv-x86_64-unknown-linux-gnu.tar.gz"
"uv-aarch64-unknown-linux-gnu.tar.gz"
"uv-x86_64-apple-darwin.tar.gz"
"uv-aarch64-apple-darwin.tar.gz"
)
ready=true
for asset in "${assets[@]}"; do
url="https://releases.astral.sh/github/uv/releases/download/${LATEST}/${asset}"
# `curl -sI` returns nothing on stderr at -s; capture exit code so a
# permanently broken DNS/TLS path is surfaced instead of collapsing
# to an opaque "000".
set +e
status=$(curl -sIo /dev/null -w '%{http_code}' --max-time 30 "$url" 2>/tmp/curl.err)
curl_rc=$?
set -e
echo "Mirror HEAD $url -> HTTP $status (curl exit=$curl_rc)"
if [ "$status" != "200" ]; then
ready=false
if [ "$curl_rc" -ne 0 ]; then
echo "::warning::curl failed for $asset (exit=$curl_rc): $(cat /tmp/curl.err 2>/dev/null || true)"
else
echo "::warning::astral mirror has not replicated $asset for uv $LATEST yet (HTTP $status)."
fi
fi
done
if [ "$ready" = "true" ]; then
echo "ready=true" >> "$GITHUB_OUTPUT"
else
echo "ready=false" >> "$GITHUB_OUTPUT"
echo "::warning::Deferring uv bump to $LATEST until all probed arches are mirrored."
fi
- name: Open bump PR
if: steps.versions.outputs.current != steps.versions.outputs.latest && steps.existing.outputs.count == '0' && steps.mirror.outputs.ready == 'true'
env:
GH_TOKEN: ${{ github.token }}
CURRENT: ${{ steps.versions.outputs.current }}
LATEST: ${{ steps.versions.outputs.latest }}
BRANCH: ${{ steps.versions.outputs.branch }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
set -euo pipefail
action_file=".github/actions/uv_setup/action.yml"
# `grep -c` returns 1 on no-match and 2 on read errors. We want
# "no match" surfaced as the explicit count-of-zero check below;
# read errors must abort. Capture the exit code separately so
# `set -e` doesn't swallow either case.
set +e
before=$(grep -cE "UV_VERSION: \"${CURRENT}\"" "$action_file")
before_rc=$?
set -e
if [ "$before_rc" -gt 1 ]; then
echo "::error::grep read error on $action_file (exit=$before_rc)"
exit 1
fi
if [ "$before" -ne 1 ]; then
echo "::error::Expected exactly 1 'UV_VERSION: \"$CURRENT\"' in $action_file, found $before"
exit 1
fi
sed -i -E "s/UV_VERSION: \"${CURRENT}\"/UV_VERSION: \"${LATEST}\"/" "$action_file"
set +e
after=$(grep -cE "UV_VERSION: \"${LATEST}\"" "$action_file")
after_rc=$?
set -e
if [ "$after_rc" -gt 1 ]; then
echo "::error::grep read error on $action_file (exit=$after_rc)"
exit 1
fi
if [ "$after" -ne 1 ]; then
echo "::error::Expected exactly 1 'UV_VERSION: \"$LATEST\"' after sed, found $after"
exit 1
fi
if git diff --quiet "$action_file"; then
echo "No changes after sed; bailing out (current=$CURRENT, latest=$LATEST)."
exit 1
fi
# Reuse-or-recreate orphan branch from a prior run that pushed
# but failed before `gh pr create` (no open PR sits on it).
# The delete can race a concurrent run (manual workflow_dispatch
# firing while the cron is mid-flight, since concurrency group
# does not cancel-in-progress); fall through with a warning so a
# losing race does not kill an otherwise-clean job mid-state.
if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then
echo "::warning::Branch $BRANCH exists on origin without an open PR; deleting before recreating."
if ! git push origin --delete "$BRANCH"; then
echo "::warning::Delete of $BRANCH failed (concurrent run, or branch already gone); the subsequent push will surface any real conflict."
fi
fi
git config --local user.name "github-actions[bot]"
git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -b "$BRANCH"
git add "$action_file"
git commit -m "chore(deps): bump uv to $LATEST"
git push --set-upstream origin "$BRANCH"
body_file="$(mktemp)"
{
printf 'Bumps the uv pin in `.github/actions/uv_setup/action.yml` from `%s` to [`%s`](https://github.com/astral-sh/uv/releases/tag/%s).\n\n' "$CURRENT" "$LATEST" "$LATEST"
printf 'Opened automatically by `bump_uv_pin.yml`. Mirror availability on `releases.astral.sh` was verified before this PR was created, so CI should not race the fallback.\n'
} > "$body_file"
gh pr create \
--head "$BRANCH" \
--base "$DEFAULT_BRANCH" \
--title "chore(deps): bump uv to $LATEST" \
--body-file "$body_file"
+43
View File
@@ -0,0 +1,43 @@
# 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"
if: github.repository_owner == 'langchain-ai'
runs-on: ubuntu-latest
steps:
- name: "📋 Checkout Code"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # 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"
+235
View File
@@ -0,0 +1,235 @@
# 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
- name: "🐍 Setup Python 3.11"
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: "3.11"
- name: "📂 Get Changed Files"
id: files
uses: Ana06/get-changed-files@25f79e676e7ea1868813e21465014798211fad8c # v2.3.0
with:
format: json
- name: "🔍 Analyze Changed Files & Generate Build Matrix"
id: set-matrix
env:
ALL_CHANGED_FILES: ${{ steps.files.outputs.all }}
run: |
python -m pip install packaging requests
python .github/scripts/check_diff.py "$ALL_CHANGED_FILES" >> $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 }}
vcr-tests: ${{ steps.set-matrix.outputs.vcr-tests }}
# 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 VCR cassette-backed integration tests in playback-only mode (no API keys)
vcr-tests:
name: "VCR Cassette Tests"
needs: [build]
if: ${{ needs.build.outputs.vcr-tests != '[]' }}
strategy:
matrix:
job-configs: ${{ fromJson(needs.build.outputs.vcr-tests) }}
fail-fast: false
uses: ./.github/workflows/_test_vcr.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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # 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'
# Verify _release.yml dropdown options stay in sync with package directories
check-release-options:
name: "Validate Release Options"
if: github.repository_owner == 'langchain-ai'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
- name: "🐍 Setup Python 3.11"
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: "3.11"
- name: "📦 Install Dependencies"
run: python -m pip install pyyaml pytest
- name: "🔍 Check release dropdown matches packages"
run: python -m pytest .github/scripts/test_release_options.py -v
# Final status check - ensures all required jobs passed before allowing merge
ci_success:
name: "✅ CI Success"
needs:
[
build,
lint,
test,
compile-integration-tests,
vcr-tests,
extended-tests,
test-pydantic,
check-release-options,
]
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
+73
View File
@@ -0,0 +1,73 @@
# See `.github/scripts/check_extras_sync.py` for the rationale.
name: "🔍 Check Extras Sync"
on:
pull_request:
paths:
- "libs/**/pyproject.toml"
- ".github/scripts/check_extras_sync.py"
- ".github/workflows/check_extras_sync.yml"
push:
branches: [master]
paths:
- "libs/**/pyproject.toml"
- ".github/scripts/check_extras_sync.py"
- ".github/workflows/check_extras_sync.yml"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
check-extras-sync:
if: github.repository_owner == 'langchain-ai'
name: "Verify extras match required deps"
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: "📋 Checkout Code"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
- name: "🐍 Set up Python and uv"
uses: "./.github/actions/uv_setup"
with:
python-version: "3.13"
enable-cache: "false"
- name: "🔍 Check extras sync"
# Iterate every package pyproject.toml under libs/. The script
# no-ops on packages without [project.optional-dependencies], so
# this is harmless on packages without extras and automatically
# picks up new partners as they're added. No `-maxdepth` cap so
# deeper future restructures (e.g. `libs/partners/<group>/<pkg>/`)
# are picked up automatically.
run: |
set -euo pipefail
mapfile -t files < <(
find libs -name pyproject.toml \
-not -path "*/.venv/*" \
-not -path "*/node_modules/*" \
-not -path "*/build/*" \
-not -path "*/dist/*" \
-not -path "*/.tox/*" \
| sort
)
if [ ${#files[@]} -eq 0 ]; then
echo "::error::No pyproject.toml files found under libs/"
exit 1
fi
failed=()
for f in "${files[@]}"; do
if ! python .github/scripts/check_extras_sync.py "$f"; then
failed+=("$f")
fi
done
if [ ${#failed[@]} -gt 0 ]; then
echo "::error::Extras-sync check failed for ${#failed[@]} package(s):"
printf '::error:: %s\n' "${failed[@]}"
exit 1
fi
+288
View File
@@ -0,0 +1,288 @@
# Validate that a release PR's declared dependencies are actually published on
# PyPI *before* the package itself is released.
#
# WHY: `release(scope): x.y.z` PRs frequently bump intra-monorepo minimum pins
# (e.g. `langchain-core>=1.4.4`). The regular PR test suite deliberately SKIPS
# minimum-version resolution for langchain-core / langchain / langchain-text-splitters
# (see `SKIP_IF_PULL_REQUEST` in `.github/scripts/get_min_versions.py`) because normal
# feature PRs may bump those in lockstep with an as-yet-unpublished sibling release.
#
# For a `release` PR, though, every runtime dependency should already be on PyPI
# unless that dependency is another package version introduced by the same PR.
# If a pin points at any other version that does not exist yet, the published wheel's
# metadata is unresolvable and `pip install <pkg>==x.y.z` breaks for end users. Without this
# workflow, that is only caught at release-trigger time, when `get_min_versions.py`
# resolves the pins against PyPI (its companion change in this PR now exits loudly on
# an unpublished pin instead of emitting `pkg==None`). This workflow adds a second,
# earlier guard: it shifts the same check left onto the release PR, so the author
# finds out before merge rather than when the release job runs.
#
# HOW: for each changed manifest, diff it against the PR base to find packages whose
# own version is bumped by this PR, then resolve each package's runtime dependencies
# against real PyPI with `uv pip compile --no-sources` — which ignores the editable
# `[tool.uv.sources]` workspace overrides so intra-monorepo deps resolve from the index
# exactly as an end user's installer would see them. Dependencies that a same-PR version
# bump satisfies are stripped first (their wheels are not published until merge);
# everything else must resolve. This reads package index, git, and TOML metadata only —
# it does not build or run the PR's own project code.
name: "🚀 Check Release Dependencies"
on:
pull_request:
types: [opened, synchronize, reopened, edited, labeled, unlabeled]
paths:
- "libs/**/pyproject.toml"
permissions:
contents: read
jobs:
check-release-deps:
name: "✅ Verify release dependencies exist on PyPI"
# Only run for release PRs (`release(scope): x.y.z`). Other PRs may bump
# intra-monorepo pins ahead of a sibling release on purpose. Maintainers can
# acknowledge an unusual coordinated release with the bypass label.
if: >-
github.repository_owner == 'langchain-ai' &&
startsWith(github.event.pull_request.title, 'release') &&
!contains(github.event.pull_request.labels.*.name, 'release-deps: acknowledged')
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
with:
fetch-depth: 0
- name: "🐍 Set up Python + uv"
uses: "./.github/actions/uv_setup"
with:
python-version: "3.12"
enable-cache: "false"
- name: "🔍 Resolve runtime dependencies against PyPI"
shell: bash
env:
# Sourced from env so the `${{ }}` expansion never lands in the `run:`
# block; the SHAs reach git only via list-form subprocess (no shell).
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
# pyproject.toml manifests changed by this PR.
mapfile -t changed < <(
git diff --name-only "$BASE_SHA" "$HEAD_SHA" -- 'libs/**/pyproject.toml'
)
if [ "${#changed[@]}" -eq 0 ]; then
# The `paths:` filter should prevent this, so an empty list is
# surprising — surface it loudly rather than passing silently.
echo "::notice::No libs/**/pyproject.toml changed in this PR; nothing to validate."
exit 0
fi
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
# Step 1: detect packages whose own version is bumped by this PR. Their
# wheels are not on PyPI until merge, so dependencies the new version
# satisfies are stripped before resolving. Emits one TSV row per bump:
# `<canonical-name>\t<name>\t<new-version>`. `uv run --with packaging`
# guarantees the PEP 508/440 parser is available on the runner.
printf '%s\n' "${changed[@]}" > "$tmp_dir/changed.txt"
uv run -q --no-project --with packaging python - "$BASE_SHA" "$tmp_dir/changed.txt" "$tmp_dir/released.txt" <<'PY'
import subprocess
import sys
import tomllib
from pathlib import Path
from packaging.utils import canonicalize_name
base_sha = sys.argv[1]
changed_path = Path(sys.argv[2])
released_path = Path(sys.argv[3])
def project_table(text: str) -> dict:
return tomllib.loads(text).get("project") or {}
released = []
for manifest in changed_path.read_text(encoding="utf-8").splitlines():
current = project_table(Path(manifest).read_text(encoding="utf-8"))
name, version = current.get("name"), current.get("version")
if name is None or version is None:
# No static name/version (e.g. a dynamic version) — it cannot be
# compared against the base, so cannot be a same-PR bump.
print(f"::warning file={manifest}::no static project.name/version; skipping bump detection")
continue
shown = subprocess.run(
["git", "show", f"{base_sha}:{manifest}"],
capture_output=True,
text=True,
)
if shown.returncode != 0:
stderr = shown.stderr.strip()
if "does not exist" in stderr or "exists on disk, but not in" in stderr:
# New manifest: absent at the base ref, so not a version bump.
continue
print(f"::error file={manifest}::failed to read base manifest: {stderr}")
sys.exit(1)
base = project_table(shown.stdout)
if base.get("name") == name and base.get("version") not in (None, version):
released.append(f"{canonicalize_name(name)}\t{name}\t{version}")
released_path.write_text("".join(f"{row}\n" for row in released), encoding="utf-8")
PY
if [ -s "$tmp_dir/released.txt" ]; then
echo "The following package versions are introduced by this PR and may be referenced before PyPI publication:"
cut -f2- "$tmp_dir/released.txt" | sed 's/^/ - /'
else
echo "No package version bumps found in changed manifests."
fi
failed=0
transient=0
for manifest in "${changed[@]}"; do
pkg_dir="$(dirname "$manifest")"
filtered_dir="$tmp_dir/${manifest//\//__}.dir"
mkdir -p "$filtered_dir"
filtered_manifest="$filtered_dir/pyproject.toml"
# Step 2: rebuild a resolver-equivalent manifest that drops only the
# dependencies a same-PR version bump satisfies. `[tool.uv]` keys that
# affect resolution (prerelease, constraint/override deps) are preserved
# — e.g. `langchain-fireworks` needs `prerelease = "allow"` to resolve
# its prerelease-only `fireworks-ai` pin. Skipped deps print here, before
# the resolver group opens, so the exclusions stay visible on a green run.
uv run -q --no-project --with packaging python - "$manifest" "$filtered_manifest" "$tmp_dir/released.txt" <<'PY'
import json
import sys
import tomllib
from pathlib import Path
from packaging.requirements import InvalidRequirement, Requirement
from packaging.utils import canonicalize_name
manifest_path = Path(sys.argv[1])
filtered_path = Path(sys.argv[2])
released_path = Path(sys.argv[3])
released_versions = {}
for line in released_path.read_text(encoding="utf-8").splitlines():
canonical_name, _name, version = line.split("\t", maxsplit=2)
released_versions[canonical_name] = version
def is_same_pr_bump(dependency: str) -> bool:
try:
requirement = Requirement(dependency)
except InvalidRequirement:
# Keep anything we cannot parse so the resolver judges it.
return False
version = released_versions.get(canonicalize_name(requirement.name))
if version is None:
return False
# Strip only when the just-bumped version satisfies the pin; a pin to
# any other (still unpublished) version must keep resolving.
return requirement.specifier.contains(version, prereleases=True)
data = tomllib.loads(manifest_path.read_text(encoding="utf-8"))
project = data.get("project")
if project is None:
print(f"::error file={manifest_path}::no [project] table to resolve")
sys.exit(1)
filtered_dependencies, skipped_dependencies = [], []
for dependency in project.get("dependencies", []):
bucket = skipped_dependencies if is_same_pr_bump(dependency) else filtered_dependencies
bucket.append(dependency)
def toml_string(value: str) -> str:
return json.dumps(value)
lines = ["[project]"]
lines.append(f"name = {toml_string(project.get('name') or 'release-deps-check')}")
lines.append(f"version = {toml_string(project.get('version') or '0.0.0')}")
if "requires-python" in project:
lines.append(f"requires-python = {toml_string(project['requires-python'])}")
lines.append("dependencies = [")
lines += [f" {toml_string(dependency)}," for dependency in filtered_dependencies]
lines.append("]")
# Preserve the `[tool.uv]` keys that change PyPI resolution. `[tool.uv.sources]`
# is intentionally dropped — `uv pip compile --no-sources` ignores it anyway.
tool_uv = (data.get("tool") or {}).get("uv") or {}
uv_lines = []
if isinstance(tool_uv.get("prerelease"), str):
uv_lines.append(f"prerelease = {toml_string(tool_uv['prerelease'])}")
for key in ("constraint-dependencies", "override-dependencies"):
values = tool_uv.get(key) or []
if values:
uv_lines.append(f"{key} = [")
uv_lines += [f" {toml_string(value)}," for value in values]
uv_lines.append("]")
if uv_lines:
lines.append("")
lines.append("[tool.uv]")
lines += uv_lines
filtered_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
if skipped_dependencies:
print("Ignoring dependencies satisfied by package versions introduced by this PR:")
for dependency in skipped_dependencies:
print(f" - {dependency}")
PY
echo "::group::Resolving ${manifest} against PyPI"
# --no-sources ignores [tool.uv.sources] editable workspace overrides,
# so intra-monorepo deps resolve from PyPI like an end-user install.
# --universal resolves across the full requires-python range, so deps
# gated behind Python-version markers are validated too.
if uv pip compile --no-sources --universal "$filtered_manifest" > "$filtered_dir/compile.log" 2>&1; then
echo "✅ ${pkg_dir}: all runtime dependencies resolve on PyPI or are released by this PR"
else
# Surface the resolver's reason (stdout+stderr were captured) and tell a
# likely-transient index/network error apart from a genuinely bad pin.
cat "$filtered_dir/compile.log"
if grep -qiE 'error sending request|failed to fetch|error trying to connect|connection|timed out|temporarily unavailable|status code (429|50[0-9])' "$filtered_dir/compile.log"; then
echo "❌ ${pkg_dir}: resolver hit a possible transient PyPI/index error"
transient=1
else
echo "❌ ${pkg_dir}: a dependency pin is not satisfiable on PyPI"
fi
failed=1
fi
echo "::endgroup::"
done
if [ "$failed" -ne 0 ]; then
if [ "$transient" -ne 0 ]; then
echo "::warning::A failure looked like a network/index error rather than an unsatisfiable pin — re-running the job may clear it."
fi
cat >&2 <<'EOF'
┌──────────────────────────────────────────────────────────────────┐
│ One or more dependency pins could not be resolved from PyPI. │
│ See the per-package resolver output above for the exact reason. │
└──────────────────────────────────────────────────────────────────┘
Dependencies on package versions introduced by this PR are ignored,
because coordinated release metadata may point at wheels that are not
published until this PR merges. Any remaining failure means the released
wheel metadata may be unresolvable for end users.
Fix by:
• Releasing the dependency package first so the pinned version exists
on PyPI, then re-running this check; or
• Relaxing the version pin to a published version.
If this is an intentional coordinated release outside the detected
package version bumps, a maintainer may add the label
`release-deps: acknowledged` to bypass this check after reviewing the
install risk.
If the resolver output above shows a network/index error (rather than
"No solution found"), this may be a transient PyPI issue — re-run the job.
EOF
exit 1
fi
+55
View File
@@ -0,0 +1,55 @@
# 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/langchain_v1/pyproject.toml"
- "libs/langchain_v1/langchain/__init__.py"
- "libs/partners/*/pyproject.toml"
- "libs/partners/**/_version.py"
permissions:
contents: read
jobs:
check_version_equality:
if: github.repository_owner == 'langchain-ai'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
- uses: astral-sh/setup-uv@0ca8f610542aa7f4acaf39e65cf4eb3c35091883 # v7
with:
python-version: "3.12"
- name: "Verify pyproject.toml & version files match"
run: |
FAILED=0
for dir in libs/core libs/langchain_v1 libs/partners/*; do
[ -f "$dir/Makefile" ] || continue
if grep -q '^check_version:' "$dir/Makefile"; then
echo "--- $dir ---"
make -C "$dir" check_version || FAILED=1
elif find "$dir" -maxdepth 2 -name '_version.py' -not -path '*/tests/*' \
| grep -q .; then
# A package ships a _version.py but has no way to verify it stays
# in sync with pyproject.toml. Don't let it pass unchecked.
echo "--- $dir ---"
echo "Error: $dir has a _version.py but no 'check_version' Makefile target"
FAILED=1
fi
done
if [ "$FAILED" -ne 0 ]; then
echo ""
echo "One or more version checks failed!"
exit 1
fi
@@ -0,0 +1,197 @@
# Auto-close issues that bypass or ignore the issue template checkboxes.
#
# GitHub issue forms enforce `required: true` checkboxes in the web UI,
# but the API bypasses form validation entirely — bots/scripts can open
# issues with every box unchecked or skip the template altogether.
#
# Rules:
# 0. No issue type -> close unless author is an org member
# 1. No checkboxes at all -> close unless author is an org member or bot
# 2. Checkboxes present but none checked -> close
# 3. "Submission checklist" section incomplete -> close
# 4. "Package (Required)" section has no selection -> close
#
# Org membership check reuses the shared helper from pr-labeler.js and
# the same GitHub App used by tag-external-issues.yml.
name: Close Unchecked Issues
on:
issues:
types: [opened]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number }}
cancel-in-progress: true
jobs:
check-boxes:
if: github.repository_owner == 'langchain-ai'
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
with:
client-id: ${{ secrets.ORG_MEMBERSHIP_APP_CLIENT_ID }}
private-key: ${{ secrets.ORG_MEMBERSHIP_APP_PRIVATE_KEY }}
- name: Validate issue checkboxes
if: steps.app-token.outcome == 'success'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const { owner, repo } = context.repo;
const issue_number = context.payload.issue.number;
const body = context.payload.issue.body ?? '';
const allChecked = (body.match(/- \[x\]/gi) || []).length;
const allUnchecked = (body.match(/- \[ \]/g) || []).length;
const total = allChecked + allUnchecked;
// ── Helpers ─────────────────────────────────────────────────
// Extract checkboxes under a markdown H2/H3 heading.
// Returns { checked, unchecked } counts, or null if the
// section heading is not found in the body.
function parseSection(heading) {
const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Find the heading line
const headingRe = new RegExp(`^#{2,3}\\s+${escaped}\\s*$`, 'm');
const headingMatch = headingRe.exec(body);
if (!headingMatch) return null;
// Slice from after the heading to the next heading or end
const rest = body.slice(headingMatch.index + headingMatch[0].length);
const nextHeading = rest.search(/\n#{2,3}\s/);
const block = nextHeading === -1 ? rest : rest.slice(0, nextHeading);
return {
checked: (block.match(/- \[x\]/gi) || []).length,
unchecked: (block.match(/- \[ \]/g) || []).length,
};
}
let _cachedMember;
async function isOrgMember() {
if (_cachedMember) return _cachedMember;
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,
);
_cachedMember = { internal: !isExternal, author };
return _cachedMember;
}
async function closeWithComment(lines) {
const templateUrl = `https://github.com/${owner}/${repo}/issues/new/choose`;
lines.push(
'',
`Please use one of the [issue templates](${templateUrl}).`,
);
// Post comment first so the author sees the reason even if
// the subsequent close call fails.
await github.rest.issues.createComment({
owner, repo, issue_number,
body: lines.join('\n'),
});
await github.rest.issues.update({
owner, repo, issue_number,
state: 'closed',
state_reason: 'not_planned',
});
}
// ── Rule 0: no issue type (API/CLI bypass) ──────────────────
// Issue types are set automatically when using web UI templates.
// External users cannot set issue types via the API (requires
// write/triage permissions), so a missing type reliably indicates
// programmatic submission.
if (!context.payload.issue.type) {
let membership;
try {
membership = await isOrgMember();
} catch (e) {
// Org membership check failed — skip Rule 0 and let
// Rules 1-4 handle validation via checkboxes.
core.warning(`Rule 0: org membership check failed, skipping: ${e.message}`);
}
if (membership?.internal) {
console.log(`No issue type, but ${membership.author} is internal — OK`);
} else if (membership) {
console.log(`No issue type and ${membership.author} is external — closing`);
await closeWithComment([
'This issue was automatically closed because it appears to have been submitted programmatically — issue types are automatically set when using the GitHub web interface, and this issue has none.',
'',
'We do not allow automated issue submission at this time.',
]);
return;
}
}
// ── Rule 1: no checkboxes at all ────────────────────────────
if (total === 0) {
const { internal, author } = await isOrgMember();
if (internal) {
console.log(`No checkboxes, but ${author} is internal — OK`);
return;
}
console.log(`No checkboxes and ${author} is external — closing`);
await closeWithComment([
'This issue was automatically closed because no issue template was used.',
]);
return;
}
// ── Rule 2: checkboxes present but none checked ─────────────
if (allChecked === 0) {
console.log(`${allUnchecked} checkbox(es) present, none checked — closing`);
await closeWithComment([
'This issue was automatically closed because none of the required checkboxes were checked. Please re-file using an issue template and complete the checklist.',
]);
return;
}
// ── Rules 34: parse sections for targeted feedback ─────────
const checklist = parseSection('Submission checklist');
const pkg = parseSection('Package (Required)');
console.log(`Section parse — checklist: ${JSON.stringify(checklist)}, pkg: ${JSON.stringify(pkg)}`);
const problems = [];
if (checklist && checklist.unchecked > 0) {
problems.push(
'the submission checklist is incomplete — please confirm you searched for duplicates, included a reproduction, etc.'
);
}
if (pkg !== null && pkg.checked === 0) {
problems.push(
'no package was selected (e.g. langchain-core, langchain, langgraph) — this helps us route the issue to the right team'
);
} else if (pkg === null) {
problems.push(
'the package selection is missing (e.g. langchain-core, langchain, langgraph) — this helps us route the issue to the right team'
);
}
if (problems.length === 0) {
console.log(`All section checks passed (${allChecked} checked) — OK`);
return;
}
console.log(`Closing — problems: ${problems.join('; ')}`);
await closeWithComment([
'Thanks for opening an issue! It was automatically closed because:',
'',
...problems.map(p => `- ${p}`),
]);
+85
View File
@@ -0,0 +1,85 @@
# 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: ${{ github.repository_owner == 'langchain-ai' && !contains(github.event.pull_request.labels.*.name, 'codspeed-ignore') }}
steps:
- name: "📋 Checkout Code"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
- name: "🐍 Setup Python 3.11"
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: "3.11"
- name: "📂 Get Changed Files"
id: files
uses: Ana06/get-changed-files@25f79e676e7ea1868813e21465014798211fad8c # v2.3.0
with:
format: json
- name: "🔍 Analyze Changed Files"
id: set-matrix
env:
ALL_CHANGED_FILES: ${{ steps.files.outputs.all }}
run: |
python -m pip install packaging requests
python .github/scripts/check_diff.py "$ALL_CHANGED_FILES" >> $GITHUB_OUTPUT
outputs:
codspeed: ${{ steps.set-matrix.outputs.codspeed }}
benchmarks:
name: "⚡ CodSpeed Benchmarks"
needs: [build]
if: ${{ needs.build.outputs.codspeed != '[]' }}
runs-on: codspeed-macro
strategy:
matrix:
job-configs: ${{ fromJson(needs.build.outputs.codspeed) }}
fail-fast: false
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # 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
with:
token: ${{ secrets.CODSPEED_TOKEN }}
run: |
cd ${{ matrix.job-configs.working-directory }}
uv run --no-sync pytest ./tests/benchmarks --codspeed
mode: ${{ matrix.job-configs.codspeed-mode }}
+408
View File
@@ -0,0 +1,408 @@
# 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-override || (inputs.working-directory != 'all' && inputs.working-directory) || (inputs.exclude != '' && format('exclude:{0}', inputs.exclude)) || 'all libs' }} (Python ${{ inputs.python-version-override || '3.10, 3.14' }})"
on:
workflow_dispatch:
inputs:
working-directory:
type: choice
description: "Library to test (select from dropdown)"
default: "all"
# Short names only — the `compute-matrix` job re-adds the `libs/` or
# `libs/partners/` prefix. When adding a new option, also update the
# `case` statement in `compute-matrix` if it isn't a partner package
# (partners are the default branch).
options:
- "all"
- "core"
- "langchain"
- "langchain_v1"
- "text-splitters"
- "standard-tests"
- "model-profiles"
- "anthropic"
- "aws"
- "chroma"
- "deepseek"
- "exa"
- "fireworks"
- "google-genai"
- "google-vertexai"
- "groq"
- "huggingface"
- "mistralai"
- "nomic"
- "ollama"
- "openai"
- "openrouter"
- "perplexity"
- "qdrant"
- "xai"
working-directory-override:
type: string
description: "Manual override — takes precedence over dropdown (e.g. libs/partners/partner-xyz)"
exclude:
type: string
description: "Comma-separated short names to drop from the 'all' run (e.g. openai,anthropic). Ignored unless dropdown is 'all' and no working-directory-override is set."
python-version-override:
type: string
description: "Python version override — defaults to 3.10 and 3.14 in matrix (e.g. 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_OVERRIDE: ${{ github.event.inputs.working-directory-override || '' }}
WORKING_DIRECTORY_CHOICE: ${{ github.event.inputs.working-directory || 'all' }}
PYTHON_VERSION_OVERRIDE: ${{ github.event.inputs.python-version-override || '' }}
EXCLUDE: ${{ github.event.inputs.exclude || '' }}
run: |
# echo "matrix=..." where matrix is a json formatted str with keys python-version and working-directory
# python-version defaults to 3.10 and 3.14, overridden to [PYTHON_VERSION_OVERRIDE] if set
# working-directory priority: override string > dropdown choice > DEFAULT_LIBS
python_version='["3.10", "3.14"]'
python_version_min_3_11='["3.11", "3.14"]'
working_directory="$DEFAULT_LIBS"
if [ -n "$PYTHON_VERSION_OVERRIDE" ]; then
python_version="[\"$PYTHON_VERSION_OVERRIDE\"]"
# Bound override version to >= 3.11 for packages requiring it
if [ "$(echo "$PYTHON_VERSION_OVERRIDE >= 3.11" | bc -l)" -eq 1 ]; then
python_version_min_3_11="[\"$PYTHON_VERSION_OVERRIDE\"]"
else
python_version_min_3_11='["3.11"]'
fi
fi
if [ -n "$WORKING_DIRECTORY_OVERRIDE" ]; then
working_directory="[\"$WORKING_DIRECTORY_OVERRIDE\"]"
elif [ "$WORKING_DIRECTORY_CHOICE" != "all" ]; then
# Map short dropdown name back to full path
case "$WORKING_DIRECTORY_CHOICE" in
core|langchain|langchain_v1|text-splitters|standard-tests|model-profiles)
working_directory="[\"libs/$WORKING_DIRECTORY_CHOICE\"]"
;;
*)
working_directory="[\"libs/partners/$WORKING_DIRECTORY_CHOICE\"]"
;;
esac
elif [ -n "$EXCLUDE" ]; then
# Only honored on the 'all' run (no override, dropdown left at 'all').
# Map each comma-separated short name to its full path (mirroring the
# case statement above), then subtract from the DEFAULT_LIBS array.
exclude_paths='[]'
IFS=',' read -ra exclude_names <<< "$EXCLUDE"
for name in "${exclude_names[@]}"; do
# Trim surrounding whitespace so "openai, anthropic" works.
name="${name#"${name%%[![:space:]]*}"}" # ltrim
name="${name%"${name##*[![:space:]]}"}" # rtrim
[ -z "$name" ] && continue
case "$name" in
core|langchain|langchain_v1|text-splitters|standard-tests|model-profiles)
path="libs/$name"
;;
*)
path="libs/partners/$name"
;;
esac
exclude_paths="$(jq -nc --argjson acc "$exclude_paths" --arg path "$path" '$acc + [$path]')"
done
working_directory="$(jq -nc --argjson libs "$working_directory" --argjson excl "$exclude_paths" '$libs - $excl')"
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
# Scopes LangSmith tracing credentials (and any other env-scoped secrets)
environment: "Scheduled testing"
needs: [compute-matrix]
timeout-minutes: 30
# Serialize same-package shards across workflow runs so a per-package
# manual dispatch doesn't race the scheduled "all libs" run against the
# same live API credentials. Keyed per (working-directory, python-version)
# so the 3.10/3.14 matrix legs within one run still execute in parallel.
concurrency:
group: integration-tests-${{ matrix.working-directory }}-${{ matrix.python-version }}
cancel-in-progress: false
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
with:
path: langchain
# These libraries exist outside of the monorepo and need to be checked out separately
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
with:
repository: langchain-ai/langchain-aws
path: langchain-aws
- name: "🔐 Configure AWS Credentials"
uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # 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).
#
# External google/aws packages live in separate repos and don't declare
# [tool.uv.sources], so `uv sync` pulls langchain-* from PyPI. Overlay
# local editable installs after sync so integration tests exercise the
# current branch's langchain code. Matches the pattern used by the
# `test-dependents` job below for deepagents.
run: |
echo "Running scheduled tests, installing dependencies with uv..."
cd langchain/${{ matrix.working-directory }}
uv sync --group test --group test_integration
case "${{ matrix.working-directory }}" in
libs/partners/google-genai)
uv pip install \
-e $GITHUB_WORKSPACE/langchain/libs/core \
-e $GITHUB_WORKSPACE/langchain/libs/standard-tests
;;
libs/partners/google-vertexai)
uv pip install \
-e $GITHUB_WORKSPACE/langchain/libs/core \
-e $GITHUB_WORKSPACE/langchain/libs/langchain_v1 \
-e $GITHUB_WORKSPACE/langchain/libs/standard-tests
;;
libs/partners/aws)
uv pip install \
-e $GITHUB_WORKSPACE/langchain/libs/core \
-e $GITHUB_WORKSPACE/langchain/libs/langchain_v1 \
-e $GITHUB_WORKSPACE/langchain/libs/langchain \
-e $GITHUB_WORKSPACE/langchain/libs/standard-tests \
-e $GITHUB_WORKSPACE/langchain/libs/partners/anthropic
;;
esac
- name: "🧾 Build LangSmith Metadata"
# GHA expression values flow through intermediate env vars (injection
# hardening) and jq -nc builds the JSON, so quotes/newlines in any
# field can't corrupt the payload.
env:
GH_SHA: ${{ github.sha }}
GH_RUN_ID: ${{ github.run_id }}
GH_RUN_ATTEMPT: ${{ github.run_attempt }}
GH_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
GH_WORKFLOW: ${{ github.workflow }}
GH_EVENT: ${{ github.event_name }}
GH_REF: ${{ github.ref }}
WORKING_DIRECTORY: ${{ matrix.working-directory }}
PYTHON_VERSION: ${{ matrix.python-version }}
run: |
metadata=$(jq -nc \
--arg github_sha "$GH_SHA" \
--arg github_run_id "$GH_RUN_ID" \
--arg github_run_attempt "$GH_RUN_ATTEMPT" \
--arg github_run_url "$GH_RUN_URL" \
--arg github_workflow "$GH_WORKFLOW" \
--arg github_event "$GH_EVENT" \
--arg github_ref "$GH_REF" \
--arg working_directory "$WORKING_DIRECTORY" \
--arg python_version "$PYTHON_VERSION" \
'{github_sha: $github_sha, github_run_id: $github_run_id, github_run_attempt: $github_run_attempt, github_run_url: $github_run_url, github_workflow: $github_workflow, github_event: $github_event, github_ref: $github_ref, working_directory: $working_directory, python_version: $python_version}')
echo "LANGSMITH_METADATA=$metadata" >> "$GITHUB_ENV"
- 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 }}
# Route traces to one project with GitHub run metadata so failures link back to the originating Actions run.
LANGSMITH_TRACING: "true"
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
LANGSMITH_PROJECT: ${{ vars.LANGSMITH_PROJECT || 'scheduled-testing-py' }}
LANGSMITH_TAGS: "github-actions,${{ matrix.working-directory }},python-${{ matrix.python-version }},sha-${{ github.sha }}"
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
with:
path: langchain
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # 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
+214
View File
@@ -0,0 +1,214 @@
# 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_CLIENT_ID: Your app's client 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:
if: github.repository_owner == 'langchain-ai'
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
- name: Generate GitHub App token
if: github.event.action == 'opened'
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
with:
client-id: ${{ secrets.ORG_MEMBERSHIP_APP_CLIENT_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@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
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@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
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@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
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@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
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}`);
+131
View File
@@ -0,0 +1,131 @@
# 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:
if: github.repository_owner == 'langchain-ai'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
issues: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
with:
client-id: ${{ secrets.ORG_MEMBERSHIP_APP_CLIENT_ID }}
private-key: ${{ secrets.ORG_MEMBERSHIP_APP_PRIVATE_KEY }}
- name: Backfill labels on open PRs
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
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.`);
+128
View File
@@ -0,0 +1,128 @@
# PR title linting.
#
# FORMAT (Conventional Commits 1.0.0):
#
# <type>[optional scope]: <description>
# [optional body]
# [optional footer(s)]
#
# Examples:
# feat(core): add multitenant 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 multitenant 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
+175
View File
@@ -0,0 +1,175 @@
# Pre-merge banned-trailer check.
name: "🏷️ PR trailer lint"
on:
pull_request:
types: [ opened, edited, synchronize, reopened ]
permissions:
pull-requests: write
jobs:
trailer-check:
if: github.repository_owner == 'langchain-ai'
name: "validate squash-merge has no banned trailers"
runs-on: ubuntu-latest
# Serialize per-PR. Rapid `edited`/`synchronize` events on a PR open can
# otherwise produce two concurrent runs that both observe "no existing
# sticky" and both call `createComment`, leaving a duplicate failure
# comment that the find-first updater will never reconcile. We queue
# (cancel-in-progress: false) rather than cancel, so the in-flight run
# finishes its sticky write before the next event evaluates.
concurrency:
group: pr-trailer-lint-${{ github.event.pull_request.number }}
cancel-in-progress: false
steps:
- name: Check PR title and body for banned trailer
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
# Bound the comment-write tail so a hung GitHub API call cannot leave
# the check stuck "in progress" past the runner default. `core.setFailed`
# is invoked before the sticky write, so the failure status is already
# recorded if this timeout fires.
timeout-minutes: 5
with:
script: |
if (!context.payload.pull_request) {
core.setFailed('No pull_request payload — workflow must run on pull_request events.');
return;
}
const { title, body, number } = context.payload.pull_request;
// Normalize line endings — GitHub returns whatever the editor used,
// and CRLF leaves stray \r chars in offending-line displays.
const fullBody = (body || '').replace(/\r\n/g, '\n');
const STICKY_MARKER = '<!-- pr-trailer-lint -->';
// Mirrors the org ruleset regex on the default branch. Keep in lock-step:
// the live source of truth is the ruleset's `commit_message_pattern.pattern`
// field at GitHub org settings → Rulesets → `block-anthropic-coauthor`
// (or whichever ruleset blocks this trailer on the default branch).
// The pattern below is informational; verify against the live ruleset
// when updating either side, or this check silently passes pushes
// that the ruleset will then reject (defeating the entire purpose).
//
// Case-folding is intentionally narrow (`[Aa]`/`[Bb]`) because the
// ruleset's pattern is narrow. Do NOT add the `i` flag — that would
// catch cases the ruleset does not, surfacing false positives the
// ruleset would let through.
const BANNED_REGEX = /Co-[Aa]uthored-[Bb]y:.*<noreply@anthropic\.com>/;
const squashMessage = `${title} (#${number})\n\n${fullBody}`;
async function findStickyComment() {
const comments = await github.paginate(github.rest.issues.listComments, {
...context.repo,
issue_number: number,
per_page: 100,
});
return comments.find(c => c.body && c.body.startsWith(STICKY_MARKER));
}
// Comment write paths can fail for several reasons that should not
// turn this advisory job red on its own: fork PRs run with
// restricted tokens, secondary rate limits, transient API errors.
// Fall back to `core.summary` so a maintainer can paste the
// remediation manually. The check still fails — `setFailed` is
// invoked before this function, so the failure signal is already
// recorded by the time the comment write is attempted.
//
// The try/catch wraps ONLY the write call so that a bug in
// `findStickyComment` (e.g., pagination throwing) surfaces with
// its true cause instead of being misattributed to "fork PR token".
async function postStickyOrSummary(commentBody, summaryHeading) {
const existing = await findStickyComment();
try {
if (existing) {
if (existing.body !== commentBody) {
await github.rest.issues.updateComment({
...context.repo,
comment_id: existing.id,
body: commentBody,
});
}
} else {
await github.rest.issues.createComment({
...context.repo,
issue_number: number,
body: commentBody,
});
}
} catch (commentErr) {
core.warning(`Could not post sticky comment (fork PR token, rate limit, or transient API error): ${commentErr.message}`);
await core.summary
.addHeading(summaryHeading)
.addRaw('Paste the following into the PR as a comment:')
.addCodeBlock(commentBody, 'markdown')
.write();
}
}
const lines = squashMessage.split('\n');
const offendingIndices = [];
for (let i = 0; i < lines.length; i++) {
if (BANNED_REGEX.test(lines[i])) {
offendingIndices.push(i);
}
}
if (offendingIndices.length === 0) {
core.info('No banned trailer in squash-merge message.');
// Mark any prior failure comment as resolved. We update rather
// than delete because `deleteComment` 403s under restricted
// fork-PR tokens, whereas `updateComment` on a bot-authored
// comment works in both modes. Wrapped in try/catch because a
// transient API failure during cleanup must NOT turn a green
// check into red.
try {
const existing = await findStickyComment();
if (existing) {
const resolvedBody = [
STICKY_MARKER,
'✅ **Trailer fixed.** The previous warning is resolved.',
].join('\n');
if (existing.body !== resolvedBody) {
await github.rest.issues.updateComment({
...context.repo,
comment_id: existing.id,
body: resolvedBody,
});
}
}
} catch (cleanupErr) {
core.warning(`Check passed but could not update prior failure comment to resolved: ${cleanupErr.message}`);
}
return;
}
const offendingExcerpt = offendingIndices
.map(i => `Line ${i + 1}: ${lines[i]}`)
.join('\n');
const commentBody = [
STICKY_MARKER,
'⚠️ **Banned trailer in PR — would block the squash-merge push to the default branch.**',
'',
'The would-be squash-merge commit message contains a `Co-authored-by: ... <noreply@anthropic.com>` line. An organization ruleset on the default branch rejects any push whose commit message matches that pattern, so this PR cannot be merged until the trailer is removed.',
'',
'**Found:**',
'```',
offendingExcerpt,
'```',
'',
'### Fix',
'',
'Edit the PR description and remove the offending line(s). The trailer is auto-inserted by some Claude-based authoring tools — strip it before opening or merging the PR. Save the description; this check will re-run automatically.',
].join('\n');
// Set the failure signal BEFORE the sticky write — if the comment
// API hangs, the runner-level timeout fires with the failure
// status already recorded. Reversing the order leaves the check
// stuck "in progress" instead of red.
core.setFailed(`PR contains banned trailer matching ${BANNED_REGEX}`);
await postStickyOrSummary(
commentBody,
'Banned trailer in PR; comment could not be posted',
);
@@ -0,0 +1,45 @@
# 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:
if: github.repository_owner == 'langchain-ai'
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](https://github.com/langchain-ai/langchain/blob/master/.github/workflows/refresh_model_profiles.yml).
secrets:
MODEL_PROFILE_BOT_CLIENT_ID: ${{ secrets.MODEL_PROFILE_BOT_CLIENT_ID }}
MODEL_PROFILE_BOT_PRIVATE_KEY: ${{ secrets.MODEL_PROFILE_BOT_PRIVATE_KEY }}
@@ -0,0 +1,61 @@
# Remove the `waiting-on-author` label from an issue or PR when the original
# author replies. Fires on every issue/PR comment; the job's `if:` filter skips
# runs unless the item is open, carries the label, and the commenter is the
# original author (and not a bot).
#
# Uses the default GITHUB_TOKEN so the label-removal event does NOT re-trigger
# other workflows: GitHub does not trigger new workflow runs from actions
# performed by the default GITHUB_TOKEN (with narrow exceptions like
# workflow_dispatch), which prevents infinite loops.
name: Remove waiting-on-author on Author Reply
on:
issue_comment:
types: [created]
permissions:
contents: read
jobs:
remove-label:
if: >-
github.repository_owner == 'langchain-ai' &&
github.event.issue.state == 'open' &&
contains(github.event.issue.labels.*.name, 'waiting-on-author') &&
github.event.comment.user.type != 'Bot' &&
github.event.comment.user.login == github.event.issue.user.login
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Remove waiting-on-author label
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { owner, repo } = context.repo;
const issue_number = context.payload.issue.number;
const author = context.payload.issue.user.login;
console.log(
`Author ${author} commented on #${issue_number} — removing waiting-on-author`,
);
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number,
name: 'waiting-on-author',
});
} catch (e) {
// 404: typically label not present (already removed or never
// applied). Could also indicate issue/repo not found — include
// e.message to disambiguate.
if (e.status === 404) {
console.log(`Label already absent (404) — nothing to do: ${e.message}`);
return;
}
throw e;
}
+196
View File
@@ -0,0 +1,196 @@
# Reopen PRs that were auto-closed by require_issue_link.yml when the
# contributor was not assigned to the linked issue. When a maintainer
# assigns the contributor to the issue, this workflow finds matching
# closed PRs, verifies the issue link, and reopens them.
#
# Uses the default GITHUB_TOKEN (not a PAT or app token) so that the
# reopen and label-removal events do NOT re-trigger other workflows.
# GitHub suppresses events created by the default GITHUB_TOKEN within
# workflow runs to prevent infinite loops.
name: Reopen PR on Issue Assignment
on:
issues:
types: [assigned]
permissions:
contents: read
jobs:
reopen-linked-prs:
if: github.repository_owner == 'langchain-ai'
runs-on: ubuntu-latest
permissions:
actions: write
pull-requests: write
steps:
- name: Find and reopen matching PRs
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { owner, repo } = context.repo;
const issueNumber = context.payload.issue.number;
const assignee = context.payload.assignee.login;
console.log(
`Issue #${issueNumber} assigned to ${assignee} — searching for closed PRs to reopen`,
);
const q = [
`is:pr`,
`is:closed`,
`author:${assignee}`,
`label:missing-issue-link`,
`repo:${owner}/${repo}`,
].join(' ');
let data;
try {
({ data } = await github.rest.search.issuesAndPullRequests({
q,
per_page: 30,
}));
} catch (e) {
throw new Error(
`Failed to search for closed PRs to reopen after assigning ${assignee} ` +
`to #${issueNumber} (HTTP ${e.status ?? 'unknown'}): ${e.message}`,
);
}
if (data.total_count === 0) {
console.log('No matching closed PRs found');
return;
}
console.log(`Found ${data.total_count} candidate PR(s)`);
// Must stay in sync with the identical pattern in require_issue_link.yml
const pattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*#(\d+)/gi;
for (const item of data.items) {
const prNumber = item.number;
const body = item.body || '';
const matches = [...body.matchAll(pattern)];
const referencedIssues = matches.map(m => parseInt(m[1], 10));
if (!referencedIssues.includes(issueNumber)) {
console.log(`PR #${prNumber} does not reference #${issueNumber} — skipping`);
continue;
}
// Skip if already bypassed
const labels = item.labels.map(l => l.name);
if (labels.includes('bypass-issue-check')) {
console.log(`PR #${prNumber} already has bypass-issue-check — skipping`);
continue;
}
// Reopen first, remove label second — a closed PR that still has
// missing-issue-link is recoverable; a closed PR with the label
// stripped is invisible to both workflows.
try {
await github.rest.pulls.update({
owner,
repo,
pull_number: prNumber,
state: 'open',
});
console.log(`Reopened PR #${prNumber}`);
} catch (e) {
if (e.status === 422) {
// Head branch deleted — PR is unrecoverable. Notify the
// contributor so they know to open a new PR.
core.warning(`Cannot reopen PR #${prNumber}: head branch was likely deleted`);
try {
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body:
`You have been assigned to #${issueNumber}, but this PR could not be ` +
`reopened because the head branch has been deleted. Please open a new ` +
`PR referencing the issue.`,
});
} catch (commentErr) {
core.warning(
`Also failed to post comment on PR #${prNumber}: ${commentErr.message}`,
);
}
continue;
}
// Transient errors (rate limit, 5xx) should fail the job so
// the label is NOT removed and the run can be retried.
throw e;
}
// Remove missing-issue-link label only after successful reopen
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: prNumber,
name: 'missing-issue-link',
});
console.log(`Removed missing-issue-link from PR #${prNumber}`);
} catch (e) {
if (e.status !== 404) throw e;
}
// Minimize stale enforcement comment (best-effort;
// sync w/ require_issue_link.yml minimize blocks)
try {
const marker = '<!-- require-issue-link -->';
const comments = await github.paginate(
github.rest.issues.listComments,
{ owner, repo, issue_number: prNumber, per_page: 100 },
);
const stale = comments.find(c => c.body && c.body.includes(marker));
if (stale) {
await github.graphql(`
mutation($id: ID!) {
minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) {
minimizedComment { isMinimized }
}
}
`, { id: stale.node_id });
console.log(`Minimized stale enforcement comment ${stale.id} as outdated`);
}
} catch (e) {
core.warning(`Could not minimize stale comment on PR #${prNumber}: ${e.message}`);
}
// Re-run the failed require_issue_link check so it picks up the
// new assignment. The re-run uses the original event payload but
// fetches live issue data, so the assignment check will pass.
//
// Limitation: we look up runs by the PR's current head SHA. If the
// contributor pushed new commits while the PR was closed, head.sha
// won't match the SHA of the original failed run and the query will
// return 0 results. This is acceptable because any push after reopen
// triggers a fresh require_issue_link run against the new SHA.
try {
const { data: pr } = await github.rest.pulls.get({
owner, repo, pull_number: prNumber,
});
const { data: runs } = await github.rest.actions.listWorkflowRuns({
owner, repo,
workflow_id: 'require_issue_link.yml',
head_sha: pr.head.sha,
status: 'failure',
per_page: 1,
});
if (runs.workflow_runs.length > 0) {
await github.rest.actions.reRunWorkflowFailedJobs({
owner, repo,
run_id: runs.workflow_runs[0].id,
});
console.log(`Re-ran failed require_issue_link run ${runs.workflow_runs[0].id} for PR #${prNumber}`);
} else {
console.log(`No failed require_issue_link runs found for PR #${prNumber} — skipping re-run`);
}
} catch (e) {
core.warning(`Could not re-run require_issue_link check for PR #${prNumber} (HTTP ${e.status ?? 'unknown'}): ${e.message}`);
}
}
+468
View File
@@ -0,0 +1,468 @@
# 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: >-
github.repository_owner == 'langchain-ai' &&
!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@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
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');
// Minimize stale enforcement comment (best-effort; must not
// abort bypass — sync w/ reopen_on_assignment.yml & step below)
try {
const marker = '<!-- require-issue-link -->';
const comments = await github.paginate(
github.rest.issues.listComments,
{ owner, repo, issue_number: prNumber, per_page: 100 },
);
const stale = comments.find(c => c.body && c.body.includes(marker));
if (stale) {
await github.graphql(`
mutation($id: ID!) {
minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) {
minimizedComment { isMinimized }
}
}
`, { id: stale.node_id });
console.log(`Minimized stale enforcement comment ${stale.id} as outdated`);
}
} catch (e) {
core.warning(`Could not minimize stale comment on PR #${prNumber}: ${e.message}`);
}
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@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
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@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
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}`);
}
// Minimize stale enforcement comment (best-effort;
// sync w/ applyMaintainerBypass above & reopen_on_assignment.yml)
try {
const marker = '<!-- require-issue-link -->';
const comments = await github.paginate(
github.rest.issues.listComments,
{ owner, repo, issue_number: prNumber, per_page: 100 },
);
const stale = comments.find(c => c.body && c.body.includes(marker));
if (stale) {
await github.graphql(`
mutation($id: ID!) {
minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) {
minimizedComment { isMinimized }
}
}
`, { id: stale.node_id });
console.log(`Minimized stale enforcement comment ${stale.id} as outdated`);
}
} catch (e) {
core.warning(`Could not minimize stale comment on PR #${prNumber}: ${e.message}`);
}
- 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@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
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. Opening a PR before maintainer approval and assignment is discouraged. 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 the approach and assign you',
'3. After assignment, open a PR. If this PR was opened early, add `Fixes #<issue_number>`, `Closes #<issue_number>`, or `Resolves #<issue_number>` to the description and it can 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.',
'',
'Opening a PR before assignment is discouraged and is **not** an indication that it will be accepted. This process exists so maintainers can confirm a change is aligned with the project direction *before* contributors invest time implementing it. Please:',
'1. Comment on the linked issue explaining the approach you would like to take and why — include enough detail for a maintainer to evaluate the design. Do **not** post a drive-by "please assign me" comment with no substance; those will be ignored.',
'2. Wait for a maintainer to approve the approach and assign you. Once assigned, this PR can 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);
+205
View File
@@ -0,0 +1,205 @@
# 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_CLIENT_ID: Your app's client 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.repository_owner == 'langchain-ai' && github.event_name != 'workflow_dispatch'
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
with:
client-id: ${{ secrets.ORG_MEMBERSHIP_APP_CLIENT_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@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
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@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
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@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
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.repository_owner == 'langchain-ai' && github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
with:
client-id: ${{ secrets.ORG_MEMBERSHIP_APP_CLIENT_ID }}
private-key: ${{ secrets.ORG_MEMBERSHIP_APP_PRIVATE_KEY }}
- name: Backfill labels on open issues
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
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.`);