Files
actions/sources/src/gradle-version.ts
T
e49d0a36a8 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]>
2026-09-07 16:34:26 -06:00

135 lines
5.4 KiB
TypeScript

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
}
}