From 2458a7912e2f1dcfe71f5ce816e93b2dd209ccb4 Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Mon, 18 May 2026 15:12:21 -0700 Subject: [PATCH] ci(infra): port four CI governance workflows (#37511) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: ... ` 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). --- .github/scripts/check_extras_sync.py | 118 +++++++++++++ .github/workflows/block_fork_main_prs.yml | 146 +++++++++++++++ .github/workflows/bump_uv_pin.yml | 205 ++++++++++++++++++++++ .github/workflows/check_extras_sync.yml | 73 ++++++++ .github/workflows/pr_lint_trailer.yml | 175 ++++++++++++++++++ 5 files changed, 717 insertions(+) create mode 100644 .github/scripts/check_extras_sync.py create mode 100644 .github/workflows/block_fork_main_prs.yml create mode 100644 .github/workflows/bump_uv_pin.yml create mode 100644 .github/workflows/check_extras_sync.yml create mode 100644 .github/workflows/pr_lint_trailer.yml diff --git a/.github/scripts/check_extras_sync.py b/.github/scripts/check_extras_sync.py new file mode 100644 index 00000000000..8d2c4bd50a2 --- /dev/null +++ b/.github/scripts/check_extras_sync.py @@ -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)) diff --git a/.github/workflows/block_fork_main_prs.yml b/.github/workflows/block_fork_main_prs.yml new file mode 100644 index 00000000000..5ff6b9a5a13 --- /dev/null +++ b/.github/workflows/block_fork_main_prs.yml @@ -0,0 +1,146 @@ +# Block PRs whose head ref is `main` (or `master`) from a fork. This topology +# (`: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 = ''; + + // 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.`); diff --git a/.github/workflows/bump_uv_pin.yml b/.github/workflows/bump_uv_pin.yml new file mode 100644 index 00000000000..d8479c679fc --- /dev/null +++ b/.github/workflows/bump_uv_pin.yml @@ -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" diff --git a/.github/workflows/check_extras_sync.yml b/.github/workflows/check_extras_sync.yml new file mode 100644 index 00000000000..8081e58ddd5 --- /dev/null +++ b/.github/workflows/check_extras_sync.yml @@ -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///`) + # 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 diff --git a/.github/workflows/pr_lint_trailer.yml b/.github/workflows/pr_lint_trailer.yml new file mode 100644 index 00000000000..17f6f099025 --- /dev/null +++ b/.github/workflows/pr_lint_trailer.yml @@ -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 = ''; + + // 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:.*/; + + 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: ... ` 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', + );