1# Pre-merge banned-trailer check.23name: "🏷️ PR trailer lint"45on:6 pull_request:7 types: [ opened, edited, synchronize, reopened ]89permissions:10 pull-requests: write1112jobs:13 trailer-check:14 if: github.repository_owner == 'langchain-ai'15 name: "validate squash-merge has no banned trailers"16 runs-on: ubuntu-latest17 # Serialize per-PR. Rapid `edited`/`synchronize` events on a PR open can18 # otherwise produce two concurrent runs that both observe "no existing19 # sticky" and both call `createComment`, leaving a duplicate failure20 # comment that the find-first updater will never reconcile. We queue21 # (cancel-in-progress: false) rather than cancel, so the in-flight run22 # finishes its sticky write before the next event evaluates.23 concurrency:24 group: pr-trailer-lint-${{ github.event.pull_request.number }}25 cancel-in-progress: false26 steps:27 - name: Check PR title and body for banned trailer28 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.029 # Bound the comment-write tail so a hung GitHub API call cannot leave30 # the check stuck "in progress" past the runner default. `core.setFailed`31 # is invoked before the sticky write, so the failure status is already32 # recorded if this timeout fires.33 timeout-minutes: 534 with:35 script: |36 if (!context.payload.pull_request) {37 core.setFailed('No pull_request payload — workflow must run on pull_request events.');38 return;39 }40 const { title, body, number } = context.payload.pull_request;41 // Normalize line endings — GitHub returns whatever the editor used,42 // and CRLF leaves stray \r chars in offending-line displays.43 const fullBody = (body || '').replace(/\r\n/g, '\n');44 const STICKY_MARKER = '<!-- pr-trailer-lint -->';4546 // Mirrors the org ruleset regex on the default branch. Keep in lock-step:47 // the live source of truth is the ruleset's `commit_message_pattern.pattern`48 // field at GitHub org settings → Rulesets → `block-anthropic-coauthor`49 // (or whichever ruleset blocks this trailer on the default branch).50 // The pattern below is informational; verify against the live ruleset51 // when updating either side, or this check silently passes pushes52 // that the ruleset will then reject (defeating the entire purpose).53 //54 // Case-folding is intentionally narrow (`[Aa]`/`[Bb]`) because the55 // ruleset's pattern is narrow. Do NOT add the `i` flag — that would56 // catch cases the ruleset does not, surfacing false positives the57 // ruleset would let through.58 const BANNED_REGEX = /Co-[Aa]uthored-[Bb]y:.*<noreply@anthropic\.com>/;5960 const squashMessage = `${title} (#${number})\n\n${fullBody}`;6162 async function findStickyComment() {63 const comments = await github.paginate(github.rest.issues.listComments, {64 ...context.repo,65 issue_number: number,66 per_page: 100,67 });68 return comments.find(c => c.body && c.body.startsWith(STICKY_MARKER));69 }7071 // Comment write paths can fail for several reasons that should not72 // turn this advisory job red on its own: fork PRs run with73 // restricted tokens, secondary rate limits, transient API errors.74 // Fall back to `core.summary` so a maintainer can paste the75 // remediation manually. The check still fails — `setFailed` is76 // invoked before this function, so the failure signal is already77 // recorded by the time the comment write is attempted.78 //79 // The try/catch wraps ONLY the write call so that a bug in80 // `findStickyComment` (e.g., pagination throwing) surfaces with81 // its true cause instead of being misattributed to "fork PR token".82 async function postStickyOrSummary(commentBody, summaryHeading) {83 const existing = await findStickyComment();84 try {85 if (existing) {86 if (existing.body !== commentBody) {87 await github.rest.issues.updateComment({88 ...context.repo,89 comment_id: existing.id,90 body: commentBody,91 });92 }93 } else {94 await github.rest.issues.createComment({95 ...context.repo,96 issue_number: number,97 body: commentBody,98 });99 }100 } catch (commentErr) {101 core.warning(`Could not post sticky comment (fork PR token, rate limit, or transient API error): ${commentErr.message}`);102 await core.summary103 .addHeading(summaryHeading)104 .addRaw('Paste the following into the PR as a comment:')105 .addCodeBlock(commentBody, 'markdown')106 .write();107 }108 }109110 const lines = squashMessage.split('\n');111 const offendingIndices = [];112 for (let i = 0; i < lines.length; i++) {113 if (BANNED_REGEX.test(lines[i])) {114 offendingIndices.push(i);115 }116 }117118 if (offendingIndices.length === 0) {119 core.info('No banned trailer in squash-merge message.');120 // Mark any prior failure comment as resolved. We update rather121 // than delete because `deleteComment` 403s under restricted122 // fork-PR tokens, whereas `updateComment` on a bot-authored123 // comment works in both modes. Wrapped in try/catch because a124 // transient API failure during cleanup must NOT turn a green125 // check into red.126 try {127 const existing = await findStickyComment();128 if (existing) {129 const resolvedBody = [130 STICKY_MARKER,131 '✅ **Trailer fixed.** The previous warning is resolved.',132 ].join('\n');133 if (existing.body !== resolvedBody) {134 await github.rest.issues.updateComment({135 ...context.repo,136 comment_id: existing.id,137 body: resolvedBody,138 });139 }140 }141 } catch (cleanupErr) {142 core.warning(`Check passed but could not update prior failure comment to resolved: ${cleanupErr.message}`);143 }144 return;145 }146147 const offendingExcerpt = offendingIndices148 .map(i => `Line ${i + 1}: ${lines[i]}`)149 .join('\n');150151 const commentBody = [152 STICKY_MARKER,153 '⚠️ **Banned trailer in PR — would block the squash-merge push to the default branch.**',154 '',155 '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.',156 '',157 '**Found:**',158 '```',159 offendingExcerpt,160 '```',161 '',162 '### Fix',163 '',164 '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.',165 ].join('\n');166167 // Set the failure signal BEFORE the sticky write — if the comment168 // API hangs, the runner-level timeout fires with the failure169 // status already recorded. Reversing the order leaves the check170 // stuck "in progress" instead of red.171 core.setFailed(`PR contains banned trailer matching ${BANNED_REGEX}`);172 await postStickyOrSummary(173 commentBody,174 'Banned trailer in PR; comment could not be posted',175 );
Findings
✓ No findings reported for this file.