From ec6d826655572544cc6457019f0a9676f690e530 Mon Sep 17 00:00:00 2001 From: ladyofcode Date: Mon, 7 Sep 2026 22:26:28 +0100 Subject: [PATCH 1/2] ci(stlc): stop the seal step opening no-op PRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `stlc build` writes a fresh empty `seal` commit into each staging SDK repo on every run. Its SHA lands in `stainless/custom-code/*.json` as `base` (and sometimes `integrated`), so the tracking files came back dirty on every run even though the generated code was byte-identical — every seal commit since 2026-08-24 has `files=0` and the same tree (`e47029ff80` for python, `d7d81c1d43` for typescript). With auto-merge disabled on this repo, `gh pr merge --auto` fails and only warns, so PR #6 sat open from 2026-09-04 and the 6-hourly cron force-pushed a new head onto it four times a day, mailing every repo watcher each time. Resolve each changed SHA to the tree it points at and skip the seal when no tree actually moved. Anything unresolvable — a new or deleted file, a change to a non-SHA field, a commit the API cannot return — is treated as a real change, so the guard can only ever suppress verified churn. Also cut the cron from every 6h to daily; it is only a backstop for a missed `repository_dispatch: seal-custom-code`, which already fires eagerly. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/stlc-generate.yml | 75 ++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/.github/workflows/stlc-generate.yml b/.github/workflows/stlc-generate.yml index 35ef668..7bac312 100644 --- a/.github/workflows/stlc-generate.yml +++ b/.github/workflows/stlc-generate.yml @@ -4,6 +4,11 @@ name: Generate SDKs with stlc # - 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). +# +# The schedule is only a backstop: `repository_dispatch: seal-custom-code` already +# fires as soon as an SDK repo seals custom code, so the cron exists purely to catch +# a missed dispatch. It ran every 6h until it was found to be the sole source of a +# no-op seal PR per run — see the churn guard in "Seal tracking files" below. on: pull_request: types: [opened, synchronize, reopened] @@ -18,7 +23,7 @@ on: - '.github/workflows/stlc-generate.yml' - '.github/actions/setup-stlc/**' schedule: - - cron: '0 */6 * * *' + - cron: '0 6 * * *' workflow_dispatch: {} repository_dispatch: types: [seal-custom-code] @@ -220,6 +225,74 @@ jobs: echo "Tracking files already in sync — nothing to seal." exit 0 fi + + # `stlc build` writes a fresh empty `seal` commit into each SDK repo on every + # run, so `base`/`integrated` get a new SHA even when the generated code is + # byte-identical. Sealing that opens a no-op PR per run (and force-pushes the + # open one, mailing every watcher). Resolve each SHA to the tree it points at + # and skip when no tree actually moved. Anything we cannot resolve is treated + # as a real change, so the guard can only ever suppress verified churn. + declare -A repo_of + while IFS=$'\t' read -r target repo; do + repo_of["$target"]="$repo" + done < <(yq -r '.targets | to_entries[] | [.key, (.value.staging_repo // .value.production_repo)] | @tsv' "$STAINLESS_WORKSPACE/openapi.stainless.yml") + + tree_of() { gh api "repos/$1/commits/$2" --jq '.commit.tree.sha' 2>/dev/null; } + + substantive=0 + while IFS= read -r line; do + state="${line:0:2}"; path="${line:3}" + + case "$state" in + " M" | "M " | "MM") ;; + *) echo "$path: $state is not a plain modification — real change."; substantive=1; break ;; + esac + + target="$(basename "$(dirname "$path")")" + repo="${repo_of[$target]:-}" + if [ -z "$repo" ]; then + echo "$path: no repo configured for target '$target' — real change." + substantive=1; break + fi + + old_json="$(git show "HEAD:$path")" || { substantive=1; break; } + new_json="$(cat "$path")" + + old_rest="$(printf '%s' "$old_json" | jq -S 'del(.base, .integrated)')" || old_rest="" + new_rest="$(printf '%s' "$new_json" | jq -S 'del(.base, .integrated)')" || new_rest="" + if [ -z "$old_rest" ] || [ "$old_rest" != "$new_rest" ]; then + echo "$path: fields other than base/integrated changed — real change." + substantive=1; break + fi + + for field in base integrated; do + o="$(printf '%s' "$old_json" | jq -r --arg f "$field" '.[$f] // ""')" || o="" + n="$(printf '%s' "$new_json" | jq -r --arg f "$field" '.[$f] // ""')" || n="" + [ "$o" = "$n" ] && continue + if [ -z "$o" ] || [ -z "$n" ]; then + echo "$path: $field missing on one side — real change." + substantive=1; break 2 + fi + ot="$(tree_of "$repo" "$o")" || ot="" + nt="$(tree_of "$repo" "$n")" || nt="" + if [ -z "$ot" ] || [ -z "$nt" ]; then + echo "$path: cannot resolve $field ($o -> $n) in $repo — real change." + substantive=1; break 2 + fi + if [ "$ot" != "$nt" ]; then + echo "$path: $field tree moved ($ot -> $nt) — real change." + substantive=1; break 2 + fi + echo "$path: $field $o -> $n, but tree $ot is unchanged — SHA churn." + done + done < <(git status --porcelain -- "$STAINLESS_WORKSPACE/custom-code") + + if [ "$substantive" -eq 0 ]; then + echo "Every tracking-file change is empty-seal SHA churn — discarding, no PR." + git checkout HEAD -- "$STAINLESS_WORKSPACE/custom-code" + exit 0 + fi + branch="stlc/seal-tracking" git checkout -B "$branch" git add "$STAINLESS_WORKSPACE/custom-code" From 862f4a02c3def30823d55f37b1d824649ae5de74 Mon Sep 17 00:00:00 2001 From: ladyofcode Date: Mon, 7 Sep 2026 22:29:39 +0100 Subject: [PATCH 2/2] ci(stlc): don't auto-merge seal PRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the churn guard in place a seal PR only appears when custom code genuinely moved, so it warrants review rather than an automatic squash. Drop the `gh pr merge --auto` attempt (which never worked here anyway — `allow_auto_merge` is false on this repo, so it only ever emitted a warning) and surface the waiting PR by number instead. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/stlc-generate.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/stlc-generate.yml b/.github/workflows/stlc-generate.yml index 7bac312..eb95d98 100644 --- a/.github/workflows/stlc-generate.yml +++ b/.github/workflows/stlc-generate.yml @@ -304,9 +304,11 @@ jobs: --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 - if ! gh pr merge --auto --squash "$branch" 2>/dev/null; then - echo "::warning::Could not enable auto-merge for $branch. Merge it promptly: stale tracking files block later builds until they're synced." - fi + # Deliberately not auto-merged. With the churn guard above, a seal PR only + # appears when custom code genuinely moved, which is worth a human look. + # Surface it loudly instead: stale tracking files block later builds. + pr_num="${open_pr:-$(gh pr list --head "$branch" --state open --json number --jq '.[0].number // empty')}" + echo "::warning title=Seal PR awaiting review::Custom code genuinely changed — review and merge ${REPO}#${pr_num:-?}. Stale tracking files block later builds until they are synced." - name: Alert on failure if: failure()