🎯 ci: Retarget Pull Requests Opened Against main to dev (#15416)

* 🎯 ci: Retarget Pull Requests Opened Against `main` to `dev`

Contributions land on `dev`; `main` only moves at release time. With `main` as
the repository's default branch, pull requests default to it, and the mismatch
has been resolved by hand at merge time.

Adds a `pull_request_target` hook that switches a newly opened or reopened pull
request's base from `main` to `dev` and posts a one-time explanation, plus a
manual `workflow_dispatch` sweep (dry-run by default) for the existing backlog.

Release-bound upstream branches keep `main` as their base: `dev`, `release/*`,
`hotfix/*`, `backport/*` and `*-main`. The `target: main` label exempts any
pull request. The exemptions apply only to branches on this repository, so a
fork branch that happens to match one of those names is still retargeted.

The job checks out the trusted base commit rather than the pull request head,
so the write-scoped token never runs contributor code.

`pull_request_target` reads its workflow definition from the base branch, so
this must exist on `main` to take effect.

* 📝 docs: Record the `dev`-First Branch Policy for Agents

`CLAUDE.md` and `AGENTS.md` never stated which branch work targets, and the
default branch is `main` — so `gh pr create` and the GitHub UI point at `main`
unless told otherwise, and an agent following the defaults gets it wrong.

States the policy, the reason a backport to `main` is never needed (`main` is
kept as a strict ancestor of `dev`), and the exemptions the retarget workflow
honors.

* 📐 docs: Record Typecheck, Formatting and Issue-Closing Gotchas for Agents

Four repeat cycle-wasters that neither file stated, each verified against the
current tree:

- `tsdown` emits without typechecking, so `npm run build` proves nothing about
  types in `packages/api`, `packages/client` or `packages/data-schemas`; only
  `packages/data-provider` runs `tsc` in its build.
- `packages/client/tsconfig.json` excludes `*.spec.ts(x)` and `*.test.ts(x)`,
  so type errors in its specs surface only at run time.
- `npm run sort-imports` with no arguments rewrites all six source roots.
- `Fixes #N` never closes an issue here, because closing keywords are honored
  only on merges into the default branch and everything merges to `dev`.

Also notes that worktrees share one stash stack, and points at
`npm run static-checks -- --against origin/dev` as the local mirror of CI.

* 🔁 fix: Address Codex Review on the Retarget Workflow

- **Backport branches are no longer exempt.** Exempting `backport/*` and
  `*-main` contradicted the invariant the policy rests on: `main` is a
  fast-forward of `dev`, so a backport merged straight to `main` is precisely
  what breaks it. `target: main` remains the deliberate, explicit escape hatch;
  `dev`, `release/*` and `hotfix/*` stay exempt as release-bound branches.
- **`npm run update` does not sync `dev`.** `config/update.js` hard-codes
  `git checkout main` and `git pull origin main` — it is the self-host
  deployment updater, not a branch-sync tool. Telling contributors to refresh
  `dev` with it would have branched them off `main` and recreated the very
  flow this change exists to prevent. Replaced with the explicit git commands.
- **A dry run no longer reports work it did not do.** The summary said
  "Retargeted N" on a dispatch that wrote nothing, which could read as a
  completed sweep.
- **A failed explanation comment is recorded, not swallowed.** The base edit
  has already landed at that point, so a later run would skip the pull request
  and the contributor would never receive the note. It now counts, fails the
  run, and names the retry: `EXPLAIN_MISSING=true`, which posts a missing
  explanation onto an already-retargeted pull request.
- **The sweep paginates.** `gh pr list --limit 500` is a cap, not a page size,
  and would have silently ignored the remainder while reporting success.

Verified against the stubbed `gh` boundary: `*-main` now retargets, the dry run
performs zero writes and says so, a failing comment yields a non-zero exit, and
`EXPLAIN_MISSING` posts onto a pull request already based on `dev`. The
paginated query returns all 258 open pull requests based on `main`.

* 🧷 fix: Address the Second Codex Round on the Retarget Workflow

- **`already_explained` now separates "lookup failed" from "no marker".** A
  transient comments-API failure returned the same status as a clean miss, so
  the run would post a second explanation and report success — defeating the
  deduplication it exists for. A failed read now declines to post and is
  recorded.
- **`CONTRIBUTING.md` syncs from the canonical remote.** The previous fix
  pulled `dev` from `origin`, which in the fork-based flow described two
  sections later is the contributor's own fork — so it would fail outright or
  silently sync a stale branch, landing them on the wrong base again.
- **The hook retries on `synchronize`.** If the `opened` run failed before
  `gh pr edit` landed, nothing retried and the pull request stayed on `main`.
  A retargeted pull request no longer matches `branches: [main]`, so this adds
  no redundant runs — and the existing backlog now drains on contributor
  activity rather than only by sweep.
- **Sweeps serialize against each other** via a shared concurrency group, while
  the hook stays keyed per pull request. Deliberately not one global group:
  GitHub keeps a single pending job per group, so a burst of openings would
  cancel queued retargets — trading a rare duplicate comment for dropped
  retargets is the wrong way round.
- **`main` is an ancestor of `dev`, not a strict one.** Right after a sync the
  two refs are equal, which the previous wording described as a violated
  invariant.

Verified: a failing comments lookup edits the base but posts nothing and exits
non-zero.
This commit is contained in:
Danny Avila 2026-08-31 11:29:34 -04:00 • committed by GitHub
parent b7ae67307e
commit f20b978075
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 302 additions and 4 deletions

155
.github/scripts/retarget-prs.sh vendored Executable file
View file

@ -0,0 +1,155 @@
#!/usr/bin/env bash
# Retarget pull requests opened against the release branch onto the development branch.
# Used by .github/workflows/pr-retarget-dev.yml for both the on-open hook and the manual sweep.
set -euo pipefail
REPO="${REPO:?REPO is required (owner/name)}"
RELEASE_BASE="${RELEASE_BASE:-main}"
TARGET_BASE="${TARGET_BASE:-dev}"
DRY_RUN="${DRY_RUN:-false}"
EXPLAIN_MISSING="${EXPLAIN_MISSING:-false}"
KEEP_LABEL="${KEEP_LABEL:-target: main}"
THROTTLE_SECONDS="${THROTTLE_SECONDS:-0}"
MARKER="<!-- librechat:auto-retarget -->"
if [ "$#" -eq 0 ]; then
echo "usage: REPO=owner/name $0 <pr-number> [pr-number...]" >&2
exit 64
fi
# Branches on the upstream repository that legitimately merge into the release branch.
# Backport branches are deliberately absent: `main` is kept as a fast-forward of `dev`, so a
# backport merged straight to `main` would break that invariant. Use the label to exempt one.
keeps_release_base() {
local head_repo="$1" head_ref="$2"
[ "$head_repo" = "$REPO" ] || return 1
case "$head_ref" in
"$TARGET_BASE" | release/* | hotfix/*) return 0 ;;
*) return 1 ;;
esac
}
# 0 = already explained, 1 = not explained, 2 = the lookup itself failed. The third status
# matters: treating a failed read as "not explained" would post a duplicate. Bodies are collected
# before grepping because piping into `grep -q` lets SIGPIPE fail the pipeline under `pipefail`.
already_explained() {
local bodies
bodies="$(gh api "repos/$REPO/issues/$1/comments" --paginate --jq '.[].body')" || return 2
grep -qF "$MARKER" <<<"$bodies"
}
comment_body() {
cat <<BODY
$MARKER
👋 Thanks for the contribution! LibreChat merges all changes into \`$TARGET_BASE\` first — \`$RELEASE_BASE\` only moves at release time — so this pull request's base branch was switched from \`$RELEASE_BASE\` to \`$TARGET_BASE\` automatically.
Nothing is needed from you; your commits, reviews and discussion are unchanged. If the diff now shows files you did not touch, rebase onto \`$TARGET_BASE\`:
\`\`\`bash
git remote add upstream https://github.com/$REPO.git
git fetch upstream $TARGET_BASE
git rebase upstream/$TARGET_BASE
git push --force-with-lease
\`\`\`
Maintainers: apply the \`$KEEP_LABEL\` label and restore the base branch if this one genuinely belongs on \`$RELEASE_BASE\`.
BODY
}
matched=0
skipped=0
failed=0
unexplained=0
# Posts the explanation unless one is already there. A failure is recorded rather than
# swallowed: the base edit has already succeeded, so a later run would skip the pull
# request and the contributor would never receive it.
post_explanation() {
local number="$1" lookup=0
already_explained "$number" || lookup=$?
if [ "$lookup" -eq 0 ]; then
echo "#$number: explanation already posted"
return 0
fi
if [ "$lookup" -eq 2 ]; then
echo "#$number: FAILED to read existing comments — not posting, to avoid a duplicate"
unexplained=$((unexplained + 1))
return 0
fi
if comment_body | gh pr comment "$number" --repo "$REPO" --body-file -; then
return 0
fi
echo "#$number: FAILED to post the explanation — re-run with EXPLAIN_MISSING=true $number"
unexplained=$((unexplained + 1))
}
for number in "$@"; do
if ! pr="$(gh api "repos/$REPO/pulls/$number")"; then
echo "#$number: FAILED to read pull request"
failed=$((failed + 1))
continue
fi
state="$(jq -r '.state' <<<"$pr")"
base_ref="$(jq -r '.base.ref' <<<"$pr")"
head_ref="$(jq -r '.head.ref' <<<"$pr")"
head_repo="$(jq -r '.head.repo.full_name // ""' <<<"$pr")"
if [ "$state" != "open" ]; then
echo "#$number: skipped — pull request is $state"
skipped=$((skipped + 1))
continue
fi
if [ "$base_ref" = "$TARGET_BASE" ] && [ "$EXPLAIN_MISSING" = "true" ] && [ "$DRY_RUN" != "true" ]; then
echo "#$number: already on $TARGET_BASE — posting any missing explanation"
post_explanation "$number"
continue
fi
if [ "$base_ref" != "$RELEASE_BASE" ]; then
echo "#$number: skipped — already based on $base_ref"
skipped=$((skipped + 1))
continue
fi
if jq -e --arg keep "$KEEP_LABEL" 'any(.labels[]; .name == $keep)' <<<"$pr" >/dev/null; then
echo "#$number: skipped — labelled '$KEEP_LABEL'"
skipped=$((skipped + 1))
continue
fi
if keeps_release_base "$head_repo" "$head_ref"; then
echo "#$number: skipped — $head_repo:$head_ref is a release-bound branch"
skipped=$((skipped + 1))
continue
fi
if [ "$DRY_RUN" = "true" ]; then
echo "#$number: would retarget $RELEASE_BASE -> $TARGET_BASE ($head_repo:$head_ref)"
matched=$((matched + 1))
continue
fi
echo "#$number: retargeting $RELEASE_BASE -> $TARGET_BASE ($head_repo:$head_ref)"
if ! gh pr edit "$number" --repo "$REPO" --base "$TARGET_BASE"; then
echo "#$number: FAILED to change base branch"
failed=$((failed + 1))
continue
fi
post_explanation "$number"
matched=$((matched + 1))
[ "$THROTTLE_SECONDS" = "0" ] || sleep "$THROTTLE_SECONDS"
done
if [ "$DRY_RUN" = "true" ]; then
echo "DRY RUN — no pull request was modified. would_retarget=$matched skipped=$skipped failed=$failed"
summary="**Dry run — nothing was modified.** Would retarget **$matched**, skip **$skipped**, failed to read **$failed**."
else
echo "retargeted=$matched skipped=$skipped failed=$failed unexplained=$unexplained"
summary="Retargeted **$matched** pull request(s) onto \`$TARGET_BASE\`, skipped **$skipped**, failed **$failed**, missing an explanation **$unexplained**."
fi
echo "$summary"
[ -z "${GITHUB_STEP_SUMMARY:-}" ] || echo "$summary" >> "$GITHUB_STEP_SUMMARY"
[ "$failed" -eq 0 ] && [ "$unexplained" -eq 0 ]