Files
Nicholas Tindleandunknown 28d65d4a21 ci(platform): keep the gitbook branch a one-way copy of master's docs (#15165)
### Why / What / How

**Why:** GitBook publishes agpt.co/docs from the `gitbook` branch. That
branch has been kept in step with `dev` by hand-made sync PRs (#14019,
#14171, #14554), and it drifts between them: the live site is currently
three weeks behind. The branch is also writable by people and by GitBook
itself, so changes could be made there that never reach `dev`. The
branch exists so that GitBook never writes to a deployable branch; that
stays.

**What:** `gitbook` becomes a one-way, docs-only copy of `master`'s
`docs/`, written only by automation. The published docs then match what
is deployed. If anything else writes to the branch, the sync reports it
in an issue and puts `master`'s docs back.

**How:** A workflow runs on every push to `master`, daily, and on manual
dispatch. It runs a small stdlib-only script as the `autogpt-batch-bot`
App. The script reads `master`'s `docs/` tree, and if `gitbook` does not
already hold exactly that, writes one commit on top of the branch head
whose tree is only `docs/`. It uses the Git Data API, never
force-pushes, and leaves author and committer unset so GitHub signs the
commit as the App. A repository ruleset (applied by hand afterwards,
recorded here as JSON) restricts updates of `gitbook` to that App.
`GITHUB_TOKEN` cannot be given a ruleset bypass, which is why the App is
used.

### Changes 🏗️

- **`.github/workflows/docs-gitbook-sync.yml`** (new): the workflow. It
only runs in this repository and only from `master`'s copy of itself.
- **`.github/workflows/scripts/gitbook_sync.py`** (new): the sync.
- A commit it wrote carries a `Synced-From: <master sha>` trailer. The
head counts as its own only if it is docs-only and its `docs/` equals
that commit's `docs/`.
- When the head is not its own, it lists the first-parent commits since
its last snapshot and who moved the branch (repository activity log),
reports them in an issue **before** moving the branch, writes a fresh
snapshot on top, and exits non-zero. The foreign commits stay in the
branch history.
- A branch that was rewritten or recreated since a previous sync is
reported the same way. The very first sync, when the App has never
written the branch, is not.
- If it cannot update the branch (or `master` has no `docs/`, or the
branch is missing), it reports that in the same issue and exits
non-zero.
- A report already on the open issue is not posted again, so a problem
that persists does not add a comment per run. Only an issue the App
itself opened is reused.
- Text taken from commits is quoted and stripped of control characters
before it reaches the issue or the job log.
- **`.github/workflows/scripts/test_gitbook_sync.py`** (new): 33 tests
over an in-memory fake of the endpoints used. They run in the existing
PR status checker job.
- **`.github/rulesets/gitbook-sync-only.json`,
`gitbook-no-force-push-or-delete.json`** (new): the two rulesets, as the
request bodies for `POST /repos/{owner}/{repo}/rulesets`. Nothing
applies them automatically.
- **`docs/content/contribute/index.md`, `docs/AGENTS.md`**: docs changes
go to `dev`; never commit to `gitbook` or edit pages in GitBook.

**Configuration:**

- Uses the existing `BATCH_BOT_APP_ID` variable and
`BATCH_BOT_PRIVATE_KEY` secret. The token is requested with `contents:
write` and `issues: write` only.
- Optional new repository variable `GITBOOK_SYNC_NOTIFY`: who to mention
on a report. The default mentions `@Significant-Gravitas/maintainers`
and `@ntindle`; the App cannot see organization teams today, so the team
mention may not notify.

**What happens when this reaches `master`:**

- The release push itself triggers the first run. It writes one commit
on `gitbook` that removes everything except `docs/` (about 6,000 files)
and brings `docs/` to `master`'s. GitBook then imports the Platform and
Integrations spaces; Home is unchanged.
- Nothing on `gitbook` is lost: its tree equals `dev` as of 2026-09-14,
which `master` contains. Anything merged into `gitbook` between now and
that release would be dropped without a report, because the first sync
is silent by design.

**Manual steps after the first successful run (admin):**

1. Apply the two rulesets: `gh api -X POST
repos/Significant-Gravitas/AutoGPT/rulesets --input
.github/rulesets/gitbook-sync-only.json`, then the same with
`gitbook-no-force-push-or-delete.json`.
2. Move #14048 to `dev` and close #15157; PRs into `gitbook` cannot be
merged once the rulesets are on.
3. In GitBook: stop change requests being merged in the three synced
spaces, and remove Git Sync from the two unused spaces that still point
at this branch.

**What this does not do, or has not been proven:**

- GitHub cannot stop a change request being merged inside GitBook. Such
a change goes live on the site, fails to push, and is overwritten by the
next copy.
- Three steps are only exercised by the first real run: the App (which
has no `workflows` permission) writing a commit that removes workflow
files, the App's ruleset bypass applying to an API ref update, and
GitBook importing an update made through the API. A rejected write is
reported and fails the run; the fallback is for an admin to push that
first commit.
- The App's key is a repository-level secret, so anyone who can push a
workflow to any branch could mint its token. The rulesets keep out
GitBook, pull-request merges and ordinary pushes; they are not a barrier
against people with write access.
- A hand-made commit that copies the snapshot format exactly is treated
as the sync's own.

### Agents and large language models used

Claude Code (model: unknown)

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] `python -m unittest discover -s .github/workflows/scripts -p
"test_*.py"` passes (54 tests, 33 new), prints no workflow commands and
writes nothing to the step summary
- [x] Dry run against this repository with a read-only token
(`DRY_RUN=1`): reads `master` and `gitbook`, detects a first sync, and
plans one commit on top of `gitbook@fb7c677bd3`
- [x] The single-entry tree built from `master`'s `docs/` is identical
to `master`'s `docs/` (checked locally with `git mktree` and `git diff`)
- [x] Both ruleset files match the shape of the live `batch-rollup
bot-only` ruleset
- [ ] First real run on `master` (only possible after release; see
above)

