From 7ad8f71bf50da156bf8ad3a8e1165d29b535dd93 Mon Sep 17 00:00:00 2001 From: Amy Blais <29708087+amyblais@users.noreply.github.com> Date: Tue, 9 Jun 2026 12:41:42 +0300 Subject: [PATCH] Automate schema migration release notes process (#36760) * Create migration-automation.yml * Create migration_automation.py * Update migration-automation.yml * Update migration_automation.py * Update migration-automation.yml * Update migration_automation.py * Update migration-automation.yml * Update migration_automation.py * Update migration-automation.yml * Update migration_automation.py * Update migration-automation.yml * Update migration_automation.py * Update migration-automation.yml * Update migration-automation.yml --------- Co-authored-by: Mattermost Build --- .github/scripts/migration_automation.py | 358 +++++++++++++++++++++ .github/workflows/migration-automation.yml | 76 +++++ 2 files changed, 434 insertions(+) create mode 100644 .github/scripts/migration_automation.py create mode 100644 .github/workflows/migration-automation.yml diff --git a/.github/scripts/migration_automation.py b/.github/scripts/migration_automation.py new file mode 100644 index 00000000000..e01ee294a99 --- /dev/null +++ b/.github/scripts/migration_automation.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 +""" +migration_automation.py + +Triggered by GitHub Actions when a new .up.sql file lands on master. + +Each Mattermost migration ships as a pair of files: NNNNNN_slug.up.sql +(applied on upgrade) and NNNNNN_slug.down.sql (applied on rollback). The +workflow filters on .up.sql files only because: + - The .up.sql is the canonical identifier for a new migration. + - Both files are committed together, so detecting the up file is enough + to locate the pair. + - We never want to trigger a separate review run for a down migration + that arrives without a matching up migration. + +For each new .up.sql file detected: + 1. Reads the .up.sql and its paired .down.sql (if present) from the repo + 2. Fetches the review-migration skill from the AI marketplace + 3. Calls Claude to produce the schema review report + 4. Calls Claude to produce the RST release note draft + changelog summary + 5. Appends the combined output to $GITHUB_STEP_SUMMARY so it renders + inline in the Actions run UI — no branch, PR, or extra secrets needed + +Required environment variables: + ANTHROPIC_API_KEY — repo secret + GITHUB_STEP_SUMMARY — file path provided automatically by GitHub Actions +""" + +import os +import re +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path + +import anthropic + + +# ── Config ──────────────────────────────────────────────────────────────────── + +MODEL = "claude-sonnet-4-6" + +# The marketplace URL intentionally points to /main so the script always uses +# the latest version of Ben Cooke's review-migration skill. Any push to +# mattermost-ai-marketplace/main WILL change the skill used by this workflow — +# that is by design. To pin to a specific revision instead, replace "main" +# with a commit SHA (e.g. "/abc1234/plugins/..."). +MARKETPLACE_BASE = ( + "https://raw.githubusercontent.com/mattermost/mattermost-ai-marketplace" + "/main/plugins/review-migration/skills/review-migration" +) +MM_GUIDE_URL = ( + "https://developers.mattermost.com/contribute/more-info/server/schema-migration-guide/" +) + +# Retry configuration +MAX_RETRIES = 3 +RETRY_BACKOFF_BASE = 2 # seconds; waits 2, 4, 8 between attempts + +# Allowlist pattern for migration file paths accepted by this script. +# Format: server/channels/db/migrations/postgres/NNNNNN_slug.up.sql +MIGRATION_PATH_RE = re.compile( + r"^server/channels/db/migrations/postgres/\d{6}_[\w-]+\.up\.sql$" +) + +RELEASE_NOTES_SKILL = """ +You are helping a Mattermost release manager write database migration release notes. + +When given a migration review report, produce two things: + +## 1. Release Note Block + +Three parts in this exact order: + +### Part A — Description +A clear, concise paragraph (2–5 sentences) describing what changed and why. +Write for database admins and self-hosters who need to understand the change at a glance. +Cover what tables/columns/indexes changed, the purpose, and any performance impact. +Do NOT include upgrade instructions here. + +Inline code formatting: use DOUBLE backticks for all table names, column names, and +identifiers in prose (RST/Sphinx style). E.g. ``roles``, ``schemeid``, ``permission_level``. +Never use single backticks in description prose. + +### Part B — Fixed compatibility statement (copy verbatim every time): +The migrations are fully backwards-compatible and no database downtime is expected for this upgrade. The SQL queries included are: + +### Part C — SQL in RST format (NOT markdown fences): +.. code-block:: sql + + + +For multiple SQL dialects use a separate labeled ``.. code-block:: sql`` block for each. + +## 2. One-Line Changelog Summary + +**Changelog summary:** `` +""" + + +# ── Startup validation ──────────────────────────────────────────────────────── + +def validate_env() -> str: + """ + Validate required env vars and return the GITHUB_STEP_SUMMARY path. + Exits with a clear error message if any required var is missing. + + ANTHROPIC_API_KEY is validated here so we fail fast with a readable + message rather than an opaque SDK error, but it is not returned — + the Anthropic SDK reads it directly from the environment. + """ + required = { + "ANTHROPIC_API_KEY": "Anthropic API key (repo secret)", + "GITHUB_STEP_SUMMARY": "Job summary file path (provided automatically by Actions)", + } + missing = [ + f" {var} ({desc})" + for var, desc in required.items() + if not os.environ.get(var, "").strip() + ] + if missing: + print("ERROR: The following required environment variables are not set:\n") + for m in missing: + print(m) + sys.exit(1) + + return os.environ["GITHUB_STEP_SUMMARY"] + + +# ── Input validation ────────────────────────────────────────────────────────── + +def validate_migration_paths(paths: list[str]) -> None: + """ + Server-side validation: reject any path that does not match the canonical + migration file pattern. Defense-in-depth against path traversal or + unexpected input if the workflow filter is ever bypassed. + """ + invalid = [p for p in paths if not MIGRATION_PATH_RE.match(p)] + if invalid: + print( + "ERROR: The following paths do not match the expected migration pattern " + f"({MIGRATION_PATH_RE.pattern}):" + ) + for p in invalid: + print(f" {p!r}") + print("Aborting — only files under the canonical migrations directory are accepted.") + sys.exit(1) + + +# ── Retry helper ────────────────────────────────────────────────────────────── + +def _is_retryable_http_error(code: int) -> bool: + return code in (429, 500, 502, 503, 504) + + +def with_retry(fn, *, label: str, retries: int = MAX_RETRIES): + """Call fn() up to `retries` times with exponential backoff.""" + last_exc = None + for attempt in range(1, retries + 1): + try: + return fn() + except urllib.error.HTTPError as e: + last_exc = e + if not _is_retryable_http_error(e.code): + raise + wait = RETRY_BACKOFF_BASE ** attempt + print(f" [{label}] HTTP {e.code} on attempt {attempt}/{retries}. " + f"Retrying in {wait}s…") + time.sleep(wait) + except (urllib.error.URLError, TimeoutError, OSError) as e: + last_exc = e + wait = RETRY_BACKOFF_BASE ** attempt + print(f" [{label}] Network error on attempt {attempt}/{retries}: {e}. " + f"Retrying in {wait}s…") + time.sleep(wait) + except anthropic.RateLimitError as e: + last_exc = e + wait = RETRY_BACKOFF_BASE ** attempt + print(f" [{label}] Anthropic rate limit on attempt {attempt}/{retries}. " + f"Retrying in {wait}s…") + time.sleep(wait) + except anthropic.APIStatusError as e: + last_exc = e + if e.status_code not in (429, 500, 502, 503, 529): + raise + wait = RETRY_BACKOFF_BASE ** attempt + print(f" [{label}] Anthropic API error {e.status_code} on attempt " + f"{attempt}/{retries}. Retrying in {wait}s…") + time.sleep(wait) + raise last_exc + + +# ── HTTP helpers ────────────────────────────────────────────────────────────── + +def fetch_url(url: str) -> str: + def _do(): + req = urllib.request.Request(url, headers={"User-Agent": "migration-bot/1.0"}) + with urllib.request.urlopen(req, timeout=30) as r: + return r.read().decode() + return with_retry(_do, label=f"GET {url}") + + +# ── Claude calls ────────────────────────────────────────────────────────────── + +def call_claude(system: str, user: str) -> str: + # No explicit api_key — the SDK reads ANTHROPIC_API_KEY from the environment + # automatically, keeping the key out of the call stack and reducing the + # chance of accidental logging. + client = anthropic.Anthropic() + + def _do(): + msg = client.messages.create( + model=MODEL, + max_tokens=4096, + system=system, + messages=[{"role": "user", "content": user}], + ) + return msg.content[0].text + + return with_retry(_do, label="Anthropic API") + + +def run_review( + up_sql: str, + down_sql: str, + skill_md: str, + reference_md: str, + guide_text: str, +) -> str: + system = f"""You are a Mattermost database migration reviewer. + +Your instructions (the review-migration skill): +{skill_md} + +Supplementary internal reference: +{reference_md} + +Official Mattermost DB Migration Guide (source of truth for rules and lock types): +{guide_text[:12000]} +""" + user = f"""Review this migration and produce the full report in the markdown +template format from your instructions. + +## Up migration (.up.sql): +```sql +{up_sql} +``` + +## Down migration (.down.sql): +```sql +{down_sql} +``` +""" + return call_claude(system, user) + + +def run_release_notes(review: str) -> str: + user = f"""Based on this migration review, produce the formatted release note +block and one-line changelog summary. + +## Migration Review: +{review} +""" + return call_claude(RELEASE_NOTES_SKILL, user) + + +# ── Processing ──────────────────────────────────────────────────────────────── + +def process( + up_path: Path, + skill_md: str, + reference_md: str, + guide_text: str, +) -> str: + """Run both skills for one migration. Returns the combined markdown.""" + up_sql = up_path.read_text(encoding="utf-8") + down_path = up_path.with_name(up_path.name.replace(".up.sql", ".down.sql")) + down_sql = down_path.read_text(encoding="utf-8") if down_path.exists() else "(no down migration)" + + print(" Running review-migration skill…") + review = run_review(up_sql, down_sql, skill_md, reference_md, guide_text) + + print(" Running migration-release-notes skill…") + release_notes = run_release_notes(review) + + return f"{review}\n\n---\n\n## Release Note Draft\n\n{release_notes}" + + +# ── Job Summary output ──────────────────────────────────────────────────────── + +def write_summary(summary_path: str, sections: list[tuple[str, str]]) -> None: + """Append all review sections to the GitHub Actions job summary file.""" + with open(summary_path, "a", encoding="utf-8") as f: + f.write("# 🔍 Migration Review & Release Notes\n\n") + for name, content in sections: + f.write(f"## `{name}`\n\n") + f.write(content) + f.write("\n\n---\n\n") + print(f"\nSummary written to job summary ({len(sections)} migration(s)).") + + +# ── Main ────────────────────────────────────────────────────────────────────── + +def main() -> None: + summary_path = validate_env() + + new_files = [f for f in sys.argv[1:] if f.strip()] + if not new_files: + print("No new migration files provided. Exiting.") + return + + validate_migration_paths(new_files) + + print(f"Processing {len(new_files)} migration(s): {new_files}\n") + + # Fetch shared resources once + print("Fetching review-migration skill from AI marketplace…") + try: + skill_md = fetch_url(f"{MARKETPLACE_BASE}/SKILL.md") + reference_md = fetch_url(f"{MARKETPLACE_BASE}/reference.md") + except Exception as e: + print( + f"\nERROR: Could not fetch review-migration skill.\n" + f"URL: {MARKETPLACE_BASE}\nReason: {e}" + ) + sys.exit(1) + + print("Fetching Mattermost DB Migration Guide…") + try: + guide_text = fetch_url(MM_GUIDE_URL) + except Exception as e: + print(f"WARNING: Could not fetch DB Migration Guide ({e}). Proceeding without it.") + guide_text = "" + + print() + + # Process each migration + sections: list[tuple[str, str]] = [] + for filepath in new_files: + up_path = Path(filepath) + print(f"→ {up_path.name}") + try: + content = process(up_path, skill_md, reference_md, guide_text) + except anthropic.AuthenticationError as e: + print(f"\nERROR: Anthropic authentication failed. " + f"Check that ANTHROPIC_API_KEY is valid.\nDetail: {e}") + sys.exit(1) + except Exception as e: + print(f"\nERROR: Failed to process {up_path.name}: {e}") + sys.exit(1) + sections.append((up_path.name, content)) + + write_summary(summary_path, sections) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/migration-automation.yml b/.github/workflows/migration-automation.yml new file mode 100644 index 00000000000..e729c70726e --- /dev/null +++ b/.github/workflows/migration-automation.yml @@ -0,0 +1,76 @@ +name: Migration Review & Release Notes + +# Fires whenever a new .up.sql file lands on master. +# Runs the review-migration and migration-release-notes skills via Claude +# and writes the combined output to the job summary, visible directly in +# the Actions run UI — no GitHub App, PAT, or special permissions needed. +# +# Required secrets: +# ANTHROPIC_API_KEY — already a repo secret + +on: + push: + branches: [master] + paths: + - 'server/channels/db/migrations/postgres/**.up.sql' + +permissions: + contents: read # checkout only + +# Only one run at a time per branch. If a new push to master arrives while +# a review is already in progress, the in-progress run is cancelled — the +# new run will cover the full push range anyway, avoiding duplicate API spend. +concurrency: + group: migration-review-${{ github.ref }} + cancel-in-progress: true + +jobs: + generate: + name: Generate migration review & release notes + runs-on: ubuntu-latest + + steps: + - name: Checkout + # actions/checkout v6.0.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + fetch-depth: 0 # full history — covers multi-commit pushes + persist-credentials: false + + - name: Set up Python + # actions/setup-python v6.2.0 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 + with: + python-version: '3.12' + + - name: Install dependencies + # Pin anthropic to a known-good version for reproducible builds. + # Bump intentionally when upgrading. + run: pip install anthropic==0.105.2 + + - name: Detect and process new migrations + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + PUSH_BASE_SHA: ${{ github.event.before }} + run: | + set -euo pipefail + + # Filter on .up.sql only: each migration is identified by its up + # file; the paired .down.sql is read by the script if present. + # Triggering on up files avoids duplicate runs when both files + # arrive in the same push. + MIGRATIONS_GLOB='server/channels/db/migrations/postgres/*.up.sql' + mapfile -t files < <( + git diff --name-only --diff-filter=A \ + "$PUSH_BASE_SHA" "$GITHUB_SHA" -- "$MIGRATIONS_GLOB" + ) + + if [ "${#files[@]}" -eq 0 ]; then + echo "No new .up.sql files — skipping." + exit 0 + fi + + echo "Found ${#files[@]} new migration(s):" + printf ' %s\n' "${files[@]}" + + python .github/scripts/migration_automation.py "${files[@]}"