From a9692ad11b4d9a06dfacfbb7406d34d3fccbae70 Mon Sep 17 00:00:00 2001 From: Vlad Chesnokov Date: Wed, 26 Aug 2026 14:34:29 +0200 Subject: [PATCH] Report patches available for version --- .../integ-test-gradle-support-status.yml | 38 ++++++ sources/src/gradle-support-status.ts | 113 ++++++++++++++---- sources/src/job-summary.ts | 13 +- .../test/jest/gradle-support-status.test.ts | 105 +++++++++------- sources/test/jest/job-summary.test.ts | 35 ++++-- 5 files changed, 220 insertions(+), 84 deletions(-) diff --git a/.github/workflows/integ-test-gradle-support-status.yml b/.github/workflows/integ-test-gradle-support-status.yml index 49876d40..095cd744 100644 --- a/.github/workflows/integ-test-gradle-support-status.yml +++ b/.github/workflows/integ-test-gradle-support-status.yml @@ -22,6 +22,8 @@ jobs: runs-on: ubuntu-latest outputs: maintenance-version: ${{ steps.maintenance-version.outputs.version }} + patch-version: ${{ steps.patch-version.outputs.version }} + newer-patch: ${{ steps.patch-version.outputs.newer }} steps: - name: Checkout sources uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -39,6 +41,27 @@ jobs: echo "Newest release in the maintenance-only line: $version" echo "version=$version" >> "$GITHUB_OUTPUT" + - name: Determine a version with a newer patch available + id: patch-version + run: | + read -r version newer <<<"$(jq -r ' + [.[].version | select(test("^[0-9]+\\.[0-9]+(\\.[0-9]+)?$"))] as $finals + | ($finals | map(split(".")[0] | tonumber) | max) as $latest + | [$finals[] | select((split(".")[0] | tonumber) == $latest)] + | group_by(split(".")[0:2] | join(".")) + | map(sort_by(split(".") | if length > 2 then (.[2] | tonumber) else 0 end) | reverse) + | map(select(length > 1)) + | first + | if . then "\(.[1]) \(.[0])" else empty end + ' sources/src/wrapper-validation/wrapper-checksums.json)" + if [ -z "${version:-}" ]; then + echo "No release line in the current major has more than one release: skipping the patch case" + else + echo "Gradle ${version} is superseded by ${newer}" + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "newer=${newer}" >> "$GITHUB_OUTPUT" + fi + - name: Setup Gradle with an end-of-life version uses: ./setup-gradle with: @@ -57,6 +80,16 @@ jobs: working-directory: .github/workflow-samples/no-wrapper run: gradle help "-DgradleVersionCheck=${{ steps.maintenance-version.outputs.version }}" + - name: Setup Gradle with a version that has a newer patch + if: ${{ steps.patch-version.outputs.version != '' }} + uses: ./setup-gradle + with: + gradle-version: ${{ steps.patch-version.outputs.version }} + - name: Build with the version that has a newer patch + if: ${{ steps.patch-version.outputs.version != '' }} + working-directory: .github/workflow-samples/no-wrapper + run: gradle help "-DgradleVersionCheck=${{ steps.patch-version.outputs.version }}" + verify-annotations: needs: report-outdated-versions runs-on: ubuntu-latest @@ -68,6 +101,8 @@ jobs: env: GH_TOKEN: ${{ github.token }} MAINTENANCE_VERSION: ${{ needs.report-outdated-versions.outputs.maintenance-version }} + PATCH_VERSION: ${{ needs.report-outdated-versions.outputs.patch-version }} + NEWER_PATCH: ${{ needs.report-outdated-versions.outputs.newer-patch }} run: | set -euo pipefail @@ -92,3 +127,6 @@ jobs: assert_logged "##[warning]Gradle 7.6.4 has reached end-of-life" assert_logged "##[notice]Gradle ${MAINTENANCE_VERSION} is in maintenance-only support" + if [ -n "${PATCH_VERSION}" ]; then + assert_logged "##[notice]Gradle ${PATCH_VERSION} is not the latest patch release: Gradle ${NEWER_PATCH} is available" + fi diff --git a/sources/src/gradle-support-status.ts b/sources/src/gradle-support-status.ts index 3b7638ba..1cc6d6d9 100644 --- a/sources/src/gradle-support-status.ts +++ b/sources/src/gradle-support-status.ts @@ -1,60 +1,129 @@ import * as core from '@actions/core' +import * as semver from 'semver' import {GradleVersion} from './execution/gradle-version' import wrapperChecksums from './wrapper-validation/wrapper-checksums.json' const FEATURE_LIFECYCLE_DOC = 'https://docs.gradle.org/current/userguide/feature_lifecycle.html#eol_support' -const LATEST_RELEASED_MAJOR = determineLatestReleasedMajor(wrapperChecksums.map(entry => entry.version)) +const RELEASED_VERSIONS = wrapperChecksums + .map(entry => entry.version) + .filter(version => new GradleVersion(version).isFinalRelease()) -export type SupportStatus = 'active' | 'maintenance' | 'eol' +const LATEST_RELEASED_MAJOR = determineLatestReleasedMajor(RELEASED_VERSIONS) -export function determineLatestReleasedMajor(versions: string[]): number | undefined { +const LATEST_PATCHES = latestPatchByMinorLine(RELEASED_VERSIONS) + +export enum SupportStatusKind { + Active = 'active', + Maintenance = 'maintenance', + Eol = 'eol', + PatchAvailable = 'patch-available' +} + +export type SupportStatus = + | {kind: SupportStatusKind.Active} + | {kind: SupportStatusKind.Maintenance} + | {kind: SupportStatusKind.Eol} + | {kind: SupportStatusKind.PatchAvailable; newerPatch: string} + +function determineLatestReleasedMajor(versions: string[]): number { const releasedMajors = versions .map(version => new GradleVersion(version)) .filter(parsed => parsed.isFinalRelease()) .map(parsed => parsed.major) - return releasedMajors.length > 0 ? Math.max(...releasedMajors) : undefined + return Math.max(0, ...releasedMajors) } -export function getSupportStatus(version: GradleVersion, latestMajor: number): SupportStatus { +type LatestPatches = Map + +function getSupportStatus(version: GradleVersion, latestMajor: number, latestPatches: LatestPatches): SupportStatus { switch (Math.max(0, latestMajor - version.major)) { - case 0: - return 'active' + case 0: { + const newerPatch = findNewerPatch(version.version, latestPatches) + return newerPatch ? {kind: SupportStatusKind.PatchAvailable, newerPatch} : {kind: SupportStatusKind.Active} + } case 1: - return 'maintenance' + return {kind: SupportStatusKind.Maintenance} default: - return 'eol' + return {kind: SupportStatusKind.Eol} } } -export function supportStatusOf(gradleVersion: string): SupportStatus | undefined { - if (LATEST_RELEASED_MAJOR === undefined) { +function latestPatchByMinorLine(releasedVersions: string[]): LatestPatches { + const latestPatches: LatestPatches = new Map() + for (const released of releasedVersions) { + const version = semver.coerce(released) + if (!version) { + continue + } + const minorLine = `${version.major}.${version.minor}` + const known = latestPatches.get(minorLine) + if (!known || semver.gt(version, known)) { + latestPatches.set(minorLine, version) + } + } + return latestPatches +} + +function findNewerPatch(gradleVersion: string, latestPatches: LatestPatches): string | undefined { + const current = semver.coerce(gradleVersion) + if (!current || !new GradleVersion(gradleVersion).isFinalRelease()) { return undefined } - return getSupportStatus(new GradleVersion(gradleVersion), LATEST_RELEASED_MAJOR) + + const latest = latestPatches.get(`${current.major}.${current.minor}`) + return latest && semver.gt(latest, current) ? latest.version : undefined +} + +export function supportStatusOf(gradleVersion: string): SupportStatus { + return getSupportStatus(new GradleVersion(gradleVersion), LATEST_RELEASED_MAJOR, LATEST_PATCHES) +} + +/** Entry point for tests */ +export function supportStatusUsing(gradleVersion: string, releasedVersions: string[]): SupportStatus { + return getSupportStatus( + new GradleVersion(gradleVersion), + determineLatestReleasedMajor(releasedVersions), + latestPatchByMinorLine(releasedVersions) + ) } export function reportSupportStatus(gradleVersions: string[]): void { - if (LATEST_RELEASED_MAJOR === undefined) { - return - } + reportOutdatedVersions(gradleVersions, LATEST_RELEASED_MAJOR, LATEST_PATCHES) +} +/** Entry point for tests */ +export function reportSupportStatusUsing(gradleVersions: string[], releasedVersions: string[]): void { + reportOutdatedVersions( + gradleVersions, + determineLatestReleasedMajor(releasedVersions), + latestPatchByMinorLine(releasedVersions) + ) +} + +function reportOutdatedVersions(gradleVersions: string[], latestMajor: number, latestPatches: LatestPatches): void { for (const gradleVersion of new Set(gradleVersions)) { const version = new GradleVersion(gradleVersion) - switch (getSupportStatus(version, LATEST_RELEASED_MAJOR)) { - case 'eol': - core.warning(eolMessage(version, LATEST_RELEASED_MAJOR), {title: 'Gradle version at end-of-life'}) + const support = getSupportStatus(version, latestMajor, latestPatches) + switch (support.kind) { + case SupportStatusKind.Eol: + core.warning(eolMessage(version, latestMajor), {title: 'Gradle version at end-of-life'}) break - case 'maintenance': - core.notice(maintenanceMessage(version, LATEST_RELEASED_MAJOR), { - title: 'Gradle version in maintenance' - }) + case SupportStatusKind.Maintenance: + core.notice(maintenanceMessage(version, latestMajor), {title: 'Gradle version in maintenance'}) + break + case SupportStatusKind.PatchAvailable: + core.notice(patchMessage(version, support.newerPatch), {title: 'Gradle patch update available'}) break } } } +function patchMessage(version: GradleVersion, newerPatch: string): string { + return `Gradle ${version.version} is not the latest patch release: Gradle ${newerPatch} is available in the same release line, and upgrading is recommended. See ${FEATURE_LIFECYCLE_DOC}` +} + function eolMessage(version: GradleVersion, latestMajor: number): string { return `Gradle ${version.version} has reached end-of-life: the ${version.major}.x release line no longer receives bug fixes or security fixes. Gradle ${latestMajor}.x is the current release line, and upgrading is recommended. See ${FEATURE_LIFECYCLE_DOC}` } diff --git a/sources/src/job-summary.ts b/sources/src/job-summary.ts index 5620f89f..c83d1968 100644 --- a/sources/src/job-summary.ts +++ b/sources/src/job-summary.ts @@ -6,7 +6,7 @@ import {CacheReport} from './cache-service' import {ProviderNote, renderCachingReport} from './caching-report' import {DependencyGraphConfig, getActionId, getGithubToken, getJobMatrix, SummaryConfig} from './configuration' import {Deprecation, getDeprecations, getErrors} from './deprecation-collector' -import {supportStatusOf} from './gradle-support-status' +import {SupportStatusKind, supportStatusOf} from './gradle-support-status' const FEATURE_LIFECYCLE_DOC = 'https://docs.gradle.org/current/userguide/feature_lifecycle.html#eol_support' @@ -169,11 +169,14 @@ function renderBuildResultRow(result: BuildResult): string { } function renderGradleVersion(gradleVersion: string): string { - switch (supportStatusOf(gradleVersion)) { - case 'eol': + const support = supportStatusOf(gradleVersion) + switch (support.kind) { + case SupportStatusKind.Eol: return `${gradleVersion} :warning:` - case 'maintenance': + case SupportStatusKind.Maintenance: return `${gradleVersion} :information_source:` + case SupportStatusKind.PatchAvailable: + return `${gradleVersion} :information_source:` default: return gradleVersion } @@ -182,7 +185,7 @@ function renderGradleVersion(gradleVersion: string): string { function renderOutdatedVersions(results: BuildResult[]): string { const hasOutdatedVersion = results .map(result => supportStatusOf(result.gradleVersion)) - .some(status => status === 'eol' || status === 'maintenance') + .some(support => support.kind === SupportStatusKind.Eol || support.kind === SupportStatusKind.Maintenance) if (!hasOutdatedVersion) { return '' } diff --git a/sources/test/jest/gradle-support-status.test.ts b/sources/test/jest/gradle-support-status.test.ts index 76c8e821..abe27dc5 100644 --- a/sources/test/jest/gradle-support-status.test.ts +++ b/sources/test/jest/gradle-support-status.test.ts @@ -8,54 +8,58 @@ jest.unstable_mockModule('@actions/core', () => ({ notice: mockNotice })) -const {determineLatestReleasedMajor, getSupportStatus, reportSupportStatus} = - await import('../../src/gradle-support-status') -const {GradleVersion} = await import('../../src/execution/gradle-version') -import wrapperChecksums from '../../src/wrapper-validation/wrapper-checksums.json' +const {SupportStatusKind, reportSupportStatusUsing, supportStatusUsing} = await import( + '../../src/gradle-support-status' +) -const latestReleasedMajor = determineLatestReleasedMajor(wrapperChecksums.map(entry => entry.version)) -const MAINTENANCE_VERSION = `${(latestReleasedMajor ?? 0) - 1}.0` +const RELEASED = ['10.4.2', '10.1.0', '10.0.0', '9.6.1', '9.0.0', '8.14', '1.0'] const DOC = 'https://docs.gradle.org/current/userguide/feature_lifecycle.html#eol_support' -describe('determineLatestReleasedMajor', () => { - it('ignores pre-releases and snapshots of an unreleased major', () => { - const versions = [ - '10.0.0-milestone-1', - '10.0.0-rc-1', - '10.0.0-SNAPSHOT', - '10.0.0-20260101120000+0000', - '9.6.1', - '8.14' - ] - - expect(determineLatestReleasedMajor(versions)).toBe(9) - }) - - it('is unaffected by the ordering of the version list', () => { - expect(determineLatestReleasedMajor(['1.0', '9.0.0', '4.10.3'])).toBe(9) - }) - - it('is undefined when no final release is present', () => { - expect(determineLatestReleasedMajor(['9.0.0-rc-1', '10.0.0-SNAPSHOT'])).toBeUndefined() - }) - - it('is undefined for an empty version list', () => { - expect(determineLatestReleasedMajor([])).toBeUndefined() - }) -}) - -describe('getSupportStatus', () => { - it.each(['10.0.0', '10.4.2', '11.0.0-milestone-1', '12.0.0'])('treats %s as active', version => { - expect(getSupportStatus(new GradleVersion(version), 10)).toBe('active') +describe('supportStatusOf', () => { + it.each(['10.0.0', '10.4.2', '11.0.0-milestone-1'])('treats %s as active', version => { + expect(supportStatusUsing(version, RELEASED)).toEqual({kind: SupportStatusKind.Active}) }) it.each(['9.0.0', '9.6.1', '9.7.0-rc-2'])('treats %s as maintenance-only', version => { - expect(getSupportStatus(new GradleVersion(version), 10)).toBe('maintenance') + expect(supportStatusUsing(version, RELEASED)).toEqual({kind: SupportStatusKind.Maintenance}) }) it.each(['8.14', '8.0.2', '7.6.4', '4.10.3', '1.0'])('treats %s as end-of-life', version => { - expect(getSupportStatus(new GradleVersion(version), 10)).toBe('eol') + expect(supportStatusUsing(version, RELEASED)).toEqual({kind: SupportStatusKind.Eol}) + }) + + it('reports a newer patch in the latest release line instead of active', () => { + expect(supportStatusUsing('10.0.0', ['10.1.0', '10.0.1', '10.0.0'])).toEqual({ + kind: SupportStatusKind.PatchAvailable, + newerPatch: '10.0.1' + }) + }) + + it('reports the newest patch when several are available', () => { + expect(supportStatusUsing('8.14', ['8.14.5', '8.14.2', '8.14'])).toEqual({ + kind: SupportStatusKind.PatchAvailable, + newerPatch: '8.14.5' + }) + }) + + it('ignores a newer minor in the same major', () => { + expect(supportStatusUsing('9.3.0', ['9.4.0', '9.3.0'])).toEqual({kind: SupportStatusKind.Active}) + }) + + it('ignores a newer patch when the release line is already outdated', () => { + const released = ['10.0.0', '9.0.1', '9.0.0', '8.0.1', '8.0'] + + expect(supportStatusUsing('9.0.0', released)).toEqual({kind: SupportStatusKind.Maintenance}) + expect(supportStatusUsing('8.0', released)).toEqual({kind: SupportStatusKind.Eol}) + }) + + it('reports no patch update for a pre-release', () => { + expect(supportStatusUsing('9.3.0-rc-1', ['9.3.1', '9.3.0'])).toEqual({kind: SupportStatusKind.Active}) + }) + + it('treats every version as active when the release data holds no final release', () => { + expect(supportStatusUsing('1.0', ['9.0.0-rc-1', '10.0.0-SNAPSHOT'])).toEqual({kind: SupportStatusKind.Active}) }) }) @@ -65,43 +69,56 @@ describe('reportSupportStatus', () => { }) it('warns about an end-of-life version', () => { - reportSupportStatus(['7.6.4']) + reportSupportStatusUsing(['7.6.4'], RELEASED) expect(mockNotice).not.toHaveBeenCalled() expect(mockWarning).toHaveBeenCalledTimes(1) const [message, properties] = mockWarning.mock.calls[0] expect(message).toContain('Gradle 7.6.4 has reached end-of-life') expect(message).toContain('the 7.x release line no longer receives bug fixes or security fixes') + expect(message).toContain('Gradle 10.x is the current release line') expect(message).toContain(DOC) expect(properties?.title).toBe('Gradle version at end-of-life') }) it('notices a maintenance-only version', () => { - reportSupportStatus([MAINTENANCE_VERSION]) + reportSupportStatusUsing(['9.0.0'], RELEASED) expect(mockWarning).not.toHaveBeenCalled() expect(mockNotice).toHaveBeenCalledTimes(1) const [message, properties] = mockNotice.mock.calls[0] - expect(message).toContain(`Gradle ${MAINTENANCE_VERSION} is in maintenance-only support`) + expect(message).toContain('Gradle 9.0.0 is in maintenance-only support') expect(message).toContain('receives critical bug fixes and security fixes only') + expect(message).toContain('reaches end-of-life when Gradle 11 is released') expect(properties?.title).toBe('Gradle version in maintenance') }) + it('notices a version with a newer patch available', () => { + reportSupportStatusUsing(['10.0.0'], ['10.0.1', '10.0.0']) + + expect(mockWarning).not.toHaveBeenCalled() + expect(mockNotice).toHaveBeenCalledTimes(1) + const [message, properties] = mockNotice.mock.calls[0] + expect(message).toContain('Gradle 10.0.0 is not the latest patch release') + expect(message).toContain('Gradle 10.0.1 is available') + expect(properties?.title).toBe('Gradle patch update available') + }) + it('says nothing about a version in the latest release line', () => { - reportSupportStatus(['999.0.0']) + reportSupportStatusUsing(['10.4.2'], RELEASED) expect(mockWarning).not.toHaveBeenCalled() expect(mockNotice).not.toHaveBeenCalled() }) it('annotates each distinct version once', () => { - reportSupportStatus(['7.6.4', '4.10.3', '7.6.4']) + reportSupportStatusUsing(['7.6.4', '4.10.3', '7.6.4'], RELEASED) expect(mockWarning).toHaveBeenCalledTimes(2) }) it('says nothing when no Gradle build ran', () => { - reportSupportStatus([]) + reportSupportStatusUsing([], RELEASED) expect(mockWarning).not.toHaveBeenCalled() expect(mockNotice).not.toHaveBeenCalled() diff --git a/sources/test/jest/job-summary.test.ts b/sources/test/jest/job-summary.test.ts index 990dfb57..d522544c 100644 --- a/sources/test/jest/job-summary.test.ts +++ b/sources/test/jest/job-summary.test.ts @@ -3,18 +3,22 @@ import * as github from '@actions/github' import {afterEach, beforeEach, describe, expect, it, jest} from '@jest/globals' import {BuildResult} from '../../src/build-results' -import {SupportStatus} from '../../src/gradle-support-status' -const mockSupportStatusOf = jest.fn<(gradleVersion: string) => SupportStatus | undefined>() -jest.unstable_mockModule('../../src/gradle-support-status', () => ({ - supportStatusOf: mockSupportStatusOf +let releasedVersions = [{version: '8.0', checksum: ''}] +jest.unstable_mockModule('../../src/wrapper-validation/wrapper-checksums.json', () => ({ + get default() { + return releasedVersions + } })) const {jobMarker, renderSummaryTable} = await import('../../src/job-summary') -beforeEach(() => { - mockSupportStatusOf.mockReturnValue('active') -}) +async function renderWith(versions: string[], results: BuildResult[]): Promise { + releasedVersions = versions.map(version => ({version, checksum: ''})) + jest.resetModules() + const {renderSummaryTable: render} = await import('../../src/job-summary') + return render(results) +} const MATRIX_INPUT_ENV = 'INPUT_WORKFLOW-JOB-CONTEXT' @@ -197,9 +201,8 @@ describe('renderSummaryTable', () => { }) describe('Gradle version support status', () => { - it('marks an outdated version and adds a details section', () => { - mockSupportStatusOf.mockReturnValue('eol') - const table = renderSummaryTable([successfulHelpBuild]) + it('marks an outdated version and adds a details section', async () => { + const table = await renderWith(['10.0.0', '8.0'], [successfulHelpBuild]) expect(table.trim()).toBe(dedent` @@ -233,14 +236,20 @@ describe('Gradle version support status', () => { `); }) - it('marks a maintenance-only version', () => { - mockSupportStatusOf.mockReturnValue('maintenance') - const table = renderSummaryTable([successfulHelpBuild]) + it('marks a maintenance-only version', async () => { + const table = await renderWith(['9.0.0', '8.0'], [successfulHelpBuild]) expect(table).toContain( `` ) expect(table).toContain('This Job uses an outdated Gradle version') }) + it('marks a version with a newer patch release available', async () => { + const table = await renderWith(['8.0.1', '8.0'], [successfulHelpBuild]) + expect(table).toContain( + `` + ) + expect(table).not.toContain('outdated Gradle version') + }) }) describe('jobMarker', () => {
8.0 :information_source:8.0 :information_source: