summaryrefslogtreecommitdiff
path: root/docs/handbook/ci/pr-validation.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/handbook/ci/pr-validation.md')
-rw-r--r--docs/handbook/ci/pr-validation.md378
1 files changed, 378 insertions, 0 deletions
diff --git a/docs/handbook/ci/pr-validation.md b/docs/handbook/ci/pr-validation.md
new file mode 100644
index 0000000000..f7933d3e75
--- /dev/null
+++ b/docs/handbook/ci/pr-validation.md
@@ -0,0 +1,378 @@
+# 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:
+
+```javascript
+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](https://www.githubstatus.com) for checking GitHub's
+system status.
+
+---
+
+## Branch Classification
+
+Both the base and head branches are classified using `supportedBranches.js`:
+
+```javascript
+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:
+
+```javascript
+{
+ 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:
+
+```javascript
+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
+
+```javascript
+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,
+ }
+}
+```
+
+4. **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-*`).
+
+```javascript
+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)
+```
+
+5. **Post review if mismatch** — If the suggested base differs from the current base,
+ a bot review is posted:
+
+```javascript
+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 })
+}
+```
+
+6. **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
+
+```javascript
+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
+
+```javascript
+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:
+
+```javascript
+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:
+
+```javascript
+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:
+
+```javascript
+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:
+
+```javascript
+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
+
+```bash
+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 |