From 10d83d260d8278c08c5605cacb5012f9b99cc66b Mon Sep 17 00:00:00 2001 From: Gui-FernandesBR Date: Tue, 21 Jul 2026 22:02:14 -0300 Subject: [PATCH 1/3] CI: make changelog automation LLM-based (Gemini) and race-safe The post-merge changelog job used a blind label->prefix mapping that double-prefixed titles already carrying one (e.g. "ENH: BUG/MNT: ..."), never deduplicated, only ever used Added/Changed/Fixed, and pushed without a rebase (so it lost races with concurrent merges). Replace the inline script with .github/scripts/update_changelog.py: - Gemini (gemini-2.5-flash) formats and places the entry in the right subsection, reusing an existing prefix and avoiding duplicates. - Only the [Unreleased] block is ever rewritten; released history is preserved byte-for-byte. - Model output is validated (all existing links kept, new PR referenced exactly once, no leaked version header, bounded growth). On failure or a missing GEMINI_API_KEY it falls back to a safe deterministic insert that detects existing prefixes and skips duplicates. - Idempotent: a PR already in [Unreleased] is a no-op. - Push now rebases and retries to survive concurrent merges. Add self-contained tests (.github/scripts/test_update_changelog.py) covering the deterministic pieces; they run without network or the google-genai package. Requires a GEMINI_API_KEY repository secret; without it the job still works via the deterministic fallback. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/scripts/test_update_changelog.py | 179 ++++++++++++ .github/scripts/update_changelog.py | 354 +++++++++++++++++++++++ .github/workflows/changelog.yml | 88 +++--- 3 files changed, 572 insertions(+), 49 deletions(-) create mode 100644 .github/scripts/test_update_changelog.py create mode 100644 .github/scripts/update_changelog.py diff --git a/.github/scripts/test_update_changelog.py b/.github/scripts/test_update_changelog.py new file mode 100644 index 000000000..6d886836a --- /dev/null +++ b/.github/scripts/test_update_changelog.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""Self-contained tests for update_changelog.py. + +Runs without network or the ``google-genai`` package (the LLM import in the +script is lazy). Exercises the deterministic pieces that keep the changelog +safe: section splitting, duplicate detection, prefix handling, the fallback +classifier, and the validator that guards LLM output. + +Run locally with either: + python .github/scripts/test_update_changelog.py + pytest .github/scripts/test_update_changelog.py +""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import update_changelog as uc # noqa: E402 + +SAMPLE = """\ +# RocketPy Change Log + +## [Unreleased] - yyyy-mm-dd + +### Added + +### Changed + +### Fixed + +## [v1.13.0] - 2026-07-21 + +### Added + +- ENH: something old [#100](https://github.com/RocketPy-Team/RocketPy/pull/100) +""" + + +def test_split_roundtrips(): + before, block, after = uc.split_changelog(SAMPLE) + assert before + block + after == SAMPLE + assert block.startswith("## [Unreleased]") + assert "## [v1.13.0]" not in block + assert "## [v1.13.0]" in after + + +def test_already_present(): + _, block, after = uc.split_changelog(SAMPLE) + assert uc.already_present(after, 100) is True + assert uc.already_present(block, 100) is False + assert uc.already_present(block, 999) is False + + +def test_detect_prefix(): + assert uc.detect_prefix("BUG: fix a thing") == "BUG" + assert uc.detect_prefix("BUG/MNT: pre-release review fixes") == "BUG/MNT" + assert uc.detect_prefix("Add a shiny feature") is None + + +def test_no_double_prefix(): + # The historical bug: a title already carrying a prefix must not gain another. + entry = uc.build_entry("BUG/MNT: pre-release fixes", 1074, "ENH", "BUG/MNT") + assert entry.startswith("- BUG/MNT: pre-release fixes ") + assert "ENH:" not in entry + assert "[#1074](https://github.com/RocketPy-Team/RocketPy/pull/1074)" in entry + + +def test_build_entry_adds_prefix_when_missing(): + entry = uc.build_entry("Add a shiny feature", 200, "ENH", None) + assert entry.startswith("- ENH: Add a shiny feature ") + + +def test_fallback_routing(): + # Bug label -> Fixed + section, prefix, _ = uc.fallback_section_and_prefix("Fix crash", "Bug,Flight") + assert section == "### Fixed" and prefix == "BUG" + # Refactor label -> Changed + section, _, _ = uc.fallback_section_and_prefix("Tidy internals", "Refactor") + assert section == "### Changed" + # Existing prefix wins the routing even without a matching label + section, prefix, existing = uc.fallback_section_and_prefix("BUG: oops", "") + assert section == "### Fixed" and existing == "BUG" + # Default + section, prefix, _ = uc.fallback_section_and_prefix("New capability", "Enhancement") + assert section == "### Added" and prefix == "ENH" + + +def test_fallback_update_inserts_once_under_right_section(): + _, block, _ = uc.split_changelog(SAMPLE) + updated = uc.fallback_update(block, "BUG: fix a thing", 321, "Bug") + assert updated.count("[#321]") == 1 + fixed = updated.split("### Fixed", 1)[1] + assert "- BUG: fix a thing" in fixed + # Not misplaced under Added. + added = updated.split("### Added", 1)[1].split("### Changed", 1)[0] + assert "[#321]" not in added + + +def test_ensure_section_inserts_in_canonical_order(): + _, block, _ = uc.split_changelog(SAMPLE) + # SAMPLE has Added/Changed/Fixed but no Removed subsection. + lines = uc.ensure_section(block.splitlines(keepends=True), "### Removed") + joined = "".join(lines) + # Removed must sit after Changed and before Fixed. + assert ( + joined.index("### Changed") + < joined.index("### Removed") + < joined.index("### Fixed") + ) + + +def test_validator_accepts_good_output(): + _, block, _ = uc.split_changelog(SAMPLE) + good = block.replace( + "### Fixed\n", + "### Fixed\n\n- BUG: fix it [#500](https://github.com/RocketPy-Team/RocketPy/pull/500)\n", + ) + assert uc.validate_llm_block(block, good, 500) is None + + +def test_validator_rejects_dropped_entry(): + old = block_with_entry() + # New block loses the pre-existing entry. + new = "## [Unreleased] - yyyy-mm-dd\n\n### Fixed\n\n- BUG: new [#500](https://github.com/RocketPy-Team/RocketPy/pull/500)\n\n" + assert uc.validate_llm_block(old, new, 500) is not None + + +def test_validator_rejects_missing_new_ref(): + _, block, _ = uc.split_changelog(SAMPLE) + assert uc.validate_llm_block(block, block, 500) is not None # no new ref at all + + +def test_validator_rejects_leaked_version_header(): + _, block, _ = uc.split_changelog(SAMPLE) + leaked = ( + "## [Unreleased] - yyyy-mm-dd\n\n### Fixed\n\n" + "- BUG: x [#500](https://github.com/RocketPy-Team/RocketPy/pull/500)\n\n" + "## [v1.13.0] - 2026-07-21\n" + ) + assert uc.validate_llm_block(block, leaked, 500) is not None + + +def test_validator_rejects_runaway_growth(): + _, block, _ = uc.split_changelog(SAMPLE) + huge = ( + "## [Unreleased] - yyyy-mm-dd\n\n### Fixed\n\n" + "- BUG: x [#500](https://github.com/RocketPy-Team/RocketPy/pull/500)\n" + + ("x" * (uc.MAX_BLOCK_GROWTH + 50)) + ) + assert uc.validate_llm_block(block, huge, 500) is not None + + +def block_with_entry(): + return ( + "## [Unreleased] - yyyy-mm-dd\n\n### Added\n\n" + "- ENH: keep me [#200](https://github.com/RocketPy-Team/RocketPy/pull/200)\n\n" + "### Fixed\n\n" + ) + + +def _run_all(): + tests = [ + v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v) + ] + failures = 0 + for test in tests: + try: + test() + print(f" ok {test.__name__}") + except AssertionError as exc: + failures += 1 + print(f" FAIL {test.__name__}: {exc}") + print(f"\n{len(tests) - failures}/{len(tests)} passed") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(_run_all()) diff --git a/.github/scripts/update_changelog.py b/.github/scripts/update_changelog.py new file mode 100644 index 000000000..7a4ccecec --- /dev/null +++ b/.github/scripts/update_changelog.py @@ -0,0 +1,354 @@ +#!/usr/bin/env python3 +"""Populate the ``[Unreleased]`` section of ``CHANGELOG.md`` for a merged PR. + +Runs in CI (``.github/workflows/changelog.yml``) after a pull request is merged +into ``develop``. It asks Gemini to place a well-formatted entry into the correct +subsection of the ``[Unreleased]`` block, then applies the result behind a +deterministic safety net so the file can never be corrupted or lose history. + +Design +------ +* The LLM only ever sees and rewrites the ``[Unreleased]`` block. Everything + else in the file (released versions) is preserved byte-for-byte. +* The LLM's output is validated before use: every ``[#N](url)`` link that + existed in ``[Unreleased]`` must survive, the new PR must be referenced + exactly once, no released ``## [vX]`` header may leak in, and the block may + not grow unreasonably. If validation fails -- or the API key is missing, or + the request errors -- we fall back to a deterministic insert that detects an + existing conventional prefix (so we never produce ``ENH: BUG: ...``) and skips + duplicates. +* If the PR is already referenced in ``[Unreleased]``, the run is a no-op + (idempotent), so re-runs and merge races never create duplicate lines. + +Security +-------- +The PR title and body are untrusted user input. They are passed to the model as +clearly-delimited data, and the strict output validation is the real guard: no +matter what a malicious PR body asks, the model cannot delete released history +or drop existing entries -- such output is rejected and the fallback runs. +""" + +from __future__ import annotations + +import json +import os +import re +import sys + +REPO = "RocketPy-Team/RocketPy" +PULL_URL = f"https://github.com/{REPO}/pull" +MODEL = "gemini-2.5-flash" + +# Max characters the LLM-rewritten block may grow relative to the original. +# One new entry plus light reformatting; anything larger is treated as suspect. +MAX_BLOCK_GROWTH = 800 +# PR bodies can be huge; only the beginning is useful context for one line. +MAX_BODY_CHARS = 4000 + +SUBSECTION_ORDER = [ + "### Added", + "### Changed", + "### Deprecated", + "### Removed", + "### Fixed", + "### Security", +] + +# A conventional-commit-style prefix already present in a PR title, e.g. "BUG:", +# "ENH:", or a compound like "BUG/MNT:". Used to avoid prepending a second one. +PREFIX_RE = re.compile(r"^([A-Z]{2,7}(?:/[A-Z]{2,7})*):\s") + +# Any "[#N](https://.../pull|issues/N)" markdown link. Used both to preserve +# existing links across an LLM rewrite and to detect duplicates. +LINK_RE = re.compile( + r"\[#(\d+)\]\((https://github\.com/RocketPy-Team/RocketPy/(?:pull|issues)/\d+)\)" +) + + +# --------------------------------------------------------------------------- # +# Pure helpers (no I/O, no network) -- these are what the test file exercises. # +# --------------------------------------------------------------------------- # +def pr_link(number: str | int) -> str: + """Canonical markdown link for a PR number.""" + return f"[#{number}]({PULL_URL}/{number})" + + +def split_changelog(text: str) -> tuple[str, str, str]: + """Split the file into (before, unreleased_block, after). + + ``unreleased_block`` runs from the ``## [Unreleased]`` header up to (but not + including) the next ``## [`` version header. Reassembling + ``before + block + after`` reproduces the file exactly. + """ + start_match = re.search(r"^## \[Unreleased\].*$", text, re.MULTILINE) + if not start_match: + raise ValueError("No '## [Unreleased]' header found in CHANGELOG.md") + start = start_match.start() + next_match = re.search(r"^## \[", text[start_match.end() :], re.MULTILINE) + end = start_match.end() + next_match.start() if next_match else len(text) + return text[:start], text[start:end], text[end:] + + +def already_present(block: str, number: str | int) -> bool: + """True if ``block`` already references this PR (avoids duplicate entries).""" + pattern = rf"/pull/{number}\)|\[#{number}\]" + return re.search(pattern, block) is not None + + +def existing_links(block: str) -> set[str]: + """Set of normalized ``#N -> url`` link tokens present in a block.""" + return {f"{m.group(1)}|{m.group(2)}" for m in LINK_RE.finditer(block)} + + +def detect_prefix(title: str) -> str | None: + """Return the conventional prefix already in ``title`` (e.g. ``BUG/MNT``).""" + match = PREFIX_RE.match(title.strip()) + return match.group(1) if match else None + + +def fallback_section_and_prefix(title: str, labels: str) -> tuple[str, str, str | None]: + """Deterministic (section, prefix, existing_prefix) used when the LLM path + is unavailable or its output is rejected.""" + labels_l = labels.lower() + existing = detect_prefix(title) + existing_u = existing or "" + + if "bug" in labels_l or any(p in existing_u for p in ("BUG", "FIX", "HOTFIX")): + section, default_prefix = "### Fixed", "BUG" + elif "refactor" in labels_l or "MNT" in existing_u: + section, default_prefix = "### Changed", "MNT" + elif "tests" in labels_l or "TST" in existing_u: + section, default_prefix = "### Changed", "TST" + elif ("c.i." in labels_l or "ci" in labels_l.split(",")) or "CI" in existing_u: + section, default_prefix = "### Changed", "CI" + elif "docs" in labels_l or "DOC" in existing_u: + section, default_prefix = "### Added", "DOC" + else: + section, default_prefix = "### Added", "ENH" + + return section, (existing or default_prefix), existing + + +def build_entry( + title: str, number: str | int, prefix: str, existing_prefix: str | None +) -> str: + """Build a single changelog bullet, never double-prefixing.""" + title = title.strip() + body = title if existing_prefix else f"{prefix}: {title}" + return f"- {body} {pr_link(number)}\n" + + +def ensure_section(lines: list[str], section: str) -> list[str]: + """Insert a missing subsection header at its canonical position.""" + target = SUBSECTION_ORDER.index(section) + insert_at = len(lines) + for i, line in enumerate(lines): + stripped = line.strip() + if stripped in SUBSECTION_ORDER and SUBSECTION_ORDER.index(stripped) > target: + insert_at = i + break + return lines[:insert_at] + [f"{section}\n", "\n"] + lines[insert_at:] + + +def fallback_update(block: str, title: str, number: str | int, labels: str) -> str: + """Deterministically insert the entry into the right subsection of ``block``.""" + section, prefix, existing = fallback_section_and_prefix(title, labels) + entry = build_entry(title, number, prefix, existing) + + lines = block.splitlines(keepends=True) + if not any(line.strip() == section for line in lines): + lines = ensure_section(lines, section) + + idx = next(i for i, line in enumerate(lines) if line.strip() == section) + insert_at = idx + 1 + # Keep the entry at the top of the list, just after the blank line that + # follows the subsection header. + if insert_at < len(lines) and lines[insert_at].strip() == "": + insert_at += 1 + lines.insert(insert_at, entry) + # If the subsection was empty, the entry now abuts the next header; the + # house style keeps a blank line before every header. Add one -- but never + # between two list items. + following = lines[insert_at + 1] if insert_at + 1 < len(lines) else "" + if following.lstrip().startswith("#"): + lines.insert(insert_at + 1, "\n") + # Guarantee a single blank line before the next version header, even when + # the entry landed as the block's last line (empty trailing subsection). + return "".join(lines).rstrip("\n") + "\n\n" + + +def validate_llm_block(old_block: str, new_block: str, number: str | int) -> str | None: + """Return an error string if the LLM output is unsafe, else ``None``.""" + stripped = new_block.strip() + if not stripped.startswith("## [Unreleased]"): + return "does not start with the '## [Unreleased]' header" + if re.search(r"^## \[v", new_block, re.MULTILINE): + return "leaked a released version header into the section" + if len(new_block) > len(old_block) + MAX_BLOCK_GROWTH: + return "grew far more than a single entry should" + + missing = existing_links(old_block) - existing_links(new_block) + if missing: + return f"dropped {len(missing)} existing link(s): {sorted(missing)}" + + new_refs = sum( + 1 + for m in LINK_RE.finditer(new_block) + if m.group(1) == str(number) and m.group(2).endswith(f"/pull/{number}") + ) + if new_refs != 1: + return f"references the new PR {new_refs} time(s), expected exactly 1" + return None + + +def build_prompt(block: str, title: str, number: str, labels: str, body: str) -> str: + """Assemble the user-content payload for the model.""" + body = (body or "").strip()[:MAX_BODY_CHARS] + return ( + "Current [Unreleased] section:\n" + "<<](https://github.com/RocketPy-Team/RocketPy/pull/)". + +Formatting rules: +- Subsections, in this order, present only when non-empty: "### Added" (new \ +features/APIs), "### Changed" (changes to existing behavior), "### Deprecated", \ +"### Removed", "### Fixed" (bug fixes), "### Security". +- Each entry is a single bullet: "- PREFIX: concise description [#N](url)". +- PREFIX is a short uppercase tag for the change type: ENH (enhancement), BUG \ +(bug fix), MNT (maintenance/refactor), DOC (documentation), DEV (dev tooling), \ +CI, TST (tests), REL (release), PERF, SEC. +- If the PR title ALREADY starts with such a prefix (e.g. "BUG: ..." or \ +"BUG/MNT: ..."), keep it exactly; do NOT add a second prefix. This is the most \ +common past mistake -- never produce "ENH: BUG: ...". +- Choose the subsection from the actual nature of the change, not blindly from \ +a label: a "BUG"/"FIX" prefix or "Bug" label => "### Fixed"; refactor/maintenance \ +=> "### Changed"; a new capability => "### Added". +- Keep the description close to the PR title; use the PR body only to make the \ +wording clearer or more accurate, never to pad. One line per entry (a single \ +extra indented line is allowed only when essential). +- Keep the "## [Unreleased] - yyyy-mm-dd" placeholder header line exactly as-is. + +Return JSON: {"reasoning": "<1-2 sentences: section + prefix choice, and any \ +dedup you did>", "unreleased_section": ""}.""" + + +def call_gemini( + block: str, title: str, number: str, labels: str, body: str, api_key: str +) -> str: + """Ask Gemini for the rewritten [Unreleased] section. Raises on any failure. + + The ``google-genai`` import is lazy so the module stays importable (and + testable) in environments without the package installed. + """ + from google import genai # noqa: PLC0415 (lazy on purpose) + from google.genai import types # noqa: PLC0415 + + client = genai.Client(api_key=api_key) + response = client.models.generate_content( + model=MODEL, + contents=build_prompt(block, title, number, labels, body), + config=types.GenerateContentConfig( + system_instruction=SYSTEM_PROMPT, + temperature=0, + response_mime_type="application/json", + response_schema={ + "type": "object", + "properties": { + "reasoning": {"type": "string"}, + "unreleased_section": {"type": "string"}, + }, + "required": ["unreleased_section"], + }, + ), + ) + data = json.loads(response.text) + reasoning = (data.get("reasoning") or "").strip() + if reasoning: + print(f"Gemini reasoning: {reasoning}") + section = data["unreleased_section"] + # Normalize trailing whitespace so a single blank line separates the block + # from the next version header when reassembled. + return section.rstrip("\n") + "\n\n" + + +def update_unreleased( + block: str, title: str, number: str, labels: str, body: str, api_key: str +) -> str: + """Return the new [Unreleased] block, preferring the LLM, falling back safely.""" + if api_key: + try: + candidate = call_gemini(block, title, number, labels, body, api_key) + error = validate_llm_block(block, candidate, number) + if error is None: + print("Applied Gemini-generated changelog entry.") + return candidate + print( + f"::warning::Rejected Gemini output ({error}); using deterministic fallback." + ) + except Exception as exc: # noqa: BLE001 (any failure -> safe fallback) + print( + f"::warning::Gemini call failed ({exc!r}); using deterministic fallback." + ) + else: + print("::warning::GEMINI_API_KEY not set; using deterministic fallback.") + + return fallback_update(block, title, number, labels) + + +def main() -> int: + title = os.environ["PR_TITLE"] + number = os.environ["PR_NUMBER"] + labels = os.environ.get("PR_LABELS", "") + body = os.environ.get("PR_BODY", "") + api_key = os.environ.get("GEMINI_API_KEY", "") + + with open("CHANGELOG.md", encoding="utf-8") as handle: + text = handle.read() + + before, block, after = split_changelog(text) + + if already_present(block, number): + print(f"PR #{number} already in the changelog; nothing to do.") + return 0 + + new_block = update_unreleased(block, title, number, labels, body, api_key) + with open("CHANGELOG.md", "w", encoding="utf-8", newline="\n") as handle: + handle.write(before + new_block + after) + + print(f"Changelog updated for PR #{number}.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index 7bf27daf7..6ad2ec49b 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -20,64 +20,54 @@ jobs: repository: RocketPy-Team/RocketPy ref: develop token: ${{ secrets.RELEASE_TOKEN }} + # Full history so the retry loop below can rebase onto the latest + # develop when another merge lands while this job runs. + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: python -m pip install --upgrade pip "google-genai>=1.0.0" - name: Update Changelog env: + # PR title/body are untrusted input, so they are passed via the + # environment and consumed inside Python (never interpolated into the + # shell). The script asks Gemini to format and place the entry, then + # validates the result; if the key is missing or the model output is + # rejected, it falls back to a safe deterministic insert. PR_TITLE: ${{ github.event.pull_request.title }} PR_NUMBER: ${{ github.event.pull_request.number }} PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} - run: | - # The PR title is untrusted input, so it is read from the environment - # inside Python rather than interpolated into a shell/sed program. - # This avoids both shell injection and sed breaking on special - # characters (\, /, &, newlines) in the title. - python - <<'PY' - import os - - labels = os.environ.get("PR_LABELS", "") - title = os.environ["PR_TITLE"] - number = os.environ["PR_NUMBER"] - - section, prefix = "### Added", "ENH" - if "Bug" in labels: - section, prefix = "### Fixed", "BUG" - elif "Refactor" in labels: - section, prefix = "### Changed", "MNT" - elif "Docs" in labels and "Git housekeeping" in labels: - section, prefix = "### Changed", "DOC" - elif "Tests" in labels: - section, prefix = "### Changed", "TST" - elif "Docs" in labels: - section, prefix = "### Added", "DOC" - - entry = ( - f"- {prefix}: {title} " - f"[#{number}](https://github.com/RocketPy-Team/RocketPy/pull/{number})\n" - ) - - with open("CHANGELOG.md", encoding="utf-8") as handle: - lines = handle.readlines() - - for index, line in enumerate(lines): - if line.rstrip("\n") == section: - insert_at = index + 1 - # Place the entry at the top of the list, after the single - # blank line that follows the section header. - if insert_at < len(lines) and lines[insert_at].strip() == "": - insert_at += 1 - lines.insert(insert_at, entry) - break - else: - raise SystemExit(f"Section {section!r} not found in CHANGELOG.md") - - with open("CHANGELOG.md", "w", encoding="utf-8") as handle: - handle.writelines(lines) - PY + PR_BODY: ${{ github.event.pull_request.body }} + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + run: python .github/scripts/update_changelog.py - name: Push Changes run: | + if git diff --quiet -- CHANGELOG.md; then + echo "CHANGELOG.md unchanged (duplicate or no-op) — nothing to commit." + exit 0 + fi + git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add CHANGELOG.md - git commit -m "DOC: Update Changelog for PR #${{ github.event.pull_request.number }}" - git push + git commit -m "DOC: update changelog for PR #${{ github.event.pull_request.number }}" + + # Survive races: if another PR merged into develop after our checkout, + # the first push is rejected (non-fast-forward). Rebase and retry. + for attempt in 1 2 3 4 5; do + if git push origin HEAD:develop; then + echo "Pushed changelog update on attempt ${attempt}." + exit 0 + fi + echo "Push rejected (attempt ${attempt}); rebasing onto latest develop..." + git pull --rebase origin develop + done + + echo "::error::Failed to push changelog update after 5 attempts." + exit 1 From a575a6e58982537e331f402eaabe05e46f502b2c Mon Sep 17 00:00:00 2001 From: Gui-FernandesBR Date: Tue, 21 Jul 2026 22:20:03 -0300 Subject: [PATCH 2/3] CI: use gemini-flash-latest model (2.5-flash blocked for new API keys) A live test showed gemini-2.5-flash returns 404 "no longer available to new users" for freshly created API keys, which would send every run to the deterministic fallback. Switch to the gemini-flash-latest alias so the job tracks the newest stable flash model and does not break when a pinned version is retired. Output validation still guards any drift. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/scripts/update_changelog.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/scripts/update_changelog.py b/.github/scripts/update_changelog.py index 7a4ccecec..e8541d89f 100644 --- a/.github/scripts/update_changelog.py +++ b/.github/scripts/update_changelog.py @@ -37,7 +37,11 @@ REPO = "RocketPy-Team/RocketPy" PULL_URL = f"https://github.com/{REPO}/pull" -MODEL = "gemini-2.5-flash" +# Alias for the newest stable "flash" model. Using the alias (rather than a +# pinned version like gemini-3.6-flash) keeps the job working when a specific +# version is retired -- pinned gemini-2.5-flash already became unavailable to +# new API keys. Output is validated regardless, so model drift is safe here. +MODEL = "gemini-flash-latest" # Max characters the LLM-rewritten block may grow relative to the original. # One new entry plus light reformatting; anything larger is treated as suspect. From c524b92ed8adcb63de85ceba1d46a4b8aed13f51 Mon Sep 17 00:00:00 2001 From: Gui-FernandesBR Date: Tue, 21 Jul 2026 22:20:05 -0300 Subject: [PATCH 3/3] DOC: document the automated LLM-based changelog flow Contributors no longer edit CHANGELOG.md by hand. Update the first-PR guide, style guide, and PR template to make clear that after a PR is merged an LLM workflow writes the entry automatically, so you only open and merge PRs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/pull_request_template.md | 2 +- docs/development/first_pr.rst | 15 ++++++++++----- docs/development/style_guide.rst | 7 ++++--- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index c614af766..d443be5e4 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -17,7 +17,7 @@ - [ ] Docs have been reviewed and added / updated - [ ] Lint (`black rocketpy/ tests/`) has passed locally - [ ] All tests (`pytest tests -m slow --runslow`) have passed locally -- [ ] `CHANGELOG.md` has been updated (if relevant) +- `CHANGELOG.md` — no action needed; an LLM workflow auto-updates it after merge ## Current behavior diff --git a/docs/development/first_pr.rst b/docs/development/first_pr.rst index c0b07e7f1..64a4f6dec 100644 --- a/docs/development/first_pr.rst +++ b/docs/development/first_pr.rst @@ -95,14 +95,19 @@ Please correct any issues that may arise from the CI checks. The CHANGELOG file ------------------ -We keep track of the changes in the ``CHANGELOG.md`` file. -When you open a PR, you should see the "Unreleased" section of the file. -An entry will simply contain the title of your PR if merged. +We keep track of the changes in the ``CHANGELOG.md`` file, but **you do not +need to edit it yourself**. When you open a PR you will see the "Unreleased" +section of the file; you can leave it as is. .. note:: - The CHANGELOG is auto-updated once a PR is merged based on the associated labels, \ - which are assigned by the maintainers. + Once your PR is merged into ``develop``, the + ``.github/workflows/changelog.yml`` workflow uses an LLM (Google Gemini) to + write a well-formatted entry into the "Unreleased" section automatically. It + picks the right subsection (Added, Changed, Fixed, ...) and prefix from your + PR title, labels, and description, and skips duplicates. In practice you only + open the PR -- the maintainers review, label, and merge it, and the changelog + entry is generated and committed for you. The review process ------------------ diff --git a/docs/development/style_guide.rst b/docs/development/style_guide.rst index 15a80e5e4..052217abf 100644 --- a/docs/development/style_guide.rst +++ b/docs/development/style_guide.rst @@ -163,9 +163,10 @@ Pull Requests ^^^^^^^^^^^^^ When opening a Pull Request, the title should be clear and concise. -It should contain only a brief desctiption of the changes without the acronym (e.g. ENH:, BUG:). -The maintainers will label your PR accordingly, which will add a prefix via a workflow to indicate -type of the PR in the CHANGELOG file. +It should contain only a brief description of the changes without the acronym (e.g. ENH:, BUG:). +The maintainers will label your PR accordingly. After the PR is merged, a workflow +uses an LLM (Google Gemini) to add the right prefix and place the entry in the +correct section of the ``CHANGELOG.md`` file automatically. Here is an example of a good PR name: