mirror of
https://github.com/gradle/actions.git
synced 2026-10-17 09:01:33 +00:00
Report EOL and maintenance status for Gradle versions (#1057)
This PR reports the support status of every Gradle version used in a workflow: - **End-of-life** — two or more major versions behind the latest release. - **Out of date** — one major version behind the latest release, or more than two minor versions behind on the current major. The information is surfaced as job annotations and in the Job Summary: an icon beside the Gradle version in the build results table, and a message below it. | version kind | job annotation | version table | below the table | | ------ | ----- | ---- | --------- | | EOL | warning | ⚠️ | expandable section, pointing to the Gradle Security Subscription | | Out of date | notice | ℹ️ | one-line legend, pointing to the Gradle release lifecycle docs | | Current | none | — | — | A single expandable section covers every end-of-life version, naming them in its summary line and listing the affected release lines in its body. The upgrade legend likewise appears once per job, however many versions carry the info icon. Notes on what is deliberately *not* reported: - **Patch releases.** Only the major and minor version are considered, so being on `9.7.0` when `9.7.1` exists is not flagged. - **Pre-releases.** Release candidates, milestones and snapshots are never reported, so testing against a nightly or an RC produces no annotations. The latest Gradle release is determined from the wrapper checksum data already bundled with the action, so no network access is required. That data is refreshed weekly, and the two-minor grace band absorbs the lag. Note that the annotations are emitted independently of `add-job-summary`: setting it to `never` suppresses the Job Summary itself, but the warning and notice annotations remain. ### Examples The `demo-job-summary` workflow has a `support-status-eol-and-outdated` job that builds with three end-of-life versions (`6.9.4`, `7.4`, `7.6.6`), two out-of-date versions (`8.0.2`, `8.14.5`) and the current release, so all three statuses appear in one summary: * **Job Summary**: https://github.com/gradle/actions/actions/runs/34166688755#summary-101879071306 * **Annotations**: https://github.com/gradle/actions/actions/runs/34166688755/job/101879071306 (expand annotations) That workflow is never triggered automatically; run it manually against a branch to review the rendering. ### Implementation * Adds `GradleVersion`, parsing and ordering versions the same way Gradle's own `org.gradle.util.GradleVersion` does. This replaces the previous `versionIsAtLeast` helper and removes the `semver` dependency. * Adds `gradle-support-status.ts`, which owns the classification policy and both of its presentations (annotations and Job Summary section) behind a three-function API, so `job-summary.ts` only asks for the icon and the rendered block. ### Testing * Unit tests for version ordering, classification, the annotations and the rendered summary. * `integ-test-gradle-support-status.yml`, which derives its expected versions from the bundled release data so the assertions cannot drift from the action's own view, then asserts against the real job log. * The demo workflow job linked above, for reviewing the rendering by eye. Documented under [Build reporting](https://github.com/gradle/actions/blob/main/docs/setup-gradle.md#gradle-version-support-status) in `docs/setup-gradle.md`. --------- Co-authored-by: Louis Jacomet <[email protected]> Co-authored-by: Daz DeBoer <[email protected]> Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Louis Jacomet
Daz DeBoer
Claude Opus 5
parent
575435b1ea
commit
e49d0a36a8
@@ -2,7 +2,6 @@ import * as core from '@actions/core'
|
||||
import * as exec from '@actions/exec'
|
||||
|
||||
import which from 'which'
|
||||
import * as semver from 'semver'
|
||||
import * as provisioner from './provision'
|
||||
import * as gradlew from './gradlew'
|
||||
|
||||
@@ -34,48 +33,6 @@ async function executeGradleBuild(executable: string | undefined, root: string,
|
||||
}
|
||||
}
|
||||
|
||||
export function versionIsAtLeast(actualVersion: string, requiredVersion: string): boolean {
|
||||
if (actualVersion === requiredVersion) {
|
||||
return true
|
||||
}
|
||||
|
||||
const actual = new GradleVersion(actualVersion)
|
||||
const required = new GradleVersion(requiredVersion)
|
||||
|
||||
const actualSemver = semver.coerce(actual.versionPart)!
|
||||
const comparisonSemver = semver.coerce(required.versionPart)!
|
||||
|
||||
if (semver.gt(actualSemver, comparisonSemver)) {
|
||||
return true // Actual version is greater than comparison. So it's at least as new.
|
||||
}
|
||||
if (semver.lt(actualSemver, comparisonSemver)) {
|
||||
return false // Actual version is less than comparison. So it's not as new.
|
||||
}
|
||||
|
||||
// Actual and required version numbers are equal, so compare the other parts
|
||||
|
||||
if (actual.snapshotPart || required.snapshotPart) {
|
||||
if (actual.snapshotPart && !required.snapshotPart && !required.stagePart) {
|
||||
return false // Actual has a snapshot, but required is a plain version. Required is newer.
|
||||
}
|
||||
if (required.snapshotPart && !actual.snapshotPart && !actual.stagePart) {
|
||||
return true // Required has a snapshot, but actual is a plain version. Actual is newer.
|
||||
}
|
||||
|
||||
return false // Cannot compare case where both versions have a snapshot or stage
|
||||
}
|
||||
|
||||
if (actual.stagePart) {
|
||||
if (required.stagePart) {
|
||||
return actual.stagePart >= required.stagePart // Compare stages for newer
|
||||
}
|
||||
|
||||
return false // Actual has a stage, but required does not. So required is always newer.
|
||||
}
|
||||
|
||||
return true // Actual has no stage part or snapshot part, so it cannot be older than required.
|
||||
}
|
||||
|
||||
export async function findGradleExecutableOnPath(): Promise<string | null> {
|
||||
return await which('gradle', {nothrow: true})
|
||||
}
|
||||
@@ -90,22 +47,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]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import * as core from '@actions/core'
|
||||
|
||||
import {GradleVersion} from './gradle-version'
|
||||
import wrapperChecksums from './wrapper-validation/wrapper-checksums.json'
|
||||
|
||||
/** Minor lines behind the latest that stay unreported on the current major. */
|
||||
const MINOR_GRACE = 2
|
||||
|
||||
const SECURITY_SUBSCRIPTION = 'https://gradle.org/security-subscription/?utm_source=github-action'
|
||||
const FEATURE_LIFECYCLE_DOC = 'https://docs.gradle.org/current/userguide/feature_lifecycle.html#eol_support'
|
||||
|
||||
enum SupportStatus {
|
||||
Current = 'current',
|
||||
Behind = 'behind',
|
||||
Eol = 'eol'
|
||||
}
|
||||
|
||||
/** The sign shown beside a version in the build-results table; Current has nothing to say. */
|
||||
const SIGN: Record<SupportStatus, string> = {
|
||||
[SupportStatus.Current]: '',
|
||||
[SupportStatus.Behind]: ':information_source:',
|
||||
[SupportStatus.Eol]: ':warning:'
|
||||
}
|
||||
|
||||
const UPGRADE_LEGEND =
|
||||
`<p>${SIGN[SupportStatus.Behind]} Gradle version is out of date — consider upgrading. ` +
|
||||
`See <a href="${FEATURE_LIFECYCLE_DOC}">Gradle release lifecycle</a></p>`
|
||||
|
||||
/**
|
||||
* Wording shared by the end-of-life annotation, which names a single version, and the end-of-life
|
||||
* section of the Job Summary, which covers every flagged version at once.
|
||||
*/
|
||||
const eolHeadline = (versions: GradleVersion[]): string =>
|
||||
versions.length === 1
|
||||
? `Gradle ${versions[0].version} is end-of-life`
|
||||
: `Gradle ${andList(versions.map(version => version.version))} are end-of-life`
|
||||
const eolDetail = (versions: GradleVersion[]): string =>
|
||||
`Gradle ${andList([...new Set(versions.map(version => `${version.major}.x`))])} releases receive ` +
|
||||
`no further fixes, security fixes included. Update to the latest Gradle version.`
|
||||
|
||||
/** Joins for prose: 'a', 'a and b', 'a, b and c'. */
|
||||
function andList(items: string[]): string {
|
||||
return items.length <= 1 ? (items[0] ?? '') : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`
|
||||
}
|
||||
|
||||
class ReleaseIndex {
|
||||
private readonly latest: GradleVersion
|
||||
|
||||
constructor(releasedVersions: string[]) {
|
||||
const finalReleases = releasedVersions
|
||||
.map(version => new GradleVersion(version))
|
||||
.filter(version => version.isFinalRelease())
|
||||
|
||||
if (finalReleases.length === 0) {
|
||||
throw new Error('The Gradle release data contains no final release')
|
||||
}
|
||||
|
||||
this.latest = finalReleases.reduce((max, version) => (max.compareTo(version) >= 0 ? max : version))
|
||||
}
|
||||
|
||||
private classifyVersion(version: GradleVersion): SupportStatus {
|
||||
if (!version.isFinalRelease()) {
|
||||
return SupportStatus.Current
|
||||
}
|
||||
|
||||
const majorsBehind = this.latest.major - version.major
|
||||
if (majorsBehind >= 2) {
|
||||
return SupportStatus.Eol
|
||||
}
|
||||
if (majorsBehind === 1) {
|
||||
return SupportStatus.Behind
|
||||
}
|
||||
if (version.major > this.latest.major) {
|
||||
return SupportStatus.Current // newer than the bundled data knows about
|
||||
}
|
||||
// Same major as the latest release: only the minor distance matters, so a newer patch is never reported.
|
||||
return this.latest.minor - version.minor > MINOR_GRACE ? SupportStatus.Behind : SupportStatus.Current
|
||||
}
|
||||
|
||||
/** Support status of a single version; an unparseable string is treated as current. */
|
||||
classify(gradleVersion: string): SupportStatus {
|
||||
const version = GradleVersion.parseUntrusted(gradleVersion)
|
||||
return version ? this.classifyVersion(version) : SupportStatus.Current
|
||||
}
|
||||
|
||||
/** The flagged versions grouped by status (Current omitted). */
|
||||
classified(gradleVersions: string[]): Map<SupportStatus, GradleVersion[]> {
|
||||
const byStatus = new Map<SupportStatus, GradleVersion[]>()
|
||||
const parsed = [...new Set(gradleVersions)]
|
||||
.map(version => GradleVersion.parseUntrusted(version))
|
||||
.filter((version): version is GradleVersion => version !== undefined)
|
||||
.sort(GradleVersion.compare)
|
||||
|
||||
for (const version of parsed) {
|
||||
const status = this.classifyVersion(version)
|
||||
if (status !== SupportStatus.Current) {
|
||||
byStatus.set(status, [...(byStatus.get(status) ?? []), version])
|
||||
}
|
||||
}
|
||||
return byStatus
|
||||
}
|
||||
}
|
||||
|
||||
const RELEASES = new ReleaseIndex(wrapperChecksums.map(entry => entry.version))
|
||||
|
||||
/** The status sign shown beside a version in the build-results table, or '' when there is nothing to say. */
|
||||
export function supportStatusSign(gradleVersion: string): string {
|
||||
return SIGN[RELEASES.classify(gradleVersion)]
|
||||
}
|
||||
|
||||
/** Job annotations: a warning for each end-of-life version, a notice for each version merely out of date. */
|
||||
export function reportSupportStatus(gradleVersions: string[]): void {
|
||||
const byStatus = RELEASES.classified(gradleVersions)
|
||||
for (const version of byStatus.get(SupportStatus.Eol) ?? []) {
|
||||
core.warning(
|
||||
`${eolHeadline([version])}. ${eolDetail([version])} If you cannot upgrade, see ${SECURITY_SUBSCRIPTION} for options`,
|
||||
{title: 'End-of-life Gradle version'}
|
||||
)
|
||||
}
|
||||
for (const version of byStatus.get(SupportStatus.Behind) ?? []) {
|
||||
core.notice(
|
||||
`Gradle ${version.version} is out of date: consider updating to the latest Gradle version. See ${FEATURE_LIFECYCLE_DOC}`,
|
||||
{title: 'Out-of-date Gradle version'}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The fold-and-paragraph report placed under the build-results table. */
|
||||
export function renderSupportStatus(gradleVersions: string[]): string {
|
||||
const byStatus = RELEASES.classified(gradleVersions)
|
||||
const blocks: string[] = []
|
||||
|
||||
const eol = byStatus.get(SupportStatus.Eol) ?? []
|
||||
if (eol.length > 0) {
|
||||
// One section however many versions are end-of-life; the table already marks which they are.
|
||||
blocks.push(renderEolSection(eol))
|
||||
}
|
||||
if (byStatus.has(SupportStatus.Behind)) {
|
||||
blocks.push(UPGRADE_LEGEND)
|
||||
}
|
||||
|
||||
// The leading blank line closes the preceding HTML block, so each rendering stands on its own.
|
||||
return blocks.length > 0 ? `\n${blocks.join('\n')}\n` : ''
|
||||
}
|
||||
|
||||
function renderEolSection(versions: GradleVersion[]): string {
|
||||
return `<details>
|
||||
<summary>${SIGN[SupportStatus.Eol]} ${eolHeadline(versions)}</summary>
|
||||
<p>${eolDetail(versions)}</p>
|
||||
<p>If you cannot upgrade, see the <a href="${SECURITY_SUBSCRIPTION}">Gradle Security Subscription</a> for options.</p>
|
||||
</details>`
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
interface Stage {
|
||||
readonly rank: number
|
||||
readonly number: number
|
||||
readonly patchNo: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A Gradle version, parsed and ordered the same way Gradle's own `org.gradle.util.GradleVersion` does
|
||||
*/
|
||||
export class GradleVersion {
|
||||
private static readonly PATTERN = /^((\d+)(\.\d+)+)(-([a-zA-Z]+)-(\w+))?(-(SNAPSHOT|\d{14}([-+]\d{4})?))?$/
|
||||
|
||||
// Ranks match Gradle's stage numbers; anything unrecognised sits between milestone and preview.
|
||||
private static readonly STAGE_RANK: Record<string, number> = {milestone: 0, preview: 2, rc: 3}
|
||||
private static readonly STAGE_UNKNOWN = 1
|
||||
|
||||
readonly major: number
|
||||
readonly minor: number
|
||||
readonly patch: number
|
||||
|
||||
private readonly versionPart: string
|
||||
private readonly stage: Stage | undefined
|
||||
private readonly snapshot: number | undefined
|
||||
|
||||
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.stage = GradleVersion.parseStage(matcher[5], matcher[6])
|
||||
this.snapshot = GradleVersion.parseSnapshot(matcher[8], matcher[9])
|
||||
|
||||
const parts = this.versionPart.split('.')
|
||||
this.major = Number(parts[0])
|
||||
this.minor = Number(parts[1])
|
||||
this.patch = parts.length > 2 ? Number(parts[2]) : 0
|
||||
}
|
||||
|
||||
isFinalRelease(): boolean {
|
||||
return this.stage === undefined && this.snapshot === undefined
|
||||
}
|
||||
|
||||
compareTo(other: GradleVersion): number {
|
||||
const parts = this.versionPart.split('.')
|
||||
const otherParts = other.versionPart.split('.')
|
||||
for (let i = 0; i < parts.length && i < otherParts.length; i++) {
|
||||
if (Number(parts[i]) !== Number(otherParts[i])) {
|
||||
return Math.sign(Number(parts[i]) - Number(otherParts[i]))
|
||||
}
|
||||
}
|
||||
if (parts.length !== otherParts.length) {
|
||||
return Math.sign(parts.length - otherParts.length)
|
||||
}
|
||||
|
||||
if (this.stage && other.stage) {
|
||||
const stageDiff = GradleVersion.compareStages(this.stage, other.stage)
|
||||
if (stageDiff !== 0) {
|
||||
return stageDiff
|
||||
}
|
||||
} else if (this.stage) {
|
||||
return -1 // a staged version precedes the final release of the same base
|
||||
} else if (other.stage) {
|
||||
return 1
|
||||
}
|
||||
|
||||
// A version with no snapshot is newer than any snapshot of the same base.
|
||||
const thisSnapshot = this.snapshot ?? Number.MAX_SAFE_INTEGER
|
||||
const otherSnapshot = other.snapshot ?? Number.MAX_SAFE_INTEGER
|
||||
if (thisSnapshot !== otherSnapshot) {
|
||||
return Math.sign(thisSnapshot - otherSnapshot)
|
||||
}
|
||||
return this.version < other.version ? -1 : this.version > other.version ? 1 : 0
|
||||
}
|
||||
|
||||
static parseUntrusted(version: string): GradleVersion | undefined {
|
||||
try {
|
||||
return new GradleVersion(version)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Comparator for sorting; `this: void` marks it safe to pass detached, as `Array.sort` does. */
|
||||
static compare(this: void, a: GradleVersion, b: GradleVersion): number {
|
||||
return a.compareTo(b)
|
||||
}
|
||||
|
||||
private static parseStage(name: string | undefined, numberPart: string | undefined): Stage | undefined {
|
||||
if (name === undefined) {
|
||||
return undefined
|
||||
}
|
||||
// Stage names are matched exactly, as Gradle does: 'RC' is not 'rc', and ranks as unknown.
|
||||
const rank = GradleVersion.STAGE_RANK[name] ?? GradleVersion.STAGE_UNKNOWN
|
||||
// The whole stage string must be a number with an optional letter suffix; anything else ranks as 0,
|
||||
// leaving versions such as branch names to be separated by the version string itself.
|
||||
const match = /^(\d+)([a-z])?$/.exec(numberPart ?? '')
|
||||
return {rank, number: match ? Number(match[1]) : 0, patchNo: match?.[2] ?? '_'}
|
||||
}
|
||||
|
||||
private static parseSnapshot(snapshot: string | undefined, timezone: string | undefined): number | undefined {
|
||||
if (snapshot === undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (snapshot === 'SNAPSHOT') {
|
||||
return 0
|
||||
}
|
||||
// A 14-digit timestamp (yyyyMMddHHmmss), optionally with a timezone offset like +0000 (UTC when absent).
|
||||
const date = `${snapshot.slice(0, 4)}-${snapshot.slice(4, 6)}-${snapshot.slice(6, 8)}`
|
||||
const time = `${snapshot.slice(8, 10)}:${snapshot.slice(10, 12)}:${snapshot.slice(12, 14)}`
|
||||
const offset = timezone ? `${timezone.slice(0, 3)}:${timezone.slice(3, 5)}` : 'Z'
|
||||
const instant = Date.parse(`${date}T${time}${offset}`)
|
||||
if (Number.isNaN(instant)) {
|
||||
// The digits matched the pattern but name no real instant. Rejecting here keeps a NaN out of
|
||||
// compareTo, which would otherwise make ordering undefined. Gradle likewise fails to parse.
|
||||
throw new Error(`'${snapshot}${timezone ?? ''}' is not a valid Gradle snapshot timestamp`)
|
||||
}
|
||||
return instant
|
||||
}
|
||||
|
||||
private static compareStages(a: Stage, b: Stage): number {
|
||||
if (a.rank !== b.rank) {
|
||||
return Math.sign(a.rank - b.rank)
|
||||
}
|
||||
if (a.number !== b.number) {
|
||||
return Math.sign(a.number - b.number)
|
||||
}
|
||||
if (a.patchNo !== b.patchNo) {
|
||||
return a.patchNo < b.patchNo ? -1 : 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -6,6 +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 {renderSupportStatus, supportStatusSign} from './gradle-support-status'
|
||||
|
||||
export async function generateJobSummary(
|
||||
buildResults: BuildResult[],
|
||||
@@ -97,7 +98,7 @@ Note that this permission is never available for a workflow triggered from a rep
|
||||
}
|
||||
|
||||
export function renderSummaryTable(results: BuildResult[]): string {
|
||||
return `${renderDeprecations()}\n${renderBuildResults(results)}`
|
||||
return `${renderDeprecations()}\n${renderBuildResults(results)}\n${renderSupportStatus(results.map(result => result.gradleVersion))}`
|
||||
}
|
||||
|
||||
function renderActionHeading(): string {
|
||||
@@ -147,8 +148,7 @@ function renderBuildResults(results: BuildResult[]): string {
|
||||
<th>Build Outcome</th>
|
||||
<th>Build Scan®</th>
|
||||
</tr>${results.map(result => renderBuildResultRow(result)).join('')}
|
||||
</table>
|
||||
`
|
||||
</table>`
|
||||
}
|
||||
|
||||
function anyFailed(results: BuildResult[]): boolean {
|
||||
@@ -160,12 +160,17 @@ function renderBuildResultRow(result: BuildResult): string {
|
||||
<tr>
|
||||
<td>${truncateString(result.rootProjectName, 30)}</td>
|
||||
<td>${truncateString(result.requestedTasks, 60)}</td>
|
||||
<td align='center'>${result.gradleVersion}</td>
|
||||
<td align='center'>${renderGradleVersion(result.gradleVersion)}</td>
|
||||
<td align='center'>${renderOutcome(result)}</td>
|
||||
<td>${renderBuildScan(result)}</td>
|
||||
</tr>`
|
||||
}
|
||||
|
||||
function renderGradleVersion(gradleVersion: string): string {
|
||||
const sign = supportStatusSign(gradleVersion)
|
||||
return sign ? `${gradleVersion} ${sign}` : gradleVersion
|
||||
}
|
||||
|
||||
function renderOutcome(result: BuildResult): string {
|
||||
return result.buildFailed ? ':x:' : ':white_check_mark:'
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from './configuration'
|
||||
import * as wrapperValidator from './wrapper-validation/wrapper-validator'
|
||||
import {initializeGradleUserHome} from './gradle-user-home'
|
||||
import {reportSupportStatus} from './gradle-support-status'
|
||||
|
||||
const GRADLE_SETUP_VAR = 'GRADLE_BUILD_ACTION_SETUP_COMPLETED'
|
||||
const GRADLE_USER_HOME = 'GRADLE_USER_HOME'
|
||||
@@ -86,6 +87,7 @@ export async function complete(
|
||||
cacheOptionsFrom(cacheConfig, develocityServerUrl, cacheToken)
|
||||
)
|
||||
await jobSummary.generateJobSummary(buildResults, cacheReport, getProviderNote(cacheConfig), summaryConfig)
|
||||
reportSupportStatus(buildResults.map(result => result.gradleVersion))
|
||||
|
||||
markBuildResultsProcessed()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user