diff --git a/.github/scripts/update_docs.py b/.github/scripts/update_docs.py index a722c4f1a57..4052570c50a 100644 --- a/.github/scripts/update_docs.py +++ b/.github/scripts/update_docs.py @@ -1,11 +1,11 @@ """ update_docs.py - + Called by the "Update Docs" GitHub Actions workflow. Selects the correct file set based on the component and release type, sends each file to the Anthropic API with release context, and writes the updated content back to disk. - + Required environment variables: ANTHROPIC_API_KEY -- Anthropic API key (stored as a GitHub secret) COMPONENT -- "Server", "Mobile", or "Desktop" @@ -16,24 +16,26 @@ once per version in sequence. Enter oldest-to-newest so the newest entry ends up at the top of changelogs. RELEASE_DATE -- Human-readable release date, e.g. "May 15, 2026" - + Optional: ESR_END_DATE -- ESR end-of-support date, e.g. "November 15, 2026" """ - + import os import sys +import threading import anthropic - +from concurrent.futures import ThreadPoolExecutor, as_completed + # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- - + COMPONENT = os.environ["COMPONENT"] # Server | Mobile | Desktop RELEASE_TYPE = os.environ["RELEASE_TYPE"] # ESR | Feature Release | etc. RELEASE_DATE = os.environ["RELEASE_DATE"] ESR_END_DATE = os.environ.get("ESR_END_DATE", "").strip() - + # Parse VERSION as a comma-separated list so the workflow can be triggered once # for multi-version releases (e.g. security patches across several branches). # Each version is applied to every file in sequence; the output of one version @@ -42,7 +44,7 @@ if not VERSIONS: print("ERROR: VERSION environment variable is empty.") sys.exit(1) - + # Guard against accidental long lists (e.g. a typo that produces many tokens). # Each version multiplies API calls (one per file per version), so cap this early. MAX_VERSIONS = 5 @@ -50,18 +52,18 @@ print(f"ERROR: {len(VERSIONS)} versions given; maximum is {MAX_VERSIONS}.") print("Split into multiple workflow runs or fix the version list.") sys.exit(1) - + # Upper bound on Claude's output per file. Most docs files are a few thousand # tokens; 32 000 provides ample headroom without approaching the model's 64 k # output limit. Raise this if a truncation error is ever hit. MAX_TOKENS = 32000 - + # Maximum characters to send to Claude per file. Changelog files can grow very # large over time, but new entries always go near the top so only the head is # needed for context. The tail is preserved and re-appended after Claude's edit. # Increase if Claude needs more history; decrease to reduce token usage. MAX_SEND_CHARS = 50_000 - + # --------------------------------------------------------------------------- # File lists per component / release type # @@ -69,7 +71,7 @@ # - ESR: releases page + changelog + both install guides # - Patch/Dot: releases page + changelog only # --------------------------------------------------------------------------- - + # Files updated for every server release type. SERVER_BASE_FILES = [ "source/product-overview/mattermost-server-releases.md", @@ -85,7 +87,7 @@ "source/administration-guide/upgrade/important-upgrade-notes.rst", "source/product-overview/ui-ada-changelog.rst", ] - + # These changelog files are only updated for Patch / Dot releases and Security releases. # Feature and ESR releases have a dedicated changelog automation (generate-changelog.yml) # that handles them separately. @@ -95,23 +97,23 @@ "source/product-overview/mattermost-v10-changelog.md", "source/product-overview/mattermost-v11-changelog.md", ] - + MOBILE_FILES = [ "source/product-overview/mobile-app-changelog.md", "source/product-overview/mattermost-mobile-releases.md", ] - + DESKTOP_BASE_FILES = [ "source/product-overview/mattermost-desktop-releases.md", "source/product-overview/desktop-app-changelog.md", ] - + DESKTOP_ESR_EXTRA_FILES = [ "source/deployment-guide/desktop/linux-desktop-install.rst", "source/deployment-guide/desktop/desktop-msi-installer-and-group-policy-install.rst", ] - - + + def get_files() -> list[str]: if COMPONENT == "Server": if RELEASE_TYPE in ("Patch / Dot Release", "Security Release"): @@ -135,15 +137,15 @@ def get_files() -> list[str]: else: print(f"ERROR: Unknown component '{COMPONENT}'. Expected Server, Mobile, or Desktop.") sys.exit(1) - - + + # --------------------------------------------------------------------------- # Prompts # --------------------------------------------------------------------------- - + SYSTEM_PROMPT = """You are a technical documentation editor for Mattermost. Your job is to update documentation files for a new software release. - + Rules: - Follow the exact same formatting, style, and conventions already present in the file. - Add new entries in the correct location (usually at the top of a table or list, or after the most recent entry). @@ -152,8 +154,8 @@ def get_files() -> list[str]: - If a file does not need changes for this release type, return it exactly as-is. - Return ONLY the content you were given (the full file, or the provided portion if the user prompt says the file is truncated). No explanations, no markdown code fences, no preamble. """ - - + + def build_user_prompt(filepath: str, content: str, version: str, truncated: bool = False) -> str: esr_note = f"\n- ESR end-of-support date: {ESR_END_DATE}" if ESR_END_DATE else "" truncation_note = ( @@ -161,76 +163,89 @@ def build_user_prompt(filepath: str, content: str, version: str, truncated: bool f"{MAX_SEND_CHARS:,} characters. Return only this portion (updated as needed) -- " "do NOT attempt to reconstruct or append the rest of the file." ) if truncated else "" - + return f"""Update the following Mattermost documentation file for a new release. - + Release details: - Component: {COMPONENT} - Version: {version} - Release type: {RELEASE_TYPE} - Release date: {RELEASE_DATE}{esr_note} - + Use the release details and your knowledge of Mattermost documentation conventions \ to determine what changes are needed. If this release type does not affect this file, \ return it unchanged. - + File path: {filepath}{truncation_note} - + --- BEGIN FILE CONTENT --- {content} --- END FILE CONTENT --- - + Return the complete file content only.""" - - + + # --------------------------------------------------------------------------- # File update logic # --------------------------------------------------------------------------- - + +# Thread-safe print: keeps log lines from interleaving when files run in parallel. +_print_lock = threading.Lock() + +def tprint(*args, **kwargs): + with _print_lock: + print(*args, **kwargs) + + def update_file(client: anthropic.Anthropic, filepath: str) -> str: """Update a single documentation file via the Claude API. - + When VERSIONS contains multiple entries, the file is updated once per version in sequence: the output of version N becomes the input for version N+1. This keeps each Claude call small and well-defined, and ensures changelog entries accumulate correctly across versions. - - Returns one of: "updated", "unchanged", "not_found". - Version-level quality failures (empty response, too-short response) are logged - and skipped via continue -- they do not surface as a file-level status. - Raises on hard failures (I/O errors, API errors) so the caller can track them. + + Returns one of: "updated", "unchanged". + Raises on all failures (missing file, I/O errors, API errors, or response/content + validation errors) so the caller can track them and fail the workflow rather than + writing partial output. """ - print(f" Reading {filepath}...") + tprint(f"[{filepath}] Reading...") try: with open(filepath, "r", encoding="utf-8") as f: original = f.read() except FileNotFoundError: - print(f" WARNING: {filepath} not found -- skipping.") - return "not_found" - + # File paths come from fixed, curated lists -- a missing file means the + # repo layout has changed or the list is out of date. Raise so the run + # fails rather than opening a PR with incomplete release docs. + raise FileNotFoundError( + f"{filepath} not found. Update the file list in update_docs.py " + "if the path has changed, or check that the repo was checked out correctly." + ) + # Large changelogs only need recent context; new entries go near the top. # Capture the tail once from the original; it stays untouched across all versions. truncated = len(original) > MAX_SEND_CHARS tail = original[MAX_SEND_CHARS:] if truncated else "" if truncated: - print( - f" NOTE: File is {len(original):,} chars; " + tprint( + f"[{filepath}] NOTE: file is {len(original):,} chars; " f"sending first {MAX_SEND_CHARS:,} chars to Claude." ) - + # current holds the working head (without tail) updated after each version pass. current = original[:MAX_SEND_CHARS] if truncated else original any_changed = False - + for version in VERSIONS: - print(f" Sending to Claude (version {version})...") + tprint(f"[{filepath}] Sending to Claude (version {version})...") response = client.messages.create( model="claude-sonnet-4-6", max_tokens=MAX_TOKENS, system=SYSTEM_PROMPT, messages=[{"role": "user", "content": build_user_prompt(filepath, current, version, truncated)}], ) - + # --- API response integrity checks (raise -> file marked as failed) --- # These guard against malformed or truncated API responses before we touch # the file. They are distinct from the content-quality guards below, which @@ -245,19 +260,21 @@ def update_file(client: anthropic.Anthropic, filepath: str) -> str: f"Claude hit max_tokens ({MAX_TOKENS}) for {filepath} (version {version}); " "output is truncated. Increase MAX_TOKENS or split the file." ) - + updated = response.content[0].text - - # For truncated files, Claude should return only the head portion, but it may - # occasionally return slightly more. Cap the result at MAX_SEND_CHARS so that - # subsequent version passes don't receive an ever-growing prompt. + + # For truncated files, Claude should return only the head portion. + # If it returns more than MAX_SEND_CHARS, slicing before reattaching the + # original tail could silently drop content -- fail hard instead so the + # operator can investigate (e.g. raise MAX_SEND_CHARS or MAX_TOKENS). if truncated and len(updated) > MAX_SEND_CHARS: - print( - f" WARNING: Claude returned {len(updated):,} chars for {filepath} " - f"(version {version}); truncating to first {MAX_SEND_CHARS:,} chars." + raise RuntimeError( + f"Claude returned {len(updated):,} chars for {filepath} (version {version}), " + f"which exceeds the {MAX_SEND_CHARS:,}-char head limit. " + "Slicing would risk dropping content when the tail is reattached. " + "Increase MAX_SEND_CHARS or MAX_TOKENS and re-run." ) - updated = updated[:MAX_SEND_CHARS] - + # --- Content quality guards --- # These raise rather than continue so that a partial application (some versions # applied, some not) is surfaced as a hard failure. A PR opened with only some @@ -267,43 +284,43 @@ def update_file(client: anthropic.Anthropic, filepath: str) -> str: f"Claude returned empty content for {filepath} (version {version}). " "Aborting to prevent partial version application." ) - + if len(updated) < len(current) * 0.5: raise RuntimeError( f"Updated content for {filepath} (version {version}) is less than 50% of " "sent content length. Aborting to prevent partial version application or data loss." ) - + if updated.strip() == current.strip(): - print(f" No changes needed for version {version}.") + tprint(f"[{filepath}] No changes needed for version {version}.") continue - + # This version changed the file -- chain its output as input to the next version. current = updated any_changed = True - print(f" Version {version} applied.") - + tprint(f"[{filepath}] Version {version} applied.") + if not any_changed: - print(f" No changes needed -- {filepath} left as-is.") + tprint(f"[{filepath}] No changes needed -- left as-is.") return "unchanged" - + # Reconstruct: updated head + the untouched tail (if file was truncated) final_content = current + tail if truncated else current - + with open(filepath, "w", encoding="utf-8") as f: f.write(final_content) - - print(f" Done -- {filepath} updated.") + + tprint(f"[{filepath}] Done -- updated.") return "updated" - - + + # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- - + def main(): files = get_files() - + print(f"\nMattermost Docs Update") print(f" Component: {COMPONENT}") print(f" Release type: {RELEASE_TYPE}") @@ -315,7 +332,7 @@ def main(): for f in files: print(f" {f}") print() - + client = anthropic.Anthropic( api_key=os.environ["ANTHROPIC_API_KEY"], # Large files (e.g. the v11 changelog at 50 k chars input + up to 32 k output @@ -324,43 +341,50 @@ def main(): timeout=300.0, max_retries=2, # default is 2; made explicit for clarity ) - + results: dict[str, list[str]] = { "updated": [], "unchanged": [], - "skipped": [], - "not_found": [], } errors: list[tuple[str, str]] = [] - - for filepath in files: - print(f"Processing: {filepath}") - try: - status = update_file(client, filepath) - results[status].append(filepath) - except (OSError, anthropic.APIStatusError, anthropic.APIConnectionError, RuntimeError) as e: - print(f" ERROR [{type(e).__name__}] processing {filepath}: {e}") - errors.append((filepath, f"{type(e).__name__}: {e}")) - print() - + + # Process files in parallel -- each file is an independent Claude API call so + # there is no ordering constraint between files. Within a single file, versions + # are still applied sequentially (inside update_file) because each version's + # output becomes the next version's input. Cap workers at 5 to stay well within + # Anthropic API rate limits while keeping the speedup significant. + max_workers = min(len(files), 5) + print(f" Processing {len(files)} file(s) with up to {max_workers} parallel worker(s).\n") + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + future_to_file = { + executor.submit(update_file, client, fp): fp + for fp in files + } + for future in as_completed(future_to_file): + filepath = future_to_file[future] + try: + status = future.result() + results[status].append(filepath) + except (OSError, anthropic.APIStatusError, anthropic.APIConnectionError, RuntimeError) as e: + tprint(f"[{filepath}] ERROR [{type(e).__name__}]: {e}") + errors.append((filepath, f"{type(e).__name__}: {e}")) + print() + # Summary -- always printed so operators can see what actually happened print("--- Summary ---") print(f" Updated: {len(results['updated'])}") print(f" Unchanged: {len(results['unchanged'])}") - print(f" Skipped: {len(results['skipped'])} (warnings above)") - print(f" Not found: {len(results['not_found'])}") print(f" Errors: {len(errors)}") - + if errors: print(f"\n{len(errors)} file(s) failed:") for fp, err in errors: print(f" {fp}: {err}") sys.exit(1) - elif results["skipped"] or results["not_found"]: - print("\nCompleted with warnings -- review skipped/not-found files above.") else: print("\nAll files processed successfully.") - - + + if __name__ == "__main__": main() diff --git a/.github/workflows/update-docs.yml b/.github/workflows/update-docs.yml index 6364d995c29..619b4398035 100644 --- a/.github/workflows/update-docs.yml +++ b/.github/workflows/update-docs.yml @@ -1,5 +1,5 @@ name: Update Docs - + # Only members with write access to this repo can trigger workflow_dispatch. # To further restrict (e.g. require a specific team), protect the branch or # add an environment with required reviewers in repo Settings -> Environments. @@ -41,7 +41,7 @@ on: required: false type: boolean default: false - + jobs: update-docs: runs-on: ubuntu-latest @@ -52,7 +52,7 @@ jobs: # opening PRs. permissions: contents: read - + steps: # Changelog Automation (docs) app -- read/write access to mattermost/docs. # Reuses the same app and credentials as generate-changelog.yml. @@ -67,7 +67,7 @@ jobs: private-key: ${{ secrets.CHANGELOG_WRITE_PRIVATE_KEY }} owner: mattermost repositories: docs - + - name: Checkout repo uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -75,15 +75,15 @@ jobs: # Credentials are persisted in the git config (default behaviour) so that # the "Commit and push changes" step can push without extra auth setup. token: ${{ steps.app-token.outputs.token }} - + - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.11' - + - name: Install dependencies run: pip install "anthropic==0.101.0" - + - name: Run docs update script env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} @@ -93,7 +93,7 @@ jobs: RELEASE_DATE: ${{ inputs.release_date }} ESR_END_DATE: ${{ inputs.esr_end_date }} run: python .github/scripts/update_docs.py - + - name: Normalize version for branch naming id: vars shell: bash @@ -108,7 +108,7 @@ jobs: safe_version="$(printf '%s' "$VERSION_INPUT" | tr ', ' '-' | tr -cd '[:alnum:]._-' | tr -s '-')" test -n "$safe_version" echo "safe_version=$safe_version" >> "$GITHUB_OUTPUT" - + - name: Commit and push changes id: commit run: | @@ -126,7 +126,7 @@ jobs: git push --force-with-lease origin "docs/${{ inputs.component }}-v${{ steps.vars.outputs.safe_version }}" echo "changed=true" >> "$GITHUB_OUTPUT" fi - + - name: Create pull request if: steps.commit.outputs.changed == 'true' env: @@ -140,11 +140,19 @@ jobs: run: | DRAFT_FLAG="" [ "$PR_DRAFT" = "true" ] && DRAFT_FLAG="--draft" - + BRANCH="docs/${{ inputs.component }}-v${{ steps.vars.outputs.safe_version }}" - + # On re-runs the branch is force-pushed but the PR may already exist. # Check before creating to avoid a duplicate-PR error. + # Ensure the label exists (create it if absent). + # Checking first means a real failure in gh label create (auth, network, etc.) + # is not silently swallowed the way "|| true" would hide it. + gh label view "release-docs" >/dev/null 2>&1 || \ + gh label create "release-docs" \ + --color "0075ca" \ + --description "Release documentation update" + existing_pr="$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number')" if [ -n "$existing_pr" ]; then echo "PR #${existing_pr} already exists for ${BRANCH} -- skipping creation." @@ -163,7 +171,7 @@ jobs: echo "" echo "Please review all changes carefully before merging." } > pr_body.md - + gh pr create \ --title "${COMPONENT} v${VERSION} ${RELEASE_TYPE} Docs" \ --body-file pr_body.md \