except:
self.skip("Git user.name and user.email must be set")
+ def _check_changelog(self, recipe, oldrecipefile):
+ """Compare extracted changelog against reference data."""
+ changelog_ref = oldrecipefile + '.changelog'
+ self.assertExists(changelog_ref, 'Changelog reference file must exist for %s' % recipe)
+ changelog_file = os.path.join(self.workspacedir, 'changelogs', '%s.txt' % recipe)
+ with open(changelog_ref, 'r') as f:
+ expected = f.read()
+ if not expected:
+ self.assertNotExists(changelog_file,
+ 'Changelog file should not exist when reference is empty')
+ else:
+ self.assertExists(changelog_file, 'Changelog file should exist after upgrade')
+ with open(changelog_file, 'r') as f:
+ actual = f.read()
+ self.assertEqual(expected, actual)
+
def test_devtool_upgrade(self):
# Check preconditions
self.assertTrue(not os.path.exists(self.workspacedir), 'This test cannot be run with a workspace directory under the build directory')
with open(newrecipefile, 'r') as f:
newlines = f.readlines()
self.assertEqual(desiredlines, newlines)
+ # Check changelog
+ self._check_changelog(recipe, oldrecipefile)
# Check devtool reset recipe
result = runCmd('devtool reset %s -n' % recipe)
result = runCmd('devtool status')
with open(newrecipefile, 'r') as f:
newlines = f.readlines()
self.assertEqual(desiredlines, newlines)
+ # Check changelog
+ self._check_changelog(recipe, oldrecipefile)
# Check devtool reset recipe
result = runCmd('devtool reset %s -n' % recipe)
result = runCmd('devtool status')
self.assertNotIn(recipe, result.output)
self.assertNotExists(os.path.join(self.workspacedir, 'recipes', recipe), 'Recipe directory should not exist after resetting')
+ self.assertNotExists(os.path.join(self.workspacedir, 'changelogs', '%s.txt' % recipe), 'Changelog file should be removed after reset')
def test_devtool_upgrade_git(self):
self._test_devtool_upgrade_git_by_recipe('devtool-upgrade-test2', '6cc6077a36fe2648a5f993fe7c16c9632f946517')
with open(newrecipefile, 'r') as f:
newlines = f.readlines()
self.assertEqual(desiredlines, newlines)
+ # Check changelog
+ self._check_changelog(recipe, oldrecipefile)
def test_devtool_upgrade_all_checksums(self):
# Check preconditions
with open(newrecipefile, 'r') as f:
newlines = f.readlines()
self.assertEqual(desiredlines, newlines)
+ # Check changelog
+ self._check_changelog(recipe, oldrecipefile)
def test_devtool_upgrade_recipe_upgrade_extra_tasks(self):
# Check preconditions
with open(newcratesincfile, 'r') as f:
newlines = f.readlines()
self.assertEqual(desiredlines, newlines)
+ # Check changelog
+ self._check_changelog(recipe, oldrecipefile)
# Check devtool reset recipe
result = runCmd('devtool reset %s -n' % recipe)
result = runCmd('devtool status')
import os
import sys
import re
+import shlex
import shutil
import tempfile
import logging
logger = logging.getLogger('devtool')
+# Common changelog filenames found in upstream source trees (matched case-insensitively):
+# changelog - util-linux, coreutils, dbus, acpid, hdparm
+# changelog.md - libslirp, ttyrun, python3-maturin, libjpeg-turbo
+# changelog.rst - python3-pluggy, python3-packaging
+# changes - openssl, python3-babel, icu, tcl
+# changes.md - openssl
+# changes.rst - python3-babel, python3-pathspec
+# changes.txt - python3-lxml, icu
+# news - systemd, glib-2.0, libxml2, dbus
+# news.md - libxml2
+# news.rst - python3-sphinx
+# news.adoc - ccache
+# history.md - python3-requests, python3-hatch-vcs
+# history.rst - python3-idna, python3-docutils
+# releases.md - rust, cargo (includes CVEs)
+# whatsnew.txt - libsdl2
+_CHANGELOG_BASENAMES = {
+ 'changelog', 'changelog.md', 'changelog.rst', 'changelog.txt',
+ 'changes', 'changes.md', 'changes.rst', 'changes.txt',
+ 'news', 'news.md', 'news.rst', 'news.adoc',
+ 'history', 'history.md', 'history.rst',
+ 'releasenotes.md', 'releasenotes.rst',
+ 'releases.md',
+ 'whatsnew.txt',
+}
+
def _run(cmd, cwd=''):
logger.debug("Running command %s> %s" % (cwd,cmd))
return bb.process.run('%s' % cmd, cwd=cwd)
if not res:
raise DevtoolError('Running extra recipe upgrade task %s for %s failed' % (task, pn))
+def _resolve_rst_includes(content, srctree):
+ """Resolve RST .. include:: directives by reading files from the source tree."""
+ result = []
+ for line in content.splitlines(True):
+ m = re.match(r'^\.\.\s+include::\s+(.+)$', line)
+ if m:
+ basename = os.path.basename(m.group(1).strip())
+ # Search for the file in the source tree
+ for dirpath, _, filenames in os.walk(srctree):
+ if basename in filenames:
+ fpath = os.path.join(dirpath, basename)
+ try:
+ with open(fpath, 'r', errors='replace') as f:
+ result.append(f.read())
+ break
+ except OSError:
+ pass
+ else:
+ result.append(line)
+ continue
+ result.append(line)
+ return ''.join(result)
+
+
+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
+ changelog_fname = None
+
+ # Try to find a changelog file that changed between versions
+ try:
+ stdout, _ = _run('git diff --name-only %s %s' % (old_tag, new_tag), srctree)
+ changed_files = [f.strip() for f in stdout.splitlines() if f.strip()]
+
+ # First pass: collect per-version release notes that changed
+ # Matches files with a version number whose path suggests release notes
+ # (e.g. Documentation/releases/v2.42-ReleaseNotes, docs/relnotes/2.53.0.adoc)
+ parts = []
+ source_files = []
+ for fname in changed_files:
+ if not re.search(r'\d+\.\d+', fname):
+ continue
+ if re.search(r'(releas|relnote|change|news|migrat)', fname, re.IGNORECASE):
+ try:
+ file_content, _ = _run('git show %s' % shlex.quote('%s:%s' % (new_tag, fname)), srctree)
+ except bb.process.ExecutionError:
+ try:
+ file_content, _ = _run('git show %s' % shlex.quote('%s:%s' % (old_tag, fname)), srctree)
+ except bb.process.ExecutionError:
+ continue
+ if file_content.strip():
+ parts.append(file_content.strip())
+ source_files.append(fname)
+ if parts:
+ changelog_content = '\n\n'.join(parts)
+ changelog_fname = ', '.join(source_files)
+
+ # Second pass: pick the largest standard changelog file (NEWS, ChangeLog, etc.)
+ if not changelog_content:
+ 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
+ except bb.process.ExecutionError as e:
+ logger.warning('Changelog file extraction failed: %s' % str(e))
+
+ # For git sources, fall back to git log if no changelog file was found
+ if not changelog_content and is_git_source:
+ try:
+ stdout, _ = _run('git log --oneline %s..%s' % (old_tag, new_tag), srctree)
+ if stdout.strip():
+ changelog_content = stdout.strip()
+ except bb.process.ExecutionError as e:
+ logger.warning('Changelog git log extraction failed: %s' % str(e))
+
+ if not changelog_content:
+ return None
+
+ # Resolve RST .. include:: directives and strip comment blocks.
+ # Only applied to .rst files to avoid mangling plain-text changelogs.
+ if changelog_fname and any(f.endswith('.rst') for f in changelog_fname.split(', ')):
+ changelog_content = _resolve_rst_includes(changelog_content, srctree)
+ # Remove RST comments (.. without ::) and their indented continuation lines
+ filtered = []
+ in_comment = False
+ for line in changelog_content.splitlines(True):
+ if line.startswith('..') and '::' not in line:
+ in_comment = True
+ elif in_comment and (line.startswith(' ') or line.strip() == ''):
+ pass
+ else:
+ in_comment = False
+ filtered.append(line)
+ changelog_content = ''.join(filtered)
+
+ # Clean up content for readability and commit message use
+ changelog_content = re.sub(r'\n{3,}', '\n\n', changelog_content).strip()
+ if not changelog_content:
+ return None
+
+ changelog_dir = os.path.join(workspace_path, 'changelogs')
+ bb.utils.mkdirhier(changelog_dir)
+ changelog_path = os.path.join(changelog_dir, '%s.txt' % pn)
+ with open(changelog_path, 'w') as f:
+ f.write('Changelog for %s: %s -> %s\n' % (pn, old_ver, new_ver))
+ if changelog_fname:
+ f.write('Source: %s\n' % changelog_fname)
+ f.write('\n')
+ f.write(changelog_content)
+ f.write('\n')
+
+ return changelog_path
+
def upgrade(args, config, basepath, workspace):
"""Entry point for the devtool 'upgrade' subcommand"""
logger.info('Upgraded source extracted to %s' % srctree)
logger.info('New recipe is %s' % rf)
+
+ # Extract changelog between versions using the tags created by
+ # _extract_new_source(): devtool-base-new for git, devtool-base-<pv> for tarballs
+ is_git = old_srcrev is not None
+ newpv = args.version or rd.getVar('PV')
+ new_tag = 'devtool-base-new' if is_git else 'devtool-base-%s' % newpv
+ changelog_file = _extract_changelog(srctree, pn, old_ver, newpv,
+ 'devtool-base', new_tag,
+ config.workspace_path, is_git)
+ if changelog_file:
+ logger.info('Changelog extracted to %s' % changelog_file)
+
if license_diff:
logger.info('License checksums have been updated in the new recipe; please refer to it for the difference between the old and the new license texts.')
preferred_version = rd.getVar('PREFERRED_VERSION_%s' % rd.getVar('PN'))