Files
langchain/.github/workflows/check_release_deps.yml
Mason Daugherty 3246caf831 ci(infra): add org guards to workflows and remove dead v0.3 docs workflow (#38665)
Adds `github.repository_owner == 'langchain-ai'` guards to 14 GitHub
Actions workflows that were missing them. Without these guards,
write-capable automation (issue/PR labeling, closing, commenting, PR
reopening, release publishing) and CI jobs fire on forks — causing
unwanted mutations, wasted runner minutes, and failed GitHub App token
generation on repos that don't have the required secrets.

Also removes `v03_api_doc_build.yml`, which pushed built docs to
`langchain-ai/langchain-api-docs-html` via `TOKEN_GITHUB_API_DOCS_HTML`
— a secret that isn't set on this repo. The workflow was dead code with
no consumers.

---

## What changed

**Write-capable workflows now guarded** (8 files):
- `auto-label-by-package` — was adding/removing issue labels on forks
- `close_unchecked_issues` — was closing issues and posting comments on
forks via GitHub App token
- `tag-external-issues` — both `tag-external` and `backfill` jobs were
generating App tokens and labeling issues on forks
- `reopen_on_assignment` — was reopening PRs and re-running workflows on
forks
- `require_issue_link` — was closing PRs, creating labels, posting
comments, and canceling workflow runs on forks
- `remove_waiting_on_author` — was removing labels on fork issues/PRs
- `pr_labeler` — was generating App tokens and adding/removing PR labels
on forks
- `pr_labeler_backfill` — same, on manual trigger from a fork

**Read-only CI workflows now guarded** (5 files):
- `check_diffs` — `build` and `check-release-options` jobs (downstream
jobs inherit the skip)
- `codspeed` — `build` job
- `check_agents_sync`, `check_versions`, `check_release_deps`

**Release workflow guarded** (1 file):
- `_release` — `build` job (all downstream jobs chain via `needs`, so
they inherit the skip). Previously relied only on `environment: Release`
approval and a `github.ref` check.

**Removed**:
- `v03_api_doc_build.yml` — dead workflow depending on unset
`TOKEN_GITHUB_API_DOCS_HTML` secret

## What was already guarded

`block_fork_main_prs`, `bump_uv_pin`, `check_extras_sync`,
`integration_tests`, `pr_lint_trailer`, `refresh_model_profiles` already
had the guard. `pr_lint` (read-only, `pull-requests: read`) and the
`_*.yml` reusable workflows (only run when called by a guarded parent)
were intentionally left unguarded.

## Release note

- GitHub Actions workflows now skip execution on forks via
`repository_owner == 'langchain-ai'` guards, preventing unwanted
issue/PR automation and CI runs outside the canonical repo.
- Removed the dead `v03_api_doc_build` workflow that depended on an
unset secret.

---

🤖 Generated with AI-agent involvement.
2026-07-04 21:30:23 -04:00

289 lines
14 KiB
YAML

# 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