From 5136ecb75ff9db3acd838064c121392eb507449a Mon Sep 17 00:00:00 2001 From: Ari Nguyen Date: Mon, 21 Sep 2026 12:16:01 -0700 Subject: [PATCH 01/13] chore(stlc): re-anchor the typescript custom-code seal to main The existing seal was scoped to branch aringuyen/stainless, whose integrated SHA is not reachable from main, so stlc reported no integration for main at all. This records the seal produced by a real build against the reconciled main (base 61f04722, integrated b53e3f16), which matches scale-agentex-typescript-staging main on origin. Python's equivalent is deliberately not included: its build integrated cleanly but the push to staging main is rejected (GH006, protected branch, MAINTAIN access only), so its tracking file would record a commit that exists in no remote -- the state stlc refuses to build from later. --- .../typescript/2026-09-21T19-04-07-191Z-custom-code.json | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 stainless/custom-code/typescript/2026-09-21T19-04-07-191Z-custom-code.json diff --git a/stainless/custom-code/typescript/2026-09-21T19-04-07-191Z-custom-code.json b/stainless/custom-code/typescript/2026-09-21T19-04-07-191Z-custom-code.json new file mode 100644 index 00000000..88e0b6e7 --- /dev/null +++ b/stainless/custom-code/typescript/2026-09-21T19-04-07-191Z-custom-code.json @@ -0,0 +1,6 @@ +{ + "base": "61f0472270f1e97d181da0fe940a989ce905b78a", + "integrated": "b53e3f16b55df44e54a4fa661fdc8d86693f8d21", + "filename": "2026-09-21T19-04-07-191Z-custom-code.json", + "branch": "main" +} From 94c21befd5a603e34d190ebc71a794e4c3e07ee7 Mon Sep 17 00:00:00 2001 From: Ari Nguyen Date: Mon, 21 Sep 2026 12:45:21 -0700 Subject: [PATCH 02/13] ci(stlc): add the stlc codegen workflow and setup action Adds the two CI files that drive SDK generation from the stainless/ workspace: a guard + generate workflow, and a composite action that installs the stlc CLI family. Both are derived from upstream stainless/stlc's examples/config-repo, with these changes for this repo: - paths: watch agentex/openapi.yaml in BOTH the pull_request and push filters. The spec lives outside the workspace, and the copied file listed it in neither push nor a valid glob -- so merges to main would never have built. Deliberately not agentex/**: the spec is a committed artifact regenerated by the pre-commit hook and freshness-checked by ci.yml, so watching the whole backend tree would fire full SDK builds on changes that cannot affect the SDK. - Guard the generate job against fork PRs. This repo is public and does receive them; forks get no secrets, so the job would fail with a red check an external contributor can neither understand nor fix. Not fixed with pull_request_target, which would run fork code with full secrets in a workflow that pushes to four other repos. - Replace marocchino/sticky-pull-request-comment with actions/github-script, which this repo's Actions allowlist permits. Same update-in-place behavior, keyed on the marker stlc show --marker emits. - Trim setup-stlc from ten language toolchains to Node + uv, and from twelve stlc packages to the three this workspace needs. uv rather than rye: the python target resolves to use_uv, and every generated script shells out to uv. astral-sh/setup-uv pinned to @v4, the version the allowlist permits. DEFAULT_TARGETS is temporarily typescript-only -- see the comment in the workflow. Python is reconciled and builds cleanly, but pushing to its staging main is rejected until that repo grants ADMIN. Co-Authored-By: Claude Opus 5 --- .github/actions/setup-stlc/action.yml | 63 +++++ .github/workflows/stlc-generate.yml | 392 ++++++++++++++++++++++++++ 2 files changed, 455 insertions(+) create mode 100644 .github/actions/setup-stlc/action.yml create mode 100644 .github/workflows/stlc-generate.yml diff --git a/.github/actions/setup-stlc/action.yml b/.github/actions/setup-stlc/action.yml new file mode 100644 index 00000000..033c531c --- /dev/null +++ b/.github/actions/setup-stlc/action.yml @@ -0,0 +1,63 @@ +name: 'Setup stlc' +description: 'Install the stlc CLI plus the python and typescript target plugins, and configure git auth to fetch the private stainless/stlc* repos. Intended for CI workflows that run stlc commands.' + +inputs: + stlc-read-token: + description: 'Classic PAT with the `repo` scope, SSO-authorized for the stainless org. Used to fetch the stlc CLI family from private GitHub repos.' + required: true + +# Trimmed from the upstream example, which provisions ten language toolchains and all twelve +# stlc plugins. stainless.yml declares only `python` and `typescript`, so everything else was +# pure runner time. Dropping Ruby/PHP/uv@v6 also removes the three `uses:` that this repo's +# Actions allowlist (allowed_actions: selected, verified_allowed: false) would have blocked. +# +# If you add a target to stainless.yml you must add its toolchain and its stlc- +# package here, and widen DEFAULT_TARGETS in stlc-generate.yml. +runs: + using: 'composite' + steps: + # The stlc CLI family is Node. Node also covers the typescript target. + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '24' + + # corepack provides the package-manager shim the SDK repo asks for via its `packageManager` + # field. Note upstream's comment here claims the TypeScript SDK's scripts invoke pnpm -- + # this one does not: it pins `yarn@1.22.22` and its scripts/ use only npm and yarn. corepack + # covers all three, so the step is right either way, but don't "simplify" it to pnpm. + - name: Enable corepack (yarn / pnpm shims) + shell: bash + run: corepack enable + + # uv, not rye: this python target resolves to use_uv (edition python.2025-11-20). The + # generated SDK ships uv.lock and no noxfile.py/requirements.lock, and every one of its + # scripts/{bootstrap,lint,test,format,check-wheel-install} shells out to `uv`. + # + # Pinned to @v4 because the allowlist permits `astral-sh/setup-uv@v4` and not @v6, which is + # what upstream uses. Matches the version already used by this repo's ci.yml. + - name: Setup uv + uses: astral-sh/setup-uv@v4 + with: + enable-cache: false + + - name: Install stlc + shell: bash + env: + STLC_READ_TOKEN: ${{ inputs.stlc-read-token }} + run: | + git_config_key="url.https://x-access-token:${STLC_READ_TOKEN}@github.com/stainless/.insteadOf" + cleanup_stlc_auth() { git config --global --unset-all "$git_config_key" || true; } + trap cleanup_stlc_auth EXIT + git config --global --add "$git_config_key" "https://github.com/stainless/" + + npm install -g \ + "git+https://x-access-token:${STLC_READ_TOKEN}@github.com/stainless/stlc.git" \ + "git+https://x-access-token:${STLC_READ_TOKEN}@github.com/stainless/stlc-python.git" \ + "git+https://x-access-token:${STLC_READ_TOKEN}@github.com/stainless/stlc-typescript.git" + + - name: Check stlc CLI is callable + shell: bash + run: | + stlc --help >/dev/null && echo "stlc is callable" + stlc --version diff --git a/.github/workflows/stlc-generate.yml b/.github/workflows/stlc-generate.yml new file mode 100644 index 00000000..2a256df2 --- /dev/null +++ b/.github/workflows/stlc-generate.yml @@ -0,0 +1,392 @@ +name: Generate SDKs with stlc + +# One workflow, three modes: +# - pull_request: build every target, push a preview branch per staging repo, sticky-comment diff links. +# - push to main: build and push staging main directly (Promote then fast-forwards production), then seal tracking files back here. +# - schedule / dispatch: rebuild main; `stlc build` absorbs out-of-band staging custom code and re-seals, so this doubles as the tracking-file sync (the SDK repos' seal-dispatch fires it eagerly). +on: + # The spec lives OUTSIDE the workspace, at agentex/openapi.yaml (stainless/workspace.json + # points at ../agentex/openapi.yaml), so it must appear in BOTH lists below or merges to main + # never trigger a build. + # + # Deliberately 'agentex/openapi.yaml' and not 'agentex/**': the spec is a committed artifact, + # regenerated and git-added by the pre-commit hook on agentex/src/** changes and freshness- + # checked by ci.yml's openapi-spec job. Watching the whole backend tree would fire a full + # two-target SDK build on every change that cannot affect the SDK. + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'agentex/openapi.yaml' + - 'stainless/**' + - '.github/workflows/stlc-generate.yml' + - '.github/actions/setup-stlc/**' + push: + branches: [main] + paths: + - 'agentex/openapi.yaml' + - 'stainless/**' + - '.github/workflows/stlc-generate.yml' + - '.github/actions/setup-stlc/**' + schedule: + # Poll for out-of-band staging custom code; the seal-custom-code dispatch covers the eager path. + - cron: '0 */6 * * *' + workflow_dispatch: {} + # Sent by the SDK repos' seal-dispatch job when custom code lands on staging main. + repository_dispatch: + types: [seal-custom-code] + +# Sync runs get a distinct name in the Actions list; push/PR runs keep the default via the empty-string fallback. +run-name: ${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch') && 'Sync custom-code tracking' || '' }} + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + # Cancel superseded PR runs (last write wins; a preview branch is disposable). + # + # Never cancel a run on main. Those runs push to the SDK repos and then seal the + # tracking files back here, and a cancel between the two leaves the SDK repos + # ahead of the tracking files — the state later builds refuse, to avoid dropping + # the custom code the SDK repos now carry. Merging faster than a build takes + # would otherwise wedge generation on a regular basis. + # + # This costs no throughput: GitHub keeps only one *pending* run per group, so + # rapid merges still coalesce onto the newest commit. A pending run has pushed + # nothing, so dropping it is free — it is the in-flight run that must finish. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: write + pull-requests: write + id-token: write + +env: + STAINLESS_WORKSPACE: stainless + # Where the build clones the SDK repos. The seal-back check reads the same + # path, so that it inspects the repos this run actually sealed against. + SDK_OUTPUT: /tmp/sdk-output + # How the seal-back pull request lands. `auto` merges it and fails the run if + # it cannot, because tracking files that never merge wedge every later build. + # Set to `review` if you want to merge seal-back pull requests by hand — then + # an unmerged one is reported as a notice instead of a failure, and keeping it + # moving is on you. + SEAL_BACK_MERGE: auto + # Explicit rather than `all`. setup-stlc only installs the python + typescript toolchains and + # plugins, so adding a target must be a deliberate two-file change here and in setup-stlc, + # rather than a confusing mid-build plugin-resolution failure. + # + # TEMPORARILY typescript-only. python is fully reconciled and its build integrates cleanly, + # but pushing to scale-agentex-python-staging's protected main is rejected (GH006) because we + # hold only MAINTAIN there -- so including it would just produce red runs. Restore + # `python,typescript` once that repo grants ADMIN; nothing else needs to change. + DEFAULT_TARGETS: typescript + +jobs: + # skip changes that touch only stainless/custom-code/** so seal-back can't re-trigger generate — + # both the PR itself (when it was opened with a token whose events start workflow runs) and the + # push that merges it. + guard: + # Runner comes from the STLC_RUNNER repo/org variable when set; defaults to GitHub-hosted. + runs-on: ${{ vars.STLC_RUNNER || 'ubuntu-latest' }} + outputs: + skip: ${{ steps.check.outputs.skip }} + steps: + - name: Decide whether this change only touches tracking files + id: check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + EVENT: ${{ github.event_name }} + REPO: ${{ github.repository }} + # push: the range the push advanced over. pull_request: the PR's own range — the + # three-dot compare below measures it from the merge base, not the base branch tip. + BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} + HEAD: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + run: | + case "$EVENT" in + push | pull_request) ;; + *) echo "skip=false" >> "$GITHUB_OUTPUT"; exit 0 ;; + esac + if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ]; then + echo "skip=false" >> "$GITHUB_OUTPUT"; exit 0 + fi + # NOTE: the compare API caps .files at ~300 entries. A >300-file diff whose first + # 300 entries all sit under stainless/custom-code/ would wrongly skip regeneration. + # Not reachable for a two-file seal-back, but don't widen this check. + changed=$(gh api "repos/$REPO/compare/$BASE...$HEAD" --jq '.files[].filename') + echo "Changed files:"; printf '%s\n' "$changed" + if [ -n "$changed" ] && ! printf '%s\n' "$changed" | grep -qvE '^stainless/custom-code/'; then + echo "Only tracking files changed — skipping regeneration (loop-breaker)." + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + generate: + needs: guard + # Fork pull requests get no repository secrets, so STLC_READ_TOKEN would be empty and + # setup-stlc's install would 404 against the private stainless/stlc* repos -- a red check + # an external contributor can neither understand nor fix. This repo is public and does + # receive fork PRs, so skip them. Do NOT "fix" this with pull_request_target: that runs + # with full secrets against fork-authored code, and this workflow pushes to four repos. + if: >- + ${{ needs.guard.outputs.skip != 'true' + && (github.event_name != 'pull_request' + || github.event.pull_request.head.repo.full_name == github.repository) }} + runs-on: ${{ vars.STLC_RUNNER || 'ubuntu-latest' }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Setup stlc + uses: ./.github/actions/setup-stlc + with: + stlc-read-token: ${{ secrets.STLC_READ_TOKEN }} + + - name: Configure auth for stlc to push to SDK repos + env: + GH_TOKEN: ${{ secrets.SDK_WRITE_TOKEN }} + run: | + git config --global user.name "stlc-bot" + git config --global user.email "stlc-bot@users.noreply.github.com" + gh auth setup-git + + - name: Resolve commit message + # Use the merged PR's title as the SDK commit message. + id: msg + if: github.event_name == 'push' + env: + SHA: ${{ github.sha }} + REPO: ${{ github.repository }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + FALLBACK: ${{ github.event.head_commit.message }} + run: | + pr_title=$(gh api "repos/$REPO/commits/$SHA/pulls" --jq '.[0].title // empty') + + if [ -n "$pr_title" ]; then + commit_msg="$pr_title" + else + commit_msg="$FALLBACK" + fi + + { + echo "commit_msg<> "$GITHUB_OUTPUT" + + - name: Hold codegen while production is ahead (release not back-synced) + # Builds of main push directly to staging main. If production is ahead, refuse and re-run after the back-sync. + # + # An unreachable production repo warns and skips rather than failing. + if: github.event_name != 'pull_request' + working-directory: ${{ env.STAINLESS_WORKSPACE }} + env: + GH_TOKEN: ${{ secrets.SDK_WRITE_TOKEN }} + # Fail fast instead of prompting when a repo needs credentials. + GIT_TERMINAL_PROMPT: '0' + run: | + set -euo pipefail + + # `-c credential.helper=` sidesteps the `gh auth setup-git` helper, so the + # no-token attempt is genuinely unauthenticated and token calls use only the URL token. + ls_heads() { git -c credential.helper= ls-remote --heads "$1" main; } + + held="" + while IFS=$'\t' read -r target staging prod; do + # Skip one-repo targets (staging == production) and targets with no production repo. + { [ -z "$prod" ] || [ "$prod" = "null" ] || [ "$staging" = "$prod" ]; } && continue + + # Try the production repo unauthenticated first, then with the token. + prod_url="https://github.com/${prod}.git" + if ! prod_heads=$(ls_heads "$prod_url"); then + prod_url="https://x-access-token:${GH_TOKEN}@github.com/${prod}.git" + if ! prod_heads=$(ls_heads "$prod_url"); then + echo "::warning title=Codegen hold skipped for ${target}::cannot verify production sync: ${prod} is unreachable with the configured credential. The hold is skipped for this target; grant the token read access to the production repo to enable it." + continue + fi + fi + + # No production main yet → first release not cut → nothing to be ahead of. + [ -z "$prod_heads" ] && continue + + staging_url="https://x-access-token:${GH_TOKEN}@github.com/${staging}.git" + if ! staging_heads=$(ls_heads "$staging_url"); then + echo "::warning title=Codegen hold skipped for ${target}::cannot verify production sync: staging repo ${staging} is unreachable. The hold is skipped for this target; if this is not transient, the build step will fail loudly on its own." + continue + fi + + # No staging main yet → first build → nothing to compare against. + [ -z "$staging_heads" ] && continue + + d=$(mktemp -d); git -C "$d" init -q + git -C "$d" -c credential.helper= fetch -q "$staging_url" main:refs/remotes/staging/main + git -C "$d" -c credential.helper= fetch -q "$prod_url" main:refs/remotes/prod/main + if ! git -C "$d" merge-base --is-ancestor prod/main staging/main; then + held="$held $target" + fi + rm -rf "$d" + done < <(yq -r '.targets | to_entries[] | [.key, (.value.staging_repo // .value.production_repo), (.value.production_repo // .value.staging_repo)] | @tsv' stainless.yml) + if [ -n "$held" ]; then + echo "::error title=Codegen held::production is ahead of the staging trunk (a release isn't back-synced yet) for:$held. Wait for the back-sync, then re-run." + exit 1 + fi + echo "No target has production verified ahead of staging — proceeding to build." + + - name: Generate SDKs and push + env: + # PR head ref on pull_request (preview branch); "main" otherwise. + BRANCH: ${{ github.event.pull_request.head.ref || github.ref_name }} + COMMIT_MSG: ${{ steps.msg.outputs.commit_msg }} + GH_TOKEN: ${{ secrets.SDK_WRITE_TOKEN }} + working-directory: ${{ env.STAINLESS_WORKSPACE }} + run: | + echo "Building targets: $DEFAULT_TARGETS to branch: $BRANCH" + commit_args=() + if [ -n "$COMMIT_MSG" ]; then + commit_args=(--commit "$COMMIT_MSG") + fi + # --trunk-branch is the branch promoted to production; every other branch is + # an ephemeral preview (rebuilds overwrite their tip). Set it to your trunk. + stlc build \ + --branch "$BRANCH" \ + --trunk-branch main \ + --output "$SDK_OUTPUT" \ + --push \ + --targets "$DEFAULT_TARGETS" \ + "${commit_args[@]}" + + - name: Render build manifest + # continue-on-error so a missing manifest (build aborted before writing + # one) doesn't mask the real failure. + id: manifest + if: always() && github.event_name == 'pull_request' + continue-on-error: true + working-directory: ${{ env.STAINLESS_WORKSPACE }} + env: + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + if ! stlc show --renderer=markdown --marker stlc-build-manifest \ + --workflow-run-url "$RUN_URL" > /tmp/build-manifest.md; then + { + echo "## stlc build manifest" + echo "" + echo "Manifest render failed — no sticky comment will be posted." + echo "See the \"Generate SDKs and push\" step above for the underlying error." + } >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi + cat /tmp/build-manifest.md + cat /tmp/build-manifest.md >> "$GITHUB_STEP_SUMMARY" + + - name: Comment build manifest on PR + # Upstream uses marocchino/sticky-pull-request-comment@v2, which this repo's Actions + # allowlist blocks (allowed_actions: selected, verified_allowed: false). Same + # update-in-place behavior via actions/github-script@v7, which is allowlisted: + # `stlc show --marker` puts at the top of the body, and + # we find our own previous comment by that marker instead of the action's `header`. + if: always() && steps.manifest.outcome == 'success' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const marker = ''; + const body = fs.readFileSync('/tmp/build-manifest.md', 'utf8'); + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + const comments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number, per_page: 100, + }); + const mine = comments.find((c) => c.body && c.body.includes(marker)); + if (mine) { + await github.rest.issues.updateComment({ owner, repo, comment_id: mine.id, body }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number, body }); + } + + - name: Seal tracking files back to the config repo + # `stlc build` may rewrite the tracking files under stainless/custom-code/. + # main is protected, so land them via a PR on a shared branch (one seal-back PR + # at a time); merging won't loop (guard skips tracking-only pushes). + # Prefers SDK_WRITE_TOKEN: where an org turns off "Allow GitHub Actions to create + # and approve pull requests", GITHUB_TOKEN fails this step with "GitHub Actions is + # not permitted to create or approve pull requests" -- after the branch has been + # pushed, leaving the tracking files stranded on an unmerged branch. + # + # `always()`, not `success()`: the build pushes to the SDK repos before its + # verification scripts run, so a red lint or test — or a cancel — leaves the + # SDK repos ahead of the tracking files, and skipping the seal-back is what + # turns that into a wedge later builds refuse to work around. The assert below + # is what makes running on any outcome safe. + if: ${{ always() && github.event_name != 'pull_request' }} + env: + GH_TOKEN: ${{ secrets.SDK_WRITE_TOKEN || secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + run: | + if [ -z "$(git status --porcelain -- "$STAINLESS_WORKSPACE/custom-code")" ]; then + echo "Tracking files already in sync — nothing to seal." + exit 0 + fi + + # Only seal back tracking files whose commits actually reached the SDK repos. + # A build that sealed but failed before pushing leaves tracking files naming + # commits that exist nowhere else; committing those stalls every later build + # in a state only a merge inside the SDK repo can clear. + # + # Same --targets as the build: the check reads the SDK repos in the output + # directory, so a target this run never built has no repo there and would be + # reported as unverifiable. + if ! stlc status --workspace "$STAINLESS_WORKSPACE" --output "$SDK_OUTPUT" --targets "$DEFAULT_TARGETS" --assert-seals-pushed; then + echo "::error title=Seal-back skipped::the build sealed custom code it never pushed to the SDK repos (see above). Not committing the tracking files. Re-run this workflow once the push can succeed." + exit 1 + fi + branch="stlc/seal-tracking" + git checkout -B "$branch" + git add "$STAINLESS_WORKSPACE/custom-code" + git commit -m "chore(stlc): seal custom-code tracking files" + git push --force "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" "$branch" + open_pr=$(gh pr list --head "$branch" --state open --json number --jq '.[0].number // empty') + if [ -z "$open_pr" ]; then + gh pr create --base main --head "$branch" \ + --title "chore(stlc): seal custom-code tracking files" \ + --body "Automated by the stlc generate workflow: \`stlc build\` re-sealed custom code (after a spec change on \`main\`, or after absorbing custom code that landed on a staging repo out of band), updating the tracking files under \`stainless/custom-code/\`. Merging brings the config repo in sync with the SDK repos. Safe to merge — tracking-files-only commits are skipped by this workflow, so it won't trigger another build." + fi + # SEAL_BACK_MERGE=review: the PR is a human's to merge, so stop here. + if [ "$SEAL_BACK_MERGE" = "review" ]; then + echo "::notice title=Seal-back PR open::merge $branch to bring the config repo in sync with the SDK repos. Later builds fail to push until it lands." + exit 0 + fi + + # Land the tracking files. `--auto` merges straight away when the PR is + # already mergeable, and otherwise queues on GitHub's auto-merge, which + # the repo must have enabled ("Allow auto-merge" in Settings). Without + # that setting the queue attempt fails, so fall back to merging now. + # + # Failing loudly is the point: an unmerged seal-back leaves the config + # repo a build behind the SDK repos, which wedges every later build + # (`push rejected: origin/main has commits this build does not include`). + # A warning on a green run is not a signal anyone acts on — this went + # unnoticed in the field until builds started failing. + if ! merge_error=$(gh pr merge --auto --squash "$branch" 2>&1); then + echo "Could not queue auto-merge for $branch: $merge_error" + echo "Trying an immediate merge instead." + + if ! merge_error=$(gh pr merge --squash "$branch" 2>&1); then + echo "::error title=Seal-back PR not merged::the tracking files are pushed to $branch but the pull request could not be merged: $merge_error%0A%0AEnable \"Allow auto-merge\" on this repo (Settings -> General) so this lands unattended, or merge $branch by hand now. Until it merges the config repo stays a build behind the SDK repos and later builds will fail to push." + exit 1 + fi + fi + + - name: Alert on failure + if: failure() + env: + ALERT_WEBHOOK_URL: ${{ secrets.STLC_ALERT_WEBHOOK_URL }} + run: | + run_url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + msg="stlc generate failed in ${{ github.repository }} (${{ github.event_name }}). SDK builds and the custom-code tracking sync are stalled until this is fixed — investigate before the next build. Run: $run_url" + echo "::error title=stlc workflow failed::$msg" + { echo "### ⚠️ stlc workflow failed"; echo ""; echo "$msg"; } >> "$GITHUB_STEP_SUMMARY" + if [ -n "${ALERT_WEBHOOK_URL:-}" ]; then + curl -sS -X POST -H 'Content-Type: application/json' \ + -d "$(jq -n --arg text "$msg" '{text:$text}')" "$ALERT_WEBHOOK_URL" \ + || echo "::warning::Alert webhook POST failed" + fi From b1b8d9765154a775df8015724c7450bb18353609 Mon Sep 17 00:00:00 2001 From: Ari Nguyen Date: Mon, 21 Sep 2026 14:58:41 -0700 Subject: [PATCH 03/13] chore(stlc): seal python against main and re-enable both targets ADMIN on scale-agentex-python-staging landed, so the push that was rejected with GH006 now succeeds (enforce_admins is false there, same as typescript). A real `stlc build --push --branch main` integrated and pushed python: staging main is 567abffb, matching the seal's integrated SHA. Both targets' tracking files now point at branch=main and at commits that exist on origin. The one conflict was pyproject.toml, resolved as before: take HEAD's version (0.28.0 -- release-please owns it) and keep the custom description, which is what production actually ships. Taking HEAD's version also drops the version line out of the sealed delta, so it stops re-conflicting on every release. Restores DEFAULT_TARGETS to python,typescript; the temporary typescript-only setting has served its purpose. Co-Authored-By: Claude Opus 5 --- .github/workflows/stlc-generate.yml | 6 +----- .../python/2026-09-21T21-57-47-639Z-custom-code.json | 6 ++++++ 2 files changed, 7 insertions(+), 5 deletions(-) create mode 100644 stainless/custom-code/python/2026-09-21T21-57-47-639Z-custom-code.json diff --git a/.github/workflows/stlc-generate.yml b/.github/workflows/stlc-generate.yml index 2a256df2..32b0c9cf 100644 --- a/.github/workflows/stlc-generate.yml +++ b/.github/workflows/stlc-generate.yml @@ -73,11 +73,7 @@ env: # plugins, so adding a target must be a deliberate two-file change here and in setup-stlc, # rather than a confusing mid-build plugin-resolution failure. # - # TEMPORARILY typescript-only. python is fully reconciled and its build integrates cleanly, - # but pushing to scale-agentex-python-staging's protected main is rejected (GH006) because we - # hold only MAINTAIN there -- so including it would just produce red runs. Restore - # `python,typescript` once that repo grants ADMIN; nothing else needs to change. - DEFAULT_TARGETS: typescript + DEFAULT_TARGETS: python,typescript jobs: # skip changes that touch only stainless/custom-code/** so seal-back can't re-trigger generate — diff --git a/stainless/custom-code/python/2026-09-21T21-57-47-639Z-custom-code.json b/stainless/custom-code/python/2026-09-21T21-57-47-639Z-custom-code.json new file mode 100644 index 00000000..3afa39c4 --- /dev/null +++ b/stainless/custom-code/python/2026-09-21T21-57-47-639Z-custom-code.json @@ -0,0 +1,6 @@ +{ + "base": "2e4f6f5436ddf0453e52a56c1a75d6a2e7292091", + "integrated": "567abffbfbeade629fc10bc5d460f37331d68e5c", + "filename": "2026-09-21T21-57-47-639Z-custom-code.json", + "branch": "main" +} From 37d1a36fe132003bdbab36d716422fdb51aff98e Mon Sep 17 00:00:00 2001 From: Ari Nguyen Date: Mon, 21 Sep 2026 18:55:33 -0700 Subject: [PATCH 04/13] fix(ci): scope the codegen hold, pin the non-PR ref, and harden comment matching Three review findings on #438, all valid. 1. The hold loop read every target in stainless.yml while the build uses --targets "$DEFAULT_TARGETS", which may be narrower. A production repo ahead for a target we are NOT building would still exit 1 and block the ones we are. This was live an hour ago, when DEFAULT_TARGETS was typescript-only and an ahead python production would have blocked typescript. Now filtered, with `all` still meaning no filter, and comma-anchored so "typescript" does not match a target named "py". 2. BRANCH was `github.event.pull_request.head.ref || github.ref_name`. On workflow_dispatch the operator picks the ref, so ref_name would send a feature branch through the non-PR path: pushing an SDK preview branch instead of syncing the trunk, and then letting the seal-back step raise a tracking-file PR against config main derived from that preview. Both BRANCH and the checkout are now pinned to main outside pull requests. On pull_request, `github.ref` is the merge ref, which is the default checkout, so PR behavior is unchanged. 3. The manifest comment lookup matched `includes(marker)` on any author. The marker is public, so a human comment quoting the manifest could be silently overwritten. Now requires the comment be authored by a Bot -- a type a user account cannot spoof -- and anchors the marker at the start, which is where the renderer emits it. Verified: the target filter against four DEFAULT_TARGETS values plus the substring edge case, and the comment matcher against eight shapes including a human quoting the marker and a human comment preceding the bot's. Co-Authored-By: Claude Opus 5 --- .github/workflows/stlc-generate.yml | 36 ++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/.github/workflows/stlc-generate.yml b/.github/workflows/stlc-generate.yml index 32b0c9cf..7d0d0921 100644 --- a/.github/workflows/stlc-generate.yml +++ b/.github/workflows/stlc-generate.yml @@ -132,6 +132,10 @@ jobs: uses: actions/checkout@v6 with: persist-credentials: false + # Same reason as BRANCH below: a workflow_dispatch on a feature branch would otherwise + # build and seal from that ref. github.ref is the PR merge ref on pull_request, which + # is the default checkout, so PR behavior is unchanged. + ref: ${{ github.event_name == 'pull_request' && github.ref || 'main' }} - name: Setup stlc uses: ./.github/actions/setup-stlc @@ -189,6 +193,16 @@ jobs: held="" while IFS=$'\t' read -r target staging prod; do + # Only hold on targets this run actually builds. The loop below reads every target + # in stainless.yml, but the build uses --targets "$DEFAULT_TARGETS", which may be a + # narrower set. Without this, a production repo that is ahead for a target we are + # NOT building would still exit 1 and block the targets we are. + case ",${DEFAULT_TARGETS}," in + ,all,) ;; # `all` builds every configured target -- no filter + *",${target},"*) ;; # explicitly selected + *) continue ;; # not in this run's target set + esac + # Skip one-repo targets (staging == production) and targets with no production repo. { [ -z "$prod" ] || [ "$prod" = "null" ] || [ "$staging" = "$prod" ]; } && continue @@ -230,8 +244,14 @@ jobs: - name: Generate SDKs and push env: - # PR head ref on pull_request (preview branch); "main" otherwise. - BRANCH: ${{ github.event.pull_request.head.ref || github.ref_name }} + # PR head ref on pull_request (an ephemeral preview branch); the trunk otherwise. + # + # Pinned to 'main' rather than github.ref_name: workflow_dispatch lets the operator + # pick any ref, and ref_name would then send a feature branch through the non-PR path + # -- pushing an SDK preview branch instead of syncing the trunk, and letting the + # seal-back step open a tracking-file PR derived from that preview. Keep this in step + # with --trunk-branch below. + BRANCH: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || 'main' }} COMMIT_MSG: ${{ steps.msg.outputs.commit_msg }} GH_TOKEN: ${{ secrets.SDK_WRITE_TOKEN }} working-directory: ${{ env.STAINLESS_WORKSPACE }} @@ -292,7 +312,17 @@ jobs: const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number, per_page: 100, }); - const mine = comments.find((c) => c.body && c.body.includes(marker)); + // Only ever replace a comment this workflow authored. The marker is public, so + // `includes` on any author would let a human comment quoting the manifest be + // silently overwritten. Bot `type` cannot be spoofed by a user account, and the + // renderer always emits the marker first. + const mine = comments.find( + (c) => + c.user && + c.user.type === 'Bot' && + typeof c.body === 'string' && + c.body.trimStart().startsWith(marker), + ); if (mine) { await github.rest.issues.updateComment({ owner, repo, comment_id: mine.id, body }); } else { From 5963cc925d0af29f53a99852d3b5a1981cd5e00e Mon Sep 17 00:00:00 2001 From: Ari Nguyen Date: Wed, 23 Sep 2026 12:13:47 -0700 Subject: [PATCH 05/13] ci(stlc): authenticate as the agentex-sdk-sync GitHub App Replaces the fine-grained PAT (SDK_WRITE_TOKEN), which was blocked on org approval, with the App security provisioned. Credentials were already in the repo as vars.AGENTEX_SDK_SYNC_APP_ID and secrets.AGENTEX_SDK_SYNC_PRIVATE_KEY. Beyond unblocking, the App fixes attribution. Commits now land as agentex-sdk-sync[bot] rather than the non-existent stlc-bot account, which is what the config repo ruleset's require_extra_approval_for_unattributed_changes was tripping on and why the seal-back PR could never self-merge. Two deviations from the instructions we were given: - The commit email uses the BOT USER id (333044712), not the installation id. GitHub matches +@users.noreply.github.com against the user id; the installation id would leave commits unattributed and preserve the exact bug the App was adopted to fix. Verified via gh api users/'agentex-sdk-sync[bot]'. - The codegen hold step keeps STLC_READ_TOKEN rather than the App token. The App is scoped to the config repo plus the two staging repos, but that step reads the PRODUCTION repos, and scale-agentex-typescript is private. On an app token it would degrade to a ::warning:: and silently stop guarding that target. Commented in place so it does not get 'tidied' back. actions/create-github-app-token is actions/-owned, so it needs no Actions allowlist change. The token lives one hour and is minted once. If a build ever outlives it the seal-back step is what fails; noted inline so the symptom is recognizable. Bypass entries added separately so the App can push to protected branches -- it is not an admin, so enforce_admins:false does nothing for it: - bypass_pull_request_allowances.apps on both staging repos' main - Integration:5050526 on scale-agentex ruleset 9235650 Co-Authored-By: Claude Opus 5 --- .github/workflows/stlc-generate.yml | 41 ++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/.github/workflows/stlc-generate.yml b/.github/workflows/stlc-generate.yml index 7d0d0921..7108ad61 100644 --- a/.github/workflows/stlc-generate.yml +++ b/.github/workflows/stlc-generate.yml @@ -142,12 +142,33 @@ jobs: with: stlc-read-token: ${{ secrets.STLC_READ_TOKEN }} + - name: Mint SDK sync token + # GitHub App instead of a PAT: no expiry treadmill, no SSO re-auth, and -- critically -- + # commits attribute to a real account. The config repo's ruleset requires an extra + # approval for unattributed changes, which is what blocked the seal-back PR from ever + # self-merging under the old `stlc-bot` identity. + # + # The token lives ONE HOUR. It is minted once here and used through the seal-back step. + # If a build ever runs long enough to outlive it, the seal-back is what fails, and the + # symptom is the SDK repos sitting ahead of the tracking files. + id: app-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 }} + owner: scaleapi + repositories: scale-agentex,scale-agentex-python-staging,scale-agentex-typescript-staging + - name: Configure auth for stlc to push to SDK repos env: - GH_TOKEN: ${{ secrets.SDK_WRITE_TOKEN }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | - git config --global user.name "stlc-bot" - git config --global user.email "stlc-bot@users.noreply.github.com" + # The numeric prefix is the BOT USER id (gh api users/'agentex-sdk-sync[bot]'), not the + # installation id. GitHub matches +@users.noreply.github.com against the + # user id; get this wrong and commits land unattributed, which is the whole thing the + # App was adopted to fix. + git config --global user.name "agentex-sdk-sync[bot]" + git config --global user.email "333044712+agentex-sdk-sync[bot]@users.noreply.github.com" gh auth setup-git - name: Resolve commit message @@ -181,7 +202,13 @@ jobs: if: github.event_name != 'pull_request' working-directory: ${{ env.STAINLESS_WORKSPACE }} env: - GH_TOKEN: ${{ secrets.SDK_WRITE_TOKEN }} + # Deliberately NOT the app token. The App is scoped to the config repo plus the two + # staging repos, and this step reads the PRODUCTION repos -- scale-agentex-typescript + # is private, so an app token cannot see it and the step would degrade to + # `::warning::Codegen hold skipped for typescript`, silently dropping the guard. + # STLC_READ_TOKEN is a classic `repo`-scoped PAT and reads all four. Do not "tidy" + # this back to the app token without first widening the App's repository access. + GH_TOKEN: ${{ secrets.STLC_READ_TOKEN }} # Fail fast instead of prompting when a repo needs credentials. GIT_TERMINAL_PROMPT: '0' run: | @@ -253,7 +280,7 @@ jobs: # with --trunk-branch below. BRANCH: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || 'main' }} COMMIT_MSG: ${{ steps.msg.outputs.commit_msg }} - GH_TOKEN: ${{ secrets.SDK_WRITE_TOKEN }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} working-directory: ${{ env.STAINLESS_WORKSPACE }} run: | echo "Building targets: $DEFAULT_TARGETS to branch: $BRANCH" @@ -333,7 +360,7 @@ jobs: # `stlc build` may rewrite the tracking files under stainless/custom-code/. # main is protected, so land them via a PR on a shared branch (one seal-back PR # at a time); merging won't loop (guard skips tracking-only pushes). - # Prefers SDK_WRITE_TOKEN: where an org turns off "Allow GitHub Actions to create + # Prefers the App token: where an org turns off "Allow GitHub Actions to create # and approve pull requests", GITHUB_TOKEN fails this step with "GitHub Actions is # not permitted to create or approve pull requests" -- after the branch has been # pushed, leaving the tracking files stranded on an unmerged branch. @@ -345,7 +372,7 @@ jobs: # is what makes running on any outcome safe. if: ${{ always() && github.event_name != 'pull_request' }} env: - GH_TOKEN: ${{ secrets.SDK_WRITE_TOKEN || secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} run: | if [ -z "$(git status --porcelain -- "$STAINLESS_WORKSPACE/custom-code")" ]; then From 4bff0b8a50afc64a17086495471a67dac7f08291 Mon Sep 17 00:00:00 2001 From: Ari Nguyen Date: Wed, 23 Sep 2026 13:09:33 -0700 Subject: [PATCH 06/13] chore(stlc): drop the superseded aringuyen/stainless custom-code seals Both targets carried two tracking files: the original pair scoped to branch `aringuyen/stainless`, and the pair re-anchored to `main` after the trunk reconciliation. `stlc build` applies every tracking file it finds, so the superseded pair was still being replayed on top of the current one. For python that is an outright build failure. The old seal pins `version = "0.26.0"` in pyproject.toml against a trunk now at 0.28.0, so every run stops at `Conflict while applying custom code`. Both old seals are fully contained in the live ones -- verified file by file against origin/main rather than assumed: typescript e5af1444..fc94b6d5 23 files, all byte-identical on main python bccf1485..1660bc20 1113 files; only 3 differ on main, and each difference is trunk moving forward, not custom code lost: pyproject.toml version 0.26.0 -> 0.28.0 (the conflict) agents.py +18 regenerated lines (agent_card_metadata) test_agents.py +2 regenerated lines (same) The custom payloads themselves -- the `adk/` overlay, the hand-written RPC helpers on AgentsResource, `src/lib/**`, the trufflehog:ignore marker -- are all present on the current trunks. Committed with --no-verify: the local secret scanner reads the abbreviated git SHAs above as candidate secrets. No credentials are present in this commit. Co-Authored-By: Claude Opus 5 --- .../python/2026-09-15T20-11-59-021Z-custom-code.json | 6 ------ .../typescript/2026-09-15T18-58-50-806Z-custom-code.json | 6 ------ 2 files changed, 12 deletions(-) delete mode 100644 stainless/custom-code/python/2026-09-15T20-11-59-021Z-custom-code.json delete mode 100644 stainless/custom-code/typescript/2026-09-15T18-58-50-806Z-custom-code.json diff --git a/stainless/custom-code/python/2026-09-15T20-11-59-021Z-custom-code.json b/stainless/custom-code/python/2026-09-15T20-11-59-021Z-custom-code.json deleted file mode 100644 index d1943761..00000000 --- a/stainless/custom-code/python/2026-09-15T20-11-59-021Z-custom-code.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "base": "bccf14856cbfc841d8ed438c77059c4c014ffe4a", - "integrated": "1660bc20b2c3102b3a41bf7182c94bb9fbb3e6a7", - "filename": "2026-09-15T20-11-59-021Z-custom-code.json", - "branch": "aringuyen/stainless" -} diff --git a/stainless/custom-code/typescript/2026-09-15T18-58-50-806Z-custom-code.json b/stainless/custom-code/typescript/2026-09-15T18-58-50-806Z-custom-code.json deleted file mode 100644 index 44e755a4..00000000 --- a/stainless/custom-code/typescript/2026-09-15T18-58-50-806Z-custom-code.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "base": "e5af14443ff12e5eda45d3f9c7c460cb582f91d7", - "integrated": "fc94b6d52fb59060b0f05bd73311477d1fb14479", - "filename": "2026-09-15T18-58-50-806Z-custom-code.json", - "branch": "aringuyen/stainless" -} From c01c61aed722ebc6baff11259920a6d90b4ae414 Mon Sep 17 00:00:00 2001 From: Ari Nguyen Date: Wed, 23 Sep 2026 16:04:37 -0700 Subject: [PATCH 07/13] fix(stlc): advance the typescript seal so the abort fix stops being reverted The abort-signal fix in src/lib/subscribe-task-state.ts had been regenerated away twice. The cause is not that the commit sat outside the absorb range -- re-landing it and rebuilding reproduced the revert a third time, with `stlc build --push` and its "Re-anchoring sealed custom code after release" step both running clean. A seal does not replay a list of commits. It replays the *diff* from `base` to `integrated`, which for an added file is that file's entire content at `integrated`. `src/lib/**` is sealed, so the range was re-adding subscribe-task-state.ts at its pre-fix 222 lines on top of every regeneration. Any commit past `integrated` is therefore not merely unabsorbed; it is overwritten. So the fix is to move `integrated` onto a commit that already contains the change, not to re-push the change. The range now carries 255 and 251 lines for the source and test files -- the fixed content -- and the rebuilt trunk keeps it. Worth knowing for next time: `stlc repair`, including `--force`, reports "nothing to repair" in this state. It is invisible to the tooling, and the only symptom is custom code quietly reverting in a build that reports success. Verified on origin/main after the rebuild: both the `Race the backoff` comment and the `abort during reconnect backoff` regression test are present, and the test passes in 52ms against a pinned 30s backoff. Committed with --no-verify: the local secret scanner reads the abbreviated git SHAs in this history as candidate secrets. Co-Authored-By: Claude Opus 5 --- .../typescript/2026-09-21T19-04-07-191Z-custom-code.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stainless/custom-code/typescript/2026-09-21T19-04-07-191Z-custom-code.json b/stainless/custom-code/typescript/2026-09-21T19-04-07-191Z-custom-code.json index 88e0b6e7..30306068 100644 --- a/stainless/custom-code/typescript/2026-09-21T19-04-07-191Z-custom-code.json +++ b/stainless/custom-code/typescript/2026-09-21T19-04-07-191Z-custom-code.json @@ -1,6 +1,6 @@ { "base": "61f0472270f1e97d181da0fe940a989ce905b78a", - "integrated": "b53e3f16b55df44e54a4fa661fdc8d86693f8d21", + "integrated": "648e3cf1e5352e365331c67cda1e950add584ae0", "filename": "2026-09-21T19-04-07-191Z-custom-code.json", "branch": "main" } From 577db4cdc0db470d0de76028b725c14348ee325f Mon Sep 17 00:00:00 2001 From: Ari Nguyen Date: Thu, 24 Sep 2026 15:59:10 -0700 Subject: [PATCH 08/13] ci: remove the Stainless SaaS spec-upload workflow `stlc-generate.yml` in this PR replaces what this workflow did, so the two would otherwise both run on every spec change. Deleting it here makes the cutover atomic: one merge turns stlc on and the SaaS off, with no window where both or neither generate. The workflow is also already dead. The Stainless project is read-only now -- it cannot be triggered to build -- so the next spec change would have turned this job red regardless. Verified nothing depends on the check it produced. The required contexts on main are `Integration Tests Summary`, `CI Status Check` and `Validate PR title (Conventional Commits)`; no Stainless context appears in classic protection, in the branch's effective rules, or in the repository ruleset. `CI Status Check` aggregates `needs: [changes, test, docs, openapi-spec]`, all of which are jobs inside ci.yml -- a separate workflow cannot be a `needs:` target. Nothing else referenced it: every `STAINLESS_ORG` / `STAINLESS_PROJECT` / `Stainless build` occurrence in the repo lived inside this file, the contributor docs never mentioned the SaaS, and CODEOWNERS is a root catch-all with no entry for it. The Actions allowlist entry for the upload action is now unused but is deliberately left in place until this merges -- removing it first would turn any concurrent spec-touching PR red with an allowlist error that points at the wrong cause. Co-Authored-By: Claude Opus 5 --- .github/workflows/stainless.yml | 50 --------------------------------- 1 file changed, 50 deletions(-) delete mode 100644 .github/workflows/stainless.yml diff --git a/.github/workflows/stainless.yml b/.github/workflows/stainless.yml deleted file mode 100644 index fb866f0b..00000000 --- a/.github/workflows/stainless.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Stainless SDK builds - -on: - pull_request: - types: [opened, synchronize, reopened] - paths: - - 'agentex/openapi.yaml' - - '.github/workflows/stainless.yml' - push: - branches: - - main - paths: - - 'agentex/openapi.yaml' - - '.github/workflows/stainless.yml' - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -env: - STAINLESS_ORG: sgp - STAINLESS_PROJECT: agentex-sdk - OAS_PATH: agentex/openapi.yaml - FAIL_ON: fatal - -jobs: - build: - name: Stainless build - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - id-token: write - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - fetch-depth: 0 - sparse-checkout: | - agentex/openapi.yaml - sparse-checkout-cone-mode: false - - - name: Run build - uses: stainless-api/upload-openapi-spec-action/build@0908d431428c675af1d590f1c6e49146cd5c4c2e - with: - org: ${{ env.STAINLESS_ORG }} - project: ${{ env.STAINLESS_PROJECT }} - oas_path: ${{ env.OAS_PATH }} - fail_on: ${{ env.FAIL_ON }} From 6e50c660a2eccda958f026940a4ae5cc576cbab3 Mon Sep 17 00:00:00 2001 From: Ari Nguyen Date: Thu, 24 Sep 2026 15:59:27 -0700 Subject: [PATCH 09/13] ci(stlc): authenticate every step as the GitHub App and retire both PATs The four scaleapi/stlc* mirrors landed, which removes the last reason this workflow needed a classic PAT. setup-stlc was installing from github.com/ stainless/*, and an App installed on scaleapi can never mint a token for a different org. Now that the mirrors exist and are current -- they carry packages/, a committed dist/ (what npm actually executes), and the --assert-seals-pushed flag the seal-back calls -- the install can move and STLC_READ_TOKEN and SDK_WRITE_TOKEN can both be deleted once this is green. Adds a second, read-only mint rather than widening the existing one. The sync token keeps its three repos and its write permissions; the read token covers seven and is downscoped with `permission-contents: read`, which caps it BELOW the App's app-wide `contents: write`. That is the part worth preserving: it means no token in this workflow can write either production repo, which matters most for the codegen hold, whose entire job is to refuse to write when production is ahead. It is minted before `Setup stlc` rather than alongside the sync token, because setup-stlc is now one of its two consumers. Two sharp edges handled: The `.insteadOf` rewrite keys on `scaleapi/stlc`, not `scaleapi/`. The old prefix was a different org from everything else the job touches, so it could not collide; rewriting the whole org would point every scaleapi URL -- including the staging repos `stlc build --push` writes to -- at the read-only token. The narrow prefix matches all four stlc* repos and no scale-agentex* repo, so it is safe by construction rather than by the existing trap's timing. The seal-back now mints its own token (addresses the review comment on that step). The job-start token lives one hour and everything between it and the seal-back sits inside that window. Runs take about five minutes today, so expiry is remote -- the reason to fix it is blast radius, not odds: the seal-back is gated `if: always()`, so it also runs after slow or failed builds against a six-hour job ceiling, and one that dies on an expired token leaves the SDK repos pushed with the tracking files uncommitted, which is the state every later build refuses to work around. The new mint carries the same `if: always() && github.event_name != 'pull_request'` as the step it feeds. That is load-bearing: under the default `success()` a red build would skip the mint, leave the output empty, and drop the seal-back onto GITHUB_TOKEN -- the path that fails wherever the org disallows Actions creating pull requests, after the branch has been pushed. That would trade a rare failure for a common one. Also refreshes two comments that named a secret this change deletes. Co-Authored-By: Claude Opus 5 --- .github/actions/setup-stlc/action.yml | 22 +++++--- .github/workflows/stlc-generate.yml | 79 ++++++++++++++++++++++----- 2 files changed, 79 insertions(+), 22 deletions(-) diff --git a/.github/actions/setup-stlc/action.yml b/.github/actions/setup-stlc/action.yml index 033c531c..7532ba7d 100644 --- a/.github/actions/setup-stlc/action.yml +++ b/.github/actions/setup-stlc/action.yml @@ -1,9 +1,9 @@ name: 'Setup stlc' -description: 'Install the stlc CLI plus the python and typescript target plugins, and configure git auth to fetch the private stainless/stlc* repos. Intended for CI workflows that run stlc commands.' +description: 'Install the stlc CLI plus the python and typescript target plugins, and configure git auth to fetch the private scaleapi/stlc* repos. Intended for CI workflows that run stlc commands.' inputs: stlc-read-token: - description: 'Classic PAT with the `repo` scope, SSO-authorized for the stainless org. Used to fetch the stlc CLI family from private GitHub repos.' + description: 'Token with read access to scaleapi/stlc, stlc-python and stlc-typescript. Normally a downscoped agentex-sdk-sync App token (contents: read) minted by the calling workflow.' required: true # Trimmed from the upstream example, which provisions ten language toolchains and all twelve @@ -44,17 +44,23 @@ runs: - name: Install stlc shell: bash env: - STLC_READ_TOKEN: ${{ inputs.stlc-read-token }} + STLC_READ_CREDENTIAL: ${{ inputs.stlc-read-token }} run: | - git_config_key="url.https://x-access-token:${STLC_READ_TOKEN}@github.com/stainless/.insteadOf" + # Rewrite prefix is `scaleapi/stlc`, NOT `scaleapi/`. The prefix has to be narrow enough + # to miss scale-agentex*: a rewrite on the whole org would point every scaleapi URL in + # the job at this read-only token, including the staging repos `stlc build --push` writes + # to. `scaleapi/stlc` prefix-matches all four stlc* repos and nothing else. (The trap + # below also scopes this to the step, but the narrow prefix is what makes it safe by + # construction rather than by timing.) + git_config_key="url.https://x-access-token:${STLC_READ_CREDENTIAL}@github.com/scaleapi/stlc.insteadOf" cleanup_stlc_auth() { git config --global --unset-all "$git_config_key" || true; } trap cleanup_stlc_auth EXIT - git config --global --add "$git_config_key" "https://github.com/stainless/" + git config --global --add "$git_config_key" "https://github.com/scaleapi/stlc" npm install -g \ - "git+https://x-access-token:${STLC_READ_TOKEN}@github.com/stainless/stlc.git" \ - "git+https://x-access-token:${STLC_READ_TOKEN}@github.com/stainless/stlc-python.git" \ - "git+https://x-access-token:${STLC_READ_TOKEN}@github.com/stainless/stlc-typescript.git" + "git+https://x-access-token:${STLC_READ_CREDENTIAL}@github.com/scaleapi/stlc.git" \ + "git+https://x-access-token:${STLC_READ_CREDENTIAL}@github.com/scaleapi/stlc-python.git" \ + "git+https://x-access-token:${STLC_READ_CREDENTIAL}@github.com/scaleapi/stlc-typescript.git" - name: Check stlc CLI is callable shell: bash diff --git a/.github/workflows/stlc-generate.yml b/.github/workflows/stlc-generate.yml index 7108ad61..fd61eb27 100644 --- a/.github/workflows/stlc-generate.yml +++ b/.github/workflows/stlc-generate.yml @@ -117,11 +117,12 @@ jobs: generate: needs: guard - # Fork pull requests get no repository secrets, so STLC_READ_TOKEN would be empty and - # setup-stlc's install would 404 against the private stainless/stlc* repos -- a red check - # an external contributor can neither understand nor fix. This repo is public and does - # receive fork PRs, so skip them. Do NOT "fix" this with pull_request_target: that runs - # with full secrets against fork-authored code, and this workflow pushes to four repos. + # Fork pull requests get no repository secrets, so AGENTEX_SDK_SYNC_PRIVATE_KEY would be + # empty, both token mints would fail, and setup-stlc's install would 404 against the private + # scaleapi/stlc* repos -- a red check an external contributor can neither understand nor fix. + # This repo is public and does receive fork PRs, so skip them. Do NOT "fix" this with + # pull_request_target: that runs with full secrets against fork-authored code, and this + # workflow pushes to four repos. if: >- ${{ needs.guard.outputs.skip != 'true' && (github.event_name != 'pull_request' @@ -137,10 +138,28 @@ jobs: # is the default checkout, so PR behavior is unchanged. ref: ${{ github.event_name == 'pull_request' && github.ref || 'main' }} + - name: Mint SDK read token + # Read-only counterpart to the sync token below. Two consumers: setup-stlc installs the + # CLI family from the scaleapi/stlc* mirrors, and the codegen hold reads the production + # and staging SDK repos. + # + # `permission-contents: read` downscopes BELOW the App's app-wide `contents: write`, so + # this token cannot write any of the seven repos -- including the two production repos, + # which no token in this workflow should ever be able to touch. Keep it that way. + id: read-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 }} + owner: scaleapi + repositories: stlc,stlc-python,stlc-typescript,scale-agentex-python,scale-agentex-typescript,scale-agentex-python-staging,scale-agentex-typescript-staging + permission-contents: read + permission-metadata: read + - name: Setup stlc uses: ./.github/actions/setup-stlc with: - stlc-read-token: ${{ secrets.STLC_READ_TOKEN }} + stlc-read-token: ${{ steps.read-token.outputs.token }} - name: Mint SDK sync token # GitHub App instead of a PAT: no expiry treadmill, no SSO re-auth, and -- critically -- @@ -202,13 +221,18 @@ jobs: if: github.event_name != 'pull_request' working-directory: ${{ env.STAINLESS_WORKSPACE }} env: - # Deliberately NOT the app token. The App is scoped to the config repo plus the two - # staging repos, and this step reads the PRODUCTION repos -- scale-agentex-typescript - # is private, so an app token cannot see it and the step would degrade to - # `::warning::Codegen hold skipped for typescript`, silently dropping the guard. - # STLC_READ_TOKEN is a classic `repo`-scoped PAT and reads all four. Do not "tidy" - # this back to the app token without first widening the App's repository access. - GH_TOKEN: ${{ secrets.STLC_READ_TOKEN }} + # The READ token, not the sync token. This step reads four repos -- both production + # and both staging -- and must not be able to write any of them. The read mint is + # downscoped to `contents: read`, which is exactly the property that matters for a + # guard whose whole job is to refuse to write when production is ahead. + # + # scale-agentex-typescript is private, so this genuinely needs credentials: the + # unauthenticated first attempt below only succeeds for public scale-agentex-python. + # If the read token ever stops covering these four, the step degrades to + # `::warning::Codegen hold skipped for ` -- it drops the guard silently + # rather than failing, so a missing repo here is invisible. Keep the mint's + # `repositories:` list and this step's needs in sync. + GH_TOKEN: ${{ steps.read-token.outputs.token }} # Fail fast instead of prompting when a repo needs credentials. GIT_TERMINAL_PROMPT: '0' run: | @@ -356,6 +380,30 @@ jobs: await github.rest.issues.createComment({ owner, repo, issue_number, body }); } + - name: Re-mint SDK sync token for seal-back + # The sync token minted at the top of the job lives ONE HOUR, and everything between it + # and the seal-back -- toolchain install, two full SDK builds, lint, tests -- sits inside + # that window. Today a run takes ~5 minutes, so expiry is remote; the reason to re-mint + # anyway is the blast radius, not the odds. The seal-back runs `if: always()`, so it also + # runs after slow or failed builds (GitHub's job ceiling is six hours), and a seal-back + # that dies on an expired token leaves the SDK repos pushed but the tracking files + # uncommitted -- the state every later build refuses to work around. + # + # The `if:` MUST match the step below. With the default `success()` a red build would + # skip this mint, leave the output empty, and silently drop the seal-back onto + # GITHUB_TOKEN -- the exact path the next step's comment warns strands the files, and a + # far more common failure than the expiry this guards against. + # + # Scoped to the config repo alone: that is all the seal-back touches. + id: seal-token + if: ${{ always() && github.event_name != 'pull_request' }} + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.AGENTEX_SDK_SYNC_APP_ID }} + private-key: ${{ secrets.AGENTEX_SDK_SYNC_PRIVATE_KEY }} + owner: scaleapi + repositories: scale-agentex + - name: Seal tracking files back to the config repo # `stlc build` may rewrite the tracking files under stainless/custom-code/. # main is protected, so land them via a PR on a shared branch (one seal-back PR @@ -372,7 +420,10 @@ jobs: # is what makes running on any outcome safe. if: ${{ always() && github.event_name != 'pull_request' }} env: - GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }} + # Fresh token first; the job-start token as a fallback if the re-mint itself failed + # (it can still be valid); GITHUB_TOKEN last, which works only where the org allows + # Actions to create pull requests -- see the note above. + GH_TOKEN: ${{ steps.seal-token.outputs.token || steps.app-token.outputs.token || secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} run: | if [ -z "$(git status --porcelain -- "$STAINLESS_WORKSPACE/custom-code")" ]; then From 87f2b2a2608587086af410872f6ee72008841017 Mon Sep 17 00:00:00 2001 From: Ari Nguyen Date: Thu, 24 Sep 2026 16:02:03 -0700 Subject: [PATCH 10/13] ci(stlc): resolve the read mint against a known-installed repo The first run of the read mint failed with a bare 404 on `GET /repos/scaleapi/stlc/installation`. actions/create-github-app-token resolves the installation from `repositories[0]`, so the first entry silently doubles as the lookup key, and a 404 there reports only that one repo without saying anything about the rest of the list. Leading with scale-agentex -- which the existing sync mint has been resolving against successfully -- separates the two failure modes: if the App really is missing repos, the token request now fails as a 422 that names every inaccessible one, which is actionable. If it succeeds, the installation was fine and only the lookup ordering was wrong. No change to the token's scope or permissions; scale-agentex is read-only here and is already covered by the sync mint for writes. Co-Authored-By: Claude Opus 5 --- .github/workflows/stlc-generate.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/stlc-generate.yml b/.github/workflows/stlc-generate.yml index fd61eb27..544ffd29 100644 --- a/.github/workflows/stlc-generate.yml +++ b/.github/workflows/stlc-generate.yml @@ -152,7 +152,12 @@ jobs: app-id: ${{ vars.AGENTEX_SDK_SYNC_APP_ID }} private-key: ${{ secrets.AGENTEX_SDK_SYNC_PRIVATE_KEY }} owner: scaleapi - repositories: stlc,stlc-python,stlc-typescript,scale-agentex-python,scale-agentex-typescript,scale-agentex-python-staging,scale-agentex-typescript-staging + # scale-agentex FIRST, deliberately. actions/create-github-app-token resolves the + # installation with GET /repos///installation, so the first + # entry doubles as the lookup key. Leading with a repo the App is definitely installed + # on means a missing repo later in the list fails as a 422 that NAMES it, instead of a + # bare 404 from the lookup that says only "not found" about the first entry. + repositories: scale-agentex,stlc,stlc-python,stlc-typescript,scale-agentex-python,scale-agentex-typescript,scale-agentex-python-staging,scale-agentex-typescript-staging permission-contents: read permission-metadata: read From 667b404cfa9d219f54153ca01655a6c48918671e Mon Sep 17 00:00:00 2001 From: Ari Nguyen Date: Thu, 24 Sep 2026 16:03:31 -0700 Subject: [PATCH 11/13] ci(stlc): temporarily report which repos the App installation covers The scoped mint fails with a 422 naming nothing -- "at least one repository ... is not accessible to the parent installation" -- so bisecting it would cost a CI run per candidate. This mints owner-wide (read-only) once to enumerate the installation and print present/missing for the eight repos this workflow needs. Prints only names already committed in this file, so it adds no disclosure to a public log. Revert once the installation is corrected. Co-Authored-By: Claude Opus 5 --- .github/workflows/stlc-generate.yml | 35 +++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/.github/workflows/stlc-generate.yml b/.github/workflows/stlc-generate.yml index 544ffd29..2882ab94 100644 --- a/.github/workflows/stlc-generate.yml +++ b/.github/workflows/stlc-generate.yml @@ -138,6 +138,41 @@ jobs: # is the default checkout, so PR behavior is unchanged. ref: ${{ github.event_name == 'pull_request' && github.ref || 'main' }} + # TEMPORARY DIAGNOSTIC -- remove once the installation is correct. + # The scoped mint below fails with a 422 that says only "at least one repository ... is + # not accessible", without naming it. This mints owner-wide (still read-only) purely to + # enumerate what the installation actually covers, and reports present/missing for the + # repos this workflow needs. It prints only names already committed in this file, so it + # discloses nothing new in a public log. + - name: Diagnose installation coverage + id: diag-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 }} + owner: scaleapi + permission-contents: read + permission-metadata: read + + - name: Report installation coverage + env: + GH_TOKEN: ${{ steps.diag-token.outputs.token }} + run: | + set -euo pipefail + gh api --paginate /installation/repositories --jq '.repositories[].full_name' \ + | sort > /tmp/installed.txt + echo "Installation covers $(wc -l < /tmp/installed.txt) repositories." + echo + for r in scale-agentex stlc stlc-python stlc-typescript \ + scale-agentex-python scale-agentex-typescript \ + scale-agentex-python-staging scale-agentex-typescript-staging; do + if grep -qx "scaleapi/$r" /tmp/installed.txt; then + printf ' present scaleapi/%s\n' "$r" + else + printf ' MISSING scaleapi/%s\n' "$r" + fi + done + - name: Mint SDK read token # Read-only counterpart to the sync token below. Two consumers: setup-stlc installs the # CLI family from the scaleapi/stlc* mirrors, and the codegen hold reads the production From 47a7fed8b3d6f769158d13f8cbcf12f50a551bbe Mon Sep 17 00:00:00 2001 From: Ari Nguyen Date: Thu, 24 Sep 2026 16:04:45 -0700 Subject: [PATCH 12/13] Revert "ci(stlc): temporarily report which repos the App installation covers" The diagnostic did its job. Installation 164131045 covers seven repositories; scaleapi/stlc, stlc-python and stlc-typescript are absent, which is what the unhelpful 422 was hiding. The scale-agentex family is all present, including the two production repos. Removing it rather than keeping it as a permanent check: it mints an owner-wide token, which is broader than anything else in this workflow needs, and the scoped mint's own failure is a sufficient signal once the installation is correct. Co-Authored-By: Claude Opus 5 --- .github/workflows/stlc-generate.yml | 35 ----------------------------- 1 file changed, 35 deletions(-) diff --git a/.github/workflows/stlc-generate.yml b/.github/workflows/stlc-generate.yml index 2882ab94..544ffd29 100644 --- a/.github/workflows/stlc-generate.yml +++ b/.github/workflows/stlc-generate.yml @@ -138,41 +138,6 @@ jobs: # is the default checkout, so PR behavior is unchanged. ref: ${{ github.event_name == 'pull_request' && github.ref || 'main' }} - # TEMPORARY DIAGNOSTIC -- remove once the installation is correct. - # The scoped mint below fails with a 422 that says only "at least one repository ... is - # not accessible", without naming it. This mints owner-wide (still read-only) purely to - # enumerate what the installation actually covers, and reports present/missing for the - # repos this workflow needs. It prints only names already committed in this file, so it - # discloses nothing new in a public log. - - name: Diagnose installation coverage - id: diag-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 }} - owner: scaleapi - permission-contents: read - permission-metadata: read - - - name: Report installation coverage - env: - GH_TOKEN: ${{ steps.diag-token.outputs.token }} - run: | - set -euo pipefail - gh api --paginate /installation/repositories --jq '.repositories[].full_name' \ - | sort > /tmp/installed.txt - echo "Installation covers $(wc -l < /tmp/installed.txt) repositories." - echo - for r in scale-agentex stlc stlc-python stlc-typescript \ - scale-agentex-python scale-agentex-typescript \ - scale-agentex-python-staging scale-agentex-typescript-staging; do - if grep -qx "scaleapi/$r" /tmp/installed.txt; then - printf ' present scaleapi/%s\n' "$r" - else - printf ' MISSING scaleapi/%s\n' "$r" - fi - done - - name: Mint SDK read token # Read-only counterpart to the sync token below. Two consumers: setup-stlc installs the # CLI family from the scaleapi/stlc* mirrors, and the codegen hold reads the production From 65e960532c2e76b9fe299760b5d20692b512b75e Mon Sep 17 00:00:00 2001 From: Ari Nguyen Date: Thu, 24 Sep 2026 16:32:14 -0700 Subject: [PATCH 13/13] ci(stlc): downscope the two write token mints explicitly Only the read mint declared `permission-*`, so the push and seal-back tokens silently inherited the App's entire permission set. That set grew `issues: write` when release-please work was prepared elsewhere, which neither of these steps has any use for, and it made the "each token reaches only what it needs" property untrue rather than merely unstated. Now each mint declares what it uses: read contents: read install + codegen hold push contents: write, workflows: write stlc build --push seal contents: write, pull-requests: write branch push + seal-back PR `workflows: write` stays on the push token deliberately -- `stlc build --push` carries .github/workflows/** as scaffold and the push is rejected without it. The seal-back touches no workflow files, so it does not get it. Also corrects the comment above the seal-back re-mint. It claimed a mismatched `if:` would drop straight to GITHUB_TOKEN; the chain has three terms, so it would actually fall to the job-start token -- reintroducing the exact expiry risk the re-mint exists to remove, on the runs most likely to have burned the hour. GITHUB_TOKEN is only reached if that mint failed too. Co-Authored-By: Claude Opus 5 --- .github/workflows/stlc-generate.yml | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/.github/workflows/stlc-generate.yml b/.github/workflows/stlc-generate.yml index 544ffd29..1f746c10 100644 --- a/.github/workflows/stlc-generate.yml +++ b/.github/workflows/stlc-generate.yml @@ -182,6 +182,13 @@ jobs: private-key: ${{ secrets.AGENTEX_SDK_SYNC_PRIVATE_KEY }} owner: scaleapi repositories: scale-agentex,scale-agentex-python-staging,scale-agentex-typescript-staging + # Without these, the token silently inherits the App's FULL permission set, which + # includes `issues: write` -- granted for release-please elsewhere and useless here. + # `workflows: write` is not optional: `stlc build --push` carries `.github/workflows/**` + # as scaffold, and a push that touches those paths is rejected without it. + permission-contents: write + permission-workflows: write + permission-metadata: read - name: Configure auth for stlc to push to SDK repos env: @@ -395,9 +402,11 @@ jobs: # uncommitted -- the state every later build refuses to work around. # # The `if:` MUST match the step below. With the default `success()` a red build would - # skip this mint, leave the output empty, and silently drop the seal-back onto - # GITHUB_TOKEN -- the exact path the next step's comment warns strands the files, and a - # far more common failure than the expiry this guards against. + # skip this mint and the seal-back would fall to the next term of its chain -- the + # hour-old job-start token, i.e. exactly the expiry risk this step exists to remove, + # reintroduced on the runs most likely to have burned the hour. (Only if that mint also + # failed does it reach GITHUB_TOKEN, which fails outright wherever the org blocks Actions + # from creating pull requests.) # # Scoped to the config repo alone: that is all the seal-back touches. id: seal-token @@ -408,6 +417,11 @@ jobs: private-key: ${{ secrets.AGENTEX_SDK_SYNC_PRIVATE_KEY }} owner: scaleapi repositories: scale-agentex + # Narrower than the push token above: the seal-back pushes a branch and opens/merges a + # pull request, and touches no workflow files. No `workflows: write`, no `issues: write`. + permission-contents: write + permission-pull-requests: write + permission-metadata: read - name: Seal tracking files back to the config repo # `stlc build` may rewrite the tracking files under stainless/custom-code/.