Pull Requests are one of the most critical control points in modern software development. But in practice, the signals they provide are not always as reliable as they should be.
In the DevOps team I’m part of, which is responsible for Git workflows and overall repository governance, even a small delay in a Pull Request can quietly turn into a real problem. CI checks that were valid at some point can become outdated while still appearing “green” and trustworthy. Over time, this creates a misleading situation: code that looks ready to merge, but no longer reflects the current state of the system.
I’ve seen cases where a PR passed all checks a few hours earlier, but by the time it was reviewed again, multiple changes had already landed in the target branch. Dependencies shifted, assumptions changed, and yet the PR still showed “all clear.” In those situations, merging required extra caution, manual re-validation, and it gradually reduced confidence in the CI signals we rely on.
If you’re part of a growing engineering team or you’re a DevOps engineer, this is a problem you’ve likely encountered as well. Maintaining the freshness and accuracy of your Pull Requests is essential for sustaining development velocity and confidence in your delivery pipeline. Tackling this early improves workflow efficiency, reduces repeated validations, and supports smoother scaling as your system grows.
In this article, I’ll walk through a practical approach to solving this challenge using a timer-based mechanism for Pull Requests. I’ll explain the reasoning behind the design, how the workflows operate together, and the key decisions that made the solution both effective and easy to adopt.
By the end, you’ll have a clear pattern to apply to improve CI signal quality, reduce unnecessary rework, and restore consistency to your review process.
A Time-Based Mechanism for Reliable CI Signals
At its core, the mechanism introduces a simple but powerful concept: time-bound validity for CI results. Instead of assuming that a successful check remains relevant indefinitely, each Pull Request is given a defined window of trust. Once that window expires without any new commits, the system treats the existing statuses as outdated and explicitly marks them as such. If new changes are pushed, the timer resets, and the PR is considered fresh again. This approach ensures that CI results always reflect the current state of the code relative to the target branch. The advantage is immediate and practical—developers no longer rely on stale signals, reviewers spend less time second-guessing the validity of checks, and teams regain confidence that a “green” PR truly means it is safe to merge. By turning implicit assumptions into explicit rules, the mechanism creates a more predictable, reliable, and scalable review process.
Implementing the Solution with GitHub Actions
To implement this solution in practice, I created two CI workflows using GitHub Actions.
Workflow 1: Add Timer Label to PRs with Old Status
This workflow runs on every push to the repository and scans all open Pull Requests whose target branch has received the new changes that triggered the workflow. Its purpose is to identify PRs that require tracking and attach a timer label to them.
It filters PRs based on two conditions:
- PRs that do not already have the
timerlabel - PRs that do not already have failed checks with the expiration message
For all matching PRs, the workflow adds the timer label, marking them for tracking in the next stage.

