Automations for config.json, API, audit log event, and Go release notes (#36075)
Some checks are pending
API / build (push) Waiting to run
Server CI / Compute Go Version (push) Waiting to run
Server CI / Check mocks (push) Blocked by required conditions
Server CI / Check go mod tidy (push) Blocked by required conditions
Server CI / Check go fix (push) Blocked by required conditions
Server CI / check-style (push) Blocked by required conditions
Server CI / Check serialization methods for hot structs (push) Blocked by required conditions
Server CI / Vet API (push) Blocked by required conditions
Server CI / Check migration files (push) Blocked by required conditions
Server CI / Generate email templates (push) Blocked by required conditions
Server CI / Check store layers (push) Blocked by required conditions
Server CI / Check mmctl docs (push) Blocked by required conditions
Server CI / Postgres (shard 0) (push) Blocked by required conditions
Server CI / Postgres (shard 1) (push) Blocked by required conditions
Server CI / Postgres (shard 2) (push) Blocked by required conditions
Server CI / Postgres (shard 3) (push) Blocked by required conditions
Server CI / Merge Postgres Test Results (push) Blocked by required conditions
Server CI / Elasticsearch v8 Compatibility (push) Blocked by required conditions
Server CI / Postgres FIPS (shard 0) (push) Blocked by required conditions
Server CI / Postgres FIPS (shard 1) (push) Blocked by required conditions
Server CI / Postgres FIPS (shard 2) (push) Blocked by required conditions
Server CI / Postgres FIPS (shard 3) (push) Blocked by required conditions
Server CI / Merge Postgres FIPS Test Results (push) Blocked by required conditions
Server CI / Run mmctl tests (push) Blocked by required conditions
Server CI / Run mmctl tests (FIPS) (push) Blocked by required conditions
Server CI / Build mattermost server app (push) Blocked by required conditions
Tools CI / check-style (mattermost-govet) (push) Waiting to run
Tools CI / Test (mattermost-govet) (push) Waiting to run
Web App CI / check-lint (push) Waiting to run
Web App CI / check-i18n (push) Blocked by required conditions
Web App CI / check-external-links (push) Blocked by required conditions
Web App CI / check-types (push) Blocked by required conditions
Web App CI / test (platform) (push) Blocked by required conditions
Web App CI / test (mattermost-redux) (push) Blocked by required conditions
Web App CI / test (channels shard 1/4) (push) Blocked by required conditions
Web App CI / test (channels shard 2/4) (push) Blocked by required conditions
Web App CI / test (channels shard 3/4) (push) Blocked by required conditions
Web App CI / test (channels shard 4/4) (push) Blocked by required conditions
Web App CI / upload-coverage (push) Blocked by required conditions
Web App CI / build (push) Blocked by required conditions
YAML Lint / yamllint (push) Waiting to run

* Create config-change-checker.yml

* Create check_config_changes_ci.py

* Update config-change-checker.yml

* Update check_config_changes_ci.py

* Update check_config_changes_ci.py

* Update check_config_changes_ci.py

* Update check_config_changes_ci.py

* Update config-change-checker.yml

* Update check_config_changes_ci.py

* Update config-change-checker.yml

* Update config.go

* Fix check_api to detect multi-line and multi-method endpoints

The previous implementation matched the .Handle(...).Methods(...) regex
line-by-line against diff lines. This silently missed two real and
common patterns in api4/:

  1. Multi-line .Handle(...) declarations — e.g. group.go has 18 of
     them, where the path lives on one line and the wrapper/handler on
     the next. The regex never matched, so PRs adding such endpoints
     produced empty release-note entries.

  2. Multi-method declarations like
     .Methods(http.MethodGet, http.MethodHead) (4 instances in file.go)
     — the old regex required a closing paren immediately after the
     first method.

The fix:

  - Add a file_at(ref, path) helper that snapshots a file at a git ref
    via 'git show', so checkers can compare full file states instead of
    pattern-matching diff text.
  - Add _scan_endpoints() that whitespace-collapses the file before
    matching, letting the regex span what were originally multiple
    lines.
  - Loosen _HANDLE_RE to capture the methods list as a substring and
    extract individual HTTP verbs with a known-method allowlist, so
    multi-method declarations produce one entry per verb.
  - Switch check_api to set-diff (after - before) / (before - after)
    on the parsed endpoint sets. This also cleanly handles routes
    that move within a file (no fragile add/remove dedup needed).
  - Anchor the new/deleted file detection to '^new file mode \d+' to
    avoid false positives from stray text in source files.

Made-with: Cursor

* Track enclosing struct in check_config to avoid dedup collisions

The previous check_config keyed its add/remove dedup on the bare field
name. The dedup intent was to ignore fields that were merely reordered
within config.go (which appear in the diff as both '-Foo' and '+Foo').

But because the key was just the field name, an unrelated rename in one
struct could silently cancel out a real new field with the same name in
a different struct. For example, in a single PR:

    -    EnableFoo *bool   // removed from ServiceSettings
    +    EnableFooV2 *bool

    -    EnableBar *bool   // removed from EmailSettings
    +    EnableFoo *bool   // newly added — but wrongly cancelled below

The dedup would see 'EnableFoo' in both lists and drop both entries,
hiding the brand-new EmailSettings.EnableFoo from the release-note
output.

The fix tracks each field's enclosing struct using a brace-depth stack
that walks the file at BASE_SHA and HEAD_SHA. Fields are keyed as
(struct_name, field_name) tuples, so identically-named fields in
different structs are distinct, and the dedup only collapses true
reorderings. As a side benefit the rendered output is now
'StructName.FieldName' which is much more useful to reviewers.

Switching to file-at-revision scanning + set diff also removes the
custom dedup logic entirely — set arithmetic handles "moved within
file" naturally.

Made-with: Cursor

* Switch remaining checkers to file-at-revision style; drop lines_by_sign

check_audit_events and check_go_version still parsed +/- diff lines
directly, with the same brittle dedup-and-cancel logic that was used in
the previous check_config. After the previous two commits the rest of
the file uses the file_at(ref, path) helper to compare full file
states between BASE_SHA and HEAD_SHA, which:

  - removes the entire moved-within-file dedup dance (set arithmetic
    handles it for free),
  - aligns all four checkers on a single, easy-to-reason-about pattern,
  - is robust to whitespace-only or reordering edits in the watched
    files.

For Dockerfile.buildenv the helper also avoids a subtle case where the
old code only inspected +/- lines: an edit to an unrelated RUN line
that didn't touch the FROM line could in theory leave both old_ver and
new_ver as None even though the version was effectively unchanged.
Reading the file at each revision compares the actual current and
previous FROM line directly.

The lines_by_sign helper now has no callers, so remove it.

Made-with: Cursor

* Update config.go

* Update config.go

* Update check_config_changes_ci.py

* Update check_config_changes_ci.py

* Update check_config_changes_ci.py

* Update check_config_changes_ci.py

* Tighten check_config_changes_ci.py: regex coverage + idempotency

- Restore tolerant `_HANDLE_RE` so 2-arg wrappers (e.g. `api.APISessionRequired(handler, handlerParamFileAPI)`)
  are not silently dropped from the api4 endpoint scan; broaden the `.Methods(...)`
  capture so string-literal variants (`Methods("GET")`) work too. Filtering moves
  back to the `_HTTP_METHODS` allowlist in `_parse_methods` to keep stray
  identifiers from being treated as HTTP verbs.
- Make `strip_old_note` also remove auto-generated lines that landed outside
  the ```release-note fence (the inject_note fallback paths) so reruns no
  longer accumulate duplicates when a PR has no fence.
- Skip the GitHub PATCH when the PR description is already up to date, so
  every commit no longer triggers an unconditional write.
- Wire up `check_go_version`'s `additions` path in `_format_lines` and
  `_AUTO_LINE_RE` so a freshly-added Dockerfile.buildenv emits a note.
- Remove the now-dead `CheckResult.to_markdown` method (replaced by
  `_format_lines`).

Made-with: Cursor

* Restore ExperimentalSettings.EnableWatermark

The field was removed in f71527f0b1 but `server/config/client.go`,
`server/config/client_test.go`, and `server/public/model/config_test.go`
still reference it (added on master in #36025). Restoring the field
makes the branch compile again so CI can go green.

Made-with: Cursor

* Replace placeholder release-note content (NONE / N/A) on injection

The script previously appended its auto-detected lines INSIDE the
```release-note fence but never displaced template placeholders, so PRs
that only had `NONE` ended up with output like:

    NONE
    Added `Foo.Bar` configuration setting.
    Go runtime updated from 1.25.8 to 1.25.9.

When the existing fence content is empty or consists only of placeholder
tokens (NONE, N/A, NA, dashes — case-insensitive), replace it entirely
with the auto-detected entries. User-written human content is still
preserved by appending instead.

Idempotent: stripping followed by re-injection keeps the placeholder
visible when there's nothing to inject, and replaces it again when there
is.

Made-with: Cursor

* Update config-change-checker.yml

* Update check_config_changes_ci.py

---------

Co-authored-by: Your Name <eva.sarafianou@gmail.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Amy Blais 2026-05-19 12:04:25 +03:00 committed by GitHub
parent 9d318dc4cd
commit 0675d0ea0b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 656 additions and 0 deletions

View file

@ -0,0 +1,590 @@
#!/usr/bin/env python3
"""
.github/scripts/check_config_changes_ci.py
CI script that detects notable changes across several Mattermost source files
and appends structured release-note entries to the PR description.
Checkers
1. config.go exported struct field additions/removals
2. api4/ API endpoint additions/removals (Handle() calls)
3. audit_events.go AuditEvent* constant additions/removals
4. Dockerfile.buildenv Go (base-image) version changes
All inputs come from environment variables set by the GitHub Actions workflow:
GITHUB_TOKEN built-in Actions token (pull-requests: write scope)
PR_NUMBER pull request number
BASE_SHA base commit SHA
HEAD_SHA head commit SHA
REPO owner/repo (e.g. mattermost/mattermost)
"""
import os
import re
import sys
import subprocess
import requests
from dataclasses import dataclass, field
from typing import Optional
# ── Environment ────────────────────────────────────────────────────────────────
GITHUB_TOKEN = os.environ["GITHUB_TOKEN"]
PR_NUMBER = int(os.environ["PR_NUMBER"])
BASE_SHA = os.environ["BASE_SHA"]
HEAD_SHA = os.environ["HEAD_SHA"]
REPO = os.environ.get("REPO", "mattermost/mattermost")
BASE_URL = "https://api.github.com"
HEADERS = {
"Authorization": f"token {GITHUB_TOKEN}",
"Accept": "application/vnd.github.v3+json",
}
# Timeout for all GitHub API requests: (connect seconds, read seconds).
# Prevents the workflow from hanging indefinitely on a slow/unresponsive API.
_TIMEOUT = (5, 30)
# Paths watched by this script (must align with `paths:` in the workflow YAML)
WATCHED_PATHS = [
"server/public/model/config.go",
"server/channels/api4/",
"server/public/model/audit_events.go",
"server/build/Dockerfile.buildenv",
]
# ── Data types ─────────────────────────────────────────────────────────────────
@dataclass
class CheckResult:
"""Holds the findings from one checker."""
label: str # Section heading, e.g. "`config.json` Changes"
additions: list = field(default_factory=list)
removals: list = field(default_factory=list)
changes: list = field(default_factory=list) # for free-form entries (version bumps)
def has_findings(self) -> bool:
return bool(self.additions or self.removals or self.changes)
def to_markdown(self) -> str:
lines = [f"### {self.label}"]
if self.additions:
lines.append("**Added:** " + ", ".join(self.additions))
if self.removals:
lines.append("**Removed:** " + ", ".join(self.removals))
for change in self.changes:
lines.append(change)
return "\n".join(lines)
# ── Diff helpers ───────────────────────────────────────────────────────────────
def get_full_patch() -> str:
"""Return unified diff for all watched paths between base and head."""
result = subprocess.run(
["git", "diff", f"{BASE_SHA}...{HEAD_SHA}", "--"] + WATCHED_PATHS,
capture_output=True,
text=True,
check=True,
)
return result.stdout
def split_patch_by_file(full_patch: str) -> dict[str, str]:
"""
Split a multi-file unified diff into {filename: patch} mapping.
Filenames are the b-side (new) path, stripped of the 'b/' prefix.
"""
patches: dict[str, str] = {}
current_file: Optional[str] = None
current_lines: list[str] = []
for line in full_patch.splitlines(keepends=True):
if line.startswith("diff --git "):
if current_file:
patches[current_file] = "".join(current_lines)
current_lines = [line]
# Extract filename from "diff --git a/foo b/foo"
m = re.search(r" b/(.+)$", line.rstrip())
current_file = m.group(1) if m else None
else:
current_lines.append(line)
if current_file:
patches[current_file] = "".join(current_lines)
return patches
def file_at(ref: str, path: str) -> str:
"""Return the full contents of `path` at git ref `ref`, or '' if absent."""
try:
return subprocess.run(
["git", "show", f"{ref}:{path}"],
capture_output=True, text=True, check=True,
).stdout
except subprocess.CalledProcessError:
return ""
# ── Checker 1 — config.go ──────────────────────────────────────────────────────
_CONFIG_PATH = "server/public/model/config.go"
_STRUCT_DECL_RE = re.compile(r"^type\s+(\w+)\s+struct\s*\{")
_FIELD_LINE_RE = re.compile(r"^\t([A-Z][A-Za-z0-9_]*)\s+\S")
def _scan_struct_fields(src: str) -> set[tuple[str, str]]:
"""
Walk Go source and return {(StructName, FieldName)} for every exported
field in every struct.
Uses a brace-depth stack so nested anonymous structs, interface bodies,
and function literals don't corrupt the enclosing struct context.
Named type declarations cannot be nested in Go, so the struct_stack
never grows beyond one entry for named structs.
"""
fields: set[tuple[str, str]] = set()
# Each entry: (struct_name, brace_depth_when_opened)
struct_stack: list[tuple[str, int]] = []
depth = 0
for line in src.splitlines():
sm = _STRUCT_DECL_RE.match(line)
if sm:
# Record depth *before* counting this line's braces
struct_stack.append((sm.group(1), depth))
depth += line.count("{") - line.count("}")
# Pop any structs whose closing brace has been passed
while struct_stack and depth <= struct_stack[-1][1]:
struct_stack.pop()
# Record fields only when we're directly inside exactly one named struct
if len(struct_stack) == 1:
fm = _FIELD_LINE_RE.match(line)
if fm:
fields.add((struct_stack[0][0], fm.group(1)))
return fields
def check_config(patches: dict[str, str]) -> CheckResult:
"""
Detect exported Go struct field additions/removals in config.go.
Compares full-file snapshots at BASE_SHA and HEAD_SHA so that fields
are always attributed to the correct struct regardless of which diff
hunks are present.
"""
result = CheckResult(label="`config.json` Field Changes")
if _CONFIG_PATH not in patches:
return result
base_fields = _scan_struct_fields(file_at(BASE_SHA, _CONFIG_PATH))
head_fields = _scan_struct_fields(file_at(HEAD_SHA, _CONFIG_PATH))
added = head_fields - base_fields
removed = base_fields - head_fields
result.additions = sorted(f"`{s}.{f}`" for s, f in added)
result.removals = sorted(f"`{s}.{f}`" for s, f in removed)
return result
# ── Checker 2 — api4/ ─────────────────────────────────────────────────────────
# Matches Handle() route registrations after whitespace-collapsing the source.
# Whitespace collapse makes multi-line declarations single-searchable.
# Group 1: path Group 2: handler func Group 3: raw Methods(...) content
#
# The wrapper pattern uses [^)]* so it tolerates any middleware arguments
# (e.g. r.APIHandler(...), r.ApiSessionRequired(..., isLocal=true), etc.)
# without having to enumerate every possible wrapper signature.
_HANDLE_RE = re.compile(
r'\.Handle\("([^"]*)"' # path
r',\s*[^)]*\((\w+)\)\)' # wrapper(...handlerFunc))
r'\.Methods\(([^)]+)\)', # .Methods(one or more methods)
)
_METHOD_RE = re.compile(r'(?:http\.Method)?(\w+)')
_HTTP_METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}
def _parse_methods(raw: str) -> list[str]:
"""Split raw Methods(...) content into individual uppercase HTTP verbs.
Filters against the set of known HTTP methods so that incidental
identifiers (handler names, constants, etc.) that happen to appear
inside Methods(...) don't produce spurious results.
"""
return [
verb
for token in raw.split(",")
if (m := _METHOD_RE.search(token.strip()))
and (verb := m.group(1).upper()) in _HTTP_METHODS
]
def _format_endpoint(path: str, handler: str, method: str) -> str:
return f"`{method.upper()} {path or '/'}` (`{handler}`)"
def _parse_endpoints(src: str) -> set[tuple[str, str, str]]:
"""
Parse Handle() registrations from a Go source file.
Whitespace-collapses the entire file first so multi-line declarations
(e.g. the 18 in group.go) are matched as a single token sequence.
Returns {(path, handler, method)} tuples.
"""
blob = " ".join(src.split())
endpoints: set[tuple[str, str, str]] = set()
for m in _HANDLE_RE.finditer(blob):
path, handler, methods_raw = m.group(1), m.group(2), m.group(3)
for method in _parse_methods(methods_raw):
endpoints.add((path or "/", handler, method))
return endpoints
def check_api(patches: dict[str, str]) -> CheckResult:
"""
Detect API endpoint additions/removals in the api4/ directory.
Compares full-file snapshots at BASE_SHA and HEAD_SHA via set arithmetic,
so multi-line and multi-method registrations are handled correctly.
"""
result = CheckResult(label="API Changes (`api4`)")
api4_patches = {
fname: patch
for fname, patch in patches.items()
if fname.startswith("server/channels/api4/") and fname.endswith(".go")
}
if not api4_patches:
return result
added_eps: set[tuple[str, str, str]] = set()
removed_eps: set[tuple[str, str, str]] = set()
for fname, patch in api4_patches.items():
base_eps = _parse_endpoints(file_at(BASE_SHA, fname))
head_eps = _parse_endpoints(file_at(HEAD_SHA, fname))
added_eps |= head_eps - base_eps
removed_eps |= base_eps - head_eps
# Anchor the check to avoid false positives from unrelated source text
if re.search(r"^new file mode \d+", patch, re.MULTILINE):
result.changes.append(f"🆕 New API file: `{fname.split('/')[-1]}`")
if re.search(r"^deleted file mode \d+", patch, re.MULTILINE):
result.changes.append(f"🗑️ Removed API file: `{fname.split('/')[-1]}`")
result.additions = sorted(_format_endpoint(p, h, m) for p, h, m in added_eps)
result.removals = sorted(_format_endpoint(p, h, m) for p, h, m in removed_eps)
return result
# ── Checker 3 — audit_events.go ───────────────────────────────────────────────
_AUDIT_EVENT_PATH = "server/public/model/audit_events.go"
_AUDIT_CONST_RE = re.compile(r"^\t(AuditEvent\w+)\s*=")
def _parse_audit_events(src: str) -> set[str]:
return {m.group(1) for line in src.splitlines() if (m := _AUDIT_CONST_RE.match(line))}
def check_audit_events(patches: dict[str, str]) -> CheckResult:
"""
Detect AuditEvent* constant additions/removals.
Uses full-file snapshots at BASE_SHA/HEAD_SHA so reorderings and
cross-constant name collisions don't produce false results.
"""
result = CheckResult(label="Audit Log Event Changes")
if _AUDIT_EVENT_PATH not in patches:
return result
base_events = _parse_audit_events(file_at(BASE_SHA, _AUDIT_EVENT_PATH))
head_events = _parse_audit_events(file_at(HEAD_SHA, _AUDIT_EVENT_PATH))
result.additions = sorted(f"`{e}`" for e in head_events - base_events)
result.removals = sorted(f"`{e}`" for e in base_events - head_events)
return result
# ── Checker 4 — Dockerfile.buildenv (Go version) ──────────────────────────────
# The Go version lives in the base image tag, e.g.:
# FROM mattermost/golang-bullseye:1.25.8@sha256:...
_DOCKERFILE_PATH = "server/build/Dockerfile.buildenv"
_IMAGE_VER_RE = re.compile(r"^FROM \S+:([0-9]+\.[0-9]+(?:\.[0-9]+)?)")
def _parse_go_version(src: str) -> Optional[str]:
for line in src.splitlines():
m = _IMAGE_VER_RE.match(line.strip())
if m:
return m.group(1)
return None
def check_go_version(patches: dict[str, str]) -> CheckResult:
"""
Detect Go runtime version changes via the base image tag.
Uses full-file snapshots so the version is read from the actual file
state at each ref rather than reconstructed from patch lines.
"""
result = CheckResult(label="Go Runtime Version")
if _DOCKERFILE_PATH not in patches:
return result
old_ver = _parse_go_version(file_at(BASE_SHA, _DOCKERFILE_PATH))
new_ver = _parse_go_version(file_at(HEAD_SHA, _DOCKERFILE_PATH))
if old_ver and new_ver and old_ver != new_ver:
result.changes.append(f"Go updated: `{old_ver}` → `{new_ver}`")
elif new_ver and not old_ver:
result.additions.append(f"`{new_ver}`")
return result
# ── PR description helpers ─────────────────────────────────────────────────────
# Matches lines that were auto-generated by this script so they can be stripped
# before re-injecting a fresh set on subsequent commits.
_AUTO_LINE_RE = re.compile(
r"^(Added|Removed) `[^`]+`.*(configuration setting|API endpoint|audit log event)\."
r"|^Go runtime updated from \S+ to \S+\."
r"|^Go runtime set to `[^`]+`\."
r"|^🆕 New API file:"
r"|^🗑️ Removed API file:"
)
# Matches placeholder content inside a release-note fence that means "nothing
# to report yet" (e.g. NONE, N/A, ---). When detected, we replace the
# placeholder rather than appending alongside it.
_PLACEHOLDER_RE = re.compile(r"^\s*(?:NONE|N/?A|-+)\s*$", re.IGNORECASE)
def _format_lines(result: CheckResult) -> list[str]:
"""Produce natural-language lines for one checker result."""
lines = []
if "`config.json`" in result.label:
for item in result.additions:
lines.append(f"Added {item} configuration setting.")
for item in result.removals:
lines.append(f"Removed {item} configuration setting.")
elif "API Changes" in result.label:
for item in result.additions:
lines.append(f"Added {item} API endpoint.")
for item in result.removals:
lines.append(f"Removed {item} API endpoint.")
lines.extend(result.changes) # new/deleted file entries
elif "Audit" in result.label:
for item in result.additions:
lines.append(f"Added {item} audit log event.")
for item in result.removals:
lines.append(f"Removed {item} audit log event.")
elif "Go Runtime" in result.label:
for item in result.additions:
# item is e.g. "`1.22`" — strip backticks for the prose form
lines.append(f"Go runtime set to {item}.")
for c in result.changes:
# c arrives as "Go updated: `1.21` → `1.22`" — rewrite it
m = re.search(r"`([^`]+)`\s*→\s*`([^`]+)`", c)
if m:
lines.append(f"Go runtime updated from {m.group(1)} to {m.group(2)}.")
else:
lines.append(c)
return lines
def build_pr_note(results: list[CheckResult]) -> str:
"""Assemble all findings into a clean plain-text block."""
lines = []
for r in results:
if r.has_findings():
lines.extend(_format_lines(r))
return "\n".join(lines)
def strip_old_note(body: str) -> str:
"""
Remove previously auto-generated lines from the PR description.
Primary path lines inside the ```release-note ... ``` fence.
Fallback path auto-generated lines that were appended outside any fence
(e.g. via the ## Release Notes section on earlier runs).
Lines are identified by pattern rather than visible markers, so the PR
description stays clean for human readers.
"""
def _clean_fence(m: re.Match) -> str:
open_tag, content, close_tag = m.group(1), m.group(2), m.group(3)
cleaned_lines = [
line for line in content.split("\n")
if not _AUTO_LINE_RE.match(line.strip())
]
return open_tag + "\n".join(cleaned_lines) + close_tag
cleaned = re.sub(
r"(```release-note)(.*?)(```)",
_clean_fence,
body or "",
flags=re.DOTALL | re.IGNORECASE,
)
# Fallback: strip any auto-generated lines that appear outside a fence
# (written by an older version of this script or via the header-inject path).
cleaned_lines = [
line for line in cleaned.splitlines()
if not _AUTO_LINE_RE.match(line.strip())
]
return "\n".join(cleaned_lines).rstrip()
def inject_note(body: str, note: str) -> str:
"""
Insert `note` using this priority order:
1. INSIDE the ```release-note block, before its closing ```
(Mattermost convention keeps everything in one place for reviewers)
2. After a recognised release-notes section header (## Release Notes, etc.)
3. Fallback: append a new ## Release Notes section at the end
"""
body = strip_old_note(body)
if not note:
return body
# 1. Mattermost-style ```release-note ... ``` block — inject INSIDE the fence.
# If the fence currently contains only a placeholder (NONE / N/A / ---),
# replace the placeholder rather than appending alongside it.
release_note_block = re.search(
r"(```release-note)(.*?)(```)",
body,
flags=re.DOTALL | re.IGNORECASE,
)
if release_note_block:
open_tag = release_note_block.group(1)
content = release_note_block.group(2) # everything between the fences
close_tag = release_note_block.group(3)
block_start = release_note_block.start()
block_end = release_note_block.end()
# Strip leading/trailing newlines inside the fence for comparison
inner = content.strip()
if _PLACEHOLDER_RE.match(inner):
# Replace the entire fence with a fresh one
new_block = f"{open_tag}\n{note}\n{close_tag}"
else:
# Append before the closing fence
new_block = open_tag + content + note + "\n" + close_tag
return body[:block_start] + new_block + body[block_end:]
# 2. Markdown section headers
for header in ["## Release Notes", "## Changelog", "## What Changed", "## What's Changed"]:
if header.lower() in body.lower():
idx = body.lower().index(header.lower()) + len(header)
return body[:idx] + "\n\n" + note + body[idx:]
# 3. Fallback — append
return body + "\n\n## Release Notes\n\n" + note
# ── GitHub API ─────────────────────────────────────────────────────────────────
def get_pr_body() -> str:
r = requests.get(
f"{BASE_URL}/repos/{REPO}/pulls/{PR_NUMBER}",
headers=HEADERS,
timeout=_TIMEOUT,
)
r.raise_for_status()
return r.json().get("body") or ""
def update_pr_body(new_body: str) -> None:
r = requests.patch(
f"{BASE_URL}/repos/{REPO}/pulls/{PR_NUMBER}",
headers=HEADERS,
json={"body": new_body},
timeout=_TIMEOUT,
)
r.raise_for_status()
# ── Main ───────────────────────────────────────────────────────────────────────
def main():
print(f"📋 PR #{PR_NUMBER} | base {BASE_SHA[:8]} → head {HEAD_SHA[:8]}")
print("🔍 Collecting diffs …")
full_patch = get_full_patch()
if not full_patch.strip():
print(" No changes in watched paths. Nothing to do.")
return
patches = split_patch_by_file(full_patch)
print(f" {len(patches)} file(s) changed in watched paths.\n")
# Run all checkers
checkers = [
check_config,
check_api,
check_audit_events,
check_go_version,
]
results: list[CheckResult] = [fn(patches) for fn in checkers]
for r in results:
if r.has_findings():
print(f"{r.label}")
if r.additions:
print(f" Added: {', '.join(r.additions)}")
if r.removals:
print(f" Removed: {', '.join(r.removals)}")
for c in r.changes:
print(f" {c}")
else:
print(f" {r.label}: no changes")
note = build_pr_note(results)
if not note:
print("\n No notable changes found across all checkers.")
return
print("\n🔄 Fetching PR description …")
body = get_pr_body()
new_body = inject_note(body, note)
if new_body == body:
print(" PR description already up to date — no changes needed.")
return
update_pr_body(new_body)
print(f"✅ PR #{PR_NUMBER} description updated.")
if __name__ == "__main__":
try:
main()
except subprocess.CalledProcessError as e:
print(f"❌ git diff failed:\n{e.stderr}", file=sys.stderr)
sys.exit(1)
except requests.HTTPError as e:
# Avoid dumping the full response body (can be large / noisy).
# status + reason gives enough context for debugging (e.g. "403 Forbidden").
reason = e.response.reason or "unknown"
print(f"❌ GitHub API error: {e.response.status_code} {reason}", file=sys.stderr)
sys.exit(1)

