diff --git a/.github/scripts/update_docs.py b/.github/scripts/update_docs.py new file mode 100644 index 00000000000..3f5eb5938bb --- /dev/null +++ b/.github/scripts/update_docs.py @@ -0,0 +1,288 @@ +""" +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: + ESR_END_DATE — ESR end-of-support date, e.g. "November 15, 2026" +""" + +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"] +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 +# +# 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-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", + "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: + 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. + +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} + +--- BEGIN FILE CONTENT --- +{content} +--- END FILE CONTENT --- + +Return the complete file content only.""" + + +# --------------------------------------------------------------------------- +# File update logic +# --------------------------------------------------------------------------- + +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 "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, send_content)}], + ) + + # --- 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}: " + 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 + + # --- Content quality guards (return "skipped", file not marked failed) --- + # Safety: don't write empty content + if not updated.strip(): + print(f" WARNING: Claude returned empty content for {filepath} — skipping.") + return "skipped" + + # 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 sent " + "content length. Skipping to avoid data loss." + ) + return "skipped" + + # 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 "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(final_content) + + print(f" Done — {filepath} updated.") + return "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 ESR_END_DATE: + print(f" ESR end date: {ESR_END_DATE}") + print(f" Files ({len(files)}):") + for f in files: + print(f" {f}") + print() + + 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 + ) + + 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() + + # 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 new file mode 100644 index 00000000000..e49ddd16818 --- /dev/null +++ b/.github/workflows/update-docs.yml @@ -0,0 +1,144 @@ +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: + 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 + esr_end_date: + description: 'ESR end-of-support date (ESR releases only, e.g., November 15, 2026)' + 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - 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 }} + COMPONENT: ${{ inputs.component }} + RELEASE_TYPE: ${{ inputs.release_type }} + VERSION: ${{ inputs.version }} + 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 + run: | + safe_version="$(printf '%s' "${{ inputs.version }}" | tr ' /' '--' | tr -cd '[:alnum:]._-')" + test -n "$safe_version" + echo "safe_version=$safe_version" >> "$GITHUB_OUTPUT" + + - 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 }}" + # --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 + + - 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 }} + ESR_END_DATE: ${{ inputs.esr_end_date }} + PR_DRAFT: ${{ inputs.pr_draft }} + 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. + 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