diff --git a/.github/workflows/sdk-update.yml b/.github/workflows/sdk-update.yml
index e1595a2..65fcd54 100644
--- a/.github/workflows/sdk-update.yml
+++ b/.github/workflows/sdk-update.yml
@@ -293,6 +293,10 @@ jobs:
GENERATION_STATUS: ${{ steps.status.outputs.result }}
OLD_VERSION: ${{ steps.bump.outputs.old_version }}
NEW_VERSION: ${{ steps.bump.outputs.new_version }}
+ # Passed through so the changelog-truncation suffix can deep-link
+ # back to this run when the oasdiff markdown is too big for the
+ # 64-KB PR body cap.
+ RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
python scripts/build_pr_body.py \
--classification .sdk-update/classification.txt \
@@ -302,6 +306,7 @@ jobs:
--generation-status "$GENERATION_STATUS" \
--old-version "$OLD_VERSION" \
--new-version "$NEW_VERSION" \
+ --run-url "$RUN_URL" \
> .sdk-update/pr-body.md
# ----- Force-push to auto/sdk-update ----------------------------------
diff --git a/scripts/build_pr_body.py b/scripts/build_pr_body.py
index d03cb36..dd333b4 100755
--- a/scripts/build_pr_body.py
+++ b/scripts/build_pr_body.py
@@ -34,6 +34,13 @@
import sys
from pathlib import Path
+# GitHub caps PR body length at 65,536 characters; gh pr create/edit fails
+# the whole call if we go over. Aim for a budget below that to leave
+# headroom for trailing newlines, gh's own formatting, and any unicode
+# expansion in the GraphQL payload.
+GITHUB_PR_BODY_LIMIT = 65_536
+SAFE_BUDGET = 60_000
+
def read_optional(path: str | None) -> str:
if not path:
@@ -44,6 +51,26 @@ def read_optional(path: str | None) -> str:
return file_path.read_text(encoding="utf-8")
+def truncate_at_line(content: str, max_chars: int, suffix: str = "") -> str:
+ """Truncate `content` to at most `max_chars` (counting `suffix`).
+
+ Slices on a line boundary so we never cut mid-line, then appends
+ `suffix`. If `content` already fits, returns it unchanged. If the
+ suffix alone is bigger than `max_chars`, returns just the suffix
+ (better to surface the truncation notice than silently drop it).
+ """
+ if len(content) <= max_chars:
+ return content
+ if len(suffix) >= max_chars:
+ return suffix
+ target = max_chars - len(suffix)
+ head = content[:target]
+ last_newline = head.rfind("\n")
+ if last_newline > 0:
+ head = head[:last_newline]
+ return head + suffix
+
+
def parse_classification(text: str) -> tuple[str, str]:
"""Returns (level, reason). Tolerant of empty input."""
if not text.strip():
@@ -134,20 +161,10 @@ def build(args: argparse.Namespace) -> str:
)
)
- # --- oasdiff markdown changelog ----------------------------------------
- changelog_md = read_optional(args.changelog_md).strip()
- if changelog_md:
- sections.append(
- "\n"
- "API changelog (from oasdiff)
\n\n"
- f"{changelog_md}\n"
- " "
- )
-
# --- Changes since last review -----------------------------------------
delta = read_optional(args.since_last_review).strip()
if delta:
- sections.append(
+ delta_section = (
"\n"
"Changes since last review
\n\n"
"```diff\n"
@@ -156,17 +173,85 @@ def build(args: argparse.Namespace) -> str:
" "
)
else:
- sections.append(
+ delta_section = (
"## Changes since last review\n\n_Initial PR — no prior review baseline._"
)
# --- Footer -------------------------------------------------------------
- sections.append(
+ footer = (
"---\n"
"_This PR was opened automatically by the `sdk-update` workflow._\n"
"_Trigger: `workflow_dispatch`._"
)
+ # --- oasdiff markdown changelog (size-budgeted) -------------------------
+ # We build this LAST so we can size it against the remaining budget. The
+ # changelog is the dominant size in practice (an oasdiff markdown for a
+ # major spec rewrite can be 100+ KB, well over GitHub's 65,536-char PR
+ # body limit). Everything before/after the changelog has a stable upper
+ # bound, so we treat the rest of the body as "fixed cost" and give the
+ # changelog whatever budget is left.
+ changelog_md = read_optional(args.changelog_md).strip()
+ fixed_sections = sections + [delta_section, footer]
+ # +2 chars per join boundary, +1 for the trailing newline we add at the
+ # bottom of the final body. Keep this estimation conservative — the
+ # cost of under-allocating is a softer notice; the cost of
+ # over-allocating is a hard PR-create failure.
+ fixed_size = sum(len(s) for s in fixed_sections) + 2 * len(fixed_sections) + 1
+
+ if changelog_md:
+ wrapper_overhead = (
+ "\n"
+ "API changelog (from oasdiff)
\n\n"
+ "\n"
+ " "
+ )
+ # Reserve room for the wrapper plus the joiner between this section
+ # and the next one.
+ budget_for_changelog_content = (
+ SAFE_BUDGET - fixed_size - len(wrapper_overhead) - 2
+ )
+ if budget_for_changelog_content > 0 and len(changelog_md) > budget_for_changelog_content:
+ suffix = (
+ "\n\n_…changelog truncated to fit GitHub's PR body size limit."
+ )
+ if args.run_url:
+ suffix += (
+ f" Full changelog available as `.sdk-update/analysis/changelog.md`"
+ f" in the workflow run: {args.run_url}"
+ )
+ else:
+ suffix += (
+ " Full changelog available as `.sdk-update/analysis/changelog.md`"
+ " in the workflow run logs."
+ )
+ suffix += "_"
+ changelog_md = truncate_at_line(
+ changelog_md, budget_for_changelog_content, suffix=suffix
+ )
+ if budget_for_changelog_content > 0:
+ sections.append(
+ "\n"
+ "API changelog (from oasdiff)
\n\n"
+ f"{changelog_md}\n"
+ " "
+ )
+ else:
+ # Pathological: even the wrapper doesn't fit. Skip the changelog
+ # rather than fail the PR create.
+ note = (
+ "_API changelog omitted to fit GitHub's PR body size limit._"
+ )
+ if args.run_url:
+ note = (
+ f"_API changelog omitted to fit GitHub's PR body size limit."
+ f" See workflow run: {args.run_url}_"
+ )
+ sections.append(note)
+
+ sections.append(delta_section)
+ sections.append(footer)
+
return "\n\n".join(sections) + "\n"
@@ -184,6 +269,16 @@ def parse_args() -> argparse.Namespace:
)
parser.add_argument("--old-version", default="")
parser.add_argument("--new-version", default="")
+ parser.add_argument(
+ "--run-url",
+ default="",
+ help=(
+ "URL of this workflow run, used in the truncation notice when "
+ "the oasdiff changelog is too big to fit in the PR body. "
+ "Typically passed as ${{ github.server_url }}/${{ github.repository "
+ "}}/actions/runs/${{ github.run_id }}."
+ ),
+ )
return parser.parse_args()