View file

@ -0,0 +1,66 @@
# .github/workflows/config-change-checker.yml
#
# Automatically detects notable additions/removals across four source files
# and appends structured release-note entries to the PR description under
# the "## Release Notes" section.
#
# Tracked files / directories:
# • server/public/model/config.go — config struct field changes
# • server/channels/api4/ — API endpoint additions/removals
# • server/public/model/audit_events.go — audit log event constant changes
# • server/build/Dockerfile.buildenv — Go runtime version changes
#
# No secrets needed — uses the built-in GITHUB_TOKEN.
name: Config Change Checker
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- 'server/public/model/config.go'
- 'server/channels/api4/**'
- 'server/public/model/audit_events.go'
- 'server/build/Dockerfile.buildenv'
# Cancel any in-progress run for the same PR when a new commit is pushed.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
check-release-notes:
name: Detect release-note-worthy changes
runs-on: ubuntu-latest
# Skip bot-authored PRs (Dependabot, mattermost-bot, etc.) — they will
# not touch these paths intentionally and cannot receive description updates
# via GITHUB_TOKEN anyway (fork-like restrictions apply to most bots).
if: github.event.pull_request.user.type != 'Bot'
permissions:
pull-requests: write # needed to update the PR description
contents: read
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Fetch enough history to diff against the base branch
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.11'
- name: Install dependencies
run: pip install requests==2.32.3 --quiet
- name: Detect changes and update PR description
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
REPO: ${{ github.repository }}
run: python3 .github/scripts/check_config_changes_ci.py