diff --git a/scripts/lib/devtool/upgrade.py b/scripts/lib/devtool/upgrade.py
index 495a5b8217..0155578d68 100644
--- a/scripts/lib/devtool/upgrade.py
+++ b/scripts/lib/devtool/upgrade.py
@@ -589,6 +589,64 @@ def _resolve_rst_includes(content, srctree):
     return ''.join(result)
 
 
+_GIT_LOG_COMMIT_RE = re.compile(r'^commit ([0-9a-f]{7,40})\b.*$', re.MULTILINE)
+_GIT_LOG_HEADER_RE = re.compile(r'^(Merge|Author|AuthorDate|Commit|CommitDate):')
+
+def _diff_git_log_changelog(old_content, new_content):
+    """Structurally diff a ChangeLog file that is itself the output of
+    `git log` (e.g. nghttp2). Such files are regenerated wholesale on every
+    release: new commits are prepended, so every existing commit shifts
+    position even though its content is unchanged. A textual diff would
+    see that shift as removed/added lines instead of showing just the
+    commits that were actually added. Comparing commit hashes instead of
+    line content tells the two cases apart: hashes already present in
+    old_content are commits that merely moved, so they're skipped; any
+    other hash is a genuinely new commit. Returns the subject line of
+    each new commit as a single string, or None if either file doesn't
+    look like git log output."""
+    old_hashes = set(_GIT_LOG_COMMIT_RE.findall(old_content))
+    new_commits = _GIT_LOG_COMMIT_RE.split(new_content)[1:]  # [hash, block, hash, block, ...]
+    if not old_hashes or not new_commits:
+        return None
+
+    subjects = []
+    for commit_hash, block in zip(new_commits[0::2], new_commits[1::2]):
+        if commit_hash in old_hashes:
+            continue
+        message_lines = [line.strip() for line in block.splitlines()
+                          if line.strip() and not _GIT_LOG_HEADER_RE.match(line)]
+        # Skip the 'Merge pull request ...' line GitHub adds as the first
+        # message line of a merge commit; the actual change subject is the
+        # next line, and keeping the merge line would duplicate it.
+        subject = next((l for l in message_lines if not l.startswith('Merge pull request ')), None)
+        # Collapse adjacent duplicates: squash-merged PRs can produce
+        # consecutive commits with the same subject line.
+        if subject and (not subjects or subjects[-1] != subject):
+            subjects.append(subject)
+    return '\n'.join(subjects) if subjects else None
+
+
+def _changelog_file_candidate(srctree, old_tag, new_tag, fname):
+    """Return the changelog text to use for a single changelog-like file that
+    changed between old_tag and new_tag, or None if it has no usable
+    content. Tries a structural git-log diff first (for files like
+    nghttp2's ChangeLog that are `git log` output regenerated on every
+    release), falling back to the added lines of a plain-text diff."""
+    try:
+        old_file, _ = _run('git show %s' % shlex.quote('%s:%s' % (old_tag, fname)), srctree)
+        new_file, _ = _run('git show %s' % shlex.quote('%s:%s' % (new_tag, fname)), srctree)
+        candidate = _diff_git_log_changelog(old_file, new_file)
+        if candidate is not None:
+            return candidate
+    except bb.process.ExecutionError:
+        pass
+
+    diff_out, _ = _run('git diff %s %s -- %s' % (old_tag, new_tag, shlex.quote(fname)), srctree)
+    added_lines = [line[1:] for line in diff_out.splitlines()
+                   if line.startswith('+') and not line.startswith('+++')]
+    return '\n'.join(added_lines) if added_lines else None
+
+
 def _extract_changelog(srctree, pn, old_ver, new_ver, old_tag, new_tag, workspace_path, is_git_source):
     """Extract changelog between old and new version using devtool git tags."""
     changelog_content = None
@@ -630,15 +688,10 @@ def _extract_changelog(srctree, pn, old_ver, new_ver, old_tag, new_tag, workspac
             for fname in changed_files:
                 basename = os.path.basename(fname).lower()
                 if basename in _CHANGELOG_BASENAMES:
-                    diff_out, _ = _run('git diff %s %s -- %s' % (old_tag, new_tag, shlex.quote(fname)), srctree)
-                    if diff_out.strip():
-                        lines = [line[1:] for line in diff_out.splitlines()
-                                 if line.startswith('+') and not line.startswith('+++')]
-                        if lines:
-                            candidate = '\n'.join(lines)
-                            if not changelog_content or len(candidate) > len(changelog_content):
-                                changelog_content = candidate
-                                changelog_fname = fname
+                    candidate = _changelog_file_candidate(srctree, old_tag, new_tag, fname)
+                    if candidate and (not changelog_content or len(candidate) > len(changelog_content)):
+                        changelog_content = candidate
+                        changelog_fname = fname
     except bb.process.ExecutionError as e:
         logger.warning('Changelog file extraction failed: %s' % str(e))
 
