This is an automated email from the ASF dual-hosted git repository.

eladkal pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new fbb0ca79e69 Reconcile provider changelogs against git log before a 
release (#70281)
fbb0ca79e69 is described below

commit fbb0ca79e69fc668a0f9b590ca1aa5c00098d569
Author: Shahar Epstein <[email protected]>
AuthorDate: Thu Aug 13 21:52:39 2026 +0300

    Reconcile provider changelogs against git log before a release (#70281)
    
    A changelog section is written by hand but describes a commit range, and
    nothing compared the two. That gap let a rebase pull a provider commit into
    the release without anyone noticing it had no entry, let a major release 
cite
    a PR number a contributor had guessed before opening their PR, and let entry
    order drift off the newest-merge-first sequence the changelog template
    produces.
    
    Ordering is the part a machine can repair, so it is repaired; the rest is
    reported and left to the release manager, who is the only one who can decide
    how a missing change should be classified.
---
 .../prepare-providers-documentation/SKILL.md       |  44 ++++
 dev/check_changelog_entries.py                     | 237 +++++++++++++++++++++
 2 files changed, 281 insertions(+)

diff --git a/.agents/skills/prepare-providers-documentation/SKILL.md 
b/.agents/skills/prepare-providers-documentation/SKILL.md
index 79f85d7443c..d5d5fe84998 100644
--- a/.agents/skills/prepare-providers-documentation/SKILL.md
+++ b/.agents/skills/prepare-providers-documentation/SKILL.md
@@ -631,6 +631,20 @@ Rules:
   so normalize to excluded.
 - Always keep the `(#NNNN)` PR suffix (or, for a collapsed chain, the
   comma-separated list of all involved PRs).
+- **Order entries within each section by merge order, newest first** — the 
exact
+  order `git log` printed them in Phase 1. `CHANGELOG_TEMPLATE.rst.jinja2`
+  iterates the changes without sorting, so that order *is* the format. It is
+  **not** descending PR number: a long-lived PR merged late carries a low 
number
+  and still belongs at the top (real example: `#64274` sits second in amazon
+  9.32.0's `Features`). Don't re-sort by PR number and don't group by theme.
+  The excluded block follows the same order. A collapsed chain — one entry
+  naming several PRs — sits at the position of its **first** commit, which is
+  when the change became relevant, not at the position of the later rework.
+- **Never adopt an entry a contributor pre-wrote at the top of
+  `changelog.rst`** — above the first version header — without checking its PR
+  number against `git log`. Those blocks are written before the PR merges, so
+  the number in them is a guess and is often wrong or nonexistent. Keep the
+  prose, replace the reference with the real merge commit's `(#NNNN)`.
 
 #### 4c. Regenerate templates with breeze
 
@@ -732,6 +746,10 @@ provider-by-provider:
     touched** that no `.. note::` appears after the
     `.. Below changes are excluded …` marker; a note belongs directly under the
     version underline, before the first `~~~` header.
+
+  The same applies to a bare `Breaking changes` / `Features` / … heading 
sitting
+  above the first version header: a contributor pre-wrote it, and it must be
+  folded into the new version section with its PR reference corrected (Phase 
4b).
 - Confirm Phase 4d ran: no `# use next version` comment remains where the
   referenced provider was bumped in this wave.
 - **If any inter-provider `>=` floor changed** (Phase 4d resolved a pin, or a
@@ -741,6 +759,26 @@ provider-by-provider:
   *"Provider dependency version bumps detected that should only be performed
   by Release Managers!"*. `git diff` the changed `pyproject.toml` files for
   `apache-airflow-providers-*` `>=` changes and list them for the RM.
+- **Reconcile every new section against `git log` — this gate is mandatory.** 
Run
+
+  ```bash
+  python3 dev/check_changelog_entries.py --fix
+  ```
+
+  It compares each provider's newest section against the commits actually being
+  released and exits non-zero on four defects that eyeballing the diff misses:
+
+  | Code | Meaning | Action |
+  | --- | --- | --- |
+  | `MISSING` | a released commit has no entry | classify and add it (Phase 3 
+ 4b) |
+  | `UNKNOWN` | an entry cites a PR outside the release range | replace with 
the real `(#NNNN)` |
+  | `SECTION` | heading is not one of the template's five | move the entry 
under a template heading |
+  | `ORDER` | entries are off merge order | repaired by `--fix` |
+
+  Re-run it after **any** rebase of the release branch. A rebase silently pulls
+  new provider commits into the release range without touching `changelog.rst`,
+  and that is exactly how a shipped change ends up undocumented. Only `ORDER`
+  is auto-repairable — resolve every other code by hand before handing off.
 - **Scan the new changelog sections for these entry defects** — grep the lines
   you added: (1) a bullet whose text starts with a lowercase letter → 
capitalize
   it (Phase 4b); (2) a bullet in a *visible* section (Features / Bug Fixes /
@@ -964,6 +1002,12 @@ above the next existing section, matching the order in
 `CHANGELOG_TEMPLATE.rst.jinja2`:
 `Breaking changes` → `Features` → `Bug Fixes` → `Misc` → `Doc-only`.
 
+Insert each entry at its **merge-order position** within the section — new
+commits are newer than everything already there, so they go at the *top*, not
+appended at the bottom. Appending is what drifts incremental providers off the
+format; `check_changelog_entries.py --fix` repairs it either way, so run it
+after this phase.
+
 If you re-bumped the version in Incremental Phase 3.5, also add or remove the
 `.. note::` block about the Airflow min version requirement to match the
 new bump kind.
diff --git a/dev/check_changelog_entries.py b/dev/check_changelog_entries.py
new file mode 100644
index 00000000000..10e3ca01b57
--- /dev/null
+++ b/dev/check_changelog_entries.py
@@ -0,0 +1,237 @@
+#!/usr/bin/env python3
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""
+Reconcile the newest ``changelog.rst`` version section against ``git log``.
+
+The release skill writes changelog sections by hand, so nothing guarantees 
they still
+match the commits actually being released. This reconciles the two and reports 
four
+defects, all of which have shipped in real waves:
+
+* ``MISSING``  — a commit in the release range has no entry (typically a 
commit that
+  landed on ``main`` after the changelog was generated and arrived via a 
rebase).
+* ``UNKNOWN``  — an entry cites a PR that is not in the release range at all 
(typically
+  a pre-written entry a contributor left at the top of ``changelog.rst``, 
sometimes with
+  a PR number that does not exist).
+* ``SECTION``  — a heading that is not one of the 
``CHANGELOG_TEMPLATE.rst.jinja2``
+  headings, so the entry does not render where readers look for it.
+* ``ORDER``    — bullets not in ``git log`` order. The template renders every 
section
+  newest-merge-first; hand-written and incrementally-appended entries drift 
off it.
+  This is the only defect that can be repaired mechanically (``--fix``).
+
+The release range runs from the tag of the *previous* version in 
``changelog.rst`` (not
+the newest tag — providers can carry placeholder ``99.x`` tags) to ``HEAD``. 
Only the topmost
+version section is inspected, and only while it is still unreleased — once its 
tag exists the
+provider is skipped, so published waves are never rewritten.
+
+Usage:
+    check_changelog_entries.py [--fix] [--base main] [CHANGELOG ...]
+
+With no paths, operates on the ``docs/changelog.rst`` files that changed since 
``--base``.
+"""
+
+from __future__ import annotations
+
+import argparse
+import re
+import subprocess
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+
+BULLET = re.compile(r"^\s*\* ``.*``\s*$")
+VERSION_HEADER = re.compile(r"^\d+\.\d+\.\d+$")
+VERSION_UNDERLINE = re.compile(r"^\.+$")
+SECTION_UNDERLINE = re.compile(r"^~+$")
+PR_NUMBER = re.compile(r"#(\d+)")
+
+TEMPLATE_SECTIONS = ("Breaking changes", "Features", "Bug Fixes", "Misc", 
"Doc-only")
+
+
+def git(*args: str) -> str:
+    return subprocess.run(["git", *args], cwd=ROOT, capture_output=True, 
text=True, check=True).stdout.strip()
+
+
+def tag_exists(tag: str) -> bool:
+    rev_parse = ["git", "rev-parse", "-q", "--verify", tag]
+    return subprocess.run(rev_parse, cwd=ROOT, capture_output=True, 
check=False).returncode == 0
+
+
+def find_newest_section(lines: list[str]) -> tuple[int, int]:
+    """Return the [start, end) line range of the topmost version section, 
empty if none."""
+    underlines = [
+        i
+        for i, line in enumerate(lines)
+        if VERSION_UNDERLINE.fullmatch(line) and i and 
VERSION_HEADER.fullmatch(lines[i - 1])
+    ]
+    if not underlines:
+        return 0, 0
+    return underlines[0], underlines[1] - 1 if len(underlines) > 1 else 
len(lines)
+
+
+def sort_key(line: str, rank: dict[str, int]) -> int | None:
+    """
+    Rank a bullet by the *first* commit behind it, or None when no PR resolves 
to a commit.
+
+    An entry that collapses a chain of PRs — a feature added and then reworked 
before it ever
+    shipped — belongs where the feature became relevant, which is its earliest 
commit. Ranks are
+    ``git log`` positions and grow going back in time, so the earliest commit 
is the largest.
+    """
+    ranks = [rank[pr] for pr in PR_NUMBER.findall(line) if pr in rank]
+    return max(ranks) if ranks else None
+
+
+def sort_run(run: list[str], rank: dict[str, int]) -> list[str]:
+    """Reorder only the bullets whose PR resolves; the rest stay pinned to 
their slot."""
+    ranked = [(i, key) for i, line in enumerate(run) if (key := sort_key(line, 
rank)) is not None]
+    ordered = [run[i] for i, _ in sorted(ranked, key=lambda pair: pair[1])]
+    out = list(run)
+    for (slot, _), line in zip(ranked, ordered):
+        out[slot] = line
+    return out
+
+
+def reorder(lines: list[str], start: int, end: int, rank: dict[str, int]) -> 
list[str]:
+    """Sort every run of consecutive bullets in [start, end) back onto git-log 
order."""
+    out, run = lines[:start], []
+    for line in lines[start:end]:
+        if BULLET.fullmatch(line):
+            run.append(line)
+            continue
+        out.extend(sort_run(run, rank))
+        run = []
+        out.append(line)
+    out.extend(sort_run(run, rank))
+    return out + lines[end:]
+
+
+def check(path: Path, fix: bool) -> list[str]:
+    provider = 
path.as_posix().removeprefix("providers/").removesuffix("/docs/changelog.rst")
+    lines = (ROOT / path).read_text().splitlines()
+    versions = [line for line in lines if VERSION_HEADER.fullmatch(line)]
+    if len(versions) < 2:
+        return [f"SKIP    {provider}: fewer than two version sections"]
+    tag_prefix = "providers-" + provider.replace("/", "-") + "/"
+    if tag_exists(tag_prefix + versions[0]):
+        return [f"SKIP    {provider}: {versions[0]} is already released, 
nothing pending to check"]
+    tag = tag_prefix + versions[1]
+    if not tag_exists(tag):
+        return [f"SKIP    {provider}: no tag {tag}"]
+
+    log = git("log", "--pretty=format:%s", f"{tag}..HEAD", "--", 
f"providers/{provider}").splitlines()
+    rank: dict[str, int] = {}
+    for position, subject in enumerate(log):
+        for pr in PR_NUMBER.findall(subject):
+            rank.setdefault(pr, position)
+
+    start, end = find_newest_section(lines)
+    section = lines[start:end]
+    text = "\n".join(lines)
+    problems = []
+
+    for subject in log:
+        prs = PR_NUMBER.findall(subject)
+        if prs and not any(f"#{pr}" in text for pr in prs):
+            problems.append(f"MISSING {provider}: {subject}")
+
+    for line in section:
+        if BULLET.fullmatch(line):
+            problems += [
+                f"UNKNOWN {provider}: #{pr} is not in {tag}..HEAD — 
{line.strip()}"
+                for pr in PR_NUMBER.findall(line)
+                if pr not in rank
+            ]
+
+    for i, line in enumerate(section[:-1]):
+        if SECTION_UNDERLINE.fullmatch(section[i + 1]) and line not in 
TEMPLATE_SECTIONS:
+            problems.append(f"SECTION {provider}: '{line}' is not a 
CHANGELOG_TEMPLATE heading")
+
+    reordered = reorder(lines, start, end, rank)
+    if reordered != lines:
+        problems.append(f"ORDER   {provider}: entries are not in git-log 
order")
+        if fix:
+            (ROOT / path).write_text("\n".join(reordered) + "\n")
+            problems[-1] += " (fixed)"
+    return problems
+
+
+def self_test() -> None:
+    lines = """\
+Changelog
+---------
+
+1.1.0
+.....
+
+Features
+~~~~~~~~
+
+* ``Collapsed chain (#400, #200)``
+* ``Newest (#400)``
+* ``No PR reference``
+* ``Oldest (#100)``
+
+1.0.0
+.....
+
+Features
+~~~~~~~~
+
+* ``Untouched (#100)``
+* ``Untouched (#400)``
+""".splitlines()
+    start, end = find_newest_section(lines)
+    got = reorder(lines, start, end, {"400": 0, "300": 1, "200": 2, "100": 3})
+    assert got[9:13] == [  # noqa: S101
+        "* ``Newest (#400)``",
+        "* ``Collapsed chain (#400, #200)``",  # ranked by #200, its first 
commit, not by #400
+        "* ``No PR reference``",  # unresolvable entries stay pinned to their 
slot
+        "* ``Oldest (#100)``",
+    ], got[9:13]
+    assert got[-2:] == ["* ``Untouched (#100)``", "* ``Untouched (#400)``"], 
got[-2:]  # noqa: S101
+    print("self-test OK")
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument("changelogs", nargs="*", type=Path)
+    parser.add_argument("--fix", action="store_true", help="repair ORDER 
defects in place")
+    parser.add_argument("--base", default="main", help="ref to diff against 
when no paths are given")
+    parser.add_argument("--self-test", action="store_true")
+    args = parser.parse_args()
+
+    if args.self_test:
+        self_test()
+        return 0
+
+    paths = args.changelogs or [
+        Path(p)
+        for p in git(
+            "diff", "--name-only", git("merge-base", args.base, "HEAD"), "--", 
"*/docs/changelog.rst"
+        ).splitlines()
+    ]
+    problems = [problem for path in sorted(paths) for problem in check(path, 
args.fix)]
+    for problem in problems:
+        print(problem)
+    unresolved = [p for p in problems if not p.startswith("SKIP") and not 
p.endswith("(fixed)")]
+    print(f"\n{len(paths)} changelog(s) checked, {len(unresolved)} unresolved 
problem(s)")
+    return 1 if unresolved else 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())

Reply via email to