From 14414063288152b033b9caabc71cfa293d124245 Mon Sep 17 00:00:00 2001 From: Rhys Stewart Date: Thu, 23 Apr 2026 20:22:13 +0100 Subject: [PATCH 1/2] ci: add docs-sync workflow to auto-update bolt-docs on SDK PRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `.github/workflows/docs-sync.yml` and four supporting scripts in `.github/scripts/docs-sync/` — a non-blocking CI job that runs on every PR commit and uses GitHub Models (gpt-4o-mini) to keep the React Native SDK docs in bolt-docs in sync with SDK changes. Co-Authored-By: Claude Sonnet 4.6 --- .github/scripts/docs-sync/analyse.py | 224 ++++++++++++++++++++++ .github/scripts/docs-sync/create-pr.sh | 76 ++++++++ .github/scripts/docs-sync/fetch-docs.sh | 18 ++ .github/scripts/docs-sync/post-comment.py | 95 +++++++++ .github/workflows/docs-sync.yml | 61 ++++++ 5 files changed, 474 insertions(+) create mode 100644 .github/scripts/docs-sync/analyse.py create mode 100644 .github/scripts/docs-sync/create-pr.sh create mode 100644 .github/scripts/docs-sync/fetch-docs.sh create mode 100644 .github/scripts/docs-sync/post-comment.py create mode 100644 .github/workflows/docs-sync.yml diff --git a/.github/scripts/docs-sync/analyse.py b/.github/scripts/docs-sync/analyse.py new file mode 100644 index 0000000..b460ac7 --- /dev/null +++ b/.github/scripts/docs-sync/analyse.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +""" +Analyse a PR diff against current bolt-docs content using GitHub Models (gpt-4o-mini). + +Two-pass approach to stay within the 8k token hard limit on GitHub Models: + + Pass 1 — send only the diff; determine whether docs need updating, which + files to change, and a detailed description of the changes needed. + + Pass 2 — for each file identified in pass 1, send its full current content + plus the change description and get back the complete updated file. + Each pass-2 call is independent so the full file is always in context. + +Reads: + bolt-docs-current/*.md — current documentation files (from fetch-docs.sh) + pr.diff — PR diff (from `gh pr diff`) + +Writes: + ai_response.txt — raw model response (pass 1) + ai_rationale.txt — human-readable rationale + ai_pr_title.txt — suggested bolt-docs PR title + ai_pr_body.txt — suggested bolt-docs PR body + bolt-docs-patch/*.md — updated doc files (only when needs_docs_update=true) + +Sets GITHUB_OUTPUT: + needs_update=true|false + parse_error=true|false + +Required env: DOCS_SUBPATH, PR_NUMBER, GITHUB_OUTPUT +""" + +import json +import os +import sys +import urllib.error +import urllib.request + +docs_subpath = os.environ["DOCS_SUBPATH"] +pr_number = os.environ["PR_NUMBER"] + +DIFF_LIMIT = 6000 # chars — keeps pass-1 prompt well under 8k tokens + +DOC_FILES = ["_index.md", "api-reference.md", "apple-pay.md", "credit-card.md", "google-pay.md", "styling.md"] + + +def write_gho(key, value): + gho_path = os.environ["GITHUB_OUTPUT"] + with open(gho_path, "a") as f: + sv = str(value) + if "\n" in sv: + f.write(f"{key}<<__GHO_EOF__\n{sv}\n__GHO_EOF__\n") + else: + f.write(f"{key}={sv}\n") + + +def write_file(name, content): + with open(name, "w") as f: + f.write(content) + + +def fail_parse(reason): + write_gho("needs_update", "false") + write_gho("parse_error", "true") + write_file("ai_rationale.txt", reason) + write_file("ai_pr_title.txt", "") + write_file("ai_pr_body.txt", "") + sys.exit(0) + + +def call_model(prompt, label=""): + github_token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") + if not github_token: + fail_parse("No GitHub token found (GH_TOKEN or GITHUB_TOKEN). Cannot call GitHub Models API.") + + payload = json.dumps({ + "model": "gpt-4o-mini", + "messages": [{"role": "user", "content": prompt}], + }).encode("utf-8") + + req = urllib.request.Request( + "https://models.inference.ai.azure.com/chat/completions", + data=payload, + headers={ + "Authorization": f"Bearer {github_token}", + "Content-Type": "application/json", + }, + method="POST", + ) + + try: + with urllib.request.urlopen(req) as resp: + raw = resp.read().decode("utf-8") + except urllib.error.HTTPError as e: + body = e.read().decode("utf-8", errors="replace") + print(f"ERROR: GitHub Models API returned HTTP {e.code} ({label})", file=sys.stderr) + print(f"body: {body}", file=sys.stderr) + fail_parse(f"GitHub Models API error {e.code}: {body[:500]}. Manual review may be needed.") + except urllib.error.URLError as e: + print(f"ERROR: GitHub Models API request failed: {e.reason} ({label})", file=sys.stderr) + fail_parse(f"GitHub Models API request failed: {e.reason}. Manual review may be needed.") + + api_data = json.loads(raw) + return api_data["choices"][0]["message"]["content"] + + +# ── Read and truncate diff ─────────────────────────────────────────────────── +with open("pr.diff") as f: + diff_content = f.read(DIFF_LIMIT) +if len(diff_content) == DIFF_LIMIT: + diff_content += f"\n...[diff truncated at {DIFF_LIMIT} chars]..." + +# ── Pass 1: Analyse the diff, identify which files need updating ────────────── +schema_pass1 = ( + "{\n" + ' "needs_docs_update": ,\n' + ' "rationale": "<2-4 sentences explaining why an update is or is not needed>",\n' + ' "pr_title": "",\n' + ' "pr_body": "",\n' + f' "files_to_update": [".md", ...],\n' + ' "changes_description": ""\n' + "}\n" + f'Valid filenames: {", ".join(DOC_FILES)}. ' + "If needs_docs_update is false, set files_to_update to []." +) + +prompt_pass1 = "\n".join([ + "You are a documentation sync assistant for the Bolt React Native SDK.", + "", + "A pull request has been made to the bolt-react-native-sdk repository.", + "Decide whether the public-facing React Native SDK documentation in bolt-docs needs updating.", + "", + "ANALYSE changes to (these require doc updates):", + "- Public API types and interfaces exported from src/: BoltCheckout, BoltCheckoutProps,", + " BoltCheckoutRef, BoltLoginButton, BoltLoginButtonProps, useBolt, BoltProvider,", + " BoltProviderProps, payment method components (ApplePayButton, GooglePayButton,", + " CreditCardForm), configuration types, callback signatures, and return types", + "- Changes to README.md that describe public usage", + "- New features, removed features, or behaviour changes merchants need to know about", + "- Changes to Apple Pay, Google Pay, or credit card integration", + "- Changes to styling, theming, or customisation options", + "", + "IGNORE (do NOT flag these for doc updates):", + "- Internal implementation details (non-exported functions/classes)", + "- Test files (__tests__/, *.test.ts, *.spec.ts)", + "- Build and CI configuration (.github/, build scripts, package.json devDependencies)", + "- Pure refactors with no public API surface change", + "- Android/iOS native code changes with no JS/TS API impact", + "", + "=== PR DIFF (bolt-react-native-sdk) ===", + diff_content, + "", + "Respond with ONLY a valid JSON object — no markdown fences, no text before or after:", + schema_pass1, +]) + +print(f"Pass 1 prompt length: {len(prompt_pass1)} chars") +print("Pass 1: Analysing diff with GitHub Models (gpt-4o-mini)...") + +response_pass1 = call_model(prompt_pass1, label="pass1") +print(f"Pass 1 response length: {len(response_pass1)} chars") + +write_file("ai_response.txt", response_pass1) + +# ── Parse pass-1 JSON ───────────────────────────────────────────────────────── +start = response_pass1.find("{") +end = response_pass1.rfind("}") + 1 + +if start == -1 or end <= 0: + print("WARNING: No JSON object found in AI response", file=sys.stderr) + fail_parse("AI response could not be parsed as JSON. Manual review may be needed.") + +try: + data = json.loads(response_pass1[start:end]) +except json.JSONDecodeError as exc: + print(f"WARNING: JSON parse error: {exc}", file=sys.stderr) + fail_parse(f"AI response JSON parse error: {exc}. Manual review may be needed.") + +needs_update = str(data.get("needs_docs_update", False)).lower() +rationale = data.get("rationale", "No rationale provided.") +pr_title = data.get("pr_title") or f"docs(react-native): sync with bolt-react-native-sdk PR #{pr_number}" +pr_body = data.get("pr_body", "") +files_to_update = [f for f in data.get("files_to_update", []) if f in DOC_FILES] +changes_description = data.get("changes_description", "") + +write_gho("needs_update", needs_update) +write_gho("parse_error", "false") +write_file("ai_rationale.txt", rationale) +write_file("ai_pr_title.txt", pr_title) +write_file("ai_pr_body.txt", pr_body) + +print(f"needs_docs_update: {needs_update}") +print(f"files_to_update: {files_to_update}") + +# ── Pass 2: Rewrite each identified file with full content in context ────────── +if needs_update == "true" and files_to_update and changes_description: + os.makedirs("bolt-docs-patch", exist_ok=True) + for filename in files_to_update: + src = os.path.join("bolt-docs-current", filename) + if not os.path.exists(src): + print(f"Skipping {filename} — not found in bolt-docs-current") + continue + + with open(src) as f: + current_content = f.read() + + prompt_pass2 = "\n".join([ + f"You are updating the documentation file '{docs_subpath}/{filename}' for the Bolt React Native SDK.", + "", + "Apply the following changes to the file content below:", + changes_description, + "", + f"=== CURRENT CONTENT OF {filename} ===", + current_content, + "", + "Return ONLY the complete updated file content.", + "Preserve the YAML frontmatter exactly. No markdown fences, no explanation.", + ]) + + print(f"Pass 2: Updating {filename} ({len(prompt_pass2)} chars prompt)...") + updated_content = call_model(prompt_pass2, label=f"pass2:{filename}") + + dest = os.path.join("bolt-docs-patch", filename) + write_file(dest, updated_content) + print(f"Staged: {filename} ({len(updated_content)} chars)") diff --git a/.github/scripts/docs-sync/create-pr.sh b/.github/scripts/docs-sync/create-pr.sh new file mode 100644 index 0000000..5b27c80 --- /dev/null +++ b/.github/scripts/docs-sync/create-pr.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# Creates or updates a documentation sync PR in bolt-docs. +# Required env: PR_NUMBER, PR_TITLE, BOLT_DOCS_REPO, DOCS_SUBPATH, GH_TOKEN, GITHUB_OUTPUT +# +# Reads: bolt-docs-patch/*.md, ai_pr_title.txt, ai_pr_body.txt +# Writes: GITHUB_OUTPUT docs_pr_url +set -euo pipefail + +readonly BRANCH="react-native-sdk-sync/pr-${PR_NUMBER}" +readonly SDK_PR_URL="https://github.com/BoltApp/bolt-react-native-sdk/pull/${PR_NUMBER}" + +# Clone bolt-docs. GH_TOKEN must be DOCS_WRITE_TOKEN (cross-repo access). +git clone \ + "https://x-access-token:${GH_TOKEN}@github.com/${BOLT_DOCS_REPO}.git" \ + bolt-docs-repo + +cd bolt-docs-repo +git config user.name "github-actions[bot]" +git config user.email "github-actions[bot]@users.noreply.github.com" + +# Reset (or create) the sync branch from master. +# Force-push is intentional: this branch is fully owned by the sync workflow. +git checkout -B "$BRANCH" origin/master + +# Apply AI-generated file patches +PATCHED=0 +for FILE in ../bolt-docs-patch/*.md; do + [ -f "$FILE" ] || continue # guard against empty glob + FILENAME=$(basename "$FILE") + cp "$FILE" "${DOCS_SUBPATH}/${FILENAME}" + echo "Applied: ${DOCS_SUBPATH}/${FILENAME}" + PATCHED=$((PATCHED + 1)) +done + +if [ "$PATCHED" -eq 0 ] || git diff --quiet HEAD; then + echo "No file changes after patching — skipping docs PR" + echo "docs_pr_url=" >> "$GITHUB_OUTPUT" + exit 0 +fi + +git add -A +git commit -m "docs(react-native): sync with bolt-react-native-sdk PR #${PR_NUMBER} + +Triggered by: ${PR_TITLE} +SDK PR: ${SDK_PR_URL}" + +git push --force origin "$BRANCH" + +# Create the docs PR on first push; reuse it on subsequent commits. +EXISTING=$(gh pr list \ + --repo "$BOLT_DOCS_REPO" \ + --head "$BRANCH" \ + --json number \ + --jq '.[0].number // empty') + +if [ -z "$EXISTING" ]; then + PR_TITLE_TEXT=$(cat ../ai_pr_title.txt) + { + cat ../ai_pr_body.txt + printf '\n\n---\n_Auto-generated by the docs-sync workflow. ' + printf 'Triggered by [bolt-react-native-sdk PR #%s](%s)._\n' "$PR_NUMBER" "$SDK_PR_URL" + } > /tmp/docs_pr_body.txt + + DOCS_PR_URL=$(gh pr create \ + --repo "$BOLT_DOCS_REPO" \ + --head "$BRANCH" \ + --base master \ + --title "$PR_TITLE_TEXT" \ + --body-file /tmp/docs_pr_body.txt) + echo "Created docs PR: $DOCS_PR_URL" +else + DOCS_PR_URL="https://github.com/${BOLT_DOCS_REPO}/pull/${EXISTING}" + echo "Updated existing docs PR: $DOCS_PR_URL" +fi + +echo "docs_pr_url=${DOCS_PR_URL}" >> "$GITHUB_OUTPUT" diff --git a/.github/scripts/docs-sync/fetch-docs.sh b/.github/scripts/docs-sync/fetch-docs.sh new file mode 100644 index 0000000..6820623 --- /dev/null +++ b/.github/scripts/docs-sync/fetch-docs.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Fetches the current React Native SDK documentation files from bolt-docs. +# Required env: BOLT_DOCS_REPO, DOCS_SUBPATH, GH_TOKEN (consumed by gh CLI) +set -euo pipefail + +readonly FILES=(_index.md api-reference.md apple-pay.md credit-card.md google-pay.md styling.md) + +mkdir -p bolt-docs-current + +for FILE in "${FILES[@]}"; do + if ! gh api "repos/${BOLT_DOCS_REPO}/contents/${DOCS_SUBPATH}/${FILE}" \ + --jq '.content' | base64 --decode > "bolt-docs-current/${FILE}"; then + echo "ERROR: Could not fetch ${FILE} from ${BOLT_DOCS_REPO}/${DOCS_SUBPATH}" >&2 + echo " Ensure DOCS_WRITE_TOKEN has read access to ${BOLT_DOCS_REPO}" >&2 + exit 1 + fi + echo "Fetched ${FILE} ($(wc -c < "bolt-docs-current/${FILE}") bytes)" +done diff --git a/.github/scripts/docs-sync/post-comment.py b/.github/scripts/docs-sync/post-comment.py new file mode 100644 index 0000000..a1b99dd --- /dev/null +++ b/.github/scripts/docs-sync/post-comment.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +""" +Posts or updates the bolt-docs-sync status comment on the SDK pull request. + +Reads: + ai_rationale.txt — analysis rationale (may be absent if analysis did not run) + +Required env: PR_NUMBER, COMMIT_SHA, GH_TOKEN (consumed by gh CLI) +Optional env: NEEDS_UPDATE, PARSE_ERROR, DOCS_PR_URL +""" + +import json +import os +import subprocess + +pr_number = os.environ["PR_NUMBER"] +commit_sha = os.environ["COMMIT_SHA"][:7] +needs_update = os.environ.get("NEEDS_UPDATE", "false") +parse_error = os.environ.get("PARSE_ERROR", "false") +docs_pr_url = os.environ.get("DOCS_PR_URL", "") + +try: + with open("ai_rationale.txt") as f: + rationale = f.read().strip() +except FileNotFoundError: + rationale = "Analysis did not complete — check the workflow logs for details." + +# ── Build status line ───────────────────────────────────────────────────────── +if parse_error == "true": + status = "⚠️ Could not parse the AI response — manual review may be needed." +elif needs_update == "true" and docs_pr_url: + pr_num = docs_pr_url.rstrip("/").split("/")[-1] + status = ( + "Changes in this PR affect the React Native SDK docs. " + "A documentation update PR has been opened:\n" + f"**➔ [BoltApp/bolt-docs#{pr_num}]({docs_pr_url})**" + ) +elif needs_update == "true": + status = ( + "✅ The AI detected possible doc changes but no file " + "differences were produced after applying the patch." + ) +else: + status = "✅ No documentation update required." + +# ── Assemble comment body ───────────────────────────────────────────────────── +body = ( + "\n" + "## 📝 Documentation Sync\n\n" + + status + "\n\n" + + "
Rationale\n\n" + + rationale + "\n\n" + + "
\n\n" + + f"_Last updated: commit `{commit_sha}`_" +) + +# ── Find existing bot comment ───────────────────────────────────────────────── +result = subprocess.run( + [ + "gh", "api", + f"repos/BoltApp/bolt-react-native-sdk/issues/{pr_number}/comments", + "--jq", + '.[] | select(.body | contains("")) | .id', + ], + capture_output=True, + text=True, +) +comment_id = result.stdout.strip().split("\n")[0].strip() + +# ── Update existing or post new ─────────────────────────────────────────────── +if comment_id: + with open("/tmp/comment_payload.json", "w") as f: + json.dump({"body": body}, f) + subprocess.run( + [ + "gh", "api", + f"repos/BoltApp/bolt-react-native-sdk/issues/comments/{comment_id}", + "-X", "PATCH", + "--input", "/tmp/comment_payload.json", + ], + check=True, + ) + print(f"Updated existing comment #{comment_id}") +else: + with open("/tmp/comment_body.txt", "w") as f: + f.write(body) + subprocess.run( + [ + "gh", "pr", "comment", pr_number, + "--repo", "BoltApp/bolt-react-native-sdk", + "--body-file", "/tmp/comment_body.txt", + ], + check=True, + ) + print("Posted new comment") diff --git a/.github/workflows/docs-sync.yml b/.github/workflows/docs-sync.yml new file mode 100644 index 0000000..baf964b --- /dev/null +++ b/.github/workflows/docs-sync.yml @@ -0,0 +1,61 @@ +name: Sync Documentation + +on: + pull_request: + types: [opened, synchronize, reopened] + branches: + - main + +jobs: + sync-docs: + name: Sync Documentation + runs-on: ubuntu-latest + continue-on-error: true + permissions: + contents: write + pull-requests: write + models: read + + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + COMMIT_SHA: ${{ github.event.pull_request.head.sha }} + BOLT_DOCS_REPO: BoltApp/bolt-docs + DOCS_SUBPATH: content/developers/sdks/react-native + + steps: + - name: Checkout SDK repo + uses: actions/checkout@v4 + + - name: Get PR diff + run: | + gh pr diff "$PR_NUMBER" > pr.diff + echo "Diff size: $(wc -c < pr.diff) bytes" + + - name: Fetch current bolt-docs react-native files + env: + GH_TOKEN: ${{ secrets.DOCS_WRITE_TOKEN }} + run: bash .github/scripts/docs-sync/fetch-docs.sh + + - name: Analyse diff with GPT-4.1 and parse response + id: analyse + env: + # GITHUB_TOKEN has models:read; BOLT_CI_GITHUB_TOKEN (PAT) may not. + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python3 .github/scripts/docs-sync/analyse.py + + - name: Create or update bolt-docs PR + id: docs_pr + if: steps.analyse.outputs.needs_update == 'true' + env: + GH_TOKEN: ${{ secrets.DOCS_WRITE_TOKEN }} + run: bash .github/scripts/docs-sync/create-pr.sh + + - name: Post or update comment on PR + if: always() + env: + NEEDS_UPDATE: ${{ steps.analyse.outputs.needs_update }} + PARSE_ERROR: ${{ steps.analyse.outputs.parse_error }} + DOCS_PR_URL: ${{ steps.docs_pr.outputs.docs_pr_url }} + run: python3 .github/scripts/docs-sync/post-comment.py From 484ee3bf3a9793bd4a467862d8fa2190cf543880 Mon Sep 17 00:00:00 2001 From: Rhys Stewart Date: Thu, 23 Apr 2026 22:06:33 +0100 Subject: [PATCH 2/2] ci: add DOCS_WRITE_TOKEN availability check to docs-sync workflow Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/docs-sync.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/docs-sync.yml b/.github/workflows/docs-sync.yml index baf964b..487bd13 100644 --- a/.github/workflows/docs-sync.yml +++ b/.github/workflows/docs-sync.yml @@ -33,6 +33,14 @@ jobs: gh pr diff "$PR_NUMBER" > pr.diff echo "Diff size: $(wc -c < pr.diff) bytes" + - name: Check DOCS_WRITE_TOKEN availability + run: | + if [ -z "${{ secrets.DOCS_WRITE_TOKEN }}" ]; then + echo "DOCS_WRITE_TOKEN is empty/not available" + else + echo "DOCS_WRITE_TOKEN is set" + fi + - name: Fetch current bolt-docs react-native files env: GH_TOKEN: ${{ secrets.DOCS_WRITE_TOKEN }}