diff options
Diffstat (limited to 'docs/handbook/ci/pr-validation.md')
| -rw-r--r-- | docs/handbook/ci/pr-validation.md | 378 |
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 | |
