|
| 1 | +# Copyright 2023-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. |
| 2 | +# |
| 3 | +# Redistribution and use in source and binary forms, with or without |
| 4 | +# modification, are permitted provided that the following conditions |
| 5 | +# are met: |
| 6 | +# * Redistributions of source code must retain the above copyright |
| 7 | +# notice, this list of conditions and the following disclaimer. |
| 8 | +# * Redistributions in binary form must reproduce the above copyright |
| 9 | +# notice, this list of conditions and the following disclaimer in the |
| 10 | +# documentation and/or other materials provided with the distribution. |
| 11 | +# * Neither the name of NVIDIA CORPORATION nor the names of its |
| 12 | +# contributors may be used to endorse or promote products derived |
| 13 | +# from this software without specific prior written permission. |
| 14 | +# |
| 15 | +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY |
| 16 | +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE |
| 17 | +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR |
| 18 | +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR |
| 19 | +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, |
| 20 | +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, |
| 21 | +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR |
| 22 | +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY |
| 23 | +# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT |
| 24 | +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE |
| 25 | +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
| 26 | + |
| 27 | +# Self-contained conventional-commit enforcement for this repository: |
| 28 | +# validates the PR title (<type>: <Title>), applies one human-readable label |
| 29 | +# per type found in the title and conforming commit subjects (org color |
| 30 | +# scheme), detects cherry-picks, and honors the maintainer-applied |
| 31 | +# skip-title-check override label. |
| 32 | + |
| 33 | +name: conventional-pr |
| 34 | + |
| 35 | +on: |
| 36 | + pull_request: |
| 37 | + types: [opened, edited, synchronize, reopened, labeled, unlabeled] |
| 38 | + pull_request_target: |
| 39 | + types: [opened, edited, synchronize, reopened, labeled, unlabeled] |
| 40 | + |
| 41 | +jobs: |
| 42 | + validate-and-label: |
| 43 | + # Same-repo PRs run on the safe pull_request event; fork PRs run on |
| 44 | + # pull_request_target so the labeling job gets a writable token (safe: |
| 45 | + # this job never checks out PR code). |
| 46 | + if: >- |
| 47 | + (github.event_name == 'pull_request' && |
| 48 | + github.event.pull_request.head.repo.full_name == github.repository) || |
| 49 | + (github.event_name == 'pull_request_target' && |
| 50 | + github.event.pull_request.head.repo.full_name != github.repository) |
| 51 | + runs-on: ubuntu-latest |
| 52 | + permissions: |
| 53 | + pull-requests: write |
| 54 | + issues: write |
| 55 | + contents: read |
| 56 | + steps: |
| 57 | + - name: Validate PR title, apply type labels, detect cherry-picks |
| 58 | + uses: actions/github-script@v7 |
| 59 | + with: |
| 60 | + script: | |
| 61 | + // Conventional-commit type -> human-readable label, org-wide color, |
| 62 | + // and description. Titles are validated against the type tokens; |
| 63 | + // the friendlier label is what gets applied. |
| 64 | + const TYPES = { |
| 65 | + build: { label: 'build', color: 'c5def5', |
| 66 | + description: 'Build system or external dependencies (build: PRs)' }, |
| 67 | + chore: { label: 'chore', color: 'fef2c0', |
| 68 | + description: 'Maintenance work, no production code change (chore: PRs)' }, |
| 69 | + ci: { label: 'CI/CD', color: 'bfd4f2', |
| 70 | + description: 'Continuous integration and workflow changes (ci: PRs)' }, |
| 71 | + docs: { label: 'documentation', color: '0075ca', |
| 72 | + description: 'Improvements or additions to documentation (docs: PRs)' }, |
| 73 | + feat: { label: 'feature', color: 'a2eeef', |
| 74 | + description: 'New feature or capability (feat: PRs)' }, |
| 75 | + fix: { label: 'fix', color: 'd73a4a', |
| 76 | + description: 'Bug fix (fix: PRs)' }, |
| 77 | + perf: { label: 'performance', color: 'fbca04', |
| 78 | + description: 'Performance improvement (perf: PRs)' }, |
| 79 | + refactor: { label: 'refactor', color: '5319e7', |
| 80 | + description: 'Code change that neither fixes a bug nor adds a feature (refactor: PRs)' }, |
| 81 | + revert: { label: 'revert', color: 'b60205', |
| 82 | + description: 'Reverts a previous change (revert: PRs)' }, |
| 83 | + style: { label: 'code style', color: 'f9d0c4', |
| 84 | + description: 'Formatting and style-only changes (style: PRs)' }, |
| 85 | + test: { label: 'testing', color: '1d76db', |
| 86 | + description: 'Adding or correcting tests (test: PRs)' }, |
| 87 | + }; |
| 88 | + const CHERRY = { label: 'cherry-pick', color: '006b75', |
| 89 | + description: 'Cherry-picked from another branch' }; |
| 90 | + const types = Object.keys(TYPES); |
| 91 | + const typeLabels = Object.values(TYPES).map((t) => t.label); |
| 92 | + const { owner, repo } = context.repo; |
| 93 | + const number = context.payload.pull_request.number; |
| 94 | +
|
| 95 | + // Create the label if missing, otherwise enforce the org color and |
| 96 | + // description. |
| 97 | + async function ensureLabel({ label: name, color, description }) { |
| 98 | + try { |
| 99 | + await github.rest.issues.createLabel( |
| 100 | + { owner, repo, name, color, description }); |
| 101 | + } catch (e) { |
| 102 | + if (e.status !== 422) throw e; // 422 = already exists |
| 103 | + const { data } = await github.rest.issues.getLabel({ owner, repo, name }); |
| 104 | + if (data.color.toLowerCase() !== color || |
| 105 | + (data.description || '') !== description) { |
| 106 | + await github.rest.issues.updateLabel( |
| 107 | + { owner, repo, name, color, description }); |
| 108 | + } |
| 109 | + } |
| 110 | + } |
| 111 | +
|
| 112 | + // Maintainer override: a human-applied skip-title-check label lets |
| 113 | + // a non-conventional title pass (with a prominent warning). Ensured |
| 114 | + // here so it is discoverable in every repo's label picker. |
| 115 | + const OVERRIDE = { label: 'skip-title-check', color: 'e4e669', |
| 116 | + description: 'Maintainer override: conventional-title check bypassed' }; |
| 117 | + await ensureLabel(OVERRIDE); |
| 118 | + const { data: current } = await github.rest.issues.listLabelsOnIssue( |
| 119 | + { owner, repo, issue_number: number }); |
| 120 | + const overridden = current.some((l) => l.name === OVERRIDE.label); |
| 121 | +
|
| 122 | + // 1. Validate the PR title (the squash-merge commit title). |
| 123 | + const title = context.payload.pull_request.title; |
| 124 | + const match = title.match(/^(\w+)(\([^)]*\))?!?: .+/); |
| 125 | + const titleValid = match !== null && types.includes(match[1]); |
| 126 | + if (!titleValid && !overridden) { |
| 127 | + core.setFailed( |
| 128 | + `PR title "${title}" does not follow the Conventional Commits ` + |
| 129 | + `format "<type>: <Title>" with type one of: ${types.join(', ')}. ` + |
| 130 | + 'See https://www.conventionalcommits.org/ - or a maintainer can ' + |
| 131 | + `apply the "${OVERRIDE.label}" label to override.`); |
| 132 | + return; |
| 133 | + } |
| 134 | + if (!titleValid) { |
| 135 | + core.warning( |
| 136 | + `PR title "${title}" is not a conventional commit; passing only ` + |
| 137 | + `because the "${OVERRIDE.label}" label was applied by a maintainer. ` + |
| 138 | + 'Consider fixing the title before squash-merging.'); |
| 139 | + } |
| 140 | +
|
| 141 | + // 2. Derive the full type set: the title plus every conforming |
| 142 | + // commit subject in the PR (merge commits are ignored). |
| 143 | + const commits = await github.paginate(github.rest.pulls.listCommits, |
| 144 | + { owner, repo, pull_number: number, per_page: 100 }); |
| 145 | + const derived = new Set(titleValid ? [match[1]] : []); |
| 146 | + for (const c of commits) { |
| 147 | + const subject = c.commit.message.split('\n', 1)[0]; |
| 148 | + if (/^Merge /.test(subject)) continue; |
| 149 | + const m = subject.match(/^(\w+)(\([^)]*\))?!?: .+/); |
| 150 | + if (m && types.includes(m[1])) derived.add(m[1]); |
| 151 | + else core.warning(`Commit ${c.sha.slice(0, 9)} subject is not ` + |
| 152 | + `a conventional commit: "${subject}"`); |
| 153 | + } |
| 154 | +
|
| 155 | + // Fail-safe: no derivable type, no manually assigned type label, |
| 156 | + // and no maintainer override. |
| 157 | + if (derived.size === 0 && !overridden && |
| 158 | + !current.some((l) => typeLabels.includes(l.name))) { |
| 159 | + core.setFailed( |
| 160 | + 'No conventional commit type could be determined from the PR ' + |
| 161 | + 'title or its commits, and no type label is assigned.'); |
| 162 | + return; |
| 163 | + } |
| 164 | +
|
| 165 | + // 3. Apply one label per derived type; drop stale auto-managed |
| 166 | + // labels (incl. legacy raw-token names from earlier versions). |
| 167 | + const wanted = [...derived].map((t) => TYPES[t].label); |
| 168 | + for (const t of derived) await ensureLabel(TYPES[t]); |
| 169 | + for (const l of current) { |
| 170 | + if ((typeLabels.includes(l.name) || types.includes(l.name)) && |
| 171 | + !wanted.includes(l.name)) { |
| 172 | + await github.rest.issues.removeLabel( |
| 173 | + { owner, repo, issue_number: number, name: l.name }); |
| 174 | + } |
| 175 | + } |
| 176 | + if (wanted.length > 0) { |
| 177 | + await github.rest.issues.addLabels( |
| 178 | + { owner, repo, issue_number: number, labels: wanted }); |
| 179 | + core.info(`Labeled: ${wanted.join(', ')}.`); |
| 180 | + } |
| 181 | +
|
| 182 | + // 4. Cherry-pick detection: the standard `git cherry-pick -x` |
| 183 | + // trailer in any commit, an explicit hint in the title/branch, or |
| 184 | + // a trailing "(#N)" squash-merge reference to a DIFFERENT PR (the |
| 185 | + // signature of cherry-picking an already squash-merged commit). |
| 186 | + // Additive only - a manually applied label is never removed. |
| 187 | + const trailer = /cherry[- ]?picked from commit [0-9a-f]{7,40}/i; |
| 188 | + const hint = /cherry[- ]?pick/i; |
| 189 | + const prRef = /\(#(\d+)\)$/; |
| 190 | + const refersToOtherPr = (subject) => { |
| 191 | + const m = subject.trim().match(prRef); |
| 192 | + return m !== null && Number(m[1]) !== number; |
| 193 | + }; |
| 194 | + const isCherryPick = |
| 195 | + commits.some((c) => trailer.test(c.commit.message)) || |
| 196 | + hint.test(title) || |
| 197 | + hint.test(context.payload.pull_request.head.ref) || |
| 198 | + refersToOtherPr(title) || |
| 199 | + commits.some((c) => refersToOtherPr(c.commit.message.split('\n', 1)[0])); |
| 200 | + if (isCherryPick) { |
| 201 | + await ensureLabel(CHERRY); |
| 202 | + await github.rest.issues.addLabels( |
| 203 | + { owner, repo, issue_number: number, labels: [CHERRY.label] }); |
| 204 | + core.info('Cherry-pick detected; labeled as "cherry-pick".'); |
| 205 | + } |
0 commit comments