<details>
  <summary>Example test plan</summary>
  
  - [ ] Create from scratch and execute an agent with at least 3 blocks
- [ ] Import an agent from file upload, and confirm it executes
correctly
  - [ ] Upload agent to marketplace
- [ ] Import an agent from marketplace and confirm it executes correctly
  - [ ] Edit an agent from monitor, and confirm it executes correctly
</details>

#### For configuration changes:

- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**)

<details>
  <summary>Examples of configuration changes</summary>

  - Changing ports
  - Adding new services that need to communicate with each other
  - Secrets or environment variable changes
  - New or infrastructure changes such as databases
</details>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> The first run on `master` will rewrite `gitbook` to a docs-only tree
and changes how published docs are updated; misconfiguration or rulesets
not applied could block sync or leave stale docs until fixed.
> 
> **Overview**
> Adds **automated one-way sync** from `master`'s `docs/` to the
`gitbook` branch (what GitBook publishes), replacing manual sync PRs and
direct edits on `gitbook`.
> 
> A new **`docs-gitbook-sync`** workflow runs on pushes to `master`,
daily, and on dispatch. It mints a **batch-bot GitHub App** token and
runs `gitbook_sync.py`, which fast-forwards `gitbook` with a
**docs-only** commit tagged `Synced-From: <master sha>`. If the branch
head was changed by anything else, the script **opens or updates an
issue** (with deduped reports), restores `master`'s docs on top, and
**fails the job** so the drift is visible. Two **ruleset JSON files**
document branch rules: no delete/force-push, and updates allowed only
for the sync App (applied manually by admins).
> 
> Contributor docs in **`docs/AGENTS.md`** and
**`docs/content/contribute/index.md`** now say to PR **`dev`**, not
`gitbook`, and warn that GitBook edits are overwritten.
**`test_gitbook_sync.py`** adds broad unit tests for sync, reporting,
and API retry behavior.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
aeb0d72c4e. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

Co-authored-by: unknown (Claude Code) <[email protected]>
2026-10-03 15:42:26 +00:00