Below is a simplified version of the workflow, focusing only on the core logic:
name: Add Timer Label to PRs with Old Status
on:
push:
jobs:
add-timer-label-prs:
runs-on: ubuntu-latest
env:
TIMER_LABEL: target-modified
TARGET_BRANCH: ${{ github.ref_name }}
steps:
- name: Fetch PRs
id: fetch-prs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
ALL='[]'
CURSOR=""
while true; do
CURSOR_FLAG=()
[[ -n "$CURSOR" ]] && CURSOR_FLAG=(-f "cursor=$CURSOR")
RESP=$(gh api graphql -f query="
query(\$cursor: String) {
repository(
owner: \"${GITHUB_REPOSITORY%/*}\",
name: \"${GITHUB_REPOSITORY#*/}\"
) {
pullRequests(
states: OPEN,
baseRefName: \"${TARGET_BRANCH}\",
first: 100,
after: \$cursor
) {
pageInfo { hasNextPage endCursor }
nodes {
number
labels(first: 50) { nodes { name } }
commits(last: 1) {
nodes {
commit {
statusCheckRollup {
contexts(first: 50) {
nodes {
... on StatusContext {
description
}
}
}
}
}
}
}
}
}
}
}" "${CURSOR_FLAG[@]}") || exit 1
PAGE=$(jq -c \
'.data.repository.pullRequests.nodes' <<< "$RESP")
ALL=$(jq -cs '.[0] + .[1]' <<< "$ALL $PAGE")
HAS_NEXT=$(jq -r \
'.data.repository.pullRequests.pageInfo.hasNextPage' \
<<< "$RESP")
CURSOR=$(jq -r \
'.data.repository.pullRequests.pageInfo.endCursor' \
<<< "$RESP")
[[ "$HAS_NEXT" == "true" ]] || break
done
echo "ALL_PRS=$ALL" >> "$GITHUB_OUTPUT"
- name: Filter PRs
id: filter-prs
run: |
PATTERN="This status is older than [0-9]+ minutes"
FILTERED=$(jq -r \
--arg label "$TIMER_LABEL" \
--arg pattern "$PATTERN" '
map(select(
# No timer label
(.labels.nodes // []
| map(.name)
| index($label)) == null
and
# No expired status
(
(.commits.nodes[0]
.commit.statusCheckRollup
.contexts.nodes // [])
| any((.description // "")
| test($pattern; "i"))
) | not
))
| [.[].number]
| join(",")
' <<< '${{ steps.fetch-prs.outputs.ALL_PRS }}')
echo "PRS_TO_LABEL=$FILTERED" >> "$GITHUB_OUTPUT"
- name: Add Labels
if: steps.filter-prs.outputs.PRS_TO_LABEL != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PRS="${{ steps.filter-prs.outputs.PRS_TO_LABEL }}"
for PR in $(echo "$PRS" | tr ',' ' '); do
gh api -X POST \
repos/$GITHUB_REPOSITORY/issues/$PR/labels \
-f labels[]="$TIMER_LABEL"
done
Workflow 2: Expire Timer and Enforce Freshness
The second workflow runs on a scheduled basis (cron) and scans all PRs with the timer label.
It evaluates two key aspects:
- Whether new commits were pushed to the source branch after the label was added
- How much time has passed since the label was applied
The behavior is as follows:
- If a new commit was pushed after the label was added, the PR is considered refreshed (and CI checks are expected to run again). The workflow removes the
timerlabel and skips further processing. - If no changes were made, the workflow checks the elapsed time. As long as the defined threshold has not been reached, no action is taken.
- Once the defined time window expires, the workflow marks all active statuses of the PR as failed, with a clear message indicating that the checks are outdated and must be re-run. After that, it removes the
timerlabel.
This step enforces the mechanism—it ensures that outdated PRs cannot remain in a misleading “green” state.

This is a simplified version of the workflow, showing only the core logic:
name: Cron Timer PR Checker
on:
schedule:
- cron: "*/30 * * * *"
jobs:
check-expired-timers:
runs-on: ubuntu-latest
env:
TIMER_LABEL: target-modified
THRESHOLD: 10800
steps:
- name: Fetch PRs
id: fetch_prs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
ALL='[]'; CURSOR=""
while true; do
RESP=$(gh api graphql \
-f query='PR_QUERY' \
-f owner='${{ github.repository_owner }}' \
-f repo='${{ github.event.repository.name }}' \
-f label="$TIMER_LABEL" \
-f cursor="$CURSOR") || exit 1
PAGE=$(echo "$RESP" \
| jq -c '.data.repository.pullRequests.nodes')
ALL=$(jq -cs '.[0] + .[1]' <<< "$ALL $PAGE")
HAS_NEXT=$(echo "$RESP" \
| jq -r '.data.repository
.pullRequests.pageInfo.hasNextPage')
CURSOR=$(echo "$RESP" \
| jq -r '.data.repository
.pullRequests.pageInfo.endCursor')
[ "$HAS_NEXT" = "true" ] || break
done
echo "ALL_PRS=$ALL" >> "$GITHUB_OUTPUT"
- name: Process timers
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
run: |
LIMIT=$((THRESHOLD / 60))
remove_label () {
gh api -X DELETE \
"repos/$REPO/issues/$1/labels/$TIMER_LABEL" \
>/dev/null
}
fail_status () {
curl -s -X POST \
-H "Authorization: Bearer $GH_TOKEN" \
"https://api.github.com/repos/$REPO/statuses/$1" \
-d "{\"state\":\"failure\",
\"context\":\"$2\",
\"target_url\":\"$3\"}" \
>/dev/null
}
echo '${{ steps.fetch_prs.outputs.ALL_PRS }}' \
| jq -c '.[]' \
| while read pr; do
pr_number=$(echo "$pr" | jq -r '.number')
sha=$(echo "$pr" | jq -r '.headRefOid')
labeled_at=$(echo "$pr" | jq -r \
'[.timelineItems.nodes[]
| select(.label.name=="'$TIMER_LABEL'")
| .createdAt] | last')
last_commit=$(echo "$pr" \
| jq -r '.commits.nodes[0]
.commit.committedDate')
[ -z "$labeled_at" ] && continue
l_ts=$(date -d "$labeled_at" +%s)
c_ts=$(date -d "$last_commit" +%s)
if [ "$c_ts" -gt "$l_ts" ]; then
remove_label "$pr_number"
continue
fi
elapsed=$(( ( $(date +%s) - l_ts ) / 60 ))
[ "$elapsed" -le "$LIMIT" ] && continue
gh api --paginate \
"repos/$REPO/commits/$sha/statuses" \
| jq -r '.[]
| select(.state!="failure")
| [.context,.target_url] | @tsv' \
| while IFS=$'\t' read ctx url; do
fail_status "$sha" "$ctx" "$url"
done
remove_label "$pr_number"
done
Design Considerations
Choosing the right timing parameters is critical:
- The expiration window (e.g., 2–3 hours) should reflect how frequently the target branch changes.
- The cron frequency should balance responsiveness with system load. Running it too frequently may create unnecessary overhead, while running it too rarely reduces effectiveness.
In fast-moving repositories, shorter intervals make sense. In more stable environments, longer windows may be sufficient. The key is to find the balance between accuracy and efficiency.
Enforcing Merge Safety
To fully prevent merging PRs with outdated checks, configure your repository so that these CI checks are required.
This ensures that once a status is marked as failed due to expiration, the PR cannot be merged until the checks are re-run and pass successfully.
The Real Effect on Team Productivity
Before introducing this mechanism in the DevOps workflows I work with, Pull Requests often created a false sense of confidence. A PR could appear fully validated with green checks, even though the underlying context had already changed. In practice, reviewers were often left to manually reassess whether the existing CI results were still relevant, and discussions frequently shifted toward whether a rerun was needed rather than focusing on the actual code changes. Over time, this introduced hesitation, slowed down the review process, and reduced the overall value of CI signals.
After implementing the timer-based approach, the behavior became much more predictable and transparent. Within our workflow, PRs that remained inactive for too long were automatically flagged, removing the uncertainty around outdated checks. Reviewers no longer had to guess whether results were still valid, and developers received a clear, system-driven signal when a refresh was required. This shift reduced unnecessary manual verification, improved trust in CI results, and made the entire review process faster and more consistent.