From 4b3117ed9fd9bf6346d0bde7a1091c5166cf3384 Mon Sep 17 00:00:00 2001 From: Lan Tian Date: Tue, 28 Jul 2026 15:40:55 +0800 Subject: [PATCH 1/3] fix(spec-sync): update PR body via REST so the LLM summary lands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gh pr edit` eagerly loads org-level PR metadata (review-request team `slug`, assignee `login`/`name`), which fails with a `read:org` GraphQL scope error under SPEC_SYNC_TOKEN — a classic PAT scoped to repo+workflow. Because the step is continue-on-error, the failure was swallowed and every spec-sync PR kept its static body with no "## What changed" section. Read and update the body through the REST pulls endpoint instead; it needs only `repo`. Co-Authored-By: Claude Opus 4.8 --- scripts/spec-sync/summarize-pr.sh | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/scripts/spec-sync/summarize-pr.sh b/scripts/spec-sync/summarize-pr.sh index 8376966..cfa0d6e 100755 --- a/scripts/spec-sync/summarize-pr.sh +++ b/scripts/spec-sync/summarize-pr.sh @@ -52,8 +52,19 @@ if [ -z "$summary" ]; then fi # Read the current body and APPEND — never overwrite. Guard the read: a failure or empty body means -# we skip rather than clobber the static preamble with a bare summary. -body="$(gh pr view "$pr_url" --json body --jq .body)" || { echo "could not read PR body; keeping it."; exit 0; } +# we skip rather than clobber the static preamble with a bare summary. We go through the REST pulls +# endpoint rather than `gh pr view`/`gh pr edit`: `gh pr edit` eagerly loads org-level PR metadata +# (review-request team `slug`, assignee `login`/`name`) and fails with a `read:org` GraphQL scope +# error under SPEC_SYNC_TOKEN — a classic PAT scoped to `repo`+`workflow`. REST pull read/update +# needs only `repo`, so it works with the token we have. +rest_path="${pr_url#https://github.com/}" # //pull/ +repo="${rest_path%/pull/*}" # / +num="${rest_path##*/}" # +if [ "$repo" = "$rest_path" ] || ! [[ "$num" =~ ^[0-9]+$ ]]; then + echo "could not parse PR URL '$pr_url'; keeping the static body."; exit 0 +fi + +body="$(gh api "repos/$repo/pulls/$num" --jq '.body // ""')" || { echo "could not read PR body; keeping it."; exit 0; } if [ -z "$body" ]; then echo "PR body empty/unreadable; keeping it."; exit 0; fi # Avoid duplicating the section if the workflow is re-run. @@ -62,4 +73,6 @@ if printf '%s\n' "$body" | grep -qF '## What changed'; then exit 0 fi -printf '%s\n\n## What changed\n_AI-generated from the PR diff — verify against the actual changes._\n\n%s\n' "$body" "$summary" | gh pr edit "$pr_url" --body-file - +printf '%s\n\n## What changed\n_AI-generated from the PR diff — verify against the actual changes._\n\n%s\n' "$body" "$summary" \ + | gh api --method PATCH "repos/$repo/pulls/$num" -F body=@- >/dev/null \ + || { echo "could not update PR body; keeping the static body."; exit 0; } From 748488f74680fdd6e7f647b8108ba6f73c036425 Mon Sep 17 00:00:00 2001 From: Lan Tian Date: Tue, 28 Jul 2026 16:57:02 +0800 Subject: [PATCH 2/3] feat(spec-sync): refresh the PR summary on human follow-up pushes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inline summarize step runs only at PR-open time, so its "## What changed" block misses commits a human adds later (e.g. a breaking removal the AI didn't wire). Two changes fix that: - summarize-pr.sh now writes the section into a stable fence and replaces it in place on re-run (idempotent), instead of the skip-if-present guard — so it can be recomputed without duplicating or touching human-written body text. - New spec-sync-summary.yml runs the summarizer on push to spec-sync/**, skipping bot pushes (the inline step covers those; skipping also avoids a double-write race at open time), so the description tracks the final diff. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/spec-sync-summary.yml | 68 +++++++++++++++++++++++++ scripts/spec-sync/summarize-pr.sh | 34 +++++++++---- 2 files changed, 92 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/spec-sync-summary.yml diff --git a/.github/workflows/spec-sync-summary.yml b/.github/workflows/spec-sync-summary.yml new file mode 100644 index 0000000..0f6873b --- /dev/null +++ b/.github/workflows/spec-sync-summary.yml @@ -0,0 +1,68 @@ +name: Spec Sync — refresh PR summary + +# The inline "Summarize the PR diff" step in spec-sync.yml only runs once, when a sync PR is first +# opened, so its "## What changed" block reflects just the mechanical + AI-wiring commits. When a +# human later pushes follow-up commits to the sync branch (e.g. a breaking removal the AI didn't +# wire), this workflow re-runs the shared summarizer so the description tracks the FINAL diff. +# +# The summarizer writes into a stable fence, so re-running REPLACES +# that block in place (idempotent) and never disturbs body text written outside the fence. Bot +# pushes (mechanical snapshot / AI wiring) are skipped — the inline step already covers those, and +# skipping avoids a double-write race at open time. Best-effort: the script degrades to leaving the +# body untouched on any hiccup (missing PR, API error), and continue-on-error is unnecessary because +# the script itself exits 0 on every non-config failure. + +on: + push: + branches: ['spec-sync/**'] + +permissions: + contents: read + pull-requests: write + +concurrency: + # One refresh per branch; a newer push supersedes an in-flight one. + group: spec-sync-summary-${{ github.ref }} + cancel-in-progress: true + +jobs: + refresh-summary: + # Only recompute for human follow-up commits. The mechanical commit is authored by + # spec-sync@users.noreply.github.com and the AI-wiring commit by a *[bot] account; both are + # already summarized inline in spec-sync.yml. + if: >- + github.event.deleted != true && + github.event.head_commit != null && + !contains(github.event.head_commit.author.email, '[bot]') && + github.event.head_commit.author.email != 'spec-sync@users.noreply.github.com' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 # summarize-pr.sh diffs origin/main...HEAD + + - name: Refresh the PR "What changed" summary + env: + GH_TOKEN: ${{ secrets.SPEC_SYNC_TOKEN }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + run: | + set -uo pipefail + git fetch --quiet origin main + + branch="${GITHUB_REF_NAME}" + owner="${GITHUB_REPOSITORY%%/*}" + # Resolve the open PR for this branch via REST (needs only `repo`, unlike `gh pr view`). + url="$(gh api "repos/${GITHUB_REPOSITORY}/pulls?head=${owner}:${branch}&state=open" --jq '.[0].html_url // ""')" + if [ -z "$url" ]; then + echo "No open PR for ${branch}; nothing to refresh." + exit 0 + fi + + # V2 excludes the hand-maintained /v2/workflow* surface from "What changed". Keep this in + # sync with the SUMMARY_SCOPE_NOTE on the V2 job in spec-sync.yml. + if [ "$branch" = "spec-sync/v2" ]; then + export SUMMARY_SCOPE_NOTE='Scope: this SDK intentionally does NOT implement /v2/workflow, /v2/workflow/jobs, or /v2/workflow/jobs/{job_id}; that surface is deferred and is not wired into the client. Do not describe any /v2/workflow* route as a client/SDK change. If the diff touches only workflow, say the spec snapshot was updated but no client surface changed.' + fi + + ./scripts/spec-sync/summarize-pr.sh "$url" diff --git a/scripts/spec-sync/summarize-pr.sh b/scripts/spec-sync/summarize-pr.sh index cfa0d6e..bb96ada 100755 --- a/scripts/spec-sync/summarize-pr.sh +++ b/scripts/spec-sync/summarize-pr.sh @@ -51,8 +51,9 @@ if [ -z "$summary" ]; then exit 0 fi -# Read the current body and APPEND — never overwrite. Guard the read: a failure or empty body means -# we skip rather than clobber the static preamble with a bare summary. We go through the REST pulls +# Read the current body, then refresh the marked "What changed" block in it (replace-or-append, see +# below) — the rest of the body is never touched. Guard the read: a failure or empty body means we +# skip rather than clobber the static preamble with a bare summary. We go through the REST pulls # endpoint rather than `gh pr view`/`gh pr edit`: `gh pr edit` eagerly loads org-level PR metadata # (review-request team `slug`, assignee `login`/`name`) and fails with a `read:org` GraphQL scope # error under SPEC_SYNC_TOKEN — a classic PAT scoped to `repo`+`workflow`. REST pull read/update @@ -67,12 +68,25 @@ fi body="$(gh api "repos/$repo/pulls/$num" --jq '.body // ""')" || { echo "could not read PR body; keeping it."; exit 0; } if [ -z "$body" ]; then echo "PR body empty/unreadable; keeping it."; exit 0; fi -# Avoid duplicating the section if the workflow is re-run. -if printf '%s\n' "$body" | grep -qF '## What changed'; then - echo 'What changed section already present; keeping the existing PR body.' - exit 0 -fi +# Wrap the AI section in stable markers so a re-run REPLACES it in place (idempotent) rather than +# appending a second copy, and so it never disturbs body text a human wrote outside the fence. This +# is what lets a later push (see .github/workflows/spec-sync-summary.yml) refresh the summary against +# the final diff. `$summary` already had stripped above, so it cannot forge the end marker. +block="$(printf '\n## What changed\n_AI-generated from the PR diff — verify against the actual changes._\n\n%s\n' "$summary")" + +# Replace the existing marked block if present, else append a fresh one. perl slurps the whole body +# so multi-line markdown is handled; the replacement is an interpolated variable, inserted verbatim. +new_body="$(BODY="$body" BLOCK="$block" perl -0777 -e ' + my ($b, $k) = ($ENV{BODY}, $ENV{BLOCK}); + if ($b =~ /.*?/s) { + $b =~ s/.*?/$k/s; + } else { + $b =~ s/\s+\z//; # trim trailing whitespace before appending + $b .= "\n\n" . $k . "\n"; + } + print $b; +')" -printf '%s\n\n## What changed\n_AI-generated from the PR diff — verify against the actual changes._\n\n%s\n' "$body" "$summary" \ - | gh api --method PATCH "repos/$repo/pulls/$num" -F body=@- >/dev/null \ - || { echo "could not update PR body; keeping the static body."; exit 0; } +printf '%s' "$new_body" | gh api --method PATCH "repos/$repo/pulls/$num" -F body=@- >/dev/null \ + || { echo "could not update PR body; keeping the existing body."; exit 0; } +echo "PR body updated with the What changed section." From 8d91a69dcfd683738785a0c414dd1451629f1bd3 Mon Sep 17 00:00:00 2001 From: Lan Tian Date: Tue, 28 Jul 2026 17:27:50 +0800 Subject: [PATCH 3/3] fix(spec-sync): drop the push workflow; guard the body rewrite (Copilot) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove spec-sync-summary.yml. A push-triggered workflow runs the pushed ref's code with secrets (SPEC_SYNC_TOKEN, ANTHROPIC_API_KEY), so anyone who can push a spec-sync/** branch could exfiltrate them — the scheduled spec-sync.yml deliberately runs only from main. Keep the marker + REST changes; the summary stays a one-shot at PR-open time. - Guard the perl rewrite in summarize-pr.sh: if perl is missing/errors or returns empty, skip the PATCH instead of wiping the PR body with an empty string. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/spec-sync-summary.yml | 68 ------------------------- scripts/spec-sync/summarize-pr.sh | 14 +++-- 2 files changed, 9 insertions(+), 73 deletions(-) delete mode 100644 .github/workflows/spec-sync-summary.yml diff --git a/.github/workflows/spec-sync-summary.yml b/.github/workflows/spec-sync-summary.yml deleted file mode 100644 index 0f6873b..0000000 --- a/.github/workflows/spec-sync-summary.yml +++ /dev/null @@ -1,68 +0,0 @@ -name: Spec Sync — refresh PR summary - -# The inline "Summarize the PR diff" step in spec-sync.yml only runs once, when a sync PR is first -# opened, so its "## What changed" block reflects just the mechanical + AI-wiring commits. When a -# human later pushes follow-up commits to the sync branch (e.g. a breaking removal the AI didn't -# wire), this workflow re-runs the shared summarizer so the description tracks the FINAL diff. -# -# The summarizer writes into a stable fence, so re-running REPLACES -# that block in place (idempotent) and never disturbs body text written outside the fence. Bot -# pushes (mechanical snapshot / AI wiring) are skipped — the inline step already covers those, and -# skipping avoids a double-write race at open time. Best-effort: the script degrades to leaving the -# body untouched on any hiccup (missing PR, API error), and continue-on-error is unnecessary because -# the script itself exits 0 on every non-config failure. - -on: - push: - branches: ['spec-sync/**'] - -permissions: - contents: read - pull-requests: write - -concurrency: - # One refresh per branch; a newer push supersedes an in-flight one. - group: spec-sync-summary-${{ github.ref }} - cancel-in-progress: true - -jobs: - refresh-summary: - # Only recompute for human follow-up commits. The mechanical commit is authored by - # spec-sync@users.noreply.github.com and the AI-wiring commit by a *[bot] account; both are - # already summarized inline in spec-sync.yml. - if: >- - github.event.deleted != true && - github.event.head_commit != null && - !contains(github.event.head_commit.author.email, '[bot]') && - github.event.head_commit.author.email != 'spec-sync@users.noreply.github.com' - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 # summarize-pr.sh diffs origin/main...HEAD - - - name: Refresh the PR "What changed" summary - env: - GH_TOKEN: ${{ secrets.SPEC_SYNC_TOKEN }} - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - run: | - set -uo pipefail - git fetch --quiet origin main - - branch="${GITHUB_REF_NAME}" - owner="${GITHUB_REPOSITORY%%/*}" - # Resolve the open PR for this branch via REST (needs only `repo`, unlike `gh pr view`). - url="$(gh api "repos/${GITHUB_REPOSITORY}/pulls?head=${owner}:${branch}&state=open" --jq '.[0].html_url // ""')" - if [ -z "$url" ]; then - echo "No open PR for ${branch}; nothing to refresh." - exit 0 - fi - - # V2 excludes the hand-maintained /v2/workflow* surface from "What changed". Keep this in - # sync with the SUMMARY_SCOPE_NOTE on the V2 job in spec-sync.yml. - if [ "$branch" = "spec-sync/v2" ]; then - export SUMMARY_SCOPE_NOTE='Scope: this SDK intentionally does NOT implement /v2/workflow, /v2/workflow/jobs, or /v2/workflow/jobs/{job_id}; that surface is deferred and is not wired into the client. Do not describe any /v2/workflow* route as a client/SDK change. If the diff touches only workflow, say the spec snapshot was updated but no client surface changed.' - fi - - ./scripts/spec-sync/summarize-pr.sh "$url" diff --git a/scripts/spec-sync/summarize-pr.sh b/scripts/spec-sync/summarize-pr.sh index bb96ada..c80287f 100755 --- a/scripts/spec-sync/summarize-pr.sh +++ b/scripts/spec-sync/summarize-pr.sh @@ -69,14 +69,15 @@ body="$(gh api "repos/$repo/pulls/$num" --jq '.body // ""')" || { echo "could no if [ -z "$body" ]; then echo "PR body empty/unreadable; keeping it."; exit 0; fi # Wrap the AI section in stable markers so a re-run REPLACES it in place (idempotent) rather than -# appending a second copy, and so it never disturbs body text a human wrote outside the fence. This -# is what lets a later push (see .github/workflows/spec-sync-summary.yml) refresh the summary against -# the final diff. `$summary` already had stripped above, so it cannot forge the end marker. +# appending a second copy, and so it never disturbs body text a human wrote outside the fence. +# `$summary` already had stripped above, so it cannot forge the end marker. block="$(printf '\n## What changed\n_AI-generated from the PR diff — verify against the actual changes._\n\n%s\n' "$summary")" # Replace the existing marked block if present, else append a fresh one. perl slurps the whole body # so multi-line markdown is handled; the replacement is an interpolated variable, inserted verbatim. -new_body="$(BODY="$body" BLOCK="$block" perl -0777 -e ' +# Guard the rewrite: a missing/erroring perl (or an empty result) must NOT reach the PATCH — patching +# an empty body would wipe the whole PR description. Skip and keep the existing body on any failure. +if ! new_body="$(BODY="$body" BLOCK="$block" perl -0777 -e ' my ($b, $k) = ($ENV{BODY}, $ENV{BLOCK}); if ($b =~ /.*?/s) { $b =~ s/.*?/$k/s; @@ -85,7 +86,10 @@ new_body="$(BODY="$body" BLOCK="$block" perl -0777 -e ' $b .= "\n\n" . $k . "\n"; } print $b; -')" +')" || [ -z "$new_body" ]; then + echo "could not render the updated body (perl failed or produced nothing); keeping the existing body." + exit 0 +fi printf '%s' "$new_body" | gh api --method PATCH "repos/$repo/pulls/$num" -F body=@- >/dev/null \ || { echo "could not update PR body; keeping the existing body."; exit 0; }