PR Validation

Overview

The ci/github-script/prepare.js script runs on every pull request to validate mergeability, classify branches, suggest optimal base branches, detect merge conflicts, and identify which CI-relevant paths were touched. It also manages bot review comments to guide contributors toward correct PR targeting.


What prepare.js Does

  1. Checks PR state — Ensures the PR is still open
  2. Waits for mergeability — Polls GitHub until mergeability is computed
  3. Classifies branches — Categorizes base and head branches using supportedBranches.js
  4. Validates branch targeting — Warns if a feature branch targets a release branch
  5. Suggests better base branches — For WIP branches, finds the optimal base by comparing commit distances
  6. Computes merge SHAs — Determines the merge commit SHA and target comparison SHA
  7. Detects touched CI paths — Identifies changes to ci/, ci/pinned.json, .github/

Mergeability Check

GitHub computes merge status asynchronously. The script polls with exponential backoff:

for (const retryInterval of [5, 10, 20, 40, 80]) {
  core.info('Checking whether the pull request can be merged...')
  const prInfo = (
    await github.rest.pulls.get({
      ...context.repo,
      pull_number,
    })
  ).data

  if (prInfo.state !== 'open') throw new Error('PR is not open anymore.')

  if (prInfo.mergeable == null) {
    core.info(
      `GitHub is still computing mergeability, waiting ${retryInterval}s...`,
    )
    await new Promise((resolve) => setTimeout(resolve, retryInterval * 1000))
    continue
  }
  // ... process PR
}
throw new Error(
  'Timed out waiting for GitHub to compute mergeability. Check https://www.githubstatus.com.',
)

Retry Schedule

Attempt Wait Time Cumulative Wait
1 5 seconds 5 seconds
2 10 seconds 15 seconds
3 20 seconds 35 seconds
4 40 seconds 75 seconds
5 80 seconds 155 seconds

If mergeability is still not computed after ~2.5 minutes, the script throws an error with a link to githubstatus.com for checking GitHub's system status.


Branch Classification

Both the base and head branches are classified using supportedBranches.js:

const baseClassification = classify(base.ref)
core.setOutput('base', baseClassification)

const headClassification =
  base.repo.full_name === head.repo.full_name
    ? classify(head.ref)
    : { type: ['wip'] }
core.setOutput('head', headClassification)

Fork Handling

For cross-fork PRs (where the head repo differs from the base repo), the head branch is always classified as { type: ['wip'] } regardless of its name. This prevents fork branches from being treated as development branches.

Classification Output

Each classification produces:

{
  branch: 'release-1.0',
  order: 1,
  stable: true,
  type: ['development', 'primary'],
  version: '1.0',
}
Field Description
branch The full branch name
order Ranking for base-branch preference (lower = better)
stable Whether the branch has a version suffix
type Array of type tags
version Extracted version number, or 'dev'

Release Branch Targeting Warning

If a WIP branch (feature, fix, etc.) targets a stable release branch, the script checks whether it's a backport:

if (
  baseClassification.stable &&
  baseClassification.type.includes('primary')
) {
  const headPrefix = head.ref.split('-')[0]
  if (!['backport', 'fix', 'revert'].includes(headPrefix)) {
    core.warning(
      `This PR targets release branch \`${base.ref}\`. ` +
        'New features should typically target \`master\`.',
    )
  }
}
Head Branch Prefix Allowed to target release? Reason
backport-* Yes Explicit backport
fix-* Yes Bug fix for release
revert-* Yes Reverting a change
feature-* Warning issued Should target master
wip-* Warning issued Should target master

Base Branch Suggestion

For WIP branches, the script computes the optimal base branch by analyzing commit distances from the head to all candidate base branches:

Algorithm

  1. List all branches — Fetch all branches in the repository via pagination
  2. Filter candidates — Keep master and all stable primary branches (release-*)
  3. Compute merge bases — For each candidate, find the merge-base commit with the PR head and count commits between them
async function mergeBase({ branch, order, version }) {
  const { data } = await github.rest.repos.compareCommitsWithBasehead({
    ...context.repo,
    basehead: `${branch}...${head.sha}`,
    per_page: 1,
    page: 2,
  })
  return {
    branch,
    order,
    version,
    commits: data.total_commits,
    sha: data.merge_base_commit.sha,
  }
}
  1. Select the best — The branch with the fewest commits ahead wins. If there's a tie, the branch with the lowest order wins (i.e., master over release-*).
let candidates = [await mergeBase(classify('master'))]
for (const release of releases) {
  const nextCandidate = await mergeBase(release)
  if (candidates[0].commits === nextCandidate.commits)
    candidates.push(nextCandidate)
  if (candidates[0].commits > nextCandidate.commits)
    candidates = [nextCandidate]
  if (candidates[0].commits < 10000) break
}

const best = candidates.sort((a, b) => a.order - b.order).at(0)
  1. Post review if mismatch — If the suggested base differs from the current base, a bot review is posted:
if (best.branch !== base.ref) {
  const current = await mergeBase(classify(base.ref))
  const body = [
    `This PR targets \`${current.branch}\`, but based on the commit history ` +
      `\`${best.branch}\` appears to be a better fit ` +
      `(${current.commits - best.commits} fewer commits ahead).`,
    '',
    `If this is intentional, you can ignore this message. Otherwise:`,
    `- [Change the base branch](...) to \`${best.branch}\`.`,
  ].join('\n')

  await postReview({ github, context, core, dry, body, reviewKey })
}
  1. Dismiss reviews if correct — If the base branch matches the suggestion, any previous bot reviews are dismissed.

Early Termination

The algorithm stops evaluating release branches once the candidate count drops below 10,000 commits. This prevents unnecessary API calls for branches that are clearly not good candidates.


Merge SHA Computation

The script computes two key SHAs for downstream CI jobs:

Mergeable PR

if (prInfo.mergeable) {
  core.info('The PR can be merged.')
  mergedSha = prInfo.merge_commit_sha
  targetSha = (
    await github.rest.repos.getCommit({
      ...context.repo,
      ref: prInfo.merge_commit_sha,
    })
  ).data.parents[0].sha
}
  • mergedSha — GitHub's trial merge commit SHA
  • targetSha — The first parent of the merge commit (base branch tip)

Conflicting PR

else {
  core.warning('The PR has a merge conflict.')
  mergedSha = head.sha
  targetSha = (
    await github.rest.repos.compareCommitsWithBasehead({
      ...context.repo,
      basehead: `${base.sha}...${head.sha}`,
    })
  ).data.merge_base_commit.sha
}
  • mergedSha — Falls back to the head SHA (no merge commit exists)
  • targetSha — The merge-base between base and head

Touched Path Detection

The script identifies which CI-relevant paths were modified in the PR:

const files = (
  await github.paginate(github.rest.pulls.listFiles, {
    ...context.repo,
    pull_number,
    per_page: 100,
  })
).map((file) => file.filename)

const touched = []
if (files.some((f) => f.startsWith('ci/'))) touched.push('ci')
if (files.includes('ci/pinned.json')) touched.push('pinned')
if (files.some((f) => f.startsWith('.github/'))) touched.push('github')
core.setOutput('touched', touched)
Touched Tag Condition Use Case
ci Any file under ci/ was changed Re-run CI infrastructure checks
pinned ci/pinned.json specifically changed Validate pin integrity
github Any file under .github/ was changed Re-run workflow lint checks

Outputs

The script sets the following outputs for downstream workflow jobs:

Output Type Description
base Object Base branch classification (branch, type, version)
head Object Head branch classification
mergedSha String Merge commit SHA (or head SHA if conflicting)
targetSha String Base comparison SHA
touched Array Which CI-relevant paths were modified

Review Lifecycle

The prepare.js script integrates with reviews.js for bot review management:

Posting a Review

When the script detects a branch targeting issue, it posts a REQUEST_CHANGES review:

await postReview({ github, context, core, dry, body, reviewKey: 'prepare' })

The review body includes:

  • A description of the issue
  • A comparison of commit distances
  • A link to GitHub's "change base branch" documentation

Dismissing Reviews

When the issue is resolved (correct base branch), previous reviews are dismissed:

await dismissReviews({ github, context, core, dry, reviewKey: 'prepare' })

The reviewKey ('prepare') ensures only reviews posted by this script are affected.


Dry Run Mode

When the --no-dry flag is NOT passed (default in local testing), all mutative operations (posting/dismissing reviews) are skipped:

module.exports = async ({ github, context, core, dry }) => {
  // ...
  if (!dry) {
    await github.rest.pulls.createReview({ ... })
  }
}

This allows safe local testing without modifying real PRs.


Local Testing

cd ci/github-script
nix-shell
gh auth login

# Dry run (default — no changes to the PR):
./run prepare YongDo-Hyun Project-Tick 123

# Live run (actually posts/dismisses reviews):
./run prepare YongDo-Hyun Project-Tick 123 --no-dry

Error Conditions

Condition Behavior
PR is closed Throws: "PR is not open anymore."
Mergeability timeout Throws: "Timed out waiting for GitHub..."
API rate limit exceeded Handled by withRateLimit.js
Merge conflict Warning issued; head SHA used as mergedSha
Wrong base branch REQUEST_CHANGES review posted

Was this handbook page helpful?

This page is part of the Project Tick Handbook, which is licensed under the Creative Commons Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) license. View full license details.
Last updated: April 18, 2026