From 06c26c850f150e7f00355b140d4fe19229aef066 Mon Sep 17 00:00:00 2001 From: Thi Quynh Nhu Nguyen Date: Tue, 29 Sep 2026 16:14:46 -0700 Subject: [PATCH 1/5] ci(release): mint the release token from the dedicated release App release-please minted its token from the codegen App, so that App's private key had to live on the production repo. Mint it instead from a dedicated release App whose key is held only in the `release` environment, which deploys from main alone. Also run github-release before release-pr, as release-please-action does, so a release-pr failure cannot stop a merged release from being tagged and the next release PR opens in the same run; add a concurrency group so two quick pushes cannot race; and pin release-please to 16.18.0, which is what `@16` resolves to today. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release-please.yml | 72 ++++++++++++++++++++-------- 1 file changed, 52 insertions(+), 20 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index e9ddc6392..5797341fc 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -3,29 +3,50 @@ name: Release Please # Hand-edited from the stlc-generated template. `.github/workflows/*.yml` is # scaffold-once, so this survives every later build -- upstream's own source cites # exactly this PAT-to-App swap as the reason that preservation exists. Do NOT run -# `stlc build --rewrite-scaffold` without reapplying these three changes. +# `stlc build --rewrite-scaffold` without reapplying the changes below. # # What changed from the generated file, and why each is load-bearing: # -# 1. App token instead of `secrets.RELEASE_PLEASE_TOKEN`, which does not exist -# and which we do not want to create -- eliminating PATs was the point of the -# App migration. It is deliberately NOT `GITHUB_TOKEN`: releases created by +# 1. A token minted from a dedicated release App instead of +# `secrets.RELEASE_PLEASE_TOKEN`, which does not exist and which we do not want +# to create -- eliminating PATs was the point of the App migration. It is +# deliberately NOT the codegen App, so the codegen App's key does not have to +# live on the production repos. Nor is it `GITHUB_TOKEN`: releases created by # GITHUB_TOKEN do not trigger other workflows, so publish-*.yml would never # fire and the release would stop one hop short of the registry. # -# 2. The `npx release-please@16` CLI instead of googleapis/release-please-action. -# scale-agentex-typescript sets `allowed_actions: selected` and does not permit -# that action; the CLI needs only actions/-owned steps, which -# `github_owned_allowed: true` covers on both production repos. +# 2. The release-please CLI (`npx`, exact version) instead of +# googleapis/release-please-action. scale-agentex-typescript sets +# `allowed_actions: selected` and does not permit that action; the CLI needs +# only actions/-owned steps, which `github_owned_allowed: true` covers on both +# production repos. # # 3. `issues: write` on the minted token. release-please drives its # autorelease:pending -> autorelease:tagged labels through the Issues API. # Without it you get duplicate release pull requests. The generated file omits # it, and the omission is silent until it bites. # -# Requires AGENTEX_SDK_SYNC_PRIVATE_KEY (secret) and AGENTEX_SDK_SYNC_APP_ID -# (variable) on the PRODUCTION repo -- a workflow only reads secrets from the repo -# it runs in, and the guard below means that is production. +# 4. `environment: release` on the job. That environment holds the release App's +# key and deploys from main only. GitHub creates any environment a job names +# that does not exist yet, with no protection, so a typo in the name silently +# creates an unprotected environment. +# +# 5. `github-release` runs before `release-pr` (the release-please-action order). +# A release-pr failure, such as the workflows refusal below, then cannot stop a +# merged release PR from being tagged and published. In the other order +# release-pr skips the run while a merged release is still untagged ("untagged, +# merged release PRs outstanding"), so the next release PR waits for another push. +# +# The release App has no `workflows` permission. If release-please reports "refusing +# to allow a GitHub App to create or update workflow", close the release PR and +# delete its branch, then re-run this workflow (or wait for the next push to main); +# it opens the release PR again from main. +# +# Requires, on the PRODUCTION repo (a workflow only reads secrets from the repo it +# runs in, and the guard below means that is production): +# - the secret AGENTEX_RELEASE_APP_PRIVATE_KEY in the environment `release` +# - the repository variable AGENTEX_RELEASE_APP_ID +# - the release App installed on this repo (contents, pull requests and issues: write) on: push: branches: @@ -35,19 +56,29 @@ on: permissions: contents: read +# One run at a time, so two quick pushes cannot race to open duplicate release PRs or +# cut the same release twice. A running job is never cancelled; a newer push replaces +# a still-queued run, which is harmless because release-please reads live repo state. +concurrency: + group: release-please + cancel-in-progress: false + jobs: release-please: # Self-routing: this file is SHA-identical on the staging trunk, where it must # stay inert. Only production cuts releases. if: github.repository == 'scaleapi/scale-agentex-python' runs-on: ubuntu-latest + # See 4 above. Keep this name identical to the provisioned environment, and give + # it no required reviewers: a reviewer there would hold every push to main. + environment: release steps: - name: Mint release token id: release-token uses: actions/create-github-app-token@v2 with: - app-id: ${{ vars.AGENTEX_SDK_SYNC_APP_ID }} - private-key: ${{ secrets.AGENTEX_SDK_SYNC_PRIVATE_KEY }} + app-id: ${{ vars.AGENTEX_RELEASE_APP_ID }} + private-key: ${{ secrets.AGENTEX_RELEASE_APP_PRIVATE_KEY }} owner: scaleapi repositories: scale-agentex-python permission-contents: write @@ -59,22 +90,23 @@ jobs: with: node-version: '20' - - name: Release PR + GitHub release + - name: GitHub release + release PR env: RP_TOKEN: ${{ steps.release-token.outputs.token }} run: | - # release-pr opens or updates the version-bump pull request; - # github-release turns an already-merged one into the tag + GitHub Release - # that publish-pypi.yml / publish-npm.yml trigger on. Both are idempotent, - # so running the pair on every push carries a release the whole way. + # github-release turns an already-merged release PR into the tag + GitHub + # Release that publish-pypi.yml / publish-npm.yml trigger on; release-pr then + # opens or updates the next version-bump pull request. Both are idempotent, + # so running the pair on every push carries a release the whole way, and a + # release-pr failure cannot stop a merged release from being tagged. # # No checkout step is needed: release-please reads the config and manifest # from the repo over the API. - npx --yes release-please@16 release-pr \ + npx --yes release-please@16.18.0 github-release \ --token="$RP_TOKEN" --repo-url="${{ github.repository }}" \ --config-file=release-please-config.json \ --manifest-file=.release-please-manifest.json - npx --yes release-please@16 github-release \ + npx --yes release-please@16.18.0 release-pr \ --token="$RP_TOKEN" --repo-url="${{ github.repository }}" \ --config-file=release-please-config.json \ --manifest-file=.release-please-manifest.json From 9ab0a819d04faca774a28cb175fb575ca9095ba7 Mon Sep 17 00:00:00 2001 From: Thi Quynh Nhu Nguyen Date: Tue, 29 Sep 2026 16:15:26 -0700 Subject: [PATCH 2/5] ci(lint-pr): drop the next-branch base check and exempt the release App main is now the only integration branch, so validate-pr-base, which failed every human PR to main unless it carried the target-main label and told contributors to retarget to next, has nothing left to enforce. Remove it, and with it the labeled/unlabeled triggers, which only that job used. Release PRs are about to be opened by the dedicated release App, whose titles come from release-please's title pattern. Exempt its bot from the title check by user ID, the same way the SDK automation App's bot is; that exemption stays, since it opens the promote PRs. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/lint-pr.yaml | 127 +++++---------------------------- 1 file changed, 16 insertions(+), 111 deletions(-) diff --git a/.github/workflows/lint-pr.yaml b/.github/workflows/lint-pr.yaml index bde91e1f3..28cc60759 100644 --- a/.github/workflows/lint-pr.yaml +++ b/.github/workflows/lint-pr.yaml @@ -7,11 +7,9 @@ on: - edited - synchronize - reopened - - labeled - - unlabeled env: - # This repo's own SDK automation App's bot, exempt from both checks below. + # This repo's own SDK automation App's bot, exempt from the title check below. # # Matched by the bot user's numeric ID, not its login. A GitHub App bot's login # follows the App's name, so renaming the App renamed its bot and silently broke @@ -19,6 +17,9 @@ env: # so any App that took it would have inherited the exemption. A user ID never # changes and is never reused. SDK_AUTOMATION_BOT_ID: '333044712' + # The release App's bot, which opens the release-please pull requests. Exempt + # from the title check too, and matched by ID for the same reasons. + RELEASE_BOT_ID: '335733747' jobs: validate-pr-title: @@ -34,19 +35,19 @@ jobs: # 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. + # don't want their PRs blocked by this check. # - # This repo's own SDK automation App is exempt too, matched by ID through - # SDK_AUTOMATION_BOT_ID at the top of this file. release-please runs here as - # a CLI under that App rather than as the release-please[bot] GitHub App, so - # its release pull requests are authored by the App's bot and the list below - # never matched them. Their titles come from release-please's configured - # pull-request-title-pattern, which is not always a Conventional Commits type - # and cannot be changed without also changing the string release-please - # parses back when it cuts the release. The same App opens the promote pull - # requests. - if [ "$PR_AUTHOR_ID" = "$SDK_AUTOMATION_BOT_ID" ]; then - echo "PR is from this repo's SDK automation ($PR_AUTHOR); skipping title check." + # This repo's own automation Apps are exempt too, matched by ID through + # SDK_AUTOMATION_BOT_ID and RELEASE_BOT_ID at the top of this file. The SDK + # automation App opens the promote pull requests. release-please runs here as + # a CLI under the release App rather than as the release-please[bot] GitHub + # App, so its release pull requests are authored by that App's bot and the + # list below never matched them. Their titles come from release-please's + # configured pull-request-title-pattern, which is not always a Conventional + # Commits type and cannot be changed without also changing the string + # release-please parses back when it cuts the release. + if [ "$PR_AUTHOR_ID" = "$SDK_AUTOMATION_BOT_ID" ] || [ "$PR_AUTHOR_ID" = "$RELEASE_BOT_ID" ]; then + echo "PR is from this repo's own automation ($PR_AUTHOR); skipping title check." exit 0 fi case "$PR_AUTHOR" in @@ -77,99 +78,3 @@ jobs: 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_AUTHOR_ID: ${{ github.event.pull_request.user.id }} - 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). - if [ "$PR_AUTHOR_ID" = "$SDK_AUTOMATION_BOT_ID" ]; then - delete_comment - echo "PR is from this repo's SDK automation ($PR_AUTHOR); allowing PR targeting main." - exit 0 - fi - 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 From 474b19cc0c139d324ffa403df67ad07d9db4ad05 Mon Sep 17 00:00:00 2001 From: Thi Quynh Nhu Nguyen Date: Tue, 29 Sep 2026 16:15:39 -0700 Subject: [PATCH 3/5] ci: stop triggering on the retired next branch The next branch is being retired and PRs now target main. Drop it from release doctor's head-ref condition, which still matches the release-please PRs, and from the tutorial tests' pull_request branches, which keep main. release-doctor.yml is edited rather than deleted: it is generated, and a deletion would be carried as custom code that can conflict with a later regeneration. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/agentex-tutorials-test.yml | 2 +- .github/workflows/release-doctor.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/agentex-tutorials-test.yml b/.github/workflows/agentex-tutorials-test.yml index 7d57dff09..2d0e23371 100644 --- a/.github/workflows/agentex-tutorials-test.yml +++ b/.github/workflows/agentex-tutorials-test.yml @@ -2,7 +2,7 @@ name: Test Tutorial Agents on: pull_request: - branches: [main, next] + branches: [main] push: branches: [main] workflow_dispatch: diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml index a20022ce7..84d635e4a 100644 --- a/.github/workflows/release-doctor.yml +++ b/.github/workflows/release-doctor.yml @@ -9,7 +9,7 @@ jobs: release_doctor: name: release doctor runs-on: ubuntu-latest - if: github.repository == 'scaleapi/scale-agentex-python' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please') || github.head_ref == 'next') + if: github.repository == 'scaleapi/scale-agentex-python' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please')) steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 From 4ba78c34876f3fc8389841fa0860414b778faab7 Mon Sep 17 00:00:00 2001 From: Thi Quynh Nhu Nguyen Date: Tue, 29 Sep 2026 16:17:17 -0700 Subject: [PATCH 4/5] ci(docs): document main as the only integration branch The contribution docs still told people to target next and to use the target-main label to get past a base-branch check that no longer exists. Describe main as the only integration branch, point API-surface changes at the spec in the config repo rather than generated files, and add the maintainer rules for promote PRs, human merges and releases. Only the custom Branch model section of CONTRIBUTING.md changes; the generated parts of the file are untouched. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 15 ++++++++++----- CONTRIBUTING.md | 36 ++++++++++++++++++++++++------------ 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7dd7f2ed3..ea404e29b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,13 +4,18 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## 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`. +- This repository is a Stainless-generated SDK. Open PRs against `main`; there is no `next` branch + any more, and no label is needed. Merging the release-please PR publishes the release to PyPI. - 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). +- Most of the SDK is generated, so API-surface changes belong in the API spec in + scaleapi/scale-agentex (`agentex/openapi.yaml`), not in generated files. Hand-written code lives + in `src/agentex/lib/` and `examples/`. +- For maintainers: promote PRs open as drafts; approve them, never mark them ready or merge them, + then, once checks are green, re-run the promote workflow. Merge a human PR into `main` only while + staging `main` is an ancestor of production `main` and no codegen run is in flight, then run the + back-sync (`stlc-sync.yml` in scaleapi/scale-agentex). Promote, then release, in one sitting. The + runbook is at the top of `.github/workflows/stlc-promote.yml` in scaleapi/scale-agentex. - See `CONTRIBUTING.md` for the full workflow. ## Development Commands diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e8e6e8813..27cb7c8e0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,19 +39,31 @@ release pipeline working, contributions need to follow the branch model and comm ### Branch model -- Always open PRs against the `next` branch — not `main`. Stainless watches `next` to produce SDK - builds and the automated version-bump PR. +- Open PRs against `main`, the only integration branch. There is no `next` branch any more, and no + label is needed. - 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. + 1. Pull the latest `main` locally and branch off it. + 2. Make and push your changes, then open a PR targeting `main`. + 3. Get the PR reviewed and merged into `main`. + 4. release-please keeps a release PR open on `main` that bumps the version and changelog. Merging + that PR cuts the release and publishes it to PyPI. +- Most of the SDK is generated from the API spec. Changes to the API surface belong in the spec in + [scaleapi/scale-agentex](https://github.com/scaleapi/scale-agentex) (`agentex/openapi.yaml`), not + in generated files, where hand edits are replayed onto every regeneration and can conflict with + it. Hand-written code lives in `src/agentex/lib/` and `examples/`, which the generator never + modifies. + +#### For maintainers + +- Promote PRs (`chore: promote staging … to production`) open as drafts. Approve them, but never + mark them ready for review or merge them; once their checks are green, re-run the promote + workflow, which fast-forwards `main`. +- Merge a human PR into `main` only while staging `main` is an ancestor of production `main` and no + codegen run is in flight, then run the back-sync workflow (`stlc-sync.yml` in + scaleapi/scale-agentex). +- Promote, then release, in one sitting. +- See the runbook at the top of `.github/workflows/stlc-promote.yml` in scaleapi/scale-agentex for + details. ### Conventional commits From 6c388bfe4673f51611dc5ad0288a1797c2bbbf14 Mon Sep 17 00:00:00 2001 From: Thi Quynh Nhu Nguyen Date: Tue, 29 Sep 2026 17:46:34 -0700 Subject: [PATCH 5/5] build(pyproject): keep the generated package description The code generator rewrites `version` on every release. The custom `description` sat on the line right after it, and git treats edits on adjacent lines as one change, so every release made the custom-code replay conflict on pyproject.toml and python codegen stopped building. Keep the generated description so no hand edit borders `version`. A comment explains why; `name` stays between the comment and `version` so the two never touch. The published summary of agentex-client returns to the generator's wording. Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7e1e4ae8a..40654d3fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,9 +2,11 @@ # This is the Stainless-generated REST client. The hand-authored ADK # overlay (formerly `src/agentex/lib/*`) now lives in `adk/` and ships # as the sibling `agentex-sdk` package — see `adk/pyproject.toml`. +# Keep `description` as generated: codegen rewrites `version` on every release, +# and a hand edit on the line next to it conflicts each time. name = "agentex-client" version = "0.28.2" -description = "The official Python REST client for the Agentex API" +description = "The official Python library for the agentex API" dynamic = ["readme"] license = "Apache-2.0" authors = [