From bf7c558a9a6d924ee55cc7769c7ceb2f4f478b36 Mon Sep 17 00:00:00 2001 From: Amy Blais <29708087+amyblais@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:28:53 +0300 Subject: [PATCH 01/15] Create update-docs.yml --- .github/workflows/update-docs.yml | 93 +++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 .github/workflows/update-docs.yml diff --git a/.github/workflows/update-docs.yml b/.github/workflows/update-docs.yml new file mode 100644 index 00000000000..54ebf88830c --- /dev/null +++ b/.github/workflows/update-docs.yml @@ -0,0 +1,93 @@ +name: Update Docs + +on: + workflow_dispatch: + inputs: + component: + description: 'Which component is releasing?' + required: true + type: choice + options: + - Server + - Mobile + - Desktop + release_type: + description: 'Release type' + required: true + type: choice + options: + - Feature Release + - ESR + - Security Release + - Patch / Dot Release + - Other + version: + description: 'Version number (e.g., 11.7, 2.40, 6.2, 5.13.6)' + required: true + type: string + release_date: + description: 'Release date (e.g., May 15, 2026)' + required: true + type: string + instructions: + description: 'Additional instructions (e.g., ESR end-of-support date, what changed, files to skip)' + required: false + type: string + pr_draft: + description: 'Open PR as draft?' + required: false + type: boolean + default: false + +jobs: + update-docs: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: pip install anthropic + + - name: Run docs update script + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + COMPONENT: ${{ inputs.component }} + RELEASE_TYPE: ${{ inputs.release_type }} + VERSION: ${{ inputs.version }} + RELEASE_DATE: ${{ inputs.release_date }} + INSTRUCTIONS: ${{ inputs.instructions }} + run: python .github/scripts/update_docs.py + + - name: Create pull request + uses: peter-evans/create-pull-request@v6 + with: + token: ${{ secrets.GITHUB_TOKEN }} + branch: docs/${{ inputs.component }}-v${{ inputs.version }} + title: '${{ inputs.component }} v${{ inputs.version }} ${{ inputs.release_type }} Docs' + body: | + ## ${{ inputs.component }} v${{ inputs.version }} ${{ inputs.release_type }} Documentation Update + + Auto-generated by the **Update Docs** workflow. + + **Release details:** + - Component: ${{ inputs.component }} + - Version: v${{ inputs.version }} + - Release type: ${{ inputs.release_type }} + - Release date: ${{ inputs.release_date }} + ${{ inputs.instructions != '' && format('- Notes: {0}', inputs.instructions) || '' }} + + Please review all changes carefully before merging. + commit-message: 'docs: update ${{ inputs.component }} files for v${{ inputs.version }} ${{ inputs.release_type }}' + labels: 'release-docs' + draft: ${{ inputs.pr_draft }} + From 836a33170324e679507045caf53b2e189ca81072 Mon Sep 17 00:00:00 2001 From: Amy Blais <29708087+amyblais@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:29:48 +0300 Subject: [PATCH 02/15] Create update_docs.py --- .github/scripts/update_docs.py | 217 +++++++++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 .github/scripts/update_docs.py diff --git a/.github/scripts/update_docs.py b/.github/scripts/update_docs.py new file mode 100644 index 00000000000..ebc4a42be84 --- /dev/null +++ b/.github/scripts/update_docs.py @@ -0,0 +1,217 @@ +""" +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" + RELEASE_TYPE — e.g. "ESR", "Feature Release", "Patch / Dot Release" + VERSION — Version number, e.g. "11.7", "2.40", "6.2", "5.13.6" + RELEASE_DATE — Human-readable release date, e.g. "May 15, 2026" + +Optional: + INSTRUCTIONS — Free-form additional context passed through to Claude +""" + +import os +import sys +import anthropic + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +COMPONENT = os.environ["COMPONENT"] # Server | Mobile | Desktop +RELEASE_TYPE = os.environ["RELEASE_TYPE"] # ESR | Feature Release | etc. +VERSION = os.environ["VERSION"] +RELEASE_DATE = os.environ["RELEASE_DATE"] +INSTRUCTIONS = os.environ.get("INSTRUCTIONS", "").strip() + +# --------------------------------------------------------------------------- +# File lists per component / release type +# +# Desktop ESR and Desktop Patch/Dot releases touch different files: +# - ESR: releases page + changelog + both install guides +# - Patch/Dot: releases page + changelog only +# --------------------------------------------------------------------------- + +SERVER_FILES = [ + "source/product-overview/mattermost-v11-changelog.md", + "source/product-overview/mattermost-server-releases.md", + "source/deployment-guide/server/linux/deploy-rhel.rst", + "source/deployment-guide/server/linux/deploy-tar.rst", + "source/product-overview/mattermost-desktop-releases.md", + "source/product-overview/release-policy.md", + "source/administration-guide/upgrade/open-source-components.rst", + "source/product-overview/deprecated-features.rst", + "source/deployment-guide/software-hardware-requirements.rst", + "source/administration-guide/upgrade/important-upgrade-notes.rst", + "source/product-overview/ui-ada-changelog.rst", +] + +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": + return SERVER_FILES + elif COMPONENT == "Mobile": + return MOBILE_FILES + elif COMPONENT == "Desktop": + if RELEASE_TYPE == "ESR": + return DESKTOP_BASE_FILES + DESKTOP_ESR_EXTRA_FILES + else: + # Patch / Dot Release, Feature Release, etc. — base files only + return DESKTOP_BASE_FILES + 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). +- Never remove or alter existing content unless it is explicitly outdated by this release. +- Do not add placeholder text, commentary, or notes — only real documentation content. +- If a file does not need changes for this release type, return it exactly as-is. +- Return ONLY the complete file content. No explanations, no markdown code fences, no preamble. +""" + + +def build_user_prompt(filepath: str, content: str) -> str: + extra = f"\n\nAdditional instructions:\n{INSTRUCTIONS}" if INSTRUCTIONS 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}{extra} + +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} + +--- BEGIN FILE CONTENT --- +{content} +--- END FILE CONTENT --- + +Return the complete file content only.""" + + +# --------------------------------------------------------------------------- +# File update logic +# --------------------------------------------------------------------------- + +def update_file(client: anthropic.Anthropic, filepath: str) -> None: + print(f" Reading {filepath}...") + try: + with open(filepath, "r", encoding="utf-8") as f: + original = f.read() + except FileNotFoundError: + print(f" WARNING: {filepath} not found — skipping.") + return + + print(f" Sending to Claude...") + response = client.messages.create( + model="claude-sonnet-4-6", + max_tokens=16000, + system=SYSTEM_PROMPT, + messages=[{"role": "user", "content": build_user_prompt(filepath, original)}], + ) + + updated = response.content[0].text + + # Safety: don't write empty content + if not updated.strip(): + print(f" ERROR: Claude returned empty content for {filepath} — skipping.") + return + + # Safety: skip if response is dramatically shorter (possible truncation) + if len(updated) < len(original) * 0.5: + print( + f" WARNING: Updated content for {filepath} is less than 50% of original " + "length. Skipping to avoid data loss." + ) + return + + # No-op: file is unchanged + if updated.strip() == original.strip(): + print(f" No changes needed — {filepath} left as-is.") + return + + with open(filepath, "w", encoding="utf-8") as f: + f.write(updated) + + print(f" Done — {filepath} updated.") + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main(): + files = get_files() + + print(f"\nMattermost Docs Update") + print(f" Component: {COMPONENT}") + print(f" Release type: {RELEASE_TYPE}") + print(f" Version: {VERSION}") + print(f" Release date: {RELEASE_DATE}") + if INSTRUCTIONS: + print(f" Instructions: {INSTRUCTIONS}") + print(f" Files ({len(files)}):") + for f in files: + print(f" {f}") + print() + + client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"]) + + errors = [] + for filepath in files: + print(f"Processing: {filepath}") + try: + update_file(client, filepath) + except Exception as e: + print(f" ERROR processing {filepath}: {e}") + errors.append((filepath, str(e))) + print() + + if errors: + print(f"\n{len(errors)} file(s) failed:") + for fp, err in errors: + print(f" {fp}: {err}") + sys.exit(1) + else: + print("All files processed successfully.") + + +if __name__ == "__main__": + main() From 2eed2fe31865cded3f9422b5ed81f46e5010255a Mon Sep 17 00:00:00 2001 From: Amy Blais <29708087+amyblais@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:33:15 +0300 Subject: [PATCH 03/15] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/update-docs.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/update-docs.yml b/.github/workflows/update-docs.yml index 54ebf88830c..d2df6b91210 100644 --- a/.github/workflows/update-docs.yml +++ b/.github/workflows/update-docs.yml @@ -48,10 +48,10 @@ jobs: steps: - name: Checkout repo - uses: actions/checkout@v4 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5 with: python-version: '3.11' From cb4dc947c3b8277bc7fb54a605a4d7e3e4d81c26 Mon Sep 17 00:00:00 2001 From: Amy Blais <29708087+amyblais@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:33:24 +0300 Subject: [PATCH 04/15] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/update-docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/update-docs.yml b/.github/workflows/update-docs.yml index d2df6b91210..1dfdc91ca80 100644 --- a/.github/workflows/update-docs.yml +++ b/.github/workflows/update-docs.yml @@ -69,7 +69,7 @@ jobs: run: python .github/scripts/update_docs.py - name: Create pull request - uses: peter-evans/create-pull-request@v6 + uses: peter-evans/create-pull-request@f230e4b8d3f1f2f2c8f2a9a0d6c1f3f3b7e3d1a0 # v6 with: token: ${{ secrets.GITHUB_TOKEN }} branch: docs/${{ inputs.component }}-v${{ inputs.version }} From 36da96bb2dca92eb83f10599209eefd8dbc4205a Mon Sep 17 00:00:00 2001 From: Amy Blais <29708087+amyblais@users.noreply.github.com> Date: Tue, 5 May 2026 13:22:16 +0300 Subject: [PATCH 05/15] Update update-docs.yml --- .github/workflows/update-docs.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/update-docs.yml b/.github/workflows/update-docs.yml index 1dfdc91ca80..f3b93bf500c 100644 --- a/.github/workflows/update-docs.yml +++ b/.github/workflows/update-docs.yml @@ -67,12 +67,20 @@ jobs: RELEASE_DATE: ${{ inputs.release_date }} INSTRUCTIONS: ${{ inputs.instructions }} run: python .github/scripts/update_docs.py + + - name: Normalize version for branch naming + id: vars + shell: bash + run: | + safe_version="$(printf '%s' "${{ inputs.version }}" | tr ' /' '--' | tr -cd '[:alnum:]._-')" + test -n "$safe_version" + echo "safe_version=$safe_version" >> "$GITHUB_OUTPUT" - name: Create pull request uses: peter-evans/create-pull-request@f230e4b8d3f1f2f2c8f2a9a0d6c1f3f3b7e3d1a0 # v6 with: token: ${{ secrets.GITHUB_TOKEN }} - branch: docs/${{ inputs.component }}-v${{ inputs.version }} + branch: docs/${{ inputs.component }}-v${{ steps.vars.outputs.safe_version }} title: '${{ inputs.component }} v${{ inputs.version }} ${{ inputs.release_type }} Docs' body: | ## ${{ inputs.component }} v${{ inputs.version }} ${{ inputs.release_type }} Documentation Update From 121da36a65003866cf7e296b40a494068f435a82 Mon Sep 17 00:00:00 2001 From: Amy Blais <29708087+amyblais@users.noreply.github.com> Date: Tue, 5 May 2026 13:24:57 +0300 Subject: [PATCH 06/15] Update update_docs.py --- .github/scripts/update_docs.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/.github/scripts/update_docs.py b/.github/scripts/update_docs.py index ebc4a42be84..1c36212006b 100644 --- a/.github/scripts/update_docs.py +++ b/.github/scripts/update_docs.py @@ -135,11 +135,10 @@ def update_file(client: anthropic.Anthropic, filepath: str) -> None: try: with open(filepath, "r", encoding="utf-8") as f: original = f.read() - except FileNotFoundError: - print(f" WARNING: {filepath} not found — skipping.") - return + except FileNotFoundError as exc: + raise FileNotFoundError(f"{filepath} not found") from exc - print(f" Sending to Claude...") + print(" Sending to Claude...") response = client.messages.create( model="claude-sonnet-4-6", max_tokens=16000, @@ -151,16 +150,13 @@ def update_file(client: anthropic.Anthropic, filepath: str) -> None: # Safety: don't write empty content if not updated.strip(): - print(f" ERROR: Claude returned empty content for {filepath} — skipping.") - return + raise RuntimeError(f"Claude returned empty content for {filepath}") # Safety: skip if response is dramatically shorter (possible truncation) if len(updated) < len(original) * 0.5: - print( - f" WARNING: Updated content for {filepath} is less than 50% of original " - "length. Skipping to avoid data loss." + raise RuntimeError( + f"Updated content for {filepath} is <50% of original length; refusing write." ) - return # No-op: file is unchanged if updated.strip() == original.strip(): @@ -180,7 +176,7 @@ def update_file(client: anthropic.Anthropic, filepath: str) -> None: def main(): files = get_files() - print(f"\nMattermost Docs Update") + print("\nMattermost Docs Update") print(f" Component: {COMPONENT}") print(f" Release type: {RELEASE_TYPE}") print(f" Version: {VERSION}") From 98a634cee529c3f0be6bb280d77989f5efb0cf79 Mon Sep 17 00:00:00 2001 From: Amy Blais <29708087+amyblais@users.noreply.github.com> Date: Fri, 8 May 2026 13:33:44 +0300 Subject: [PATCH 07/15] Update update_docs.py --- .github/scripts/update_docs.py | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/.github/scripts/update_docs.py b/.github/scripts/update_docs.py index 1c36212006b..589794addd2 100644 --- a/.github/scripts/update_docs.py +++ b/.github/scripts/update_docs.py @@ -135,10 +135,11 @@ def update_file(client: anthropic.Anthropic, filepath: str) -> None: try: with open(filepath, "r", encoding="utf-8") as f: original = f.read() - except FileNotFoundError as exc: - raise FileNotFoundError(f"{filepath} not found") from exc + except FileNotFoundError: + print(f" WARNING: {filepath} not found — skipping.") + return - print(" Sending to Claude...") + print(f" Sending to Claude...") response = client.messages.create( model="claude-sonnet-4-6", max_tokens=16000, @@ -146,17 +147,30 @@ def update_file(client: anthropic.Anthropic, filepath: str) -> None: messages=[{"role": "user", "content": build_user_prompt(filepath, original)}], ) + if not response.content or response.content[0].type != "text": + raise RuntimeError( + f"Unexpected API response structure for {filepath}: " + f"content={response.content!r}" + ) + if response.stop_reason == "max_tokens": + raise RuntimeError( + f"Claude hit max_tokens ({max_tokens}) for {filepath}; output is truncated. " + "Increase max_tokens or split the file." + ) updated = response.content[0].text # Safety: don't write empty content if not updated.strip(): - raise RuntimeError(f"Claude returned empty content for {filepath}") + print(f" ERROR: Claude returned empty content for {filepath} — skipping.") + return # Safety: skip if response is dramatically shorter (possible truncation) if len(updated) < len(original) * 0.5: - raise RuntimeError( - f"Updated content for {filepath} is <50% of original length; refusing write." + print( + f" WARNING: Updated content for {filepath} is less than 50% of original " + "length. Skipping to avoid data loss." ) + return # No-op: file is unchanged if updated.strip() == original.strip(): @@ -176,7 +190,7 @@ def update_file(client: anthropic.Anthropic, filepath: str) -> None: def main(): files = get_files() - print("\nMattermost Docs Update") + print(f"\nMattermost Docs Update") print(f" Component: {COMPONENT}") print(f" Release type: {RELEASE_TYPE}") print(f" Version: {VERSION}") @@ -195,9 +209,9 @@ def main(): print(f"Processing: {filepath}") try: update_file(client, filepath) - except Exception as e: - print(f" ERROR processing {filepath}: {e}") - errors.append((filepath, str(e))) + 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() if errors: From 7972411dce88a0dfff9158074c1b054f1980537a Mon Sep 17 00:00:00 2001 From: Amy Blais <29708087+amyblais@users.noreply.github.com> Date: Fri, 8 May 2026 13:37:17 +0300 Subject: [PATCH 08/15] Update update_docs.py --- .github/scripts/update_docs.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/scripts/update_docs.py b/.github/scripts/update_docs.py index 589794addd2..3f51c16b50e 100644 --- a/.github/scripts/update_docs.py +++ b/.github/scripts/update_docs.py @@ -147,6 +147,16 @@ def update_file(client: anthropic.Anthropic, filepath: str) -> None: messages=[{"role": "user", "content": build_user_prompt(filepath, original)}], ) + if not response.content or response.content[0].type != "text": + raise RuntimeError( + f"Unexpected API response structure for {filepath}: " + f"content={response.content!r}" + ) + if response.stop_reason == "max_tokens": + raise RuntimeError( + f"Claude hit max_tokens (16000) for {filepath}; output is truncated. " + "Increase max_tokens or split the file." + ) if not response.content or response.content[0].type != "text": raise RuntimeError( f"Unexpected API response structure for {filepath}: " From 518d76f1292b76a8c735a6eda34f54c65ff9ca8c Mon Sep 17 00:00:00 2001 From: Amy Blais <29708087+amyblais@users.noreply.github.com> Date: Fri, 8 May 2026 13:38:53 +0300 Subject: [PATCH 09/15] Update update-docs.yml --- .github/workflows/update-docs.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/update-docs.yml b/.github/workflows/update-docs.yml index f3b93bf500c..76797e51f58 100644 --- a/.github/workflows/update-docs.yml +++ b/.github/workflows/update-docs.yml @@ -67,7 +67,7 @@ jobs: RELEASE_DATE: ${{ inputs.release_date }} INSTRUCTIONS: ${{ inputs.instructions }} run: python .github/scripts/update_docs.py - + - name: Normalize version for branch naming id: vars shell: bash @@ -98,4 +98,3 @@ jobs: commit-message: 'docs: update ${{ inputs.component }} files for v${{ inputs.version }} ${{ inputs.release_type }}' labels: 'release-docs' draft: ${{ inputs.pr_draft }} - From 6896d533faebe06b61928dc5bbe93ffed8ac45a4 Mon Sep 17 00:00:00 2001 From: Amy Blais <29708087+amyblais@users.noreply.github.com> Date: Wed, 13 May 2026 12:05:41 +0300 Subject: [PATCH 10/15] Update update-docs.yml --- .github/workflows/update-docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/update-docs.yml b/.github/workflows/update-docs.yml index 76797e51f58..072c24d8ad6 100644 --- a/.github/workflows/update-docs.yml +++ b/.github/workflows/update-docs.yml @@ -56,7 +56,7 @@ jobs: python-version: '3.11' - name: Install dependencies - run: pip install anthropic + run: pip install "anthropic==0.101.0" - name: Run docs update script env: From c46f9fbb3d30dd915b33d40defa83d6eea9583cd Mon Sep 17 00:00:00 2001 From: Amy Blais <29708087+amyblais@users.noreply.github.com> Date: Thu, 21 May 2026 12:39:50 +0300 Subject: [PATCH 11/15] Update update_docs.py --- .github/scripts/update_docs.py | 35 ++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/.github/scripts/update_docs.py b/.github/scripts/update_docs.py index 3f51c16b50e..e7993993ca7 100644 --- a/.github/scripts/update_docs.py +++ b/.github/scripts/update_docs.py @@ -31,6 +31,11 @@ RELEASE_DATE = os.environ["RELEASE_DATE"] INSTRUCTIONS = os.environ.get("INSTRUCTIONS", "").strip() +# 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 + # --------------------------------------------------------------------------- # File lists per component / release type # @@ -44,6 +49,8 @@ "source/product-overview/mattermost-server-releases.md", "source/deployment-guide/server/linux/deploy-rhel.rst", "source/deployment-guide/server/linux/deploy-tar.rst", + # Included because server ESR releases update the compatible desktop version + # reference on this page. Remove if that is no longer the case. "source/product-overview/mattermost-desktop-releases.md", "source/product-overview/release-policy.md", "source/administration-guide/upgrade/open-source-components.rst", @@ -142,11 +149,15 @@ def update_file(client: anthropic.Anthropic, filepath: str) -> None: print(f" Sending to Claude...") response = client.messages.create( model="claude-sonnet-4-6", - max_tokens=16000, + max_tokens=MAX_TOKENS, system=SYSTEM_PROMPT, messages=[{"role": "user", "content": build_user_prompt(filepath, original)}], ) + # --- 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 + # are softer checks that skip a file with a warning rather than failing it. if not response.content or response.content[0].type != "text": raise RuntimeError( f"Unexpected API response structure for {filepath}: " @@ -154,21 +165,13 @@ def update_file(client: anthropic.Anthropic, filepath: str) -> None: ) if response.stop_reason == "max_tokens": raise RuntimeError( - f"Claude hit max_tokens (16000) for {filepath}; output is truncated. " - "Increase max_tokens or split the file." - ) - if not response.content or response.content[0].type != "text": - raise RuntimeError( - f"Unexpected API response structure for {filepath}: " - f"content={response.content!r}" - ) - if response.stop_reason == "max_tokens": - raise RuntimeError( - f"Claude hit max_tokens ({max_tokens}) for {filepath}; output is truncated. " - "Increase max_tokens or split the file." + f"Claude hit max_tokens ({MAX_TOKENS}) for {filepath}; output is truncated. " + "Increase MAX_TOKENS or split the file." ) + updated = response.content[0].text + # --- Content quality guards (skip with warning, file not marked failed) --- # Safety: don't write empty content if not updated.strip(): print(f" ERROR: Claude returned empty content for {filepath} — skipping.") @@ -212,7 +215,11 @@ def main(): print(f" {f}") print() - client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"]) + client = anthropic.Anthropic( + api_key=os.environ["ANTHROPIC_API_KEY"], + timeout=120.0, # seconds per request; prevents hung runs on slow responses + max_retries=2, # default is 2; made explicit for clarity + ) errors = [] for filepath in files: From 2958bade445382d8ca95ada3c74cd4147837a38f Mon Sep 17 00:00:00 2001 From: Amy Blais <29708087+amyblais@users.noreply.github.com> Date: Thu, 21 May 2026 12:39:59 +0300 Subject: [PATCH 12/15] Update update-docs.yml --- .github/workflows/update-docs.yml | 76 ++++++++++++++++++++++--------- 1 file changed, 54 insertions(+), 22 deletions(-) diff --git a/.github/workflows/update-docs.yml b/.github/workflows/update-docs.yml index 072c24d8ad6..49d3fe4545d 100644 --- a/.github/workflows/update-docs.yml +++ b/.github/workflows/update-docs.yml @@ -1,5 +1,9 @@ + · YML 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. on: workflow_dispatch: inputs: @@ -30,7 +34,7 @@ on: required: true type: string instructions: - description: 'Additional instructions (e.g., ESR end-of-support date, what changed, files to skip)' + description: 'Additional context for this release (e.g., "ESR end-of-support date: November 15, 2026"). Keep concise — appended to the Claude prompt as-is.' required: false type: string pr_draft: @@ -48,10 +52,10 @@ jobs: steps: - name: Checkout repo - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Python - uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.11' @@ -76,25 +80,53 @@ jobs: test -n "$safe_version" echo "safe_version=$safe_version" >> "$GITHUB_OUTPUT" - - name: Create pull request - uses: peter-evans/create-pull-request@f230e4b8d3f1f2f2c8f2a9a0d6c1f3f3b7e3d1a0 # v6 - with: - token: ${{ secrets.GITHUB_TOKEN }} - branch: docs/${{ inputs.component }}-v${{ steps.vars.outputs.safe_version }} - title: '${{ inputs.component }} v${{ inputs.version }} ${{ inputs.release_type }} Docs' - body: | - ## ${{ inputs.component }} v${{ inputs.version }} ${{ inputs.release_type }} Documentation Update + - name: Commit and push changes + id: commit + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git checkout -b "docs/${{ inputs.component }}-v${{ steps.vars.outputs.safe_version }}" + git add source/ + if git diff --cached --quiet; then + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "No files were modified — skipping PR creation." + else + git commit -m "docs: update ${{ inputs.component }} files for v${{ inputs.version }} ${{ inputs.release_type }}" + git push origin "docs/${{ inputs.component }}-v${{ steps.vars.outputs.safe_version }}" + echo "changed=true" >> "$GITHUB_OUTPUT" + fi - Auto-generated by the **Update Docs** workflow. + - name: Create pull request + if: steps.commit.outputs.changed == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COMPONENT: ${{ inputs.component }} + VERSION: ${{ inputs.version }} + RELEASE_TYPE: ${{ inputs.release_type }} + RELEASE_DATE: ${{ inputs.release_date }} + INSTRUCTIONS: ${{ inputs.instructions }} + PR_DRAFT: ${{ inputs.pr_draft }} + run: | + DRAFT_FLAG="" + [ "$PR_DRAFT" = "true" ] && DRAFT_FLAG="--draft" - **Release details:** - - Component: ${{ inputs.component }} - - Version: v${{ inputs.version }} - - Release type: ${{ inputs.release_type }} - - Release date: ${{ inputs.release_date }} - ${{ inputs.instructions != '' && format('- Notes: {0}', inputs.instructions) || '' }} + { + echo "## ${COMPONENT} v${VERSION} ${RELEASE_TYPE} Documentation Update" + echo "" + echo "Auto-generated by the **Update Docs** workflow." + echo "" + echo "**Release details:**" + echo "- Component: ${COMPONENT}" + echo "- Version: v${VERSION}" + echo "- Release type: ${RELEASE_TYPE}" + echo "- Release date: ${RELEASE_DATE}" + [ -n "${INSTRUCTIONS}" ] && echo "- Notes: ${INSTRUCTIONS}" + echo "" + echo "Please review all changes carefully before merging." + } > pr_body.md - Please review all changes carefully before merging. - commit-message: 'docs: update ${{ inputs.component }} files for v${{ inputs.version }} ${{ inputs.release_type }}' - labels: 'release-docs' - draft: ${{ inputs.pr_draft }} + gh pr create \ + --title "${COMPONENT} v${VERSION} ${RELEASE_TYPE} Docs" \ + --body-file pr_body.md \ + --label "release-docs" \ + $DRAFT_FLAG From a0e1d4fc128c73eb2174fdd49bf18c51e3d1c565 Mon Sep 17 00:00:00 2001 From: Amy Blais <29708087+amyblais@users.noreply.github.com> Date: Thu, 21 May 2026 12:44:25 +0300 Subject: [PATCH 13/15] Update update_docs.py --- .github/scripts/update_docs.py | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/scripts/update_docs.py b/.github/scripts/update_docs.py index e7993993ca7..f855533c375 100644 --- a/.github/scripts/update_docs.py +++ b/.github/scripts/update_docs.py @@ -50,7 +50,6 @@ "source/deployment-guide/server/linux/deploy-rhel.rst", "source/deployment-guide/server/linux/deploy-tar.rst", # Included because server ESR releases update the compatible desktop version - # reference on this page. Remove if that is no longer the case. "source/product-overview/mattermost-desktop-releases.md", "source/product-overview/release-policy.md", "source/administration-guide/upgrade/open-source-components.rst", From 46c0a09ab8cd5f2b2d374c390307597512f2b432 Mon Sep 17 00:00:00 2001 From: Amy Blais <29708087+amyblais@users.noreply.github.com> Date: Fri, 22 May 2026 12:24:37 +0300 Subject: [PATCH 14/15] Update update-docs.yml --- .github/workflows/update-docs.yml | 62 ++++++++++++++++++------------- 1 file changed, 37 insertions(+), 25 deletions(-) diff --git a/.github/workflows/update-docs.yml b/.github/workflows/update-docs.yml index 49d3fe4545d..e49ddd16818 100644 --- a/.github/workflows/update-docs.yml +++ b/.github/workflows/update-docs.yml @@ -1,4 +1,3 @@ - · YML name: Update Docs # Only members with write access to this repo can trigger workflow_dispatch. @@ -33,8 +32,8 @@ on: description: 'Release date (e.g., May 15, 2026)' required: true type: string - instructions: - description: 'Additional context for this release (e.g., "ESR end-of-support date: November 15, 2026"). Keep concise — appended to the Claude prompt as-is.' + esr_end_date: + description: 'ESR end-of-support date (ESR releases only, e.g., November 15, 2026)' required: false type: string pr_draft: @@ -69,7 +68,7 @@ jobs: RELEASE_TYPE: ${{ inputs.release_type }} VERSION: ${{ inputs.version }} RELEASE_DATE: ${{ inputs.release_date }} - INSTRUCTIONS: ${{ inputs.instructions }} + ESR_END_DATE: ${{ inputs.esr_end_date }} run: python .github/scripts/update_docs.py - name: Normalize version for branch naming @@ -92,7 +91,9 @@ jobs: echo "No files were modified — skipping PR creation." else git commit -m "docs: update ${{ inputs.component }} files for v${{ inputs.version }} ${{ inputs.release_type }}" - git push origin "docs/${{ inputs.component }}-v${{ steps.vars.outputs.safe_version }}" + # --force-with-lease safely overwrites the remote branch on re-runs + # without clobbering any concurrent pushes from other users. + git push --force-with-lease origin "docs/${{ inputs.component }}-v${{ steps.vars.outputs.safe_version }}" echo "changed=true" >> "$GITHUB_OUTPUT" fi @@ -104,29 +105,40 @@ jobs: VERSION: ${{ inputs.version }} RELEASE_TYPE: ${{ inputs.release_type }} RELEASE_DATE: ${{ inputs.release_date }} - INSTRUCTIONS: ${{ inputs.instructions }} + ESR_END_DATE: ${{ inputs.esr_end_date }} PR_DRAFT: ${{ inputs.pr_draft }} run: | DRAFT_FLAG="" [ "$PR_DRAFT" = "true" ] && DRAFT_FLAG="--draft" - { - echo "## ${COMPONENT} v${VERSION} ${RELEASE_TYPE} Documentation Update" - echo "" - echo "Auto-generated by the **Update Docs** workflow." - echo "" - echo "**Release details:**" - echo "- Component: ${COMPONENT}" - echo "- Version: v${VERSION}" - echo "- Release type: ${RELEASE_TYPE}" - echo "- Release date: ${RELEASE_DATE}" - [ -n "${INSTRUCTIONS}" ] && echo "- Notes: ${INSTRUCTIONS}" - echo "" - echo "Please review all changes carefully before merging." - } > pr_body.md + BRANCH="docs/${{ inputs.component }}-v${{ steps.vars.outputs.safe_version }}" - gh pr create \ - --title "${COMPONENT} v${VERSION} ${RELEASE_TYPE} Docs" \ - --body-file pr_body.md \ - --label "release-docs" \ - $DRAFT_FLAG + # On re-runs the branch is force-pushed but the PR may already exist. + # Check before creating to avoid a duplicate-PR error. + 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." + else + { + echo "## ${COMPONENT} v${VERSION} ${RELEASE_TYPE} Documentation Update" + echo "" + echo "Auto-generated by the **Update Docs** workflow." + echo "" + echo "**Release details:**" + echo "- Component: ${COMPONENT}" + echo "- Version: v${VERSION}" + echo "- Release type: ${RELEASE_TYPE}" + echo "- Release date: ${RELEASE_DATE}" + [ -n "${ESR_END_DATE}" ] && echo "- ESR end-of-support: ${ESR_END_DATE}" + 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 \ + --base master \ + --head "$BRANCH" \ + --label "release-docs" \ + $DRAFT_FLAG + fi From 68ef475d1c4828cbe3ae17ffce7788686b563f09 Mon Sep 17 00:00:00 2001 From: Amy Blais <29708087+amyblais@users.noreply.github.com> Date: Fri, 22 May 2026 12:25:00 +0300 Subject: [PATCH 15/15] Update update_docs.py --- .github/scripts/update_docs.py | 95 +++++++++++++++++++++++++--------- 1 file changed, 70 insertions(+), 25 deletions(-) diff --git a/.github/scripts/update_docs.py b/.github/scripts/update_docs.py index f855533c375..3f5eb5938bb 100644 --- a/.github/scripts/update_docs.py +++ b/.github/scripts/update_docs.py @@ -14,7 +14,7 @@ RELEASE_DATE — Human-readable release date, e.g. "May 15, 2026" Optional: - INSTRUCTIONS — Free-form additional context passed through to Claude + ESR_END_DATE — ESR end-of-support date, e.g. "November 15, 2026" """ import os @@ -29,13 +29,19 @@ RELEASE_TYPE = os.environ["RELEASE_TYPE"] # ESR | Feature Release | etc. VERSION = os.environ["VERSION"] RELEASE_DATE = os.environ["RELEASE_DATE"] -INSTRUCTIONS = os.environ.get("INSTRUCTIONS", "").strip() +ESR_END_DATE = os.environ.get("ESR_END_DATE", "").strip() # 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 # @@ -45,11 +51,11 @@ # --------------------------------------------------------------------------- SERVER_FILES = [ - "source/product-overview/mattermost-v11-changelog.md", "source/product-overview/mattermost-server-releases.md", "source/deployment-guide/server/linux/deploy-rhel.rst", "source/deployment-guide/server/linux/deploy-tar.rst", # Included because server ESR releases update the compatible desktop version + # reference on this page. Remove if that is no longer the case. "source/product-overview/mattermost-desktop-releases.md", "source/product-overview/release-policy.md", "source/administration-guide/upgrade/open-source-components.rst", @@ -109,7 +115,7 @@ def get_files() -> list[str]: def build_user_prompt(filepath: str, content: str) -> str: - extra = f"\n\nAdditional instructions:\n{INSTRUCTIONS}" if INSTRUCTIONS else "" + esr_note = f"\n- ESR end-of-support date: {ESR_END_DATE}" if ESR_END_DATE else "" return f"""Update the following Mattermost documentation file for a new release. @@ -117,7 +123,7 @@ def build_user_prompt(filepath: str, content: str) -> str: - Component: {COMPONENT} - Version: {VERSION} - Release type: {RELEASE_TYPE} -- Release date: {RELEASE_DATE}{extra} +- 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, \ @@ -136,21 +142,38 @@ def build_user_prompt(filepath: str, content: str) -> str: # File update logic # --------------------------------------------------------------------------- -def update_file(client: anthropic.Anthropic, filepath: str) -> None: +def update_file(client: anthropic.Anthropic, filepath: str) -> str: + """Update a single documentation file via the Claude API. + + Returns one of: "updated", "unchanged", "skipped", "not_found". + Raises on hard failures (I/O errors, API errors) so the caller can track them. + """ print(f" Reading {filepath}...") try: with open(filepath, "r", encoding="utf-8") as f: original = f.read() except FileNotFoundError: print(f" WARNING: {filepath} not found — skipping.") - return + return "not_found" + + # Large changelogs only need recent context; new entries go near the top. + # Send only the head and reconstruct the full file afterward. + truncated = len(original) > MAX_SEND_CHARS + send_content = original[:MAX_SEND_CHARS] if truncated else original + tail = original[MAX_SEND_CHARS:] if truncated else "" + + if truncated: + print( + f" NOTE: File is {len(original):,} chars; " + f"sending first {MAX_SEND_CHARS:,} chars to Claude." + ) print(f" Sending to Claude...") response = client.messages.create( model="claude-sonnet-4-6", max_tokens=MAX_TOKENS, system=SYSTEM_PROMPT, - messages=[{"role": "user", "content": build_user_prompt(filepath, original)}], + messages=[{"role": "user", "content": build_user_prompt(filepath, send_content)}], ) # --- API response integrity checks (raise → file marked as failed) --- @@ -170,29 +193,33 @@ def update_file(client: anthropic.Anthropic, filepath: str) -> None: updated = response.content[0].text - # --- Content quality guards (skip with warning, file not marked failed) --- + # --- Content quality guards (return "skipped", file not marked failed) --- # Safety: don't write empty content if not updated.strip(): - print(f" ERROR: Claude returned empty content for {filepath} — skipping.") - return + print(f" WARNING: Claude returned empty content for {filepath} — skipping.") + return "skipped" - # Safety: skip if response is dramatically shorter (possible truncation) - if len(updated) < len(original) * 0.5: + # Safety: skip if response is dramatically shorter than what was sent + if len(updated) < len(send_content) * 0.5: print( - f" WARNING: Updated content for {filepath} is less than 50% of original " - "length. Skipping to avoid data loss." + f" WARNING: Updated content for {filepath} is less than 50% of sent " + "content length. Skipping to avoid data loss." ) - return + return "skipped" - # No-op: file is unchanged - if updated.strip() == original.strip(): + # No-op: the sent portion is unchanged (compare against what was sent, not full file) + if updated.strip() == send_content.strip(): print(f" No changes needed — {filepath} left as-is.") - return + return "unchanged" + + # Reconstruct: Claude's updated head + the untouched tail (if file was truncated) + final_content = updated + tail if truncated else updated with open(filepath, "w", encoding="utf-8") as f: - f.write(updated) + f.write(final_content) print(f" Done — {filepath} updated.") + return "updated" # --------------------------------------------------------------------------- @@ -207,8 +234,8 @@ def main(): print(f" Release type: {RELEASE_TYPE}") print(f" Version: {VERSION}") print(f" Release date: {RELEASE_DATE}") - if INSTRUCTIONS: - print(f" Instructions: {INSTRUCTIONS}") + if ESR_END_DATE: + print(f" ESR end date: {ESR_END_DATE}") print(f" Files ({len(files)}):") for f in files: print(f" {f}") @@ -220,23 +247,41 @@ def main(): max_retries=2, # default is 2; made explicit for clarity ) - errors = [] + results: dict[str, list[str]] = { + "updated": [], + "unchanged": [], + "skipped": [], + "not_found": [], + } + errors: list[tuple[str, str]] = [] + for filepath in files: print(f"Processing: {filepath}") try: - update_file(client, filepath) + 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() + # 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("All files processed successfully.") + print("\nAll files processed successfully.") if __name__ == "__main__":