diff --git a/.github/workflows/test-spring-boot-rc-version.yml b/.github/workflows/test-spring-boot-rc-version.yml new file mode 100644 index 000000000000..61387de80819 --- /dev/null +++ b/.github/workflows/test-spring-boot-rc-version.yml @@ -0,0 +1,159 @@ +name: Test Spring Boot RC Version +on: + workflow_dispatch: + inputs: + base_branch: + description: 'Base branch for update branch and PR target. Defaults to trigger branch.' + required: false + type: string + +env: + BASE_BRANCH: ${{ github.event.inputs.base_branch || github.ref_name }} + +permissions: + contents: write + pull-requests: write + issues: write + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Generate Version File + run: | + python ./sdk/spring/scripts/generate_spring_versions_and_pr_description.py --prefer-prerelease + - name: Generate Spring Cloud Azure Support File + run: | + python ./sdk/spring/scripts/generate_spring_cloud_azure_support_file.py --include-rc + - name: Confirm Whether to Update + run: | + if [[ ! -f 'spring-versions.txt' ]]; then + echo "No new Spring Boot version, no updates!" + elif grep -q -- "-RC" spring-versions.txt; then + echo "Has RC version, create PR to test!" + mapfile -t versions < spring-versions.txt + { + echo "need_update_version=true" + printf 'update_branch=update-spring-dependencies-%s-%s\n' "$(date +%Y%m%d)" "${GITHUB_RUN_ID}" + printf 'spring_boot_version=%s\n' "${versions[0]}" + printf 'spring_cloud_version=%s\n' "${versions[1]}" + printf 'PR_TITLE=Test Spring Boot RC version %s and Spring Cloud %s\n' "${versions[0]}" "${versions[1]}" + echo 'pr_descriptions<> "$GITHUB_ENV" + else + echo "No RC version, cancel update!" + fi + - name: Generate spring_boot_managed_external_dependencies.txt + if: ${{ env.need_update_version == 'true' }} + run: | + echo "Updating Spring Boot Dependencies Version: ${{ env.spring_boot_version }}" + echo "Updating Spring Cloud Dependencies Version: ${{ env.spring_cloud_version }}" + git fetch origin "${{ env.BASE_BRANCH }}" + git checkout -B "${{ env.update_branch }}" "origin/${{ env.BASE_BRANCH }}" + pip install termcolor + python ./sdk/spring/scripts/get_spring_boot_managed_external_dependencies.py -b "${{ env.spring_boot_version }}" -c "${{ env.spring_cloud_version }}" + - name: Update external_dependencies.txt + if: ${{ env.need_update_version == 'true' }} + run: | + pip install termcolor + pip install in_place + python ./sdk/spring/scripts/sync_external_dependencies.py -b "${{ env.spring_boot_version }}" -sbmvn 4 + - name: Update Versions + if: ${{ env.need_update_version == 'true' }} + run: | + python ./eng/versioning/update_versions.py --sr + - name: Update ChangeLog + if: ${{ env.need_update_version == 'true' }} + run: | + python ./sdk/spring/scripts/update_changelog.py -b "${{ env.spring_boot_version }}" -c "${{ env.spring_cloud_version }}" + - name: Push Commit + if: ${{ env.need_update_version == 'true' }} + run: | + git config --global user.email github-actions@github.com + git config --global user.name github-actions + git add -A + git commit -m "Upgrade external dependencies to align with Spring Boot ${{ env.spring_boot_version }}" + python - <<'PY' + import json + from pathlib import Path + + spring_boot_version = "${{ env.spring_boot_version }}" + spring_cloud_version = "${{ env.spring_cloud_version }}" + placeholder = "NONE_SUPPORTED_SPRING_CLOUD_VERSION" + matrix_path = Path("./sdk/spring/pipeline/spring-cloud-azure-supported-spring.json") + + with matrix_path.open("r", encoding="utf-8") as f: + entries = json.load(f) + + updated = False + for entry in entries: + if ( + entry.get("spring-boot-version") == spring_boot_version + and entry.get("spring-cloud-version") == placeholder + ): + entry["spring-cloud-version"] = spring_cloud_version + updated = True + break + + if not updated: + print( + "No RC support-matrix placeholder entry found; " + "skipping support-file patch as a no-op." + ) + raise SystemExit(0) + + with matrix_path.open("w", encoding="utf-8") as f: + json.dump(entries, f, indent=2) + f.write("\n") + PY + git add ./sdk/spring/pipeline/spring-cloud-azure-supported-spring.json + if [[ -n "$(git diff --cached -- ./sdk/spring/pipeline/spring-cloud-azure-supported-spring.json)" ]]; then + git commit -m "Upgrade spring-cloud-azure-supported-spring" + else + echo "No support matrix changes detected, skip commit." + fi + git push origin "HEAD:${{ env.update_branch }}" + - name: Create Pull Request + id: create_pr + if: ${{ env.need_update_version == 'true' }} + uses: actions/github-script@v7 + with: + script: | + const body = [ + `Test Spring Boot RC version [${process.env.spring_boot_version}](https://repo1.maven.org/maven2/org/springframework/boot/spring-boot-dependencies/${process.env.spring_boot_version}/spring-boot-dependencies-${process.env.spring_boot_version}.pom) and Spring Cloud version [${process.env.spring_cloud_version}](https://repo1.maven.org/maven2/org/springframework/cloud/spring-cloud-dependencies/${process.env.spring_cloud_version}/spring-cloud-dependencies-${process.env.spring_cloud_version}.pom).`, + process.env.pr_descriptions || '', + '', + `This PR is created by GitHub Actions: https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${process.env.GITHUB_RUN_ID}` + ].join('\n'); + const pr = await github.rest.pulls.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: process.env.PR_TITLE, + head: process.env.update_branch, + base: process.env.BASE_BRANCH, + body, + draft: true + }); + core.setOutput('pull_request_number', String(pr.data.number)); + - name: Comment on Pull Request + if: ${{ env.need_update_version == 'true' }} + uses: actions/github-script@v7 + with: + script: | + const prNumber = Number('${{ steps.create_pr.outputs.pull_request_number }}'); + if (!prNumber) { + console.log('No pull request was created, nothing to comment on.'); + return; + } + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: '/azp run java - spring - tests' + }); diff --git a/.github/workflows/update-spring-cloud-azure-support-file.yml b/.github/workflows/update-spring-cloud-azure-support-file.yml new file mode 100644 index 000000000000..488d72bc65d5 --- /dev/null +++ b/.github/workflows/update-spring-cloud-azure-support-file.yml @@ -0,0 +1,157 @@ +name: Update Spring Cloud Azure Support File +on: + schedule: + - cron: '0 0 * * *' + workflow_dispatch: + inputs: + base_branch: + description: 'Base branch for update branch and PR target. Defaults to trigger branch.' + required: false + type: string + +env: + BASE_BRANCH: ${{ github.event.inputs.base_branch || github.ref_name }} + PR_TITLE: "Update Spring Boot and Spring Cloud versions for the Spring compatibility tests" + +permissions: + contents: read + pull-requests: read + issues: read + +jobs: + check-open-pr: + name: Check Open Pull Request + runs-on: ubuntu-latest + outputs: + has_open_pr: ${{ steps.check.outputs.has_open_pr }} + steps: + - uses: actions/checkout@v4 + - name: Check for Existing Open Pull Request + id: check + uses: actions/github-script@v7 + with: + script: | + const prTitle = process.env.PR_TITLE; + const pullRequests = await github.paginate(github.rest.pulls.list, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100 + }); + + const openPRs = pullRequests.filter(pr => pr.title === prTitle); + core.setOutput('has_open_pr', openPRs.length > 0 ? 'true' : 'false'); + + update: + name: Update Support File and Create PR + needs: check-open-pr + if: ${{ needs.check-open-pr.outputs.has_open_pr == 'false' }} + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + issues: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set Branch Name with Timestamp + run: | + TIMESTAMP=$(date +%Y%m%d-%H%M%S) + GITHUB_ACTION_URL="https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}" + + { + echo "BRANCH_NAME=update-spring-cloud-azure-support-file-${TIMESTAMP}" + echo "COMMIT_MESSAGE<> "$GITHUB_ENV" + - name: Make Decision Based on Git Diff + run: | + git fetch origin "${{ env.BASE_BRANCH }}" + git checkout -B "${{ env.BRANCH_NAME }}" "origin/${{ env.BASE_BRANCH }}" + python ./sdk/spring/scripts/generate_spring_cloud_azure_support_file.py + if [[ -n "$(git status -s)" ]]; then + echo "NEED_UPDATE_FILE=true" >> "$GITHUB_ENV" + else + echo "No file changes, no commits." + fi + - name: Update Spring Cloud Azure Timeline + if: ${{ env.NEED_UPDATE_FILE == 'true' }} + run: | + TODAY=$(date +%Y-%m-%d) + TIMELINE_FILE=docs/spring/Spring-Cloud-Azure-Timeline.md + SUPPORT_FILE=sdk/spring/pipeline/spring-cloud-azure-supported-spring.json + + OLD_4X=$(git show "HEAD:${SUPPORT_FILE}" | jq -Sc '[.[] | select(."spring-boot-version" | startswith("4."))] | sort_by([."spring-boot-version", ."spring-cloud-version", .supportStatus, .current])') + NEW_4X=$(jq -Sc '[.[] | select(."spring-boot-version" | startswith("4."))] | sort_by([."spring-boot-version", ."spring-cloud-version", .supportStatus, .current])' "${SUPPORT_FILE}") + + if [[ "${OLD_4X}" == "${NEW_4X}" ]]; then + echo "No Spring Boot 4.x changes detected, skip timeline update." + exit 0 + fi + + SUPPORTED_LINES=$(jq -r ' + .[] + | select(.supportStatus == "SUPPORTED") + | select(.["spring-boot-version"] | startswith("4.")) + | " - spring-boot-dependencies:\(.["spring-boot-version"]) and spring-cloud-dependencies:\(.["spring-cloud-version"])." + ' "${SUPPORT_FILE}") + + if [[ -z "${SUPPORTED_LINES}" ]]; then + echo "No supported Spring Boot 4.x entries found, skip timeline update." + exit 0 + fi + + NEW_ENTRY=$(printf ' - **%s**: In "java - spring - compatibility - tests" pipeline, run unit tests:\n%s' "$TODAY" "$SUPPORTED_LINES") + awk -v entry="$NEW_ENTRY" ' + { print } + /^## Timeline$/ && !inserted { print entry; inserted=1 } + ' "$TIMELINE_FILE" > "$TIMELINE_FILE.tmp" + mv "$TIMELINE_FILE.tmp" "$TIMELINE_FILE" + - name: Push Commit + if: ${{ env.NEED_UPDATE_FILE == 'true' }} + run: | + git config --global user.email github-actions@github.com + git config --global user.name github-actions + git add sdk/spring/pipeline/spring-cloud-azure-supported-spring.json + git add docs/spring/Spring-Cloud-Azure-Timeline.md + git commit -m "$COMMIT_MESSAGE" + git push origin "${{ env.BRANCH_NAME }}" + - name: Create Pull Request + id: create_pr + if: ${{ env.NEED_UPDATE_FILE == 'true' }} + uses: actions/github-script@v7 + with: + script: | + const pr = await github.rest.pulls.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: process.env.PR_TITLE, + head: process.env.BRANCH_NAME, + base: process.env.BASE_BRANCH, + body: process.env.PULL_REQUEST_BODY + }); + core.setOutput('pull_request_number', String(pr.data.number)); + - name: Comment on Pull Request + if: ${{ env.NEED_UPDATE_FILE == 'true' }} + uses: actions/github-script@v7 + with: + script: | + const prNumber = Number('${{ steps.create_pr.outputs.pull_request_number }}'); + if (!prNumber) { + console.log('No pull request was created, nothing to comment on.'); + return; + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: '/azp run java - spring - tests' + }); diff --git a/.github/workflows/update-spring-dependencies.yml b/.github/workflows/update-spring-dependencies.yml new file mode 100644 index 000000000000..86efccd61a7a --- /dev/null +++ b/.github/workflows/update-spring-dependencies.yml @@ -0,0 +1,150 @@ +name: Update Spring Dependencies +on: + schedule: + - cron: '0 0 * * *' + workflow_dispatch: + inputs: + base_branch: + description: 'Base branch for update branch and PR target. Defaults to trigger branch.' + required: false + type: string + +env: + BASE_BRANCH: ${{ github.event.inputs.base_branch || github.ref_name }} + PR_TITLE_PREFIX: 'External dependencies upgrade - Spring Boot' + +permissions: + contents: read + pull-requests: read + issues: read + +jobs: + check: + name: Check for Open Spring Boot Upgrade PRs + runs-on: ubuntu-latest + outputs: + skip_pipeline: ${{ steps.check_prs.outputs.skip_pipeline }} + steps: + - uses: actions/checkout@v4 + - name: Check for Open Spring Boot Upgrade PRs + id: check_prs + uses: actions/github-script@v7 + with: + script: | + const prTitlePrefix = process.env.PR_TITLE_PREFIX; + const pullRequests = await github.paginate(github.rest.pulls.list, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100 + }); + + const openPRs = pullRequests.filter(pr => pr.title.startsWith(prTitlePrefix)); + core.setOutput('skip_pipeline', openPRs.length > 0 ? 'true' : 'false'); + + update: + name: Update Dependencies and Create PR + runs-on: ubuntu-latest + needs: check + if: ${{ needs.check.outputs.skip_pipeline != 'true' }} + permissions: + contents: write + pull-requests: write + issues: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Generate Version File + run: | + python ./sdk/spring/scripts/generate_spring_versions_and_pr_description.py + - name: Confirm Whether to Update + run: | + if [[ ! -f 'spring-versions.txt' ]]; then + echo "No new Spring Boot version, no updates." + elif grep -q -- '-' spring-versions.txt; then + echo "Has non-GA version, cancel update!" + else + { + echo "need_update_version=true" + printf 'update_branch=update-spring-dependencies-%s-%s\n' "$(date +%Y%m%d)" "${GITHUB_RUN_ID}" + printf 'spring_boot_version=%s\n' "$(sed -n '1p' spring-versions.txt)" + printf 'spring_cloud_version=%s\n' "$(sed -n '2p' spring-versions.txt)" + printf 'last_spring_boot_version=%s\n' "$(sed -n '3p' spring-versions.txt)" + printf 'last_spring_cloud_version=%s\n' "$(sed -n '4p' spring-versions.txt)" + printf 'PR_TITLE=%s %s and Spring Cloud %s\n' "${PR_TITLE_PREFIX}" "$(sed -n '1p' spring-versions.txt)" "$(sed -n '2p' spring-versions.txt)" + echo 'pr_descriptions<> "$GITHUB_ENV" + fi + - name: Generate spring_boot_managed_external_dependencies.txt + if: ${{ env.need_update_version == 'true' }} + run: | + echo "Updating Spring Boot Dependencies Version: ${{ env.spring_boot_version }}" + echo "Updating Spring Cloud Dependencies Version: ${{ env.spring_cloud_version }}" + git fetch origin "${{ env.BASE_BRANCH }}" + git checkout -B "${{ env.update_branch }}" "origin/${{ env.BASE_BRANCH }}" + pip install termcolor + python ./sdk/spring/scripts/get_spring_boot_managed_external_dependencies.py -b "${{ env.spring_boot_version }}" -c "${{ env.spring_cloud_version }}" + - name: Update external_dependencies.txt + if: ${{ env.need_update_version == 'true' }} + run: | + pip install termcolor + pip install in_place + python ./sdk/spring/scripts/sync_external_dependencies.py -b "${{ env.spring_boot_version }}" -sbmvn 4 + - name: Update Versions + if: ${{ env.need_update_version == 'true' }} + run: | + python ./eng/versioning/update_versions.py --sr + - name: Update ChangeLog + if: ${{ env.need_update_version == 'true' }} + run: | + python ./sdk/spring/scripts/update_changelog.py -b "${{ env.spring_boot_version }}" -c "${{ env.spring_cloud_version }}" + - name: Push Commit + if: ${{ env.need_update_version == 'true' }} + run: | + git config --global user.email github-actions@github.com + git config --global user.name github-actions + git rm ./sdk/spring/scripts/spring_boot_${{ env.last_spring_boot_version }}_managed_external_dependencies.txt || true + git add -A + git commit -m "Upgrade external dependencies to align with Spring Boot ${{ env.spring_boot_version }}" + git push origin "HEAD:${{ env.update_branch }}" + - name: Create Pull Request + id: create_pr + if: ${{ env.need_update_version == 'true' }} + uses: actions/github-script@v7 + with: + script: | + const body = [ + `Updates external dependencies to align with Spring Boot version [${process.env.spring_boot_version}](https://repo1.maven.org/maven2/org/springframework/boot/spring-boot-dependencies/${process.env.spring_boot_version}/spring-boot-dependencies-${process.env.spring_boot_version}.pom) from [${process.env.last_spring_boot_version}](https://repo1.maven.org/maven2/org/springframework/boot/spring-boot-dependencies/${process.env.last_spring_boot_version}/spring-boot-dependencies-${process.env.last_spring_boot_version}.pom) and Spring Cloud version [${process.env.spring_cloud_version}](https://repo1.maven.org/maven2/org/springframework/cloud/spring-cloud-dependencies/${process.env.spring_cloud_version}/spring-cloud-dependencies-${process.env.spring_cloud_version}.pom) from [${process.env.last_spring_cloud_version}](https://repo1.maven.org/maven2/org/springframework/cloud/spring-cloud-dependencies/${process.env.last_spring_cloud_version}/spring-cloud-dependencies-${process.env.last_spring_cloud_version}.pom).`, + process.env.pr_descriptions || '', + '', + `This PR is created by GitHub Actions: https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${process.env.GITHUB_RUN_ID}` + ].join('\n'); + const pr = await github.rest.pulls.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: process.env.PR_TITLE, + head: process.env.update_branch, + base: process.env.BASE_BRANCH, + body, + draft: false + }); + core.setOutput('pull_request_number', String(pr.data.number)); + - name: Comment on Pull Request + if: ${{ env.need_update_version == 'true' }} + uses: actions/github-script@v7 + with: + script: | + const prNumber = Number('${{ steps.create_pr.outputs.pull_request_number }}'); + if (!prNumber) { + console.log('No pull request was created, nothing to comment on.'); + return; + } + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: '/azp run java - spring - tests' + }); diff --git a/.vscode/cspell.json b/.vscode/cspell.json index acd03fb12e20..9b7f6959af3c 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -220,6 +220,9 @@ "adfs", "ADFS", "agentic", + "initializr", + "Initializr", + "INITIALIZR", "akhtabar", "alzimmer", "amqp", diff --git a/sdk/spring/scripts/generate_spring_cloud_azure_support_file.py b/sdk/spring/scripts/generate_spring_cloud_azure_support_file.py new file mode 100644 index 000000000000..3036fcd2ab43 --- /dev/null +++ b/sdk/spring/scripts/generate_spring_cloud_azure_support_file.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""Generate sdk/spring/pipeline/spring-cloud-azure-supported-spring.json.""" + +import argparse +import json +import os +import re +import urllib.request + +SPRING_METADATA_URL = "https://spring.io/project_metadata/spring-boot" +SPRING_INITIALIZR_INFO_URL = "https://start.spring.io/actuator/info" +SUPPORT_FILE = "sdk/spring/pipeline/spring-cloud-azure-supported-spring.json" +NONE_SUPPORTED = "NONE_SUPPORTED_SPRING_CLOUD_VERSION" +# Azure SDK for Java still keeps Spring Boot 2.7.18 as supported, even though other inactive +# versions are marked END_OF_LIFE when they drop out of the current Spring metadata feed. +LEGACY_SUPPORTED_VERSION = "2.7.18" + + +def version_key(version): + match = re.match(r"^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:[-.]?([A-Za-z]+)(\d*)?)?$", version) + if not match: + return (0, 0, 0, 0, 0, version) + + major = int(match.group(1) or 0) + minor = int(match.group(2) or 0) + patch = int(match.group(3) or 0) + qualifier = (match.group(4) or "").upper() + qualifier_num = int(match.group(5) or 0) + + # Higher rank means newer release status for the same base version. + if not qualifier: + qualifier_rank = 3 # GA + elif qualifier.startswith("RC"): + qualifier_rank = 2 + elif qualifier.startswith("M"): + qualifier_rank = 1 + elif qualifier.startswith("SNAPSHOT"): + qualifier_rank = 0 + else: + qualifier_rank = 0 + + return (major, minor, patch, qualifier_rank, qualifier_num, qualifier) + + +def fetch_json(url): + req = urllib.request.Request(url, headers={"User-Agent": "spring-cloud-azure-tools-migration"}) + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def compare_versions(a, b): + ka = version_key(a) + kb = version_key(b) + if ka == kb: + return 0 + if ka < kb: + return -1 + return 1 + + +def parse_range_expression(expr): + rules = [] + for token in expr.split(): + m = re.match(r"^([><])(=*)(.*)$", token) + if not m: + continue + op = m.group(1) + inclusive = m.group(2) == "=" + version = m.group(3) + rules.append((op, inclusive, version)) + if not rules: + raise RuntimeError("Cannot parse Spring Initializr range: {}".format(expr)) + return rules + + +def in_range(version, rules): + for op, inclusive, bound in rules: + cmp_result = compare_versions(version, bound) + if op == ">": + if cmp_result < 0 or (cmp_result == 0 and not inclusive): + return False + elif op == "<": + if cmp_result > 0 or (cmp_result == 0 and not inclusive): + return False + return True + + +def find_compatible_spring_cloud_version(spring_boot_version, spring_cloud_ranges): + for cloud_version in sorted(spring_cloud_ranges.keys(), key=version_key, reverse=True): + expr = spring_cloud_ranges[cloud_version] + if in_range(spring_boot_version, parse_range_expression(expr)): + return cloud_version + return NONE_SUPPORTED + + +def is_snapshot_milestone_or_rc(version, include_rc): + if version is None: + return False + has_snapshot_or_milestone = "SNAPSHOT" in version or "M" in version + has_rc = "RC" in version + return has_snapshot_or_milestone or (has_rc and not include_rc) + + +def is_version_supported(version): + return compare_versions(version, "3.5.0") >= 0 + + +def load_existing_support_map(): + try: + with open(SUPPORT_FILE, "r", encoding="utf-8") as f: + existing = json.load(f) + except (FileNotFoundError, json.JSONDecodeError, TypeError): + return {} + return {item.get("spring-boot-version"): item for item in existing if item.get("spring-boot-version")} + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--include-rc", action="store_true", help="Include RC versions in generated output") + args = parser.parse_args() + + spring_metadata = fetch_json(SPRING_METADATA_URL) + releases = spring_metadata.get("projectReleases", []) + + initializr_info = fetch_json(SPRING_INITIALIZR_INFO_URL) + spring_cloud_ranges = ( + initializr_info.get("bom-ranges", {}).get("spring-cloud", {}) + or initializr_info.get("build", {}).get("bom-ranges", {}).get("spring-cloud", {}) + or initializr_info.get("build", {}).get("versions", {}).get("spring-cloud", {}) + or initializr_info.get("serviceCapabilities", {}).get("bom", {}).get("spring-cloud", {}) + or initializr_info.get("serviceBom", {}).get("spring-cloud", {}) + ) + if not spring_cloud_ranges: + raise RuntimeError("Cannot locate spring-cloud compatibility map in Spring Initializr response") + + existing_map = load_existing_support_map() + active_versions = set() + current_items = [] + + for release in releases: + version = release.get("version") + if not version: + continue + if not is_version_supported(version): + continue + if is_snapshot_milestone_or_rc(version, args.include_rc): + continue + + existing = existing_map.get(version, {}) + support_status = existing.get("supportStatus") + if support_status is None: + if release.get("releaseStatus") in ("GENERAL_AVAILABILITY", "PRERELEASE"): + support_status = "SUPPORTED" + else: + support_status = "TODO" + + cloud_version = existing.get("spring-cloud-version") + if existing.get("supportStatus") != "END_OF_LIFE": + cloud_version = find_compatible_spring_cloud_version(version, spring_cloud_ranges) + if cloud_version == NONE_SUPPORTED and support_status == "SUPPORTED": + # Keep matrix entries non-supported when Initializr does not provide a compatible Spring Cloud BOM. + support_status = "TODO" + + current_items.append( + { + "current": bool(release.get("current", False)), + "releaseStatus": release.get("releaseStatus"), + "snapshot": bool(release.get("snapshot", False)), + "supportStatus": support_status, + "spring-boot-version": version, + "spring-cloud-version": cloud_version, + } + ) + active_versions.add(version) + + snapshot_items = [] + for version, metadata in existing_map.items(): + if version in active_versions: + continue + if is_snapshot_milestone_or_rc(version, args.include_rc): + continue + cloned = dict(metadata) + cloned["current"] = False + if version != LEGACY_SUPPORTED_VERSION: + cloned["supportStatus"] = "END_OF_LIFE" + snapshot_items.append(cloned) + + result = current_items + snapshot_items + result.sort(key=lambda item: version_key(item.get("spring-boot-version", "0")), reverse=True) + + os.makedirs(os.path.dirname(SUPPORT_FILE), exist_ok=True) + with open(SUPPORT_FILE, "w", encoding="utf-8") as f: + json.dump(result, f, indent=2, separators=(",", " : ")) + f.write("\n") + + +if __name__ == "__main__": + main() diff --git a/sdk/spring/scripts/generate_spring_versions_and_pr_description.py b/sdk/spring/scripts/generate_spring_versions_and_pr_description.py new file mode 100644 index 000000000000..ee1b508d5050 --- /dev/null +++ b/sdk/spring/scripts/generate_spring_versions_and_pr_description.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +"""Generate spring-versions.txt and pr-descriptions.txt for Spring dependency update workflows.""" + +import argparse +import html +import json +import re +import urllib.error +import urllib.request + +SPRING_METADATA_URL = "https://spring.io/project_metadata/spring-boot" +SPRING_INITIALIZR_INFO_URL = "https://start.spring.io/actuator/info" +SPRING_BOOT_RELEASE_TAG_URL = "https://github.com/spring-projects/spring-boot/releases/tag/v{}" +SPRING_BOOT_RELEASE_API_URL = "https://api.github.com/repos/spring-projects/spring-boot/releases/tags/v{}" +EXTERNAL_DEPENDENCIES_FILE = "eng/versioning/external_dependencies.txt" +SUPPORT_MATRIX_FILE = "sdk/spring/pipeline/spring-cloud-azure-supported-spring.json" +SPRING_VERSIONS_OUTPUT = "spring-versions.txt" +PR_DESCRIPTIONS_OUTPUT = "pr-descriptions.txt" + + +def version_key(version): + match = re.match(r"^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:[-.]?([A-Za-z]+)(\d*)?)?$", version) + if not match: + return (0, 0, 0, 0, 0, version) + + major = int(match.group(1) or 0) + minor = int(match.group(2) or 0) + patch = int(match.group(3) or 0) + qualifier = (match.group(4) or "").upper() + qualifier_num = int(match.group(5) or 0) + + # Higher rank means newer release status for the same base version. + if not qualifier: + qualifier_rank = 3 # GA + elif qualifier.startswith("RC"): + qualifier_rank = 2 + elif qualifier.startswith("M"): + qualifier_rank = 1 + elif qualifier.startswith("SNAPSHOT"): + qualifier_rank = 0 + else: + qualifier_rank = 0 + + return (major, minor, patch, qualifier_rank, qualifier_num, qualifier) + + +def fetch_json(url): + req = urllib.request.Request(url, headers={"User-Agent": "spring-cloud-azure-tools-migration"}) + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def compare_versions(a, b): + ka = version_key(a) + kb = version_key(b) + if ka == kb: + return 0 + if ka < kb: + return -1 + return 1 + + +def sort_versions_desc(versions): + return sorted(versions, key=version_key, reverse=True) + + +def read_current_supported_versions(): + # Prefer the support matrix because it reflects the current Spring compatibility target. + boot = None + cloud = None + try: + with open(SUPPORT_MATRIX_FILE, "r", encoding="utf-8") as f: + entries = json.load(f) + current_entries = [ + e for e in entries + if e.get("current") and e.get("supportStatus") == "SUPPORTED" + ] + if current_entries: + current = current_entries[0] + boot = current.get("spring-boot-version") + cloud = current.get("spring-cloud-version") + except (FileNotFoundError, json.JSONDecodeError, TypeError): + pass + + if boot and cloud: + return boot, cloud + + # Fallback to external dependencies for compatibility with older layouts. + boot_candidates = { + "org.springframework.boot:spring-boot-dependencies", + "org.springframework.boot:spring-boot-starter-parent", + "org.springframework.boot:spring-boot-starter", + "org.springframework.boot:spring-boot-maven-plugin", + } + with open(EXTERNAL_DEPENDENCIES_FILE, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or ";" not in line: + continue + artifact, version = line.split(";", 1) + if artifact in boot_candidates and not boot: + boot = version + elif artifact == "org.springframework.cloud:spring-cloud-dependencies" and not cloud: + cloud = version + if not boot or not cloud: + raise RuntimeError( + "Failed to read current Spring Boot/Cloud versions from support matrix and external_dependencies.txt" + ) + return boot, cloud + + +def parse_range_expression(expr): + rules = [] + for token in expr.split(): + m = re.match(r"^([><])(=*)(.*)$", token) + if not m: + continue + op = m.group(1) + inclusive = m.group(2) == "=" + version = m.group(3) + rules.append((op, inclusive, version)) + if not rules: + raise RuntimeError("Cannot parse Spring Initializr range: {}".format(expr)) + return rules + + +def in_range(version, rules): + for op, inclusive, bound in rules: + cmp_result = compare_versions(version, bound) + if op == ">": + if cmp_result < 0 or (cmp_result == 0 and not inclusive): + return False + elif op == "<": + if cmp_result > 0 or (cmp_result == 0 and not inclusive): + return False + return True + + +def find_compatible_spring_cloud_version(spring_boot_version, spring_cloud_ranges): + for cloud_version in sort_versions_desc(list(spring_cloud_ranges.keys())): + expr = spring_cloud_ranges[cloud_version] + if in_range(spring_boot_version, parse_range_expression(expr)): + return cloud_version + raise RuntimeError( + "No compatible spring-cloud version found for spring-boot {}".format(spring_boot_version) + ) + + +def release_notes_html(version): + release_url = SPRING_BOOT_RELEASE_TAG_URL.format(version) + try: + body = fetch_json(SPRING_BOOT_RELEASE_API_URL.format(version)).get("body", "") + except (urllib.error.HTTPError, urllib.error.URLError): + body = "" + + if body: + body = body.replace("\r\n", "\n") + body = re.split(r"\n##\s+.*Contributors.*", body, maxsplit=1, flags=re.IGNORECASE)[0] + body = html.escape(body).replace("\n", "
") + else: + body = "Release notes unavailable from GitHub API." + + return ( + "
Release notes" + "

Sourced from spring-boot releases.

" + "{}" + "
".format(release_url, body) + ) + + +def write_outputs(target_boot, target_cloud, current_boot, current_cloud, notes): + with open(SPRING_VERSIONS_OUTPUT, "w", encoding="utf-8") as f: + f.write(target_boot + "\n") + f.write(target_cloud + "\n") + f.write(current_boot + "\n") + f.write(current_cloud + "\n") + + with open(PR_DESCRIPTIONS_OUTPUT, "w", encoding="utf-8") as f: + f.write(notes) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--target-major", default="4", help="Target Spring Boot major version.") + parser.add_argument( + "--prefer-prerelease", + action="store_true", + help="Prefer latest prerelease when both GA and prerelease updates are available.", + ) + args = parser.parse_args() + + spring_metadata = fetch_json(SPRING_METADATA_URL) + releases = spring_metadata.get("projectReleases", []) + ga_versions = [ + r.get("version") for r in releases + if r.get("releaseStatus") == "GENERAL_AVAILABILITY" and r.get("version", "").startswith(args.target_major) + ] + rc_versions = [ + r.get("version") for r in releases + if r.get("releaseStatus") == "PRERELEASE" and r.get("version", "").startswith(args.target_major) + ] + ga_versions = [v for v in ga_versions if v] + rc_versions = [v for v in rc_versions if v] + + if not ga_versions and not rc_versions: + return + + latest_ga = sort_versions_desc(ga_versions)[0] if ga_versions else None + latest_rc = sort_versions_desc(rc_versions)[0] if rc_versions else None + + current_boot, current_cloud = read_current_supported_versions() + is_new_ga = latest_ga is not None and compare_versions(latest_ga, current_boot) > 0 + is_new_rc = latest_rc is not None and compare_versions(latest_rc, current_boot) > 0 + + if not is_new_ga and not is_new_rc: + return + + initializr_info = fetch_json(SPRING_INITIALIZR_INFO_URL) + spring_cloud_ranges = ( + initializr_info + .get("bom-ranges", {}) + .get("spring-cloud", {}) + ) + if not spring_cloud_ranges: + spring_cloud_ranges = ( + initializr_info + .get("build", {}) + .get("bom-ranges", {}) + .get("spring-cloud", {}) + ) + if not spring_cloud_ranges: + spring_cloud_ranges = ( + initializr_info + .get("build", {}) + .get("versions", {}) + .get("spring-cloud", {}) + ) + if not spring_cloud_ranges: + spring_cloud_ranges = ( + initializr_info + .get("serviceCapabilities", {}) + .get("bom", {}) + .get("spring-cloud", {}) + ) + if not spring_cloud_ranges: + spring_cloud_ranges = ( + initializr_info + .get("serviceBom", {}) + .get("spring-cloud", {}) + ) + + if not spring_cloud_ranges: + raise RuntimeError("Cannot locate spring-cloud compatibility map in Spring Initializr response") + + if args.prefer_prerelease and is_new_rc: + target_boot = latest_rc + elif is_new_ga: + target_boot = latest_ga + else: + target_boot = latest_rc + + if target_boot == latest_ga: + target_cloud = find_compatible_spring_cloud_version(target_boot, spring_cloud_ranges) + else: + try: + target_cloud = find_compatible_spring_cloud_version(target_boot, spring_cloud_ranges) + except RuntimeError: + if latest_ga is None: + raise + target_cloud = find_compatible_spring_cloud_version(latest_ga, spring_cloud_ranges) + + write_outputs(target_boot, target_cloud, current_boot, current_cloud, release_notes_html(target_boot)) + + +if __name__ == "__main__": + main()