diff --git a/.github/workflows/lint-pr.yaml b/.github/workflows/lint-pr.yaml new file mode 100644 index 000000000..dc165a271 --- /dev/null +++ b/.github/workflows/lint-pr.yaml @@ -0,0 +1,144 @@ +name: Lint PR + +on: + pull_request: + types: + - opened + - edited + - synchronize + - reopened + - labeled + - unlabeled + +jobs: + validate-pr-title: + name: Validate PR title (Conventional Commits) + runs-on: ubuntu-latest + steps: + - name: Check Conventional Commits format + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + run: | + # Exempt automated PRs (Stainless codegen, release-please, dependabot, etc.). + # These bots may not always emit Conventional-Commits-formatted titles + # (dependabot's default "Bump foo from 1.0 to 1.1" doesn't match) and we + # don't want their PRs blocked by this check. Mirrors validate-pr-base. + case "$PR_AUTHOR" in + stainless-app|stainless-app\[bot\]|release-please\[bot\]|github-actions\[bot\]|dependabot\[bot\]) + echo "PR is from automation ($PR_AUTHOR); skipping title check." + exit 0 + ;; + esac + + # Conventional Commits: ()(!): + PATTERN='^(feat|fix|docs|style|refactor|test|chore|ci|build|perf|revert)(\([^)]+\))?!?: .+' + + if printf '%s' "$PR_TITLE" | grep -qE "$PATTERN"; then + echo "PR title is a valid Conventional Commit: $PR_TITLE" + exit 0 + fi + + # ::error must be on stdout for GitHub Actions to surface it as an annotation. + echo "::error title=Invalid PR title::PR title must follow Conventional Commits format. Got: $PR_TITLE" + { + echo " Got: $PR_TITLE" + echo " Expected: ()(!): " + echo " Types: feat, fix, docs, style, refactor, test, chore, ci, build, perf, revert" + echo "" + echo " Examples:" + echo " feat: add new endpoint" + echo " fix(client): handle empty response" + echo " chore!: drop python 3.11 support" + } >&2 + exit 1 + + validate-pr-base: + name: Validate PR base branch + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Validate base branch and manage PR comment + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + PR_BASE: ${{ github.event.pull_request.base.ref }} + HAS_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'target-main') }} + run: | + MARKER='' + + # Look up an existing marker comment so we can update/delete it. + # --paginate handles PRs with >30 comments. If the lookup fails + # (transient API error, fork PR token without read scope), continue + # with no existing_id so we still emit the failure annotation. + existing_id=$(gh api --paginate "repos/$REPO/issues/$PR_NUMBER/comments" \ + --jq ".[] | select(.body | contains(\"$MARKER\")) | .id" 2>/dev/null \ + | head -n1) || existing_id="" + + delete_comment() { + if [ -n "$existing_id" ]; then + gh api -X DELETE "repos/$REPO/issues/comments/$existing_id" >/dev/null 2>&1 || true + fi + } + + # PR doesn't target main — nothing to enforce. + if [ "$PR_BASE" != "main" ]; then + delete_comment + echo "PR base is '$PR_BASE'; check passes." + exit 0 + fi + + # Exempt automated PRs (must mirror validate-pr-title's list). + case "$PR_AUTHOR" in + stainless-app|stainless-app\[bot\]|release-please\[bot\]|github-actions\[bot\]|dependabot\[bot\]) + delete_comment + echo "PR is from automation ($PR_AUTHOR); allowing PR targeting main." + exit 0 + ;; + esac + + # Per-PR opt-out via label. + if [ "$HAS_LABEL" = "true" ]; then + delete_comment + echo "Found 'target-main' label; allowing PR targeting main." + exit 0 + fi + + # Failure path: try to post or update an explanatory comment. + # The write may fail on fork PRs (GITHUB_TOKEN has read-only scope + # upstream) or due to transient API errors. Guard each gh call so + # the ::error annotation and exit 1 still run regardless. + body_file=$(mktemp) + { + echo "$MARKER" + echo + echo "**This PR is targeting \`main\`, but PRs should target the \`next\` branch by default.**" + echo + echo "The \`main\` branch is reserved for release-please and Stainless automation. To resolve, pick one of:" + echo + 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\`." + echo "- **Add the \`target-main\` label** if this is an intentional exception (e.g. an urgent hotfix). The check will re-run and pass." + echo + echo "See \`CONTRIBUTING.md\` for the full branch model." + } > "$body_file" + + comment_status="ok" + if [ -n "$existing_id" ]; then + gh api -X PATCH "repos/$REPO/issues/comments/$existing_id" \ + -F body=@"$body_file" >/dev/null 2>&1 || comment_status="failed" + [ "$comment_status" = "ok" ] && echo "Updated existing PR comment ($existing_id)." + else + gh pr comment "$PR_NUMBER" --repo "$REPO" --body-file "$body_file" >/dev/null 2>&1 || comment_status="failed" + [ "$comment_status" = "ok" ] && echo "Posted new PR comment." + fi + + if [ "$comment_status" = "failed" ]; then + 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." + fi + + # ::error must be on stdout to surface as an annotation. + echo "::error title=PR should target 'next'::Re-target to 'next' or add the 'target-main' label. See the PR comment for full details." + exit 1 diff --git a/CLAUDE.md b/CLAUDE.md index ffb1e50f6..7e0df1779 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,17 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +## Contribution workflow + +- This repository is a Stainless-generated SDK. Open PRs against the `next` branch (not `main`). + Stainless watches `next` and release-please opens release PRs from `next` → `main`. +- PR titles must follow [Conventional Commits](https://www.conventionalcommits.org/) — the + `Validate PR title (Conventional Commits)` CI check enforces this on every PR. +- The `Validate PR base branch` CI check fails on PRs targeting `main` from non-automation accounts + and posts a comment with resolution steps. Add the `target-main` label only for genuine + exceptions (e.g. an urgent hotfix). +- See `CONTRIBUTING.md` for the full workflow. + ## Development Commands ### Package Management in the top level repo diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 867cd143b..1f2925313 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,6 +32,39 @@ Alternatively if you don't want to install `Rye`, you can stick with the standar $ pip install -r requirements-dev.lock ``` +## Contribution workflow + +This repository is generated and released by [Stainless](https://www.stainless.com/). To keep the +release pipeline working, contributions need to follow the branch model and commit conventions below. + +### Branch model + +- Always open PRs against the `next` branch — not `main`. Stainless watches `next` to produce SDK + builds and the automated version-bump PR. +- Typical flow: + 1. Pull the latest `next` locally and branch off it. + 2. Make and push your changes, then open a PR targeting `next`. + 3. Get the PR reviewed and merged into `next`. + 4. Stainless will open (or update) a release PR bumping the version — review and merge that PR + to ship to `main`/PyPI. A new release PR will not be cut while a previous one is still open, + so unblock pending release PRs before expecting a new one. +- Do not merge generated code directly into `next` via PR. Let the generator produce those changes. +- The `Validate PR base branch` CI check fails on PRs targeting `main` from non-automation accounts + and posts a comment with resolution steps. If you genuinely need to PR directly to `main` (e.g. an + urgent hotfix), add the `target-main` label to bypass the check. + +### Conventional commits + +Commit messages and PR titles must follow [Conventional Commits](https://www.conventionalcommits.org/), +because the changelog and release notes are derived from them. The `Validate PR title (Conventional Commits)` +CI check enforces this on every PR. Common prefixes: + +- `feat(api): ...` — new functionality +- `fix(types): ...` — bug fixes +- `docs(readme): ...` — documentation-only changes (required for manual README/docs overrides to be + picked up by the generator) +- `chore(internal): ...` — internal changes that don't affect users + ## Modifying/Adding code Most of the SDK is generated code. Modifications to code will be persisted between generations, but may