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 <louis@gradle.com>
Co-authored-by: Daz DeBoer <daz@gradle.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vlad Chesnokov
2026-09-07 16:34:26 -06:00
committed by GitHub
co-authored by Louis Jacomet Daz DeBoer Claude Opus 5
parent 575435b1ea
commit e49d0a36a8
17 changed files with 1031 additions and 211 deletions
+1
View File
@@ -36,6 +36,7 @@ jobs:
other-integ-tests:
permissions:
contents: write
actions: read
needs: caching-integ-tests
uses: ./.github/workflows/suite-integ-test-other.yml
concurrency:
+1
View File
@@ -47,6 +47,7 @@ jobs:
other-integ-tests:
permissions:
contents: write
actions: read
needs: caching-integ-tests
uses: ./.github/workflows/suite-integ-test-other.yml
concurrency:
+73
View File
@@ -57,6 +57,79 @@ jobs:
build-root-directory: .github/workflow-samples/groovy-dsl
dependency-graph: generate-and-upload
# Exercises every Gradle version support status in a single Job Summary: end-of-life versions all
# share one expandable section and are each marked with a warning sign, out-of-date versions share
# a single info sign and legend, and the current version is left unmarked.
# Three end-of-life versions and two out-of-date ones exercise the plural wording throughout, and
# the two 7.x versions alongside a 6.x one check that each affected release line is named once,
# as "Gradle 6.x and 7.x releases".
# Versions are picked relative to Gradle 9.x being current; revisit once Gradle 10 is released.
support-status-eol-and-outdated:
needs: build-distribution
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Java 11, since Gradle 6.x cannot run on the Java 17 used elsewhere in this workflow.
- name: Initialize integ-test
uses: ./.github/actions/init-integ-test
with:
java-version: '11'
- name: Setup Gradle 6.9.4 (end-of-life)
uses: ./setup-gradle
with:
gradle-version: '6.9.4'
- name: Build with Gradle 6.9.4
working-directory: .github/workflow-samples/no-wrapper
run: gradle help
- name: Setup Gradle 7.4 (end-of-life)
uses: ./setup-gradle
with:
gradle-version: '7.4'
- name: Build with Gradle 7.4
working-directory: .github/workflow-samples/no-wrapper
run: gradle help
- name: Setup Gradle 7.6.6 (end-of-life)
uses: ./setup-gradle
with:
gradle-version: '7.6.6'
- name: Build with Gradle 7.6.6
working-directory: .github/workflow-samples/no-wrapper
run: gradle help
- name: Setup Gradle 8.0.2 (out of date)
uses: ./setup-gradle
with:
gradle-version: '8.0.2'
- name: Build with Gradle 8.0.2
working-directory: .github/workflow-samples/no-wrapper
run: gradle help
- name: Setup Gradle 8.14.5 (out of date)
uses: ./setup-gradle
with:
gradle-version: '8.14.5'
- name: Build with Gradle 8.14.5
working-directory: .github/workflow-samples/no-wrapper
run: gradle help
# Gradle 9 and later require Java 17
- name: Setup Java 17
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: temurin
java-version: 17
- name: Setup Gradle current (no status reported)
uses: ./setup-gradle
with:
gradle-version: current
- name: Build with the current Gradle version
working-directory: .github/workflow-samples/no-wrapper
run: gradle help
successful-builds-with-no-summary:
needs: build-distribution
runs-on: ubuntu-latest
@@ -0,0 +1,148 @@
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-outdated-versions:
runs-on: ubuntu-latest
outputs:
prev-oldest: ${{ steps.versions.outputs.prev-oldest }}
prev-newest: ${{ steps.versions.outputs.prev-newest }}
steps:
- name: Checkout sources
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Initialize integ-test
uses: ./.github/actions/init-integ-test
# Derived from the bundled release data so the expectations cannot drift from the action's own view.
- name: Determine versions for each support status
id: versions
run: |
set -euo pipefail
eval "$(jq -r '
[ .[].version
| select(test("^[0-9]+\\.[0-9]+(\\.[0-9]+)?$"))
| {v: ., k: (((split(".") | map(tonumber)) + [0, 0, 0]) | .[0:3])}
]
| sort_by(.k) as $finals
| ($finals | last) as $latest
| [$finals[] | select(.k[0] == $latest.k[0] - 1)] as $prev
| "KNOWN_LATEST=\($latest.v)",
"PREV_OLDEST=\($prev | first | .v)",
"PREV_NEWEST=\($prev | last | .v)"
' sources/src/wrapper-validation/wrapper-checksums.json)"
echo "Known latest : ${KNOWN_LATEST}"
echo "Previous major, oldest : ${PREV_OLDEST}"
echo "Previous major, newest : ${PREV_NEWEST}"
{
echo "prev-oldest=${PREV_OLDEST}"
echo "prev-newest=${PREV_NEWEST}"
} >> "$GITHUB_OUTPUT"
- 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
working-directory: .github/workflow-samples/no-wrapper
run: gradle help "-DgradleVersionCheck=7.6.4"
- name: Setup Gradle with the oldest release of the previous major
uses: ./setup-gradle
with:
gradle-version: ${{ steps.versions.outputs.prev-oldest }}
- name: Build with the oldest release of the previous major
working-directory: .github/workflow-samples/no-wrapper
run: gradle help "-DgradleVersionCheck=${{ steps.versions.outputs.prev-oldest }}"
- name: Setup Gradle with the newest release of the previous major
uses: ./setup-gradle
with:
gradle-version: ${{ steps.versions.outputs.prev-newest }}
- name: Build with the newest release of the previous major
working-directory: .github/workflow-samples/no-wrapper
run: gradle help "-DgradleVersionCheck=${{ steps.versions.outputs.prev-newest }}"
verify-report:
needs: report-outdated-versions
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
steps:
- name: Check the support-status report was emitted
env:
GH_TOKEN: ${{ github.token }}
PREV_OLDEST: ${{ needs.report-outdated-versions.outputs.prev-oldest }}
PREV_NEWEST: ${{ needs.report-outdated-versions.outputs.prev-newest }}
run: |
set -euo pipefail
job_id=$(gh api \
"/repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/attempts/${GITHUB_RUN_ATTEMPT}/jobs" \
--paginate --jq '.jobs[] | select(.name | endswith("report-outdated-versions")) | .id')
if [ -z "$job_id" ]; then
echo "Could not find the 'report-outdated-versions' Job in this workflow run"
exit 1
fi
gh api --allow-escape-sequences "/repos/${GITHUB_REPOSITORY}/actions/jobs/${job_id}/logs" > job.log
assert_logged() {
if ! grep -qF -- "$1" job.log; then
echo "Expected the Job log to contain: $1"
echo "--- support-status output found instead ---"
grep -E '##\[(warning|notice)\]|<summary>:warning:|consider upgrading' job.log || echo "(none)"
exit 1
fi
}
refute_logged() {
if grep -qF -- "$1" job.log; then
echo "Did not expect the Job log to contain: $1"
exit 1
fi
}
# The runner renders '::warning::'/'::notice::' into the log as '##[warning]'/'##[notice]'.
assert_logged "##[warning]Gradle 7.6.4 is end-of-life"
assert_logged "##[notice]Gradle ${PREV_OLDEST} is out of date: consider updating to the latest Gradle version."
assert_logged "##[notice]Gradle ${PREV_NEWEST} is out of date: consider updating to the latest Gradle version."
# Out-of-date versions get a notice, never a warning.
refute_logged "##[warning]Gradle ${PREV_OLDEST}"
refute_logged "##[warning]Gradle ${PREV_NEWEST}"
# The job summary is echoed to the log, so the rendered report can be checked directly.
assert_logged "<summary>:warning: Gradle 7.6.4 is end-of-life</summary>"
assert_logged "Update to the latest Gradle version."
assert_logged "<a href=\"https://gradle.org/security-subscription/?utm_source=github-action\">Gradle Security Subscription</a>"
# Table signs: a warning for end-of-life, the info sign for everything still supported.
assert_logged "<td align='center'>7.6.4 :warning:</td>"
assert_logged "<td align='center'>${PREV_OLDEST} :information_source:</td>"
assert_logged "<td align='center'>${PREV_NEWEST} :information_source:</td>"
# One legend for the whole job, no matter how many versions carry the info sign.
legends=$(grep -cF -- "consider upgrading" job.log || true)
if [ "$legends" != "1" ]; then
echo "Expected exactly one upgrade legend, found ${legends}"
exit 1
fi
assert_logged "<p>:information_source: Gradle version is out of date — consider upgrading. See <a href=\"https://docs.gradle.org/current/userguide/feature_lifecycle.html#eol_support\">Gradle release lifecycle</a></p>"
@@ -28,6 +28,14 @@ jobs:
secrets:
DEVELOCITY_ACCESS_KEY: ${{ secrets.DV_SOLUTIONS_ACCESS_KEY }}
gradle-support-status:
permissions:
contents: read
actions: read
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:
+1
View File
@@ -1,3 +1,4 @@
.git
.vscode
.claude
.idea
+24
View File
@@ -518,6 +518,30 @@ so that a Job Summary is never generated, or so that a Job Summary is only gener
add-job-summary: 'on-failure' # Valid values are 'always' (default), 'never', and 'on-failure'
```
### Gradle version support status
The Job Summary reports the support status of each Gradle version used in the workflow, and the action adds a
Job annotation for any version that is no longer current. The latest Gradle release is determined from release data
bundled with the action, so no network access is required.
A version is reported when it is:
- **End-of-life** — two or more major versions behind the latest release. Each such version is marked with :warning:
in the build results table, a single expandable section below the table explains that the affected release lines
receive no further fixes (security fixes included), and a warning annotation is added to the Job for each version.
If you cannot upgrade, the [Gradle Security Subscription](https://gradle.org/security-subscription/) offers
continued support for older versions.
- **Out of date** — one major version behind the latest release, or more than two minor versions behind on the current
major. The version is marked with :information_source: in the build results table and a notice annotation is added to
the Job. Note that a version one major behind is still in "maintenance only" support and receives critical bug fixes
and security fixes; an older minor of the current major has simply been superseded. See
[Gradle release lifecycle](https://docs.gradle.org/current/userguide/feature_lifecycle.html#eol_support) for details.
Patch releases are not reported: only the major and minor version are considered. Release candidates, milestones and
snapshots are never reported, so testing against a pre-release build will not produce annotations.
Note that these annotations are always emitted, independent of the `add-job-summary` setting. Setting
`add-job-summary: 'never'` suppresses the Job Summary itself, but the warning and notice annotations remain.
### Excluding specific Gradle builds from Job Summary
The Job Summary works by installing an init-script in Gradle User Home which will record details of any Gradle execution during the workflow.
-9
View File
@@ -19,7 +19,6 @@
"@actions/tool-cache": "4.0.0",
"@octokit/webhooks-types": "7.6.1",
"cheerio": "1.2.0",
"semver": "7.8.5",
"string-argv": "0.3.2",
"unhomoglyph": "1.0.6",
"which": "7.0.0"
@@ -28,7 +27,6 @@
"@jest/globals": "30.4.1",
"@types/jest": "30.0.0",
"@types/node": "24.13.3",
"@types/semver": "7.8.0",
"@types/unzipper": "0.10.11",
"@types/which": "3.0.4",
"@typescript-eslint/eslint-plugin": "8.66.0",
@@ -2625,13 +2623,6 @@
"undici-types": "~7.18.0"
}
},
"node_modules/@types/semver": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/stack-utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
-2
View File
@@ -45,7 +45,6 @@
"@actions/tool-cache": "4.0.0",
"@octokit/webhooks-types": "7.6.1",
"cheerio": "1.2.0",
"semver": "7.8.5",
"string-argv": "0.3.2",
"unhomoglyph": "1.0.6",
"which": "7.0.0"
@@ -54,7 +53,6 @@
"@jest/globals": "30.4.1",
"@types/jest": "30.0.0",
"@types/node": "24.13.3",
"@types/semver": "7.8.0",
"@types/unzipper": "0.10.11",
"@types/which": "3.0.4",
"@typescript-eslint/eslint-plugin": "8.66.0",
-62
View File
@@ -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]
}
}
+152
View File
@@ -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>`
}
+134
View File
@@ -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
}
}
+9 -4
View File
@@ -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&nbsp;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:'
}
+2
View File
@@ -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()
@@ -0,0 +1,253 @@
import * as fs from 'fs'
import * as path from 'path'
import {beforeEach, describe, expect, it, jest} from '@jest/globals'
const mockWarning = jest.fn<(message: string, properties?: {title?: string}) => void>()
const mockNotice = jest.fn<(message: string, properties?: {title?: string}) => void>()
jest.unstable_mockModule('@actions/core', () => ({
warning: mockWarning,
notice: mockNotice
}))
// Mirrors the real release shape: latest 9.7.1, last 8.x minor is 8.14 with patches up to 8.14.5.
const RELEASED = [
'9.7.1',
'9.7.0',
'9.6.1',
'9.6.0',
'9.5.1',
'9.5.0',
'9.4.1',
'9.4.0',
'9.2.1',
'9.2.0',
'9.0.0',
'8.14.5',
'8.14',
'8.13',
'8.3',
'8.0.2',
'8.0',
'7.6.4',
'1.0'
]
const asChecksumEntries = (versions: string[]): {version: string; checksum: string}[] =>
versions.map(version => ({version, checksum: ''}))
let releasedVersions = asChecksumEntries(RELEASED)
jest.unstable_mockModule('../../src/wrapper-validation/wrapper-checksums.json', () => ({
get default() {
return releasedVersions
}
}))
const {renderSupportStatus, reportSupportStatus, supportStatusSign} = await import('../../src/gradle-support-status')
/** Re-imports the module so that its release index is built from `released` instead of RELEASED. */
async function withReleaseData(released: string[]): Promise<typeof import('../../src/gradle-support-status')> {
releasedVersions = asChecksumEntries(released)
jest.resetModules()
try {
return await import('../../src/gradle-support-status')
} finally {
releasedVersions = asChecksumEntries(RELEASED)
}
}
const NO_SIGN = ''
const OUT_OF_DATE = ':information_source:'
const END_OF_LIFE = ':warning:'
const DOC = 'https://docs.gradle.org/current/userguide/feature_lifecycle.html#eol_support'
const SECURITY_SUBSCRIPTION = 'https://gradle.org/security-subscription/?utm_source=github-action'
describe('supportStatusSign', () => {
it.each(['7.6.4', '1.0', '4.10.3'])('marks %s as end-of-life', version => {
expect(supportStatusSign(version)).toBe(END_OF_LIFE)
})
it.each(['8.0', '8.0.2', '8.3', '8.13', '8.14', '8.14.3', '8.14.5'])(
'marks %s as out of date, being on the previous major',
version => {
expect(supportStatusSign(version)).toBe(OUT_OF_DATE)
}
)
it.each(['8.15', '8.15.1'])(
'marks %s, a previous-major minor newer than the release data, as out of date',
version => {
expect(supportStatusSign(version)).toBe(OUT_OF_DATE)
}
)
it('marks a previous major absent from the release data as out of date', async () => {
const {supportStatusSign: sign} = await withReleaseData(['9.7.1', '7.6.4'])
expect(sign('8.0')).toBe(OUT_OF_DATE)
})
it.each(['9.0.0', '9.2.1', '9.4.1'])('marks %s as out of date, being more than 2 minors back', version => {
expect(supportStatusSign(version)).toBe(OUT_OF_DATE)
})
it.each(['9.5.0', '9.5.1', '9.6.1', '9.7.1'])('leaves %s unmarked, inside the grace band', version => {
expect(supportStatusSign(version)).toBe(NO_SIGN)
})
it('does not mark a version merely because a newer patch exists', () => {
expect(supportStatusSign('9.6.0')).toBe(NO_SIGN)
expect(supportStatusSign('9.7.0')).toBe(NO_SIGN)
})
it.each(['10.0', '10.4.2'])('leaves %s unmarked, newer than the release data', version => {
expect(supportStatusSign(version)).toBe(NO_SIGN)
})
it.each(['9.8.0-rc-1', '7.0-milestone-1', '9.8-20260101120000+0000'])(
'leaves non-final version %s unmarked',
version => {
expect(supportStatusSign(version)).toBe(NO_SIGN)
}
)
it('does not throw on an unparseable version', () => {
expect(supportStatusSign('')).toBe(NO_SIGN)
expect(supportStatusSign('unknown')).toBe(NO_SIGN)
})
})
describe('bundled release data', () => {
it('contains a final release, so the release index builds instead of failing', async () => {
const bundled: {version: string}[] = JSON.parse(
fs.readFileSync(path.resolve('src/wrapper-validation/wrapper-checksums.json'), 'utf-8')
)
const {supportStatusSign: sign} = await withReleaseData(bundled.map(entry => entry.version))
expect(sign('1.0')).toBe(END_OF_LIFE)
})
})
describe('reportSupportStatus', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('warns about an end-of-life version', () => {
reportSupportStatus(['7.6.4'])
expect(mockWarning).toHaveBeenCalledTimes(1)
expect(mockNotice).not.toHaveBeenCalled()
const [message, properties] = mockWarning.mock.calls[0]
expect(message).toBe(
`Gradle 7.6.4 is end-of-life. Gradle 7.x releases receive no further fixes, security fixes included. Update to the latest Gradle version. If you cannot upgrade, see ${SECURITY_SUBSCRIPTION} for options`
)
expect(properties?.title).toBe('End-of-life Gradle version')
})
it.each(['8.0', '8.14.3', '8.14.5', '9.2.1'])('notices %s as out of date', version => {
reportSupportStatus([version])
expect(mockWarning).not.toHaveBeenCalled()
expect(mockNotice).toHaveBeenCalledTimes(1)
const [message, properties] = mockNotice.mock.calls[0]
expect(message).toBe(
`Gradle ${version} is out of date: consider updating to the latest Gradle version. See ${DOC}`
)
expect(properties?.title).toBe('Out-of-date Gradle version')
})
it.each(['9.5.1', '9.6.1', '9.7.1'])('stays quiet about %s, inside the grace band', version => {
reportSupportStatus([version])
expect(mockWarning).not.toHaveBeenCalled()
expect(mockNotice).not.toHaveBeenCalled()
})
it('annotates each distinct version once, at the level its status warrants', () => {
reportSupportStatus(['7.6.4', '4.10.3', '7.6.4', '8.0', '9.2.1', '8.0'])
expect(mockWarning).toHaveBeenCalledTimes(2)
expect(mockNotice).toHaveBeenCalledTimes(2)
})
it('says nothing when no Gradle build ran', () => {
reportSupportStatus([])
expect(mockWarning).not.toHaveBeenCalled()
expect(mockNotice).not.toHaveBeenCalled()
})
})
describe('renderSupportStatus', () => {
const LEGEND =
`<p>${OUT_OF_DATE} Gradle version is out of date — consider upgrading. ` +
`See <a href="${DOC}">Gradle release lifecycle</a></p>`
it('folds end-of-life versions under a warning sign, linking the security subscription', () => {
const rendered = renderSupportStatus(['7.6.4'])
expect(rendered).toContain(`<summary>${END_OF_LIFE} Gradle 7.6.4 is end-of-life</summary>`)
expect(rendered).toContain(
'Gradle 7.x releases receive no further fixes, security fixes included. Update to the latest Gradle version.'
)
expect(rendered).toContain(`<a href="${SECURITY_SUBSCRIPTION}">Gradle Security Subscription</a>`)
expect(rendered).not.toContain(LEGEND)
})
it('names every end-of-life version in the summary line, and reads as a plural', () => {
const rendered = renderSupportStatus(['7.6.4', '1.0', '4.10.3'])
expect(rendered).toContain(`<summary>${END_OF_LIFE} Gradle 1.0, 4.10.3 and 7.6.4 are end-of-life</summary>`)
})
it('names every affected release line when versions span more than one', () => {
const rendered = renderSupportStatus(['7.6.4', '1.0', '4.10.3'])
expect(rendered).toContain('Gradle 1.x, 4.x and 7.x releases receive no further fixes')
})
it('names each release line once when several versions share one', () => {
const rendered = renderSupportStatus(['7.6.4', '7.6.6', '1.0'])
expect(rendered).toContain('Gradle 1.x and 7.x releases receive no further fixes')
})
it('names no version outside the versions that are end-of-life', () => {
expect(renderSupportStatus(['7.6.4'])).not.toContain('9.7.1')
})
it.each(['8.0', '8.14.3', '8.14.5', '9.2.1'])('adds only the legend for %s', version => {
expect(renderSupportStatus([version]).trim()).toBe(LEGEND)
})
it('adds the legend exactly once however many versions are flagged', () => {
const rendered = renderSupportStatus(['9.2.1', '9.4.1', '8.14.5', '8.14.3', '8.3', '8.0'])
expect(rendered.trim()).toBe(LEGEND)
})
it('emits a single section however many versions are end-of-life, plus the single legend', () => {
const rendered = renderSupportStatus(['9.6.1', '9.2.1', '8.14.5', '8.0', '7.6.4', '1.0'])
expect(rendered.match(/<details>/g)).toHaveLength(1)
expect(rendered.match(/consider upgrading/g)).toHaveLength(1)
expect(rendered).toContain(`<summary>${END_OF_LIFE} Gradle 1.0 and 7.6.4 are end-of-life</summary>`)
})
it('names no version outside the fold', () => {
const rendered = renderSupportStatus(['8.0', '9.2.1'])
expect(rendered).not.toContain('8.0')
expect(rendered).not.toContain('9.2.1')
})
it('renders nothing when every version is inside the grace band', () => {
expect(renderSupportStatus(['9.5.1', '9.6.1', '9.7.1'])).toBe('')
})
it('renders nothing when no Gradle build ran', () => {
expect(renderSupportStatus([])).toBe('')
})
})
+108 -85
View File
@@ -1,154 +1,177 @@
import {describe, expect, it} from '@jest/globals'
import {versionIsAtLeast, parseGradleVersionFromOutput} from '../../src/execution/gradle'
import {GradleVersion} from '../../src/gradle-version'
import {parseGradleVersionFromOutput} from '../../src/execution/gradle'
describe('gradle', () => {
describe('can compare versions that are', () => {
function versionsAreOrdered(versions: string[]): void {
function order(a: string, b: string): number {
return Math.sign(GradleVersion.compare(new GradleVersion(a), new GradleVersion(b)))
}
/** Asserts every pairing of `versions`, which must be listed oldest first. */
function ascending(versions: string[]): void {
for (let i = 0; i < versions.length; i++) {
// Compare with all other versions
for (let j = 0; j < versions.length; j++) {
if (i >= j) {
it(`${versions[i]} is at least ${versions[j]}`, () => {
expect(versionIsAtLeast(versions[i], versions[j])).toBe(true)
})
} else {
it(`${versions[i]} is NOT at least ${versions[j]}`, () => {
expect(versionIsAtLeast(versions[i], versions[j])).toBe(false)
})
}
}
}
}
function versionsAreNotOrdered(versions: string[]): void {
for (let i = 0; i < versions.length; i++) {
// Compare with all other versions
for (let j = 0; j < versions.length; j++) {
if (i !== j) {
it(`${versions[i]} is NOT at least ${versions[j]}`, () => {
expect(versionIsAtLeast(versions[i], versions[j])).toBe(false)
})
}
}
}
}
function versionsAreEqual(versions: string[]): void {
for (let i = 0; i < versions.length; i++) {
// Compare with all other versions
for (let j = 0; j < versions.length; j++) {
it(`${versions[i]} is at least ${versions[j]}`, () => {
expect(versionIsAtLeast(versions[i], versions[j])).toBe(true)
const expected = Math.sign(i - j)
it(`${versions[i]} vs ${versions[j]} is ${expected}`, () => {
expect(order(versions[i], versions[j])).toBe(expected)
})
}
}
}
describe('GradleVersion', () => {
describe('orders', () => {
describe('simple versions', () => {
versionsAreOrdered(['6.0', '6.7', '6.7.1', '6.7.2', '7.0', '7.0.1', '7.1', '8.0', '8.12.1'])
ascending(['6.0', '6.7', '6.7.1', '6.7.2', '7.0', '7.0.1', '7.1', '8.0', '8.12.1'])
})
versionsAreEqual(['7.0', '7.0.0'])
versionsAreEqual(['7.1', '7.1.0'])
describe('a shorter base is older than a longer one that extends it', () => {
ascending(['7.0', '7.0.0'])
ascending(['7.1', '7.1.0'])
})
describe('rc versions', () => {
versionsAreOrdered([
'8.10', '8.11-rc-1', '8.11-rc-2', '8.11', '8.11.1-rc-1', '8.11.1'
])
ascending(['8.10', '8.11-rc-1', '8.11-rc-2', '8.11', '8.11.1-rc-1', '8.11.1'])
})
describe('milestone versions', () => {
versionsAreOrdered([
'8.12.1', '8.12.2-milestone-1', '8.12.2', '8.13-milestone-1', '8.13-milestone-2', '8.13'
])
versionsAreOrdered([
'8.12.1', '8.12.2-milestone-1', '8.12.2-milestone-2', '8.12.2-rc-1', '8.12.2'
])
ascending(['8.12.1', '8.12.2-milestone-1', '8.12.2', '8.13-milestone-1', '8.13-milestone-2', '8.13'])
})
describe('preview versions', () => {
versionsAreOrdered([
'8.12.1', '8.12.2-preview-1', '8.12.2', '8.13-preview-1', '8.13-preview-2', '8.13'
])
versionsAreOrdered([
'8.12.1', '8.12.2-milestone-1', '8.12.2-preview-1', '8.12.2-rc-1', '8.12.2'
])
ascending(['8.12.1', '8.12.2-preview-1', '8.12.2', '8.13-preview-1', '8.13-preview-2', '8.13'])
})
describe('milestone before preview before rc before final', () => {
ascending(['8.12.2-milestone-1', '8.12.2-preview-1', '8.12.2-rc-1', '8.12.2'])
})
describe('snapshot versions', () => {
versionsAreOrdered([
'8.10.1', '8.10.2-20240828012138+0000', '8.10.2', '8.11-20240829002031+0000', '8.11'
])
versionsAreOrdered([
'9.0', '9.1-branch-provider_api_migration_public_api_changes-20240826121451+0000', '9.1'
])
versionsAreNotOrdered([
'8.10.2-20240828012138+0000', '8.10.2-20240828010000+1000', '8.10.2-milestone-1'
])
ascending(['8.10.1', '8.10.2-20240828012138+0000', '8.10.2', '8.11-20240829002031+0000', '8.11'])
ascending(['9.0', '9.1-branch-provider_api_migration_public_api_changes-20240826121451+0000', '9.1'])
})
describe('snapshots order by instant, accounting for timezone', () => {
ascending(['8.10.2-milestone-1', '8.10.2-20240828010000+1000', '8.10.2-20240828012138+0000', '8.10.2'])
})
})
describe('can parse version from output', () => {
it('major version', async () => {
// Gradle's `Stage.from` matches the whole stage string against /(\d+)([a-z])?/, so a stage number is
// read only when the string is entirely a number with an optional letter suffix.
describe('reads a stage number only from a fully numeric stage string', () => {
it('orders plain stage numbers numerically, not as text', () => {
// Were these compared as text, '8.11-rc-10' would sort before '8.11-rc-2'.
expect(order('8.11-rc-2', '8.11-rc-10')).toBe(-1)
})
describe('a letter suffix orders after the bare number', () => {
ascending(['8.11-rc-1', '8.11-rc-1a', '8.11-rc-1b', '8.11-rc-2'])
})
it('ignores digits embedded in a longer stage string', () => {
// 'issue10' and 'issue9' both rank as stage number 0, leaving the version string to break the
// tie; reading the digits positionally would instead sort 'issue10' after 'issue9'.
expect(order('8.0-branch-issue10-20240828012138+0000', '8.0-branch-issue9-20240828012138+0000')).toBe(-1)
})
})
describe('matches stage names exactly, as Gradle does', () => {
// 'RC' is not 'rc', so it ranks as unknown (1) and precedes preview (2) rather than following it.
describe('an unrecognised spelling ranks between milestone and preview', () => {
ascending(['8.0-milestone-1', '8.0-RC-1', '8.0-preview-1', '8.0-rc-1', '8.0'])
})
})
describe('rejects a timestamp that names no real instant', () => {
it.each(['8.0-99999999999999', '8.0-20241301012138', '8.0-20240828992138', '8.0-20240828012138+9999'])(
'throws for %s',
version => {
expect(() => new GradleVersion(version)).toThrow('is not a valid Gradle snapshot timestamp')
}
)
it.each(['8.0-99999999999999', '8.0-20240828012138+9999'])(
'reports %s as unparseable rather than yielding an uncomparable version',
version => {
expect(GradleVersion.parseUntrusted(version)).toBeUndefined()
}
)
})
describe('isFinalRelease', () => {
it.each(['1.0', '8.14.5', '9.7.1'])('treats %s as final', version => {
expect(new GradleVersion(version).isFinalRelease()).toBe(true)
})
it.each(['8.11-rc-1', '8.0-milestone-6', '8.10.2-20240828012138+0000', '9.0-SNAPSHOT'])(
'treats %s as not final',
version => {
expect(new GradleVersion(version).isFinalRelease()).toBe(false)
}
)
})
describe('parseUntrusted', () => {
it.each(['', 'unknown', 'v1.0'])('returns undefined for the invalid version %s', version => {
expect(GradleVersion.parseUntrusted(version)).toBeUndefined()
})
})
})
describe('parseGradleVersionFromOutput', () => {
it('major version', () => {
const output = `
------------------------------------------------------------
Gradle 8.9
------------------------------------------------------------
`
const version = await parseGradleVersionFromOutput(output)!
expect(version).toBe('8.9')
expect(parseGradleVersionFromOutput(output)).toBe('8.9')
})
it('patch version', async () => {
it('patch version', () => {
const output = `
------------------------------------------------------------
Gradle 8.9.1
------------------------------------------------------------
`
const version = await parseGradleVersionFromOutput(output)!
expect(version).toBe('8.9.1')
expect(parseGradleVersionFromOutput(output)).toBe('8.9.1')
})
it('rc version', async () => {
it('rc version', () => {
const output = `
------------------------------------------------------------
Gradle 8.9-rc-1
------------------------------------------------------------
`
const version = await parseGradleVersionFromOutput(output)!
expect(version).toBe('8.9-rc-1')
expect(parseGradleVersionFromOutput(output)).toBe('8.9-rc-1')
})
it('milestone version', async () => {
it('milestone version', () => {
const output = `
------------------------------------------------------------
Gradle 8.0-milestone-6
------------------------------------------------------------
`
const version = await parseGradleVersionFromOutput(output)!
expect(version).toBe('8.0-milestone-6')
expect(parseGradleVersionFromOutput(output)).toBe('8.0-milestone-6')
})
it('snapshot version', async () => {
it('snapshot version', () => {
const output = `
------------------------------------------------------------
Gradle 8.10.2-20240828012138+0000
------------------------------------------------------------
`
const version = await parseGradleVersionFromOutput(output)!
expect(version).toBe('8.10.2-20240828012138+0000')
expect(parseGradleVersionFromOutput(output)).toBe('8.10.2-20240828012138+0000')
})
it('branch version', async () => {
it('branch version', () => {
const output = `
------------------------------------------------------------
Gradle 9.0-branch-provider_api_migration_public_api_changes-20240830060514+0000
------------------------------------------------------------
`
const version = await parseGradleVersionFromOutput(output)!
expect(version).toBe('9.0-branch-provider_api_migration_public_api_changes-20240830060514+0000')
expect(parseGradleVersionFromOutput(output)).toBe(
'9.0-branch-provider_api_migration_public_api_changes-20240830060514+0000'
)
})
})
})
+79 -11
View File
@@ -1,9 +1,31 @@
import dedent from 'dedent'
import * as github from '@actions/github'
import {afterEach, describe, expect, it} from '@jest/globals'
import {afterEach, describe, expect, it, jest} from '@jest/globals'
import {BuildResult} from '../../src/build-results'
import {jobMarker, renderSummaryTable} from '../../src/job-summary'
// The support-status policy itself is covered by gradle-support-status.test.ts; these tests only
// check that its output reaches the table. '8.0' is the sole release so the tables below, all of
// which build with Gradle 8.0, render without a status sign.
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')
/** Renders the summary table with the release index built from `versions` rather than the bundled data. */
async function renderWith(versions: string[], results: BuildResult[]): Promise<string> {
releasedVersions = versions.map(version => ({version, checksum: ''}))
jest.resetModules()
const {renderSummaryTable: render} = await import('../../src/job-summary')
return render(results)
}
const DOC = 'https://docs.gradle.org/current/userguide/feature_lifecycle.html#eol_support'
const SECURITY_SUBSCRIPTION = 'https://gradle.org/security-subscription/?utm_source=github-action'
const MATRIX_INPUT_ENV = 'INPUT_WORKFLOW-JOB-CONTEXT'
@@ -30,19 +52,20 @@ const failedHelpBuild: BuildResult = {
const longArgsBuild: BuildResult = {
...successfulHelpBuild,
requestedTasks: 'check publishMyLongNamePluginPublicationToMavenCentral publishMyLongNamePluginPublicationToPluginPortal',
requestedTasks:
'check publishMyLongNamePluginPublicationToMavenCentral publishMyLongNamePluginPublicationToPluginPortal'
}
const scanPublishDisabledBuild: BuildResult = {
...successfulHelpBuild,
buildScanUri: '',
buildScanFailed: false,
buildScanFailed: false
}
const scanPublishFailedBuild: BuildResult = {
...successfulHelpBuild,
buildScanUri: '',
buildScanFailed: true,
buildScanFailed: true
}
describe('renderSummaryTable', () => {
@@ -66,7 +89,7 @@ describe('renderSummaryTable', () => {
<td><a href="https://scans.gradle.com/s/abc123" rel="nofollow" target="_blank"><img src="https://img.shields.io/badge/Build%20Scan%C2%AE-06A0CE?logo=Gradle" alt="Build Scan published" /></a></td>
</tr>
</table>
`);
`)
})
it('failed build', () => {
const table = renderSummaryTable([failedHelpBuild])
@@ -87,7 +110,7 @@ describe('renderSummaryTable', () => {
<td><a href="https://scans.gradle.com/s/abc123" rel="nofollow" target="_blank"><img src="https://img.shields.io/badge/Build%20Scan%C2%AE-06A0CE?logo=Gradle" alt="Build Scan published" /></a></td>
</tr>
</table>
`);
`)
})
describe('when build scan', () => {
it('publishing disabled', () => {
@@ -109,7 +132,7 @@ describe('renderSummaryTable', () => {
<td><a href="https://scans.gradle.com" rel="nofollow" target="_blank"><img src="https://img.shields.io/badge/Not%20published-lightgrey" alt="Build Scan not published" /></a></td>
</tr>
</table>
`);
`)
})
it('publishing failed', () => {
const table = renderSummaryTable([scanPublishFailedBuild])
@@ -130,7 +153,7 @@ describe('renderSummaryTable', () => {
<td><a href="https://docs.gradle.com/develocity/gradle-plugin/#troubleshooting" rel="nofollow" target="_blank"><img src="https://img.shields.io/badge/Publish%20failed-orange" alt="Build Scan publish failed" /></a></td>
</tr>
</table>
`);
`)
})
})
it('multiple builds', () => {
@@ -159,7 +182,7 @@ describe('renderSummaryTable', () => {
<td><a href="https://scans.gradle.com/s/abc123" rel="nofollow" target="_blank"><img src="https://img.shields.io/badge/Build%20Scan%C2%AE-06A0CE?logo=Gradle" alt="Build Scan published" /></a></td>
</tr>
</table>
`);
`)
})
it('truncating long requested tasks', () => {
const table = renderSummaryTable([longArgsBuild])
@@ -180,11 +203,56 @@ describe('renderSummaryTable', () => {
<td><a href="https://scans.gradle.com/s/abc123" rel="nofollow" target="_blank"><img src="https://img.shields.io/badge/Build%20Scan%C2%AE-06A0CE?logo=Gradle" alt="Build Scan published" /></a></td>
</tr>
</table>
`);
`)
})
})
})
describe('Gradle version support status', () => {
it('warns on an end-of-life version and folds the detail below the table', async () => {
const table = await renderWith(['10.0.0', '8.0'], [successfulHelpBuild])
expect(table.trim()).toBe(dedent`
<table>
<tr>
<th>Gradle Root Project</th>
<th>Requested Tasks</th>
<th>Gradle Version</th>
<th>Build Outcome</th>
<th>Build&nbsp;Scan®</th>
</tr>
<tr>
<td>root</td>
<td>help</td>
<td align='center'>8.0 :warning:</td>
<td align='center'>:white_check_mark:</td>
<td><a href="https://scans.gradle.com/s/abc123" rel="nofollow" target="_blank"><img src="https://img.shields.io/badge/Build%20Scan%C2%AE-06A0CE?logo=Gradle" alt="Build Scan published" /></a></td>
</tr>
</table>
<details>
<summary>:warning: Gradle 8.0 is end-of-life</summary>
<p>Gradle 8.x releases receive no further fixes, security fixes included. Update to the latest Gradle version.</p>
<p>If you cannot upgrade, see the <a href="${SECURITY_SUBSCRIPTION}">Gradle Security Subscription</a> for options.</p>
</details>
`)
})
it('signs a still-supported but outdated version with the info sign and one legend', async () => {
const table = await renderWith(['9.0.0', '8.1', '8.0'], [successfulHelpBuild])
expect(table).toContain(`<td align='center'>8.0 :information_source:</td>`)
expect(table).toContain(
`<p>:information_source: Gradle version is out of date — consider upgrading. ` +
`See <a href="${DOC}">Gradle release lifecycle</a></p>`
)
expect(table).not.toContain('<details>')
})
it('adds nothing for a version inside the grace band', async () => {
const table = await renderWith(['8.2', '8.0'], [successfulHelpBuild])
expect(table).toContain(`<td align='center'>8.0</td>`)
expect(table).not.toContain(':information_source:')
expect(table).not.toContain('consider upgrading')
})
})
describe('jobMarker', () => {
const original = process.env[MATRIX_INPUT_ENV]