diff --git a/dev-tools/scripts/smokeTestRelease.py b/dev-tools/scripts/smokeTestRelease.py
index 93c66d740a0..8e60add3dad 100755
--- a/dev-tools/scripts/smokeTestRelease.py
+++ b/dev-tools/scripts/smokeTestRelease.py
@@ -69,6 +69,10 @@ def unshortenURL(url):
# Set to False to avoid re-downloading the packages...
FORCE_CLEAN = True
+# Set to True via --skip-changelog-current to skip checks that verify the changelog
+# is current (i.e. that 'logchange release' and 'logchange generate' have been run).
+SKIP_CHANGELOG_CURRENT = False
+
def getHREFs(urlString):
@@ -390,6 +394,67 @@ def testChangelogMd(dir, version):
if 'v%s' % version not in content and version not in content:
raise RuntimeError('Version %s not found in CHANGELOG.md' % version)
+
+def testChangelogFolder(dir, version):
+ "Checks changelog folder structure."
+ changelog_folder = os.path.join(dir, 'changelog')
+
+ if not os.path.exists(changelog_folder):
+ raise RuntimeError('changelog folder not found at %s' % changelog_folder)
+
+ print(' check changelog folder structure...')
+
+ # Check that 'unreleased' folder exists and is empty
+ unreleased_folder = os.path.join(changelog_folder, 'unreleased')
+ if not os.path.exists(unreleased_folder):
+ raise RuntimeError('changelog/unreleased folder not found')
+
+ unreleased_contents = os.listdir(unreleased_folder)
+ # Filter out hidden files like .gitkeep
+ unreleased_contents = [f for f in unreleased_contents if not f.startswith('.')]
+ if len(unreleased_contents) > 0:
+ raise RuntimeError('changelog/unreleased folder is not empty, contains: %s' % unreleased_contents)
+
+ # Explicitly verify that the version folder for the current release exists
+ version_folder = os.path.join(changelog_folder, 'v%s' % version)
+ if not os.path.exists(version_folder):
+ raise RuntimeError('changelog/v%s folder not found (run logchange release first)' % version)
+
+ # Pattern to match version folders (e.g., v9.5.0, v10.0.0, v10.1.0-beta1, v10.1.0-beta-1, v10.1.0-RC1)
+ # Uses a prefix match to handle any suffix format without silently skipping unusual variants.
+ version_pattern = re.compile(r'^v\d+\.\d+\.\d+')
+
+ # Check all subdirectories in changelog
+ for entry in os.listdir(changelog_folder):
+ entry_path = os.path.join(changelog_folder, entry)
+
+ # Skip if not a directory
+ if not os.path.isdir(entry_path):
+ continue
+
+ # Skip the unreleased folder (already checked)
+ if entry == 'unreleased':
+ continue
+
+ release_date_file = os.path.join(entry_path, 'release-date.txt')
+
+ # Check if this is a version folder (vX.Y.Z format)
+ if version_pattern.match(entry):
+ # Version folders for the current release should NOT have release-date.txt
+ # Only check if this is the version being released
+ if entry == 'v%s' % version:
+ if os.path.exists(release_date_file):
+ raise RuntimeError('changelog/%s folder should not contain release-date.txt (version not yet released)' % entry)
+ else:
+ # Other version folders (past releases) should have release-date.txt
+ if not os.path.exists(release_date_file):
+ raise RuntimeError('changelog/%s folder is missing release-date.txt' % entry)
+ else:
+ # Non-version folders (e.g., documentation or support directories) are allowed
+ # and are not required to contain a release-date.txt file.
+ # They are intentionally ignored by this check.
+ pass
+
reChangesSectionHREF = re.compile('(.*?)', re.IGNORECASE)
reUnderbarNotDashHTML = re.compile(r'
(\s*(SOLR)_\d\d\d\d+)')
reUnderbarNotDashTXT = re.compile(r'\s+((SOLR)_\d\d\d\d+)', re.MULTILINE)
@@ -398,9 +463,41 @@ def testChangelogMd(dir, version):
def checkChangesContent(s, version, name, isHTML):
currentVersionTuple = versionToTuple(version, name)
- if isHTML and s.find('Release %s' % version) == -1:
+ if isHTML and not SKIP_CHANGELOG_CURRENT and s.find('Release %s' % version) == -1:
raise RuntimeError('did not see "Release %s" in %s' % (version, name))
+ # Validate h2 header structure for Changes.html (requires logchange generate to have been run)
+ if isHTML and not SKIP_CHANGELOG_CURRENT:
+ print(' validate h2 header structure...')
+ h2_pattern = re.compile(r'', re.IGNORECASE | re.DOTALL)
+ headers = h2_pattern.findall(s)
+
+ if len(headers) < 2:
+ raise RuntimeError('Expected at least 2 release h2 headers in %s, found %d' % (name, len(headers)))
+
+ # First header should be "Release {version}" without a date
+ first_header = headers[0].strip()
+ expected_first = 'Release %s' % version
+
+ # Check if first header matches version and does NOT have a date pattern [yyyy-mm-dd]
+ if not first_header.startswith(expected_first):
+ raise RuntimeError('First h2 header should start with "%s", but got "%s" in %s' % (expected_first, first_header, name))
+
+ date_pattern = re.compile(r'\[\d{4}-\d{2}-\d{2}\]')
+ if date_pattern.search(first_header):
+ raise RuntimeError('First h2 header for "%s" should NOT contain a release date, but got "%s" in %s' % (version, first_header, name))
+
+ # Second header should be "Release {version} [yyyy-mm-dd]" with a date
+ second_header = headers[1].strip()
+ if not second_header.startswith('Release '):
+ raise RuntimeError('Second h2 header should start with "Release", but got "%s" in %s' % (second_header, name))
+
+ if not date_pattern.search(second_header):
+ raise RuntimeError('Second h2 header should contain a release date [yyyy-mm-dd], but got "%s" in %s' % (second_header, name))
+
+ print(' - First h2: "%s" (no date)' % first_header)
+ print(' - Second h2: "%s" (with date)' % second_header)
+
if isHTML:
r = reUnderbarNotDashHTML
else:
@@ -410,7 +507,7 @@ def checkChangesContent(s, version, name, isHTML):
if m is not None:
raise RuntimeError('incorrect issue (_ instead of -) in %s: %s' % (name, m.group(1)))
- if s.lower().find('not yet released') != -1:
+ if not SKIP_CHANGELOG_CURRENT and s.lower().find('not yet released') != -1:
raise RuntimeError('saw "not yet released" in %s' % name)
if not isHTML:
@@ -652,6 +749,8 @@ def verifyUnpacked(java, artifact, unpackPath, gitRevision, version, testArgs):
raise RuntimeError('solr: unexpected files/dirs in artifact %s: %s' % (artifact, in_root_folder))
if isSrc:
+ if not SKIP_CHANGELOG_CURRENT:
+ testChangelogFolder(unpackPath, version)
print(' make sure no JARs/WARs in src dist...')
lines = os.popen('find . -name \\*.jar').readlines()
if len(lines) != 0:
@@ -715,7 +814,8 @@ def verifyUnpacked(java, artifact, unpackPath, gitRevision, version, testArgs):
os.chdir(unpackPath)
- testChangelogMd('.', version)
+ if not SKIP_CHANGELOG_CURRENT:
+ testChangelogMd('.', version)
def find_available_port(max_attempts=100):
@@ -1067,6 +1167,8 @@ def parse_config():
help='Only perform download and sha hash check steps')
parser.add_argument('--dev-mode', action='store_true', default=False,
help='Enable dev mode, will not check branch compatibility')
+ parser.add_argument('--skip-changelog-current', action='store_true', default=False,
+ help='Skip checks that verify the changelog is current (i.e. logchange release/generate have been run)')
parser.add_argument('url', help='Url pointing to release to test')
parser.add_argument('test_args', nargs=argparse.REMAINDER,
help='Arguments to pass to gradle for testing, e.g. -Dwhat=ever.')
@@ -1117,6 +1219,9 @@ def main():
if not c.version.startswith(scriptVersion + '.') and not c.dev_mode:
raise RuntimeError('smokeTestRelease.py for %s.X is incompatible with a %s release.' % (scriptVersion, c.version))
+ global SKIP_CHANGELOG_CURRENT
+ SKIP_CHANGELOG_CURRENT = c.skip_changelog_current
+
print('NOTE: output encoding is %s' % sys.stdout.encoding)
smokeTest(c.java, c.url, c.revision, c.version, c.tmp_dir, c.is_signed, c.local_keys, ' '.join(c.test_args),
downloadOnly=c.download_only)