483 lines
17 KiB
Python

"""Keep the `gitbook` branch a one-way, docs-only copy of master's docs/.
GitBook publishes agpt.co/docs from the `gitbook` branch. Nothing else should
write to that branch: a ruleset restricts updates to the App this runs as. Each
run that finds a difference writes one commit on top of `gitbook` whose tree is
exactly master's docs/ directory, so the branch only ever fast-forwards.
A commit this script wrote names the master commit it copied in a
`Synced-From:` trailer. If the branch head is anything else, something other
than this script wrote to the branch. The run reports those commits in an
issue, then writes a fresh snapshot on top, which puts master's docs back.
The foreign commits stay in the branch history; nothing is force-pushed.
"""
import hashlib
import http.client
import json
import os
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, Dict, List, NamedTuple, Optional, Tuple
DOCS_PATH = "docs"
SYNC_TRAILER = "Synced-From"
TRAILER_PATTERN = re.compile(rf"^{SYNC_TRAILER}: ([0-9a-f]{{40}})$", re.MULTILINE)
# How far back to look for the last snapshot when the head is not one.
HISTORY_LIMIT = 100
MAX_REPORTED = 20
ISSUE_TITLE = "Docs sync: the `gitbook` branch needs attention"
ATTEMPTS = 3
RETRIED_STATUSES = {429, 500, 502, 503, 504}
CONTROL_CHARACTERS = re.compile(r"[\x00-\x1f\x7f-\x9f]")
class SyncError(Exception):
"""The sync cannot go on. Reported in the issue, then the run fails."""
class ApiError(SyncError):
def __init__(self, method: str, path: str, status: int, body: str) -> None:
super().__init__(
f"{method} {path} -> HTTP {status}: {' '.join(body.split())[:500]}"
)
self.status = status
class GitHub:
retry_delay = 2.0
def __init__(self, api_url: str, repo: str, token: str, actor: str) -> None:
self.api_url = api_url.rstrip("/")
self.repo = repo
self.token = token
# The login the token acts as, e.g. "autogpt-batch-bot[bot]".
self.actor = actor
def call(
self,
method: str,
path: str,
body: Optional[Dict[str, Any]] = None,
retry: bool = True,
) -> Any:
"""Call the API. `retry=False` is for calls that are not safe to
repeat: a retried POST that had in fact succeeded posts twice."""
request = urllib.request.Request(
f"{self.api_url}/repos/{self.repo}{path}",
data=json.dumps(body).encode() if body is not None else None,
method=method,
headers={
"Authorization": f"Bearer {self.token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"Content-Type": "application/json",
"User-Agent": "autogpt-gitbook-sync",
},
)
attempts = ATTEMPTS if retry else 1
for attempt in range(1, attempts + 1):
try:
with urllib.request.urlopen(request, timeout=30) as response:
return json.load(response)
except urllib.error.HTTPError as e:
error = ApiError(
method, path, e.code, e.read().decode(errors="replace")
)
if e.code not in RETRIED_STATUSES:
raise error from e
except (OSError, http.client.HTTPException, ValueError) as e:
# Connection errors, timeouts, and replies cut short or not JSON.
error = ApiError(method, path, 0, f"{type(e).__name__}: {e}")
if attempt < attempts:
time.sleep(self.retry_delay * attempt)
raise error
class Snapshot(NamedTuple):
"""What a commit holds, as far as the sync cares."""
sha: str
docs_tree: Optional[str]
docs_only: bool
synced_from: Optional[str]
def synced_from(message: str) -> Optional[str]:
"""Return the master SHA a snapshot commit says it copied, if any."""
matches = TRAILER_PATTERN.findall(message)
return matches[-1] if matches else None
def branch_head(gh: GitHub, branch: str) -> str:
return gh.call("GET", f"/git/ref/heads/{branch}")["object"]["sha"]
def read_snapshot(gh: GitHub, sha: str) -> Snapshot:
commit = gh.call("GET", f"/git/commits/{sha}")
entries = gh.call("GET", f"/git/trees/{commit['tree']['sha']}")["tree"]
docs = [e for e in entries if e["path"] == DOCS_PATH and e["type"] == "tree"]
return Snapshot(
sha=sha,
docs_tree=docs[0]["sha"] if docs else None,
docs_only=len(entries) == 1 and bool(docs),
synced_from=synced_from(commit["message"]),
)
def is_own_snapshot(gh: GitHub, target: Snapshot) -> bool:
"""True if `target` is exactly the docs/ of the commit its trailer names.
The trailer alone is not enough: an amended or cherry-picked snapshot
keeps the trailer while carrying different content.
"""
if not (target.synced_from and target.docs_only):
return False
try:
named = read_snapshot(gh, target.synced_from)
except ApiError as e:
if e.status in (404, 422):
return False
raise
return named.docs_tree == target.docs_tree
def foreign_commits(gh: GitHub, head: str) -> Tuple[List[Dict[str, Any]], bool]:
"""First-parent commits on top of the last snapshot, newest first.
`head` is known not to be a snapshot, so it is foreign whatever its
message says. Following first parents keeps a merge to one entry instead
of every commit it brought in.
The second value says whether a snapshot was reached. If not, the list is
only the newest HISTORY_LIMIT commits and the branch may never have been
synced, or may have been rewritten since.
"""
foreign: List[Dict[str, Any]] = []
sha = head
for _ in range(HISTORY_LIMIT):
commit = gh.call("GET", f"/git/commits/{sha}")
if (
foreign
and synced_from(commit["message"])
and is_own_snapshot(gh, read_snapshot(gh, sha))
):
return foreign, True
foreign.append(commit)
if not commit["parents"]:
break
sha = commit["parents"][0]["sha"]
return foreign, False
def has_written_before(gh: GitHub, branch: str) -> bool:
"""Whether the account this runs as has ever updated the branch.
Tells the first sync, where the branch simply predates the sync, from a
branch that lost its snapshots by being rewritten or recreated.
"""
actor = urllib.parse.quote(gh.actor)
try:
return bool(
gh.call(
"GET", f"/activity?ref=refs/heads/{branch}&actor={actor}&per_page=1"
)
)
except ApiError as e:
# Unknown is treated as yes: better a needless report than a silent
# overwrite of somebody's commits.
print(f"Could not read {branch} activity: {e}")
return True
def ref_updates(gh: GitHub, branch: str, foreign: List[Dict[str, Any]]) -> List[str]:
"""Who moved the branch since the sync last wrote it, as GitHub recorded.
Commits name their author; this names the account that pushed or merged
them, which is what shows how they got past the ruleset. Best effort: the
report goes out without it if the activity log cannot be read.
"""
try:
updates = gh.call("GET", f"/activity?ref=refs/heads/{branch}&per_page=100")
except ApiError as e:
print(f"Could not read {branch} activity: {e}")
return []
logins = [(u.get("actor") or {}).get("login", "unknown") for u in updates]
if gh.actor in logins:
since = range(logins.index(gh.actor))
else:
shas = {c["sha"] for c in foreign}
since = [i for i, u in enumerate(updates) if u.get("after") in shas]
return [
f"{updates[i]['timestamp'][:10]} {updates[i]['activity_type']} "
f"by {inert(logins[i])}"
for i in list(since)[:MAX_REPORTED]
]
def inert(text: str) -> str:
"""Quote text from a commit so it cannot mention, link or format.
Control characters go too: a carriage return in an author name would end
the quoted span in the issue and start a new line in the job log, where
the runner reads a line beginning with "::" as a command.
"""
flat = " ".join(CONTROL_CHARACTERS.sub(" ", text).split())
return "`" + flat.replace("`", "'")[:200] + "`"
def describe(commit: Dict[str, Any]) -> str:
subject = commit["message"].splitlines()[0] if commit["message"] else ""
author = commit["author"]["name"]
committer = commit["committer"]["name"]
who = author if author == committer else f"{author}, committed by {committer}"
return (
f"{commit['sha']} {inert(subject)} "
f"({inert(who)}, {commit['committer']['date'][:10]})"
)
def snapshot_message(source_branch: str, source_sha: str) -> str:
return (
f"docs: sync {DOCS_PATH}/ from {source_branch}@{source_sha[:10]}\n\n"
f"One-way copy for GitBook, written by docs-gitbook-sync.yml.\n"
f"Do not commit to this branch: change {DOCS_PATH}/ through a PR to dev.\n\n"
f"{SYNC_TRAILER}: {source_sha}"
)
def write_snapshot(
gh: GitHub, branch: str, parent: str, source_branch: str, source: Snapshot
) -> str:
tree = gh.call(
"POST",
"/git/trees",
{
"tree": [
{
"path": DOCS_PATH,
"mode": "040000",
"type": "tree",
"sha": source.docs_tree,
}
]
},
)
# No author or committer: GitHub then signs the commit as the App.
commit = gh.call(
"POST",
"/git/commits",
{
"message": snapshot_message(source_branch, source.sha),
"tree": tree["sha"],
"parents": [parent],
},
)
# Never forced: if the branch moved since `parent` was read, this fails and
# the next run starts over from the new head.
gh.call(
"PATCH", f"/git/refs/heads/{branch}", {"sha": commit["sha"], "force": False}
)
return commit["sha"]
def foreign_section(
gh: GitHub,
branch: str,
source_branch: str,
foreign: List[Dict[str, Any]],
reached_snapshot: bool,
) -> List[str]:
lines = [
f"`{branch}` is a one-way copy of `{DOCS_PATH}/` on `{source_branch}`, "
"written only by `.github/workflows/docs-gitbook-sync.yml`. "
"These commits were written to it by something else:",
"",
*(f"- {describe(c)}" for c in foreign[:MAX_REPORTED]),
]
if not reached_snapshot:
lines.append(
"- ...and the history below them. None of the sync's own commits "
f"is within {HISTORY_LIMIT} commits of the head, so the branch was "
"rewritten, recreated, or had another branch pushed over it."
)
elif len(foreign) > MAX_REPORTED:
lines.append(f"- ...and {len(foreign) - MAX_REPORTED} more")
updates = ref_updates(gh, branch, foreign)
if updates:
lines += ["", "How they got there:", "", *(f"- {u}" for u in updates)]
return lines + [
"",
f"This run now puts `{source_branch}`'s docs back on top of them, which "
"takes these changes off the published site. The commits stay in the "
"branch history. If that write fails, a second report follows here.",
"",
"To keep a change, open a PR against `dev` with it. Then check how the "
f"commit got past the ruleset that restricts updates to `{branch}`.",
]
def failed_section(branch: str, source_branch: str, error: SyncError) -> List[str]:
run = os.environ.get("GITHUB_RUN_ID")
where = (
f" ([run]({os.environ.get('GITHUB_SERVER_URL', 'https://github.com')}/"
f"{os.environ.get('GITHUB_REPOSITORY')}/actions/runs/{run}))"
if run
else ""
)
return [
f"The docs sync could not update `{branch}`{where}:",
"",
f" {error}",
"",
f"The published docs will not follow `{source_branch}` until this is fixed.",
]
def report(gh: GitHub, lines: List[str], notify: str, key: str) -> str:
"""File the report on the sync's open issue, or open one.
Only an issue this account opened is reused: the repository is public, so
anyone can open one with the same title.
`key` names what is being reported. The sync runs daily and on every push
to master, so a problem that persists would otherwise be posted again by
every run. A report whose key is already on the open issue is not repeated.
"""
marker = f"<!-- gitbook-sync:{key} -->"
body = "\n".join(lines + (["", f"cc {notify}"] if notify else []) + ["", marker])
creator = urllib.parse.quote(gh.actor)
mine = gh.call("GET", f"/issues?state=open&creator={creator}&per_page=100")
existing = [
i for i in mine if i["title"] == ISSUE_TITLE and "pull_request" not in i
]
if not existing:
created = gh.call(
"POST", "/issues", {"title": ISSUE_TITLE, "body": body}, retry=False
)
return created["html_url"]
issue = existing[0]
comments = gh.call("GET", f"/issues/{issue['number']}/comments?per_page=100")
said = [issue.get("body") or ""] + [c.get("body") or "" for c in comments]
if any(marker in text for text in said):
print(f"Already reported on {issue['html_url']}; not repeating it.")
else:
gh.call(
"POST", f"/issues/{issue['number']}/comments", {"body": body}, retry=False
)
return issue["html_url"]
def summarize(line: str) -> None:
print(line)
summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
if summary_path:
with open(summary_path, "a", encoding="utf-8") as f:
f.write(line + "\n")
def sync(
gh: GitHub, source_branch: str, target_branch: str, notify: str, dry_run: bool
) -> int:
source = read_snapshot(gh, branch_head(gh, source_branch))
if not source.docs_tree:
raise SyncError(
f"{source_branch}@{source.sha} has no {DOCS_PATH}/ directory to copy"
)
target = read_snapshot(gh, branch_head(gh, target_branch))
own = is_own_snapshot(gh, target)
if own and target.docs_tree == source.docs_tree:
summarize(
f"`{target_branch}` already holds `{DOCS_PATH}/` as of "
f"`{source_branch}@{source.sha[:10]}`. Nothing to do."
)
return 0
found: List[str] = []
if not own:
foreign, reached_snapshot = foreign_commits(gh, target.sha)
if reached_snapshot or has_written_before(gh, target_branch):
for commit in foreign[:MAX_REPORTED]:
print(f"::warning::Not written by the sync: {describe(commit)}")
found = foreign_section(
gh, target_branch, source_branch, foreign, reached_snapshot
)
else:
print(f"The sync has never written {target_branch}: first sync.")
if dry_run:
summarize(
f"Dry run: would write `{DOCS_PATH}/` from `{source_branch}@"
f"{source.sha[:10]}` on top of `{target_branch}@{target.sha[:10]}`"
+ (" and report commits the sync did not write." if found else ".")
)
return 0
if found:
# Reported before the branch moves. Once the snapshot is on top, the
# next run sees nothing wrong, so a report that failed after the write
# would never be sent.
url = report(gh, found, notify, key=f"foreign:{target.sha}")
summarize(f"Reported commits the sync did not write: {url}")
written = write_snapshot(gh, target_branch, target.sha, source_branch, source)
summarize(
f"Wrote `{DOCS_PATH}/` from `{source_branch}@{source.sha[:10]}` to "
f"`{target_branch}` as `{written[:10]}`."
)
if found:
print("Failing the run so that the foreign commits are noticed.")
return 1
return 0
def run(
gh: GitHub, source_branch: str, target_branch: str, notify: str, dry_run: bool
) -> int:
try:
return sync(gh, source_branch, target_branch, notify, dry_run)
except SyncError as e:
print(f"Error: {e}")
if dry_run:
return 1
# A sync that cannot run leaves the site stale with nobody watching,
# so say so where the foreign commits are reported.
try:
report(
gh,
failed_section(target_branch, source_branch, e),
notify,
key="failed:" + hashlib.sha1(str(e).encode()).hexdigest()[:12],
)
except SyncError as report_error:
print(f"Could not report the failure: {report_error}")
return 1
def main() -> int:
try:
gh = GitHub(
os.environ.get("GITHUB_API_URL", "https://api.github.com"),
os.environ["GITHUB_REPOSITORY"],
os.environ["GITHUB_TOKEN"],
os.environ["SYNC_ACTOR"],
)
except KeyError as e:
print(f"Error: Missing required environment variable: {e}")
return 1
return run(
gh,
source_branch=os.environ.get("SOURCE_BRANCH", "master"),
target_branch=os.environ.get("TARGET_BRANCH", "gitbook"),
notify=os.environ.get("NOTIFY", ""),
dry_run=os.environ.get("DRY_RUN", "").lower() in ("1", "true"),
)
if __name__ == "__main__":
sys.exit(main())