Skip to content

Commit 1e8640c

Browse files
authored
[TRTLLM-12092][infra] Add PR Base Freshness Check Action (NVIDIA#13430)
Signed-off-by: Ivy Zhang <25222398+crazydemo@users.noreply.github.com>
1 parent 8e0d4e7 commit 1e8640c

2 files changed

Lines changed: 236 additions & 0 deletions

File tree

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
#!/usr/bin/env python3
2+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3+
# SPDX-License-Identifier: Apache-2.0
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
"""Check whether a PR's base is too far behind the target branch.
17+
18+
Fails (or warns, in warn-only mode) when the merge-base between the PR head
19+
and the target branch is older than configured thresholds. See
20+
``reports/TRTLLM-12092-design.md`` for the rationale.
21+
"""
22+
23+
import os
24+
import subprocess
25+
import sys
26+
27+
28+
def _git(*args: str) -> str:
29+
return subprocess.run(["git", *args], capture_output=True, text=True, check=True).stdout.strip()
30+
31+
32+
def _env_int(name: str, default: int) -> int:
33+
raw = os.environ.get(name, "").strip()
34+
if not raw:
35+
return default
36+
try:
37+
return int(raw)
38+
except ValueError:
39+
print(f"::warning::{name}='{raw}' is not an integer; using default {default}")
40+
return default
41+
42+
43+
def _env_bool(name: str, default: bool = False) -> bool:
44+
return os.environ.get(name, str(default)).strip().lower() in {"1", "true", "yes"}
45+
46+
47+
def main() -> int:
48+
pr_head = os.environ.get("PR_HEAD_SHA", "").strip()
49+
target_ref = os.environ.get("TARGET_REF", "origin/main").strip()
50+
commits_limit = _env_int("COMMITS_BEHIND_LIMIT", 200)
51+
age_limit_days = _env_int("BASE_AGE_LIMIT_DAYS", 14)
52+
enforce = _env_bool("ENFORCE")
53+
54+
if not pr_head:
55+
print("::error::PR_HEAD_SHA is not set")
56+
return 1
57+
58+
merge_base = _git("merge-base", pr_head, target_ref)
59+
commits_behind = int(_git("rev-list", "--count", f"{merge_base}..{target_ref}"))
60+
61+
target_ts = int(_git("show", "-s", "--format=%ct", target_ref))
62+
base_ts = int(_git("show", "-s", "--format=%ct", merge_base))
63+
age_days = max(0.0, (target_ts - base_ts) / 86400.0)
64+
65+
base_summary = _git("show", "-s", "--format=%h %s", merge_base)
66+
target_summary = _git("show", "-s", "--format=%h %s", target_ref)
67+
68+
commits_exceeded = commits_behind > commits_limit
69+
age_exceeded = age_days > age_limit_days
70+
stale = commits_exceeded or age_exceeded
71+
72+
summary_lines = [
73+
"PR base freshness report",
74+
f" target ref: {target_ref}",
75+
f" commits behind target: {commits_behind} (limit: {commits_limit})",
76+
f" base commit age: {age_days:.1f} days (limit: {age_limit_days})",
77+
f" merge base: {base_summary}",
78+
f" target HEAD: {target_summary}",
79+
]
80+
print("\n".join(summary_lines))
81+
82+
gh_summary = os.environ.get("GITHUB_STEP_SUMMARY")
83+
if gh_summary:
84+
with open(gh_summary, "a", encoding="utf-8") as fh:
85+
fh.write("## PR Base Freshness\n\n")
86+
fh.write("| metric | value | limit |\n")
87+
fh.write("| --- | --- | --- |\n")
88+
fh.write(f"| commits behind `{target_ref}` | {commits_behind} | {commits_limit} |\n")
89+
fh.write(f"| merge-base age (days) | {age_days:.1f} | {age_limit_days} |\n\n")
90+
fh.write(f"- merge base: `{base_summary}`\n")
91+
fh.write(f"- target HEAD: `{target_summary}`\n")
92+
93+
if not stale:
94+
print("PR base freshness OK.")
95+
return 0
96+
97+
reasons = []
98+
if commits_exceeded:
99+
reasons.append(f"{commits_behind} commits behind (limit {commits_limit})")
100+
if age_exceeded:
101+
reasons.append(f"base is {age_days:.1f} days old (limit {age_limit_days})")
102+
reason_str = "; ".join(reasons)
103+
104+
guidance = (
105+
"To resolve: rebase onto the target branch (preferred) or merge it into this "
106+
"branch, then push again."
107+
)
108+
109+
if not enforce:
110+
print(
111+
f"::warning::[warn-only] PR base is stale: {reason_str}. "
112+
"This check will start blocking merges once enforcement is enabled. "
113+
f"{guidance}"
114+
)
115+
return 0
116+
117+
print(f"::error::PR base is stale: {reason_str}. {guidance}")
118+
return 1
119+
120+
121+
if __name__ == "__main__":
122+
sys.exit(main())
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
name: PR Base Freshness
17+
18+
on:
19+
pull_request:
20+
# auto_merge_enabled is important: a PR that was fresh when opened can
21+
# sit in review long enough for main to fly ahead. Clicking "Enable
22+
# auto-merge" would otherwise trust a stale cached check result.
23+
types:
24+
- opened
25+
- synchronize
26+
- reopened
27+
- ready_for_review
28+
- auto_merge_enabled
29+
30+
concurrency:
31+
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
32+
cancel-in-progress: true
33+
34+
jobs:
35+
base-freshness:
36+
name: PR Base Freshness
37+
runs-on: ubuntu-latest
38+
# Skip draft PRs — they are still being iterated on. Real merges happen
39+
# after ready_for_review (which is in the trigger list), so the check will
40+
# run then.
41+
if: github.event.pull_request.draft == false
42+
steps:
43+
# We only need the check script and enough git metadata to compute
44+
# merge-base + commit dates. Everything below keeps the checkout as
45+
# cheap as possible on a large repo:
46+
# - fetch-depth: 500 covers the vast majority of real PRs; the
47+
# next step deepens progressively if a deeper merge-base is
48+
# needed.
49+
# - sparse-checkout materializes only the script we execute.
50+
# - lfs / submodules / tags are disabled — none are needed.
51+
# The script is only ever read, never executed against PR code.
52+
- name: Checkout target branch (shallow, script only)
53+
uses: actions/checkout@v6
54+
with:
55+
ref: ${{ github.event.pull_request.base.ref }}
56+
fetch-depth: 500
57+
fetch-tags: false
58+
lfs: false
59+
submodules: false
60+
sparse-checkout: .github/scripts
61+
sparse-checkout-cone-mode: false
62+
63+
# Fetch the PR head commit via refs/pull/N/head (works for both
64+
# same-repo and fork PRs). Deepen progressively if merge-base is
65+
# not yet visible — an "always stale" PR may need more history.
66+
- name: Fetch PR head and ensure merge-base is reachable
67+
env:
68+
PR_NUMBER: ${{ github.event.pull_request.number }}
69+
TARGET_BRANCH: ${{ github.event.pull_request.base.ref }}
70+
run: |
71+
set -e
72+
git fetch --depth=500 --no-tags origin \
73+
"+refs/pull/${PR_NUMBER}/head:refs/remotes/origin/pr-head"
74+
for round in 1 2 3 4; do
75+
if git merge-base origin/pr-head "origin/${TARGET_BRANCH}" >/dev/null 2>&1; then
76+
break
77+
fi
78+
echo "merge-base not visible yet, deepening by 2000 commits (round ${round})"
79+
git fetch --deepen=2000 --no-tags origin \
80+
"${TARGET_BRANCH}" "+refs/pull/${PR_NUMBER}/head:refs/remotes/origin/pr-head"
81+
done
82+
if ! git merge-base origin/pr-head "origin/${TARGET_BRANCH}" >/dev/null 2>&1; then
83+
echo "::error::Could not find merge-base between PR head and ${TARGET_BRANCH} after deepening to ~8500 commits."
84+
exit 1
85+
fi
86+
87+
- uses: actions/setup-python@v6
88+
with:
89+
python-version: '3.12'
90+
91+
- name: Check PR base freshness
92+
# Thresholds and enforcement are driven by repo-level Actions
93+
# variables so they can be tuned via Settings -> Secrets and
94+
# variables -> Actions -> Variables, without editing this file.
95+
# Fallback literals below are the phase 1 (warn-only) starting
96+
# values and take effect when a variable is not set.
97+
env:
98+
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
99+
TARGET_REF: origin/${{ github.event.pull_request.base.ref }}
100+
COMMITS_BEHIND_LIMIT: ${{ vars.PR_BASE_FRESHNESS_COMMITS_LIMIT || '150' }}
101+
BASE_AGE_LIMIT_DAYS: ${{ vars.PR_BASE_FRESHNESS_AGE_LIMIT_DAYS || '10' }}
102+
# Set repo variable PR_BASE_FRESHNESS_ENFORCE='true' to turn this
103+
# from warn-only into a blocking required check.
104+
ENFORCE: ${{ vars.PR_BASE_FRESHNESS_ENFORCE || 'false' }}
105+
run: |
106+
# Bootstrap-safe: on the PR that first introduces this workflow,
107+
# the script does not yet exist on the target branch, so the
108+
# sparse checkout yields an empty .github/scripts directory.
109+
# Treat that as a clean skip rather than a failure.
110+
if [ ! -f .github/scripts/pr_base_freshness_check.py ]; then
111+
echo "::notice::Freshness check script not yet available on the target branch; skipping. This is expected on the PR that introduces the workflow."
112+
exit 0
113+
fi
114+
python3 .github/scripts/pr_base_freshness_check.py

0 commit comments

Comments
 (0)