Files
datastore/ci/jobs/docs_autogen_nightly.py
zeekayandHanzo Dev ee284e8075 finish the rebrand where a person actually reads it
The fork already renamed its internals - the built binary is datastore, the
packages are datastore-server and friends, and the shipped config.xml already
says Datastore - but the surfaces a human reads still said ClickHouse. Issue
templates asked which ClickHouse version was affected, the PR template told
contributors to close upstream's issues, and CI job names, log lines, error
messages and argparse descriptions named a product this repo does not ship.

Only prose moved: display names, log and error messages, comments, docstrings
and form labels. Everything a machine resolves stayed exactly as it was,
because renaming those breaks a build rather than a sentence - binary and path
tokens such as clickhouse-client and /etc/clickhouse-server, python and shell
identifiers, CLICKHOUSE_* environment variables, the /ClickHouse container
mount, docker images in a namespace we do not own, S3 buckets, maven and pypi
coordinates, and every URL pointing at upstream. The attribution in NOTICE and
README is untouched.

Two links were wrong rather than merely stale, so they are corrected here. The
PR template's Closes and Related placeholders sent contributors to upstream's
tracker instead of this one, and an earlier pass had rewritten a source
reference to github.com/Datastore/Datastore, which does not resolve.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-14 14:07:52 -07:00

207 lines
8.3 KiB
Python

import shlex
import sys
from ci.defs.defs import BASE_BRANCH
from praktika.git import Git
from praktika.info import Info
from praktika.result import Result
from praktika.utils import Shell
# The self-extracting master binary from the CH_ARM_BINARY artifact. Required
# artifacts are downloaded into the input dir under their basename, so it lands
# at ci/tmp/clickhouse (not the producer's build tree), as a plain file that
# must be made executable before use -- the same convention other consumers
# follow (e.g. ci/jobs/install_check.py). The generator resolves the path to an
# absolute one, so a repo-relative value is fine even though it runs the binary
# from a scratch directory.
BINARY = "ci/tmp/clickhouse"
GENERATOR = "ci/jobs/scripts/docs/autogenerate/autogenerate_docs.py"
# A stable branch, force-pushed each run, so the bot keeps a single open PR that
# always carries exactly one commit off the latest master.
BRANCH = "robot/docs-autogen"
# The label the docs check exempts from the generated-region guard (see
# ci/jobs/docs_job_mintlify.py). It also skips the changelog requirement.
# The label is provisioned once in the repository as part of the rollout (it
# already exists); the workflow only attaches it, so it stays the single source
# of truth for the label's color and description.
LABEL = "pr-autogenerated-docs"
TITLE = "Docs: regenerate reference documentation from source"
REVIEWERS = ("Blargian", "dhtclk")
BODY = """\
This pull request is opened automatically by the nightly documentation
autogeneration workflow. It regenerates settings, functions, table and database
engines, data types, formats, table functions, and window functions from the
structured documentation embedded in the Datastore source and exposed through
the corresponding `system.*` tables.
The generator preserves page frontmatter and hand-written content outside the
`{/*AUTOGENERATED_START*/}` / `{/*AUTOGENERATED_END*/}` regions. Some pages are
fully generated below their frontmatter. Do not edit generated content by hand --
edit the structured documentation in the defining source code instead; the next
nightly run regenerates the pages.
### Changelog category (leave one):
- Not for changelog (changelog entry is not required)
### Changelog entry (a [user-readable short description](https://github.com/ClickHouse/ClickHouse/blob/master/docs/changelog_entry_guidelines.md) of the changes that goes into CHANGELOG.md):
"""
def regenerate():
"""Regenerate the docs tree in place from the master binary.
The artifact downloader materializes a plain file, so make it executable
before invoking the generator."""
return Shell.check(
f"chmod +x {BINARY} && python3 {GENERATOR} --write --binary {BINARY} --docs-dir docs",
verbose=True,
)
def _open_bot_pr():
"""Return the number of the open bot pull request for BRANCH in the base
repository, or "" if none.
`gh pr list --head` matches by bare branch name, so a fork PR opened from a
branch also named BRANCH would show up too. The same head branch can also
have multiple open PRs when their base branches differ. Scope to the base
branch and filter to non-cross-repository PRs so we only ever act on the
bot's own PR to the intended base."""
return Shell.get_output(
f"gh pr list --head {BRANCH} --base {BASE_BRANCH} --state open"
" --json number,isCrossRepository"
" --jq 'map(select(.isCrossRepository == false)) | .[0].number // empty'"
).strip()
def _push_branch():
"""Force-push HEAD as the GitHub App so refreshed PRs trigger CI."""
return Git.push(
Info().repo_name,
f"HEAD:refs/heads/{BRANCH}",
force=True,
)
def open_or_refresh_pr():
"""Commit the regenerated docs and open (or refresh) the bot's pull request.
When regeneration produced no changes the docs are already in sync with the
source, so any pull request the bot opened earlier is now stale (someone may
have merged the same regeneration, or a later source change made the tree
match master again). Rather than leave it advertising outdated changes, the
stale pull request is closed and its branch deleted."""
if not Shell.get_output("git status --porcelain docs/").strip():
print("Docs already in sync with the source; no changes to publish.")
pr = _open_bot_pr()
if not pr:
return True
print(f"Closing stale bot pull request #{pr} and deleting branch {BRANCH}.")
return Shell.check(
f"gh pr close {pr} --delete-branch --comment "
+ shlex.quote(
"The generated documentation is back in sync with the source; "
"closing this stale autogenerated pull request."
),
verbose=True,
)
commit = [
"git config user.name 'robot-clickhouse'",
"git config user.email '[email protected]'",
f"git checkout -B {BRANCH}",
"git add -A docs/",
f"git commit -m {shlex.quote(TITLE)}",
]
if not Shell.check(" && ".join(commit), verbose=True):
return False
if not _push_branch():
return False
pr = _open_bot_pr()
if not pr:
# First run: no bot PR yet -- create it with the label and request the
# docs maintainers directly so the generated changes are reviewed.
reviewers = " ".join(
f"--reviewer {shlex.quote(reviewer)}" for reviewer in REVIEWERS
)
return Shell.check(
f"gh pr create --base {BASE_BRANCH} --head {BRANCH} "
f"--title {shlex.quote(TITLE)} --body {shlex.quote(BODY)} "
f"--label {LABEL} {reviewers}",
verbose=True,
)
# Existing PR: the force-push above already refreshed it. Ensure the label is
# present, addressing the PR by number so a same-named fork PR is untouched.
return Shell.check(f"gh pr edit {pr} --add-label {LABEL}", verbose=True)
def on_current_base_branch():
"""Refuse to run unless HEAD is the current tip of the base branch.
The workflow is SCHEDULE-triggered, but GitHub also generates a
`workflow_dispatch` trigger and reruns replay their original ref/sha. The
branch-name check rejects feature-branch dispatches; fetching the remote base
branch and comparing its tip with HEAD also rejects reruns of older `master`
commits. The check runs again after generation so a run cannot publish if
`master` advanced while it was working."""
branch = Info().git_branch
if branch != BASE_BRANCH:
print(
f"Refusing to run: this workflow force-pushes '{BRANCH}' and manages a"
f" pull request, so it must run against '{BASE_BRANCH}', not '{branch}'"
" (e.g. a manual workflow_dispatch or a rerun from another ref).",
file=sys.stderr,
)
return False
if not Shell.check(
f"git fetch --no-tags origin {shlex.quote(BASE_BRANCH)}", verbose=True
):
print(
f"Refusing to run: could not fetch the current '{BASE_BRANCH}' tip.",
file=sys.stderr,
)
return False
head = Shell.get_output("git rev-parse HEAD").strip()
current_base = Shell.get_output("git rev-parse FETCH_HEAD").strip()
if not head or not current_base or head != current_base:
print(
f"Refusing to run: HEAD ({head or 'unknown'}) is not the current "
f"'{BASE_BRANCH}' tip ({current_base or 'unknown'}).",
file=sys.stderr,
)
return False
return True
if __name__ == "__main__":
results = [
Result.from_commands_run(
name="Run against current master", command=on_current_base_branch
)
]
# Fail closed: only regenerate / touch the pull request when the guard and
# each prior step succeeded.
if results[-1].is_ok():
results.append(
Result.from_commands_run(name="Regenerate docs", command=regenerate)
)
if results[-1].is_ok():
results.append(
Result.from_commands_run(
name="Master is still current", command=on_current_base_branch
)
)
if results[-1].is_ok():
results.append(
Result.from_commands_run(
name="Open or refresh PR", command=open_or_refresh_pr
)
)
Result.create_from(results=results).complete_job()