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:
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