From 84ebd36e66b70f66a9e30e1521740a303bc8e1ce Mon Sep 17 00:00:00 2001 From: Declan Brady Date: Thu, 7 May 2026 12:03:48 -0400 Subject: [PATCH 1/6] ci: add conventional commit PR title check and PR-base validation Adds .github/workflows/lint-pr.yaml with two checks: 1. Conventional Commits: validates the PR title using amannn/action-semantic-pull-request (matches the pattern used in scale-agentex and agentex). Bypassable via the skip-conventional-commit-check label for automation that needs it. 2. PR base validation: fails on PRs targeting main, with exemptions for known automation accounts (stainless-app, release-please, github-actions, dependabot) and an opt-out via the target-main label. Encourages developer PRs to target next, where Stainless codegen and release-please integrate, while keeping main accessible for hotfixes. --- .github/workflows/lint-pr.yaml | 68 ++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 .github/workflows/lint-pr.yaml diff --git a/.github/workflows/lint-pr.yaml b/.github/workflows/lint-pr.yaml new file mode 100644 index 000000000..9d1d80adf --- /dev/null +++ b/.github/workflows/lint-pr.yaml @@ -0,0 +1,68 @@ +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 + permissions: + statuses: write + pull-requests: read + steps: + - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + types: | + feat + fix + docs + style + refactor + test + chore + ci + build + perf + revert + requireScope: false + # Allow skipping this check with a label (e.g. for automated/bot PRs) + ignoreLabels: | + skip-conventional-commit-check + + validate-pr-base: + name: Validate PR base branch + runs-on: ubuntu-latest + if: github.event.pull_request.base.ref == 'main' + permissions: + pull-requests: read + steps: + - name: Check base branch + env: + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + HAS_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'target-main') }} + run: | + # Exempt automated PRs (Stainless codegen, release-please, dependabot, etc.) + case "$PR_AUTHOR" in + stainless-app|stainless-app[bot]|release-please[bot]|github-actions[bot]|dependabot[bot]) + echo "PR is from automation ($PR_AUTHOR); allowing PR targeting main." + exit 0 + ;; + esac + + if [ "$HAS_LABEL" = "true" ]; then + echo "Found 'target-main' label; allowing PR targeting main." + exit 0 + fi + + echo "::error title=PR should target 'next'::PRs should target the 'next' branch by default. The 'main' branch is reserved for release-please and Stainless automation. If this PR is an intentional hotfix or automation exception, add the 'target-main' label to bypass this check, or re-target the PR base to 'next'." + exit 1 From 9f95e7934a0fc4093a8aacdb9faa44ce63a535c4 Mon Sep 17 00:00:00 2001 From: Declan Brady Date: Thu, 7 May 2026 12:08:33 -0400 Subject: [PATCH 2/6] ci: replace third-party action with inline conventional-commit regex The Stainless TS/Python repos restrict GitHub Actions to an allowlist that doesn't include amannn/action-semantic-pull-request. Using a pure-shell regex check keeps the workflow self-contained and removes the third-party dependency, which also reduces supply-chain risk. --- .github/workflows/lint-pr.yaml | 49 +++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/.github/workflows/lint-pr.yaml b/.github/workflows/lint-pr.yaml index 9d1d80adf..93011c1fd 100644 --- a/.github/workflows/lint-pr.yaml +++ b/.github/workflows/lint-pr.yaml @@ -14,30 +14,35 @@ jobs: validate-pr-title: name: Validate PR title (Conventional Commits) runs-on: ubuntu-latest - permissions: - statuses: write - pull-requests: read + if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip-conventional-commit-check') }} steps: - - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 + - name: Check Conventional Commits format env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - types: | - feat - fix - docs - style - refactor - test - chore - ci - build - perf - revert - requireScope: false - # Allow skipping this check with a label (e.g. for automated/bot PRs) - ignoreLabels: | - skip-conventional-commit-check + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + # Conventional Commits: ()(!): + # Allowed types match the project's release-please changelog sections. + 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 + + { + echo "::error title=Invalid PR title::PR title must follow Conventional Commits format." + 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" + echo "" + echo " Add the 'skip-conventional-commit-check' label to bypass this check." + } >&2 + exit 1 validate-pr-base: name: Validate PR base branch From 3ed13c60f60c1f42e204731588fbd00acb25ea55 Mon Sep 17 00:00:00 2001 From: Declan Brady Date: Thu, 7 May 2026 12:09:43 -0400 Subject: [PATCH 3/6] fix(ci): escape brackets in case patterns for bot logins In POSIX shell case patterns, [bot] is a glob character class that matches a single character (b/o/t), not the literal string '[bot]'. This meant release-please[bot], dependabot[bot], and the other bot exemptions never matched and those PRs would have been failed by validate-pr-base. Escape the brackets so the patterns match the actual GitHub App login strings. Caught by Greptile review. --- .github/workflows/lint-pr.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint-pr.yaml b/.github/workflows/lint-pr.yaml index 93011c1fd..993e6e994 100644 --- a/.github/workflows/lint-pr.yaml +++ b/.github/workflows/lint-pr.yaml @@ -58,7 +58,7 @@ jobs: run: | # Exempt automated PRs (Stainless codegen, release-please, dependabot, etc.) case "$PR_AUTHOR" in - stainless-app|stainless-app[bot]|release-please[bot]|github-actions[bot]|dependabot[bot]) + stainless-app|stainless-app\[bot\]|release-please\[bot\]|github-actions\[bot\]|dependabot\[bot\]) echo "PR is from automation ($PR_AUTHOR); allowing PR targeting main." exit 0 ;; From 2c4ff6237ce447ef1de87378b0fdf55c3823e7b6 Mon Sep 17 00:00:00 2001 From: Declan Brady Date: Thu, 7 May 2026 12:27:33 -0400 Subject: [PATCH 4/6] ci: comment on validate-pr-base failure, drop conventional-commit skip label, document workflow Workflow changes: - validate-pr-base now posts (or updates) an explanatory PR comment when it fails, telling the contributor exactly how to resolve: either re-target the PR to `next` or add the `target-main` label. The comment is removed automatically when the check passes (re-target, label, or bot exemption). - validate-pr-title no longer honors the `skip-conventional-commit-check` label. Conventional Commits is enforced unconditionally so the release-please changelog stays consistent. Docs: - CONTRIBUTING.md gains (or updates) a "Contribution workflow" section covering the branch model and conventional commits, with the new CI enforcement notes and the `target-main` label escape hatch. - CLAUDE.md gains a short "Contribution workflow" section pointing to the same guidance for Claude Code sessions. --- .github/workflows/lint-pr.yaml | 62 +++++++++++++++++++++++++++++----- CLAUDE.md | 11 ++++++ CONTRIBUTING.md | 33 ++++++++++++++++++ 3 files changed, 98 insertions(+), 8 deletions(-) diff --git a/.github/workflows/lint-pr.yaml b/.github/workflows/lint-pr.yaml index 993e6e994..974413df6 100644 --- a/.github/workflows/lint-pr.yaml +++ b/.github/workflows/lint-pr.yaml @@ -14,7 +14,6 @@ jobs: validate-pr-title: name: Validate PR title (Conventional Commits) runs-on: ubuntu-latest - if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip-conventional-commit-check') }} steps: - name: Check Conventional Commits format env: @@ -39,35 +38,82 @@ jobs: echo " feat: add new endpoint" echo " fix(client): handle empty response" echo " chore!: drop python 3.11 support" - echo "" - echo " Add the 'skip-conventional-commit-check' label to bypass this check." } >&2 exit 1 validate-pr-base: name: Validate PR base branch runs-on: ubuntu-latest - if: github.event.pull_request.base.ref == 'main' permissions: - pull-requests: read + pull-requests: write steps: - - name: Check base branch + - 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: | - # Exempt automated PRs (Stainless codegen, release-please, dependabot, etc.) + MARKER='' + + # Find any existing marker comment so we can update or delete it. + existing_id=$(gh api "repos/$REPO/issues/$PR_NUMBER/comments" \ + --jq ".[] | select(.body | contains(\"$MARKER\")) | .id" | head -n1) + + 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 (Stainless codegen, release-please, dependabot, etc.). 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 - echo "::error title=PR should target 'next'::PRs should target the 'next' branch by default. The 'main' branch is reserved for release-please and Stainless automation. If this PR is an intentional hotfix or automation exception, add the 'target-main' label to bypass this check, or re-target the PR base to 'next'." + # Failure: post or update an explanatory PR comment, then fail the check. + 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" + + if [ -n "$existing_id" ]; then + gh api -X PATCH "repos/$REPO/issues/comments/$existing_id" \ + -F body=@"$body_file" >/dev/null + echo "Updated existing PR comment ($existing_id)." + else + gh pr comment "$PR_NUMBER" --repo "$REPO" --body-file "$body_file" >/dev/null + echo "Posted new PR comment." + fi + + echo "::error title=PR should target 'next'::See the PR comment for resolution steps. Re-target to 'next' or add the 'target-main' label." 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 From b3d8a6a3d0e053673c9be23c84716c4b5990e99a Mon Sep 17 00:00:00 2001 From: Declan Brady Date: Thu, 7 May 2026 13:46:50 -0400 Subject: [PATCH 5/6] fix(ci): emit ::error to stdout so GitHub Actions surfaces the annotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub Actions only processes workflow commands (::error, ::warning, etc.) when they appear on stdout. The previous code wrote the ::error line inside a {...} >&2 block, sending it to stderr — the job still exited 1, but the red annotation linking back to the failing PR title never appeared in the Actions UI. Move the ::error to stdout and keep the long-form details on stderr. Caught by Greptile review on the sgp-typescript PR; applied to all 4 repos for consistency since the same pattern existed in each. --- .github/workflows/lint-pr.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/lint-pr.yaml b/.github/workflows/lint-pr.yaml index 974413df6..8a8655007 100644 --- a/.github/workflows/lint-pr.yaml +++ b/.github/workflows/lint-pr.yaml @@ -28,8 +28,11 @@ jobs: exit 0 fi + # The ::error workflow command must be written to stdout for GitHub Actions + # to pick it up as an annotation. The supplementary detail goes to stderr so + # it shows up in the log but not as an annotation. + echo "::error title=Invalid PR title::PR title must follow Conventional Commits format. Got: $PR_TITLE" { - echo "::error title=Invalid PR title::PR title must follow Conventional Commits format." echo " Got: $PR_TITLE" echo " Expected: ()(!): " echo " Types: feat, fix, docs, style, refactor, test, chore, ci, build, perf, revert" From 4cf67eb71bedc84bf72a93e6652b0260e092abe4 Mon Sep 17 00:00:00 2001 From: Declan Brady Date: Thu, 7 May 2026 13:57:00 -0400 Subject: [PATCH 6/6] fix(ci): exempt bots from title check, harden comment posting, paginate lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from review feedback, all in lint-pr.yaml: 1. Exempt automation accounts from validate-pr-title. Bots like dependabot create PRs with non-Conventional-Commits titles by default ("Bump foo from 1.0 to 1.1"), and we don't want them blocked. The exemption list mirrors validate-pr-base for consistency. 2. Make gh-CLI write failures non-fatal in validate-pr-base. Previously, if `gh pr comment` or `gh api -X PATCH` failed (fork PR token without upstream write scope, transient API error, rate limit), `set -e` would abort the script and the ::error annotation + exit 1 never ran. Now each write is guarded so the failure annotation always reaches the contributor, and a ::warning explains when the comment itself couldn't be written. 3. Use --paginate when looking up the marker comment. The default page size is 30, so on busy PRs the marker could be missed and a duplicate "PR should target next" comment posted on every workflow re-run. Verified locally with a 28-case bash harness covering all branches: valid/invalid title formats, bot-account variants (including stainless-appx near-misses that should NOT be exempted), no-comment vs existing-comment paths for the base check, and all gh-call failure modes — every case behaves as expected. --- .github/workflows/lint-pr.yaml | 50 ++++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/.github/workflows/lint-pr.yaml b/.github/workflows/lint-pr.yaml index 8a8655007..dc165a271 100644 --- a/.github/workflows/lint-pr.yaml +++ b/.github/workflows/lint-pr.yaml @@ -18,9 +18,20 @@ jobs: - 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: ()(!): - # Allowed types match the project's release-please changelog sections. PATTERN='^(feat|fix|docs|style|refactor|test|chore|ci|build|perf|revert)(\([^)]+\))?!?: .+' if printf '%s' "$PR_TITLE" | grep -qE "$PATTERN"; then @@ -28,9 +39,7 @@ jobs: exit 0 fi - # The ::error workflow command must be written to stdout for GitHub Actions - # to pick it up as an annotation. The supplementary detail goes to stderr so - # it shows up in the log but not as an annotation. + # ::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" @@ -61,9 +70,13 @@ jobs: run: | MARKER='' - # Find any existing marker comment so we can update or delete it. - existing_id=$(gh api "repos/$REPO/issues/$PR_NUMBER/comments" \ - --jq ".[] | select(.body | contains(\"$MARKER\")) | .id" | head -n1) + # 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 @@ -78,7 +91,7 @@ jobs: exit 0 fi - # Exempt automated PRs (Stainless codegen, release-please, dependabot, etc.). + # 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 @@ -94,7 +107,10 @@ jobs: exit 0 fi - # Failure: post or update an explanatory PR comment, then fail the check. + # 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" @@ -109,14 +125,20 @@ jobs: 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 - echo "Updated existing PR comment ($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 - echo "Posted new PR comment." + 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 - echo "::error title=PR should target 'next'::See the PR comment for resolution steps. Re-target to 'next' or add the 'target-main' label." + # ::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