mirror of
https://github.com/hwchase17/langchain.git
synced 2026-06-09 10:17:00 +00:00
ci(infra): port four CI governance workflows (#37511)
Four GitHub Actions workflows ported from the Deep Agents monorepo to enforce repository hygiene rules that were not previously applied here. ## Changes - **Fork-main PR guard**: closes PRs from forks whose head is `main` or `master`, with a sticky comment explaining how to reopen from a feature branch. Prevents the "Update branch" → admin-override path that lets a `Merge branch 'master' into master` commit land on the default branch and bypass squash-only policy. Maintainers can override with a `bypass-fork-main-check` label. - **Monthly uv pin bump**: opens a PR on the first of each month to advance `UV_VERSION` in the composite setup action. Probes `releases.astral.sh` across four architectures before committing so CI doesn't race a lagging mirror on fresh-release days — the gap Dependabot's `github-actions` ecosystem can't cover because it tracks `uses:` SHA pins, not the inline `UV_VERSION` value. - **Extras-sync validation**: a Python script (`check_extras_sync.py`) and companion workflow that detect version-constraint drift between `[project.dependencies]` and `[project.optional-dependencies]` across every `libs/**/pyproject.toml`. Runs on PRs touching any `pyproject.toml` and on pushes to `master`; is a no-op on packages that declare no extras. - **Banned-trailer pre-merge lint**: rejects PR descriptions containing a `Co-authored-by: ... <noreply@anthropic.com>` trailer before the PR reaches merge, where the org ruleset would reject the squash-push anyway. Posts a sticky comment with remediation steps; updates it to a "resolved" state when the trailer is removed, rather than deleting (which requires elevated token scope on fork PRs).
This commit is contained in:
118
.github/scripts/check_extras_sync.py
vendored
Normal file
118
.github/scripts/check_extras_sync.py
vendored
Normal 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))
|
||||||
146
.github/workflows/block_fork_main_prs.yml
vendored
Normal file
146
.github/workflows/block_fork_main_prs.yml
vendored
Normal 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
.github/workflows/bump_uv_pin.yml
vendored
Normal file
205
.github/workflows/bump_uv_pin.yml
vendored
Normal 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 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"
|
||||||
73
.github/workflows/check_extras_sync.yml
vendored
Normal file
73
.github/workflows/check_extras_sync.yml
vendored
Normal 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 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
|
||||||
175
.github/workflows/pr_lint_trailer.yml
vendored
Normal file
175
.github/workflows/pr_lint_trailer.yml
vendored
Normal 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',
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user