diff --git a/.github/workflows/integ-test-gradle-support-status.yml b/.github/workflows/integ-test-gradle-support-status.yml new file mode 100644 index 00000000..463ab274 --- /dev/null +++ b/.github/workflows/integ-test-gradle-support-status.yml @@ -0,0 +1,60 @@ +name: Test Gradle support status reporting + +on: + workflow_call: + inputs: + cache-key-prefix: + type: string + default: '0' + skip-dist: + type: boolean + default: false + +env: + SKIP_DIST: ${{ inputs.skip-dist }} + GRADLE_BUILD_ACTION_CACHE_KEY_PREFIX: gradle-support-status-${{ inputs.cache-key-prefix }} + +permissions: + contents: read + +jobs: + report-version-status: + runs-on: ubuntu-latest + steps: + - name: Checkout sources + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Initialize integ-test + uses: ./.github/actions/init-integ-test + + - name: Setup Gradle with an end-of-life version + uses: ./setup-gradle + with: + cache-read-only: false + gradle-version: '7.6.4' + - name: Build with the end-of-life version + id: build-end-of-life + working-directory: .github/workflow-samples/no-wrapper + run: gradle help "-DgradleVersionCheck=7.6.4" + + - name: Setup Gradle with the current version + uses: ./setup-gradle + with: + gradle-version: current + - name: Build with the current version + id: build-current + working-directory: .github/workflow-samples/no-wrapper + run: gradle help + + - name: Check the end-of-life version was reported as 'eol' + if: ${{ steps.build-end-of-life.outputs.gradle-version-status != 'eol' }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + core.setFailed("Expected Gradle 7.6.4 to be reported as 'eol', but was '${{ steps.build-end-of-life.outputs.gradle-version-status }}'") + + - name: Check the current version was reported as 'active' + if: ${{ steps.build-current.outputs.gradle-version-status != 'active' }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + core.setFailed("Expected the current Gradle version to be reported as 'active', but was '${{ steps.build-current.outputs.gradle-version-status }}'") diff --git a/.github/workflows/suite-integ-test-other.yml b/.github/workflows/suite-integ-test-other.yml index 8f88e95b..3d7894a7 100644 --- a/.github/workflows/suite-integ-test-other.yml +++ b/.github/workflows/suite-integ-test-other.yml @@ -28,6 +28,11 @@ jobs: secrets: DEVELOCITY_ACCESS_KEY: ${{ secrets.DV_SOLUTIONS_ACCESS_KEY }} + gradle-support-status: + uses: ./.github/workflows/integ-test-gradle-support-status.yml + with: + skip-dist: ${{ inputs.skip-dist }} + provision-gradle-versions: uses: ./.github/workflows/integ-test-provision-gradle-versions.yml with: diff --git a/.gitignore b/.gitignore index 0886188e..b375ea47 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .git .vscode .claude +.idea diff --git a/dependency-submission/action-types.yml b/dependency-submission/action-types.yml index 54b43c2e..5e757642 100644 --- a/dependency-submission/action-types.yml +++ b/dependency-submission/action-types.yml @@ -156,3 +156,6 @@ outputs: gradle-version: type: string + + gradle-version-status: + type: string diff --git a/dependency-submission/action.yml b/dependency-submission/action.yml index 73c87630..7f3ccadc 100644 --- a/dependency-submission/action.yml +++ b/dependency-submission/action.yml @@ -229,7 +229,11 @@ outputs: description: Path to the GitHub Dependency Graph snapshot file generated by a Gradle build. Note that this output applies to a Step executing Gradle, not to the `setup-gradle` Step itself. gradle-version: description: Version of Gradle that was setup by the action - + gradle-version-status: + description: | + Support status of the Gradle version that executed the build, following + https://docs.gradle.org/current/userguide/feature_lifecycle.html: + 'active', 'maintenance' or 'eol' runs: using: 'node24' main: '../dist/dependency-submission/main/index.js' diff --git a/setup-gradle/action-types.yml b/setup-gradle/action-types.yml index e4c99831..504e4a72 100644 --- a/setup-gradle/action-types.yml +++ b/setup-gradle/action-types.yml @@ -179,3 +179,6 @@ outputs: gradle-version: type: string + + gradle-version-status: + type: string diff --git a/setup-gradle/action.yml b/setup-gradle/action.yml index 6130fa36..25a38517 100644 --- a/setup-gradle/action.yml +++ b/setup-gradle/action.yml @@ -246,7 +246,11 @@ outputs: description: Path to the GitHub Dependency Graph snapshot file generated by a Gradle build. Note that this output applies to a Step executing Gradle, not to the `setup-gradle` Step itself. gradle-version: description: Version of Gradle that was setup by the action - + gradle-version-status: + description: | + Support status of the Gradle version that executed the build, following + https://docs.gradle.org/current/userguide/feature_lifecycle.html: + 'active', 'maintenance' or 'eol' runs: using: 'node24' main: '../dist/setup-gradle/main/index.js' diff --git a/sources/src/build-results.ts b/sources/src/build-results.ts index 401e7cba..363a2268 100644 --- a/sources/src/build-results.ts +++ b/sources/src/build-results.ts @@ -6,6 +6,7 @@ export interface BuildResult { get rootProjectDir(): string get requestedTasks(): string get gradleVersion(): string + get versionStatus(): string | undefined get gradleHomeDir(): string get buildFailed(): boolean get configCacheHit(): boolean diff --git a/sources/src/execution/gradle-version.ts b/sources/src/execution/gradle-version.ts new file mode 100644 index 00000000..3465ab40 --- /dev/null +++ b/sources/src/execution/gradle-version.ts @@ -0,0 +1,24 @@ +export class GradleVersion { + static PATTERN = /((\d+)(\.\d+)+)(-([a-z]+)-(\w+))?(-(SNAPSHOT|\d{14}([-+]\d{4})?))?/ + + major: number + versionPart: string + stagePart: string + snapshotPart: string + + constructor(readonly version: string) { + const matcher = GradleVersion.PATTERN.exec(version) + if (!matcher) { + throw new Error(`'${version}' is not a valid Gradle version string (examples: '1.0', '1.0-rc-1')`) + } + + this.major = Number(matcher[2]) + this.versionPart = matcher[1] + this.stagePart = matcher[4] + this.snapshotPart = matcher[7] + } + + isFinalRelease(): boolean { + return !this.stagePart && !this.snapshotPart + } +} diff --git a/sources/src/execution/gradle.ts b/sources/src/execution/gradle.ts index 425f3d97..e45c4b82 100644 --- a/sources/src/execution/gradle.ts +++ b/sources/src/execution/gradle.ts @@ -5,6 +5,7 @@ import which from 'which' import * as semver from 'semver' import * as provisioner from './provision' import * as gradlew from './gradlew' +import {GradleVersion} from './gradle-version' export async function provisionAndMaybeExecute( gradleVersion: string, @@ -90,22 +91,3 @@ export function parseGradleVersionFromOutput(output: string): string | undefined const versionString = output.match(regex)?.[1] return versionString } - -class GradleVersion { - static PATTERN = /((\d+)(\.\d+)+)(-([a-z]+)-(\w+))?(-(SNAPSHOT|\d{14}([-+]\d{4})?))?/ - - versionPart: string - stagePart: string - snapshotPart: string - - constructor(readonly version: string) { - const matcher = GradleVersion.PATTERN.exec(version) - if (!matcher) { - throw new Error(`'${version}' is not a valid Gradle version string (examples: '1.0', '1.0-rc-1')`) - } - - this.versionPart = matcher[1] - this.stagePart = matcher[4] - this.snapshotPart = matcher[7] - } -} diff --git a/sources/src/gradle-support-status.ts b/sources/src/gradle-support-status.ts new file mode 100644 index 00000000..36a6b0ce --- /dev/null +++ b/sources/src/gradle-support-status.ts @@ -0,0 +1,59 @@ +import * as core from '@actions/core' + +import {BuildResult} from './build-results' +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' + +const LATEST_RELEASED_MAJOR = determineLatestReleasedMajor(wrapperChecksums.map(entry => entry.version)) +const LATEST_MAJOR_VERSION_ENV = 'GRADLE_ACTIONS_LATEST_GRADLE_MAJOR' + +export function determineLatestReleasedMajor(versions: string[]): number | undefined { + const releasedMajors = versions + .map(version => new GradleVersion(version)) + .filter(parsed => parsed.isFinalRelease()) + .map(parsed => parsed.major) + return releasedMajors.length > 0 ? Math.max(...releasedMajors) : undefined +} + +export function exportLatestReleasedMajor(): void { + if (LATEST_RELEASED_MAJOR !== undefined) { + core.exportVariable(LATEST_MAJOR_VERSION_ENV, LATEST_RELEASED_MAJOR) + } +} + +export function reportSupportStatus(buildResults: BuildResult[]): void { + if (LATEST_RELEASED_MAJOR === undefined) { + return + } + + const statusByVersion = new Map() + for (const {gradleVersion, versionStatus} of buildResults) { + if (versionStatus) { + statusByVersion.set(gradleVersion, versionStatus) + } + } + + for (const [gradleVersion, status] of statusByVersion) { + const version = new GradleVersion(gradleVersion) + switch (status) { + case 'eol': + core.warning(eolMessage(version, LATEST_RELEASED_MAJOR), {title: 'Gradle version at end-of-life'}) + break + case 'maintenance': + core.notice(maintenanceMessage(version, LATEST_RELEASED_MAJOR), { + title: 'Gradle version in maintenance' + }) + break + } + } +} + +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}` +} + +function maintenanceMessage(version: GradleVersion, latestMajor: number): string { + return `Gradle ${version.version} is in maintenance-only support: the ${version.major}.x release line receives critical bug fixes and security fixes only, and reaches end-of-life when Gradle ${latestMajor + 1} is released. Consider upgrading to Gradle ${latestMajor}.x. See ${FEATURE_LIFECYCLE_DOC}` +} diff --git a/sources/src/resources/init-scripts/gradle-actions.build-result-capture-service.plugin.groovy b/sources/src/resources/init-scripts/gradle-actions.build-result-capture-service.plugin.groovy index 47587a71..6cb95009 100644 --- a/sources/src/resources/init-scripts/gradle-actions.build-result-capture-service.plugin.groovy +++ b/sources/src/resources/init-scripts/gradle-actions.build-result-capture-service.plugin.groovy @@ -15,6 +15,7 @@ settingsEvaluated { settings -> spec.getParameters().getRequestedTasks().set(gradle.startParameter.taskNames.join(" ")) spec.getParameters().getGradleHomeDir().set(gradle.gradleHomeDir.absolutePath) spec.getParameters().getInvocationId().set(gradle.ext.invocationId) + spec.getParameters().getVersionStatus().set(gradle.ext.gradleVersionStatus ?: "") }) gradle.services.get(BuildEventListenerRegistryInternal).onOperationCompletion(projectTracker) @@ -30,6 +31,7 @@ abstract class BuildResultsRecorder implements BuildService getRequestedTasks() Property getGradleHomeDir() Property getInvocationId() + Property getVersionStatus() } void started(BuildOperationDescriptor buildOperation, OperationStartEvent startEvent) {} @@ -49,6 +51,16 @@ abstract class BuildResultsRecorder implements BuildService= GradleVersion.version("3.0") def atLeastGradle6 = version >= GradleVersion.version("6.0") def atLeastGradle7 = version >= GradleVersion.version("7.0") @@ -84,13 +87,46 @@ def captureUsingBuildService(invocationId) { apply from: 'gradle-actions.build-result-capture-service.plugin.groovy' } +String supportStatusOf(version, String latestGradleMajor) { + def latestMajor = latestGradleMajor?.find(/^\d+$/)?.toInteger() + def currentMajor = version.version.find(/^\d+/)?.toInteger() + if (!latestMajor || !currentMajor) { + return null + } + + switch (Math.max(0, latestMajor - currentMajor)) { + case 0: + return "active" + case 1: + return "maintenance" + default: + return "eol" + } +} + +void setStepOutput(String name, String value) { + def githubOutput = System.getenv("GITHUB_OUTPUT") + if (githubOutput) { + new File(githubOutput) << "${name}=${value}\n" + } else { + // Retained for compatibility with older GHES versions + println("::set-output name=${name}::${value}") + } +} + void captureUsingBuildFinished(gradle, String invocationId, ResultsWriter resultsWriter) { gradle.buildFinished { result -> + def versionStatus = gradle.ext.gradleVersionStatus + if (versionStatus) { + setStepOutput("gradle-version-status", versionStatus) + } + def buildResults = [ rootProjectName: rootProject.name, rootProjectDir: rootProject.projectDir.absolutePath, requestedTasks: gradle.startParameter.taskNames.join(" "), gradleVersion: GradleVersion.current().version, + versionStatus: versionStatus, gradleHomeDir: gradle.gradleHomeDir.absolutePath, buildFailed: result.failure != null, configCacheHit: false @@ -110,13 +146,7 @@ void captureUsingBuildScanPublished(buildScanExtension, String invocationId, Res ] resultsWriter.writeToResultsFile("build-scans", invocationId, scanResults) - def githubOutput = System.getenv("GITHUB_OUTPUT") - if (githubOutput) { - new File(githubOutput) << "build-scan-url=${buildScan.buildScanUri}\n" - } else { - // Retained for compatibility with older GHES versions - println("::set-output name=build-scan-url::${buildScan.buildScanUri}") - } + setStepOutput("build-scan-url", buildScan.buildScanUri.toString()) } onError { error -> diff --git a/sources/src/setup-gradle.ts b/sources/src/setup-gradle.ts index a8255ae5..e929dc15 100644 --- a/sources/src/setup-gradle.ts +++ b/sources/src/setup-gradle.ts @@ -19,6 +19,7 @@ import { } from './configuration' import * as wrapperValidator from './wrapper-validation/wrapper-validator' import {initializeGradleUserHome} from './gradle-user-home' +import {exportLatestReleasedMajor, reportSupportStatus} from './gradle-support-status' const GRADLE_SETUP_VAR = 'GRADLE_BUILD_ACTION_SETUP_COMPLETED' const GRADLE_USER_HOME = 'GRADLE_USER_HOME' @@ -46,6 +47,7 @@ export async function setup( core.saveState(GRADLE_USER_HOME, gradleUserHome) initializeGradleUserHome(userHome, gradleUserHome, cacheConfig.getCacheEncryptionKey()) + exportLatestReleasedMajor() // Exchange the long-lived access key(s) for short-lived tokens, resolving the token for the // configured Develocity server and retaining it for the post-action (save) step. @@ -89,6 +91,8 @@ export async function complete( markBuildResultsProcessed() + reportSupportStatus(buildResults) + core.info('Completed post-action step') return true diff --git a/sources/test/init-scripts/src/test/groovy/com/gradle/gradlebuildaction/TestBuildResultRecorder.groovy b/sources/test/init-scripts/src/test/groovy/com/gradle/gradlebuildaction/TestBuildResultRecorder.groovy index 2cca2095..6fb4f026 100644 --- a/sources/test/init-scripts/src/test/groovy/com/gradle/gradlebuildaction/TestBuildResultRecorder.groovy +++ b/sources/test/init-scripts/src/test/groovy/com/gradle/gradlebuildaction/TestBuildResultRecorder.groovy @@ -6,6 +6,7 @@ import static org.junit.Assume.assumeTrue class TestBuildResultRecorder extends BaseInitScriptTest { def initScript = 'gradle-actions.build-result-capture.init.gradle' + String latestGradleMajor def "produces build results file for build with #testGradleVersion"() { assumeTrue testGradleVersion.compatibleWithCurrentJvm @@ -269,6 +270,63 @@ task expectFailure { testGradleVersion << SETTINGS_PLUGIN_VERSIONS } + def "captures version status '#expectedStatus' when the latest released major is #latestMajor"() { + assumeTrue GRADLE_8_X.compatibleWithCurrentJvm + + when: + latestGradleMajor = latestMajor + run(GRADLE_8_X.gradleVersion) + + then: + assertVersionStatus(expectedStatus) + + where: + latestMajor | expectedStatus + '7' | 'active' + '8' | 'active' + '9' | 'maintenance' + '10' | 'eol' + '13' | 'eol' + } + + def "captures version status for #testGradleVersion"() { + assumeTrue testGradleVersion.compatibleWithCurrentJvm + + when: + latestGradleMajor = '9' + run(testGradleVersion.gradleVersion) + + then: + assertVersionStatus(expectedStatus) + + where: + testGradleVersion | expectedStatus + GRADLE_6_X | 'eol' + GRADLE_7_X | 'eol' + GRADLE_8_X | 'maintenance' + } + + def "captures no version status when the action did not supply the latest released major"() { + assumeTrue GRADLE_8_X.compatibleWithCurrentJvm + + when: + run(GRADLE_8_X.gradleVersion) + + then: + assertVersionStatus(null) + } + + def "captures no version status when the latest released major is not a number"() { + assumeTrue GRADLE_8_X.compatibleWithCurrentJvm + + when: + latestGradleMajor = 'not-a-number' + run(GRADLE_8_X.gradleVersion) + + then: + assertVersionStatus(null) + } + def run(def args = ['help'], def gradleVersion) { return run(args, initScript, gradleVersion, jvmArgs, envVars) } @@ -278,17 +336,26 @@ task expectFailure { } def getJvmArgs() { - [ + def jvmArgs = [ "-DRUNNER_TEMP=${testProjectDir.absolutePath}".toString(), "-DGITHUB_ACTION=github-step-id".toString() ] + if (latestGradleMajor != null) { + jvmArgs << "-DGRADLE_ACTIONS_LATEST_GRADLE_MAJOR=${latestGradleMajor}".toString() + } + jvmArgs } def getEnvVars() { - [ + def envVars = [ RUNNER_TEMP: testProjectDir.absolutePath, - GITHUB_ACTION: 'github-step-id' + GITHUB_ACTION: 'github-step-id', + GITHUB_OUTPUT: githubOutputFile.absolutePath ] + if (latestGradleMajor != null) { + envVars.GRADLE_ACTIONS_LATEST_GRADLE_MAJOR = latestGradleMajor + } + envVars } void assertResults(String task, TestGradleVersion testGradleVersion, boolean hasFailure, boolean configCacheHit = false) { @@ -308,6 +375,24 @@ task expectFailure { assert scanResults['buildScanFailed'] == scanUploadFailed } + void assertVersionStatus(String expectedStatus) { + def results = new JsonSlurper().parse(buildResultFile) + assert results['versionStatus'] == expectedStatus + if (expectedStatus == null) { + assert !githubOutput.contains('gradle-version-status') + } else { + assert githubOutput.contains("gradle-version-status=${expectedStatus}") + } + } + + private File getGithubOutputFile() { + new File(testProjectDir, 'github-output') + } + + private String getGithubOutput() { + githubOutputFile.exists() ? githubOutputFile.text : '' + } + private File getBuildResultFile() { def buildResultsDir = new File(testProjectDir, '.gradle-actions/build-results') assert buildResultsDir.directory diff --git a/sources/test/jest/gradle-support-status.test.ts b/sources/test/jest/gradle-support-status.test.ts new file mode 100644 index 00000000..03f864e6 --- /dev/null +++ b/sources/test/jest/gradle-support-status.test.ts @@ -0,0 +1,109 @@ +import {beforeEach, describe, expect, it, jest} from '@jest/globals' + +import {BuildResult} from '../../src/build-results' + +// Mock @actions/core +const mockWarning = jest.fn<(message: string, properties?: {title?: string}) => void>() +const mockNotice = jest.fn<(message: string, properties?: {title?: string}) => void>() +const mockExportVariable = jest.fn<(name: string, value: string | number) => void>() +jest.unstable_mockModule('@actions/core', () => ({ + warning: mockWarning, + notice: mockNotice, + exportVariable: mockExportVariable +})) + +const {determineLatestReleasedMajor, exportLatestReleasedMajor, reportSupportStatus} = + await import('../../src/gradle-support-status') + +function build(gradleVersion: string, versionStatus?: string): BuildResult { + return {gradleVersion, versionStatus} as BuildResult +} + +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('exportLatestReleasedMajor', () => { + it('exports the boundary the init script needs to classify the running version', () => { + exportLatestReleasedMajor() + + expect(mockExportVariable).toHaveBeenCalledWith('GRADLE_ACTIONS_LATEST_GRADLE_MAJOR', expect.any(Number)) + }) +}) + +describe('reportSupportStatus', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('warns about a version recorded as end-of-life', () => { + reportSupportStatus([build('7.6.4', 'eol')]) + + 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') + expect(properties?.title).toBe('Gradle version at end-of-life') + }) + + it('notices a version recorded as maintenance-only', () => { + reportSupportStatus([build('8.14', 'maintenance')]) + + expect(mockWarning).not.toHaveBeenCalled() + expect(mockNotice).toHaveBeenCalledTimes(1) + const [message, properties] = mockNotice.mock.calls[0] + expect(message).toContain('Gradle 8.14 is in maintenance-only support') + expect(properties?.title).toBe('Gradle version in maintenance') + }) + + it('says nothing about a version recorded as active', () => { + reportSupportStatus([build('9.6.1', 'active')]) + + expect(mockWarning).not.toHaveBeenCalled() + expect(mockNotice).not.toHaveBeenCalled() + }) + + it('says nothing when the init script recorded no status', () => { + reportSupportStatus([build('7.6.4')]) + + expect(mockWarning).not.toHaveBeenCalled() + expect(mockNotice).not.toHaveBeenCalled() + }) + + it('annotates each distinct version once', () => { + reportSupportStatus([build('7.6.4', 'eol'), build('4.10.3', 'eol'), build('7.6.4', 'eol')]) + + expect(mockWarning).toHaveBeenCalledTimes(2) + }) + + it('says nothing when no Gradle build ran', () => { + reportSupportStatus([]) + + expect(mockWarning).not.toHaveBeenCalled() + expect(mockNotice).not.toHaveBeenCalled() + }) +})