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
- Checks PR state — Ensures the PR is still open
- Waits for mergeability — Polls GitHub until mergeability is computed
- Classifies branches — Categorizes base and head branches using
supportedBranches.js - Validates branch targeting — Warns if a feature branch targets a release branch
- Suggests better base branches — For WIP branches, finds the optimal base by comparing commit distances
- Computes merge SHAs — Determines the merge commit SHA and target comparison SHA
- 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
- List all branches — Fetch all branches in the repository via pagination
- Filter candidates — Keep
masterand all stable primary branches (release-*) - 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,
}
}
- Select the best — The branch with the fewest commits ahead wins. If there's a tie,
the branch with the lowest
orderwins (i.e.,masteroverrelease-*).
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)
- 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 })
}
- 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 SHAtargetSha— 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 |