Skip to content

Commit f03fb3d

Browse files
authored
ci: add conventional commit and PR base checks (#346)
1 parent cf249b9 commit f03fb3d

3 files changed

Lines changed: 188 additions & 0 deletions

File tree

.github/workflows/lint-pr.yaml

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
name: Lint PR
2+
3+
on:
4+
pull_request:
5+
types:
6+
- opened
7+
- edited
8+
- synchronize
9+
- reopened
10+
- labeled
11+
- unlabeled
12+
13+
jobs:
14+
validate-pr-title:
15+
name: Validate PR title (Conventional Commits)
16+
runs-on: ubuntu-latest
17+
steps:
18+
- name: Check Conventional Commits format
19+
env:
20+
PR_TITLE: ${{ github.event.pull_request.title }}
21+
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
22+
run: |
23+
# Exempt automated PRs (Stainless codegen, release-please, dependabot, etc.).
24+
# These bots may not always emit Conventional-Commits-formatted titles
25+
# (dependabot's default "Bump foo from 1.0 to 1.1" doesn't match) and we
26+
# don't want their PRs blocked by this check. Mirrors validate-pr-base.
27+
case "$PR_AUTHOR" in
28+
stainless-app|stainless-app\[bot\]|release-please\[bot\]|github-actions\[bot\]|dependabot\[bot\])
29+
echo "PR is from automation ($PR_AUTHOR); skipping title check."
30+
exit 0
31+
;;
32+
esac
33+
34+
# Conventional Commits: <type>(<optional-scope>)(!): <subject>
35+
PATTERN='^(feat|fix|docs|style|refactor|test|chore|ci|build|perf|revert)(\([^)]+\))?!?: .+'
36+
37+
if printf '%s' "$PR_TITLE" | grep -qE "$PATTERN"; then
38+
echo "PR title is a valid Conventional Commit: $PR_TITLE"
39+
exit 0
40+
fi
41+
42+
# ::error must be on stdout for GitHub Actions to surface it as an annotation.
43+
echo "::error title=Invalid PR title::PR title must follow Conventional Commits format. Got: $PR_TITLE"
44+
{
45+
echo " Got: $PR_TITLE"
46+
echo " Expected: <type>(<optional-scope>)(!): <subject>"
47+
echo " Types: feat, fix, docs, style, refactor, test, chore, ci, build, perf, revert"
48+
echo ""
49+
echo " Examples:"
50+
echo " feat: add new endpoint"
51+
echo " fix(client): handle empty response"
52+
echo " chore!: drop python 3.11 support"
53+
} >&2
54+
exit 1
55+
56+
validate-pr-base:
57+
name: Validate PR base branch
58+
runs-on: ubuntu-latest
59+
permissions:
60+
pull-requests: write
61+
steps:
62+
- name: Validate base branch and manage PR comment
63+
env:
64+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
65+
REPO: ${{ github.repository }}
66+
PR_NUMBER: ${{ github.event.pull_request.number }}
67+
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
68+
PR_BASE: ${{ github.event.pull_request.base.ref }}
69+
HAS_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'target-main') }}
70+
run: |
71+
MARKER='<!-- lint-pr-validate-base -->'
72+
73+
# Look up an existing marker comment so we can update/delete it.
74+
# --paginate handles PRs with >30 comments. If the lookup fails
75+
# (transient API error, fork PR token without read scope), continue
76+
# with no existing_id so we still emit the failure annotation.
77+
existing_id=$(gh api --paginate "repos/$REPO/issues/$PR_NUMBER/comments" \
78+
--jq ".[] | select(.body | contains(\"$MARKER\")) | .id" 2>/dev/null \
79+
| head -n1) || existing_id=""
80+
81+
delete_comment() {
82+
if [ -n "$existing_id" ]; then
83+
gh api -X DELETE "repos/$REPO/issues/comments/$existing_id" >/dev/null 2>&1 || true
84+
fi
85+
}
86+
87+
# PR doesn't target main — nothing to enforce.
88+
if [ "$PR_BASE" != "main" ]; then
89+
delete_comment
90+
echo "PR base is '$PR_BASE'; check passes."
91+
exit 0
92+
fi
93+
94+
# Exempt automated PRs (must mirror validate-pr-title's list).
95+
case "$PR_AUTHOR" in
96+
stainless-app|stainless-app\[bot\]|release-please\[bot\]|github-actions\[bot\]|dependabot\[bot\])
97+
delete_comment
98+
echo "PR is from automation ($PR_AUTHOR); allowing PR targeting main."
99+
exit 0
100+
;;
101+
esac
102+
103+
# Per-PR opt-out via label.
104+
if [ "$HAS_LABEL" = "true" ]; then
105+
delete_comment
106+
echo "Found 'target-main' label; allowing PR targeting main."
107+
exit 0
108+
fi
109+
110+
# Failure path: try to post or update an explanatory comment.
111+
# The write may fail on fork PRs (GITHUB_TOKEN has read-only scope
112+
# upstream) or due to transient API errors. Guard each gh call so
113+
# the ::error annotation and exit 1 still run regardless.
114+
body_file=$(mktemp)
115+
{
116+
echo "$MARKER"
117+
echo
118+
echo "**This PR is targeting \`main\`, but PRs should target the \`next\` branch by default.**"
119+
echo
120+
echo "The \`main\` branch is reserved for release-please and Stainless automation. To resolve, pick one of:"
121+
echo
122+
echo "- **Re-target the PR to \`next\`** (recommended). On the PR page, click **Edit** next to the title and change the base branch to \`next\`."
123+
echo "- **Add the \`target-main\` label** if this is an intentional exception (e.g. an urgent hotfix). The check will re-run and pass."
124+
echo
125+
echo "See \`CONTRIBUTING.md\` for the full branch model."
126+
} > "$body_file"
127+
128+
comment_status="ok"
129+
if [ -n "$existing_id" ]; then
130+
gh api -X PATCH "repos/$REPO/issues/comments/$existing_id" \
131+
-F body=@"$body_file" >/dev/null 2>&1 || comment_status="failed"
132+
[ "$comment_status" = "ok" ] && echo "Updated existing PR comment ($existing_id)."
133+
else
134+
gh pr comment "$PR_NUMBER" --repo "$REPO" --body-file "$body_file" >/dev/null 2>&1 || comment_status="failed"
135+
[ "$comment_status" = "ok" ] && echo "Posted new PR comment."
136+
fi
137+
138+
if [ "$comment_status" = "failed" ]; then
139+
echo "::warning title=Could not write PR comment::Likely a fork PR (no upstream write scope) or a transient API error. The check still fails — see the next annotation for resolution steps."
140+
fi
141+
142+
# ::error must be on stdout to surface as an annotation.
143+
echo "::error title=PR should target 'next'::Re-target to 'next' or add the 'target-main' label. See the PR comment for full details."
144+
exit 1

CLAUDE.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22

33
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
44

5+
## Contribution workflow
6+
7+
- This repository is a Stainless-generated SDK. Open PRs against the `next` branch (not `main`).
8+
Stainless watches `next` and release-please opens release PRs from `next``main`.
9+
- PR titles must follow [Conventional Commits](https://www.conventionalcommits.org/) — the
10+
`Validate PR title (Conventional Commits)` CI check enforces this on every PR.
11+
- The `Validate PR base branch` CI check fails on PRs targeting `main` from non-automation accounts
12+
and posts a comment with resolution steps. Add the `target-main` label only for genuine
13+
exceptions (e.g. an urgent hotfix).
14+
- See `CONTRIBUTING.md` for the full workflow.
15+
516
## Development Commands
617

718
### Package Management in the top level repo

CONTRIBUTING.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,39 @@ Alternatively if you don't want to install `Rye`, you can stick with the standar
3232
$ pip install -r requirements-dev.lock
3333
```
3434

35+
## Contribution workflow
36+
37+
This repository is generated and released by [Stainless](https://www.stainless.com/). To keep the
38+
release pipeline working, contributions need to follow the branch model and commit conventions below.
39+
40+
### Branch model
41+
42+
- Always open PRs against the `next` branch — not `main`. Stainless watches `next` to produce SDK
43+
builds and the automated version-bump PR.
44+
- Typical flow:
45+
1. Pull the latest `next` locally and branch off it.
46+
2. Make and push your changes, then open a PR targeting `next`.
47+
3. Get the PR reviewed and merged into `next`.
48+
4. Stainless will open (or update) a release PR bumping the version — review and merge that PR
49+
to ship to `main`/PyPI. A new release PR will not be cut while a previous one is still open,
50+
so unblock pending release PRs before expecting a new one.
51+
- Do not merge generated code directly into `next` via PR. Let the generator produce those changes.
52+
- The `Validate PR base branch` CI check fails on PRs targeting `main` from non-automation accounts
53+
and posts a comment with resolution steps. If you genuinely need to PR directly to `main` (e.g. an
54+
urgent hotfix), add the `target-main` label to bypass the check.
55+
56+
### Conventional commits
57+
58+
Commit messages and PR titles must follow [Conventional Commits](https://www.conventionalcommits.org/),
59+
because the changelog and release notes are derived from them. The `Validate PR title (Conventional Commits)`
60+
CI check enforces this on every PR. Common prefixes:
61+
62+
- `feat(api): ...` — new functionality
63+
- `fix(types): ...` — bug fixes
64+
- `docs(readme): ...` — documentation-only changes (required for manual README/docs overrides to be
65+
picked up by the generator)
66+
- `chore(internal): ...` — internal changes that don't affect users
67+
3568
## Modifying/Adding code
3669

3770
Most of the SDK is generated code. Modifications to code will be persisted between generations, but may

0 commit comments

Comments
 (0)