diff --git a/.github/workflows/ci-integ-test-full.yml b/.github/workflows/ci-integ-test-full.yml index 44a029a2..0d49750a 100644 --- a/.github/workflows/ci-integ-test-full.yml +++ b/.github/workflows/ci-integ-test-full.yml @@ -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: diff --git a/.github/workflows/ci-integ-test.yml b/.github/workflows/ci-integ-test.yml index 8d269c60..6f37d384 100644 --- a/.github/workflows/ci-integ-test.yml +++ b/.github/workflows/ci-integ-test.yml @@ -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: diff --git a/.github/workflows/demo-job-summary.yml b/.github/workflows/demo-job-summary.yml index 24678645..fbf366ce 100644 --- a/.github/workflows/demo-job-summary.yml +++ b/.github/workflows/demo-job-summary.yml @@ -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 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..ac35a6e3 --- /dev/null +++ b/.github/workflows/integ-test-gradle-support-status.yml @@ -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)\]|: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 ":warning: Gradle 7.6.4 is end-of-life" + assert_logged "Update to the latest Gradle version." + assert_logged "Gradle Security Subscription" + + # Table signs: a warning for end-of-life, the info sign for everything still supported. + assert_logged "7.6.4 :warning:" + assert_logged "${PREV_OLDEST} :information_source:" + assert_logged "${PREV_NEWEST} :information_source:" + + # 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 "

:information_source: Gradle version is out of date — consider upgrading. See Gradle release lifecycle

" diff --git a/.github/workflows/suite-integ-test-other.yml b/.github/workflows/suite-integ-test-other.yml index 8f88e95b..6d656c83 100644 --- a/.github/workflows/suite-integ-test-other.yml +++ b/.github/workflows/suite-integ-test-other.yml @@ -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: 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/docs/setup-gradle.md b/docs/setup-gradle.md index 2cf0fa21..4692142a 100644 --- a/docs/setup-gradle.md +++ b/docs/setup-gradle.md @@ -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. diff --git a/sources/package-lock.json b/sources/package-lock.json index 06aad2ed..da591b37 100644 --- a/sources/package-lock.json +++ b/sources/package-lock.json @@ -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", diff --git a/sources/package.json b/sources/package.json index e63cf74e..658eb1cd 100644 --- a/sources/package.json +++ b/sources/package.json @@ -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", diff --git a/sources/src/execution/gradle.ts b/sources/src/execution/gradle.ts index 425f3d97..36db1f53 100644 --- a/sources/src/execution/gradle.ts +++ b/sources/src/execution/gradle.ts @@ -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 { 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] - } -} diff --git a/sources/src/gradle-support-status.ts b/sources/src/gradle-support-status.ts new file mode 100644 index 00000000..82fbc5b7 --- /dev/null +++ b/sources/src/gradle-support-status.ts @@ -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.Current]: '', + [SupportStatus.Behind]: ':information_source:', + [SupportStatus.Eol]: ':warning:' +} + +const UPGRADE_LEGEND = + `

${SIGN[SupportStatus.Behind]} Gradle version is out of date — consider upgrading. ` + + `See Gradle release lifecycle

` + +/** + * 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 { + const byStatus = new Map() + 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 `
+ ${SIGN[SupportStatus.Eol]} ${eolHeadline(versions)} +

${eolDetail(versions)}

+

If you cannot upgrade, see the Gradle Security Subscription for options.

+
` +} diff --git a/sources/src/gradle-version.ts b/sources/src/gradle-version.ts new file mode 100644 index 00000000..f47045a5 --- /dev/null +++ b/sources/src/gradle-version.ts @@ -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 = {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 + } +} diff --git a/sources/src/job-summary.ts b/sources/src/job-summary.ts index 3f5b679c..06232717 100644 --- a/sources/src/job-summary.ts +++ b/sources/src/job-summary.ts @@ -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 { Build Outcome Build Scan® ${results.map(result => renderBuildResultRow(result)).join('')} - - ` +` } function anyFailed(results: BuildResult[]): boolean { @@ -160,12 +160,17 @@ function renderBuildResultRow(result: BuildResult): string { ${truncateString(result.rootProjectName, 30)} ${truncateString(result.requestedTasks, 60)} - ${result.gradleVersion} + ${renderGradleVersion(result.gradleVersion)} ${renderOutcome(result)} ${renderBuildScan(result)} ` } +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:' } diff --git a/sources/src/setup-gradle.ts b/sources/src/setup-gradle.ts index a8255ae5..0305843f 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 {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() 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..e1f15153 --- /dev/null +++ b/sources/test/jest/gradle-support-status.test.ts @@ -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 { + 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 = + `

${OUT_OF_DATE} Gradle version is out of date — consider upgrading. ` + + `See Gradle release lifecycle

` + + it('folds end-of-life versions under a warning sign, linking the security subscription', () => { + const rendered = renderSupportStatus(['7.6.4']) + + expect(rendered).toContain(`${END_OF_LIFE} Gradle 7.6.4 is end-of-life`) + expect(rendered).toContain( + 'Gradle 7.x releases receive no further fixes, security fixes included. Update to the latest Gradle version.' + ) + expect(rendered).toContain(`Gradle Security Subscription`) + 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(`${END_OF_LIFE} Gradle 1.0, 4.10.3 and 7.6.4 are end-of-life`) + }) + + 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(/
/g)).toHaveLength(1) + expect(rendered.match(/consider upgrading/g)).toHaveLength(1) + expect(rendered).toContain(`${END_OF_LIFE} Gradle 1.0 and 7.6.4 are end-of-life`) + }) + + 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('') + }) +}) diff --git a/sources/test/jest/gradle-version.test.ts b/sources/test/jest/gradle-version.test.ts index 16f664c6..eb20fd16 100644 --- a/sources/test/jest/gradle-version.test.ts +++ b/sources/test/jest/gradle-version.test.ts @@ -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 { - 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) - }) - } - } +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++) { + for (let j = 0; j < versions.length; j++) { + 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 () => { - const output = ` - ------------------------------------------------------------ - Gradle 8.9 - ------------------------------------------------------------ - ` - const version = await parseGradleVersionFromOutput(output)! - expect(version).toBe('8.9') + // 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) }) - - it('patch version', async () => { - const output = ` - ------------------------------------------------------------ - Gradle 8.9.1 - ------------------------------------------------------------ - ` - const version = await parseGradleVersionFromOutput(output)! - expect(version).toBe('8.9.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('rc version', async () => { - const output = ` - ------------------------------------------------------------ - Gradle 8.9-rc-1 - ------------------------------------------------------------ - ` - const version = await parseGradleVersionFromOutput(output)! - expect(version).toBe('8.9-rc-1') + + 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) }) - - it('milestone version', async () => { - const output = ` - ------------------------------------------------------------ - Gradle 8.0-milestone-6 - ------------------------------------------------------------ - ` - const version = await parseGradleVersionFromOutput(output)! - expect(version).toBe('8.0-milestone-6') + }) + + 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']) }) - - it('snapshot version', async () => { - const output = ` - ------------------------------------------------------------ - Gradle 8.10.2-20240828012138+0000 - ------------------------------------------------------------ - ` - const version = await parseGradleVersionFromOutput(output)! - expect(version).toBe('8.10.2-20240828012138+0000') + }) + + 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('branch version', async () => { - 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') + + 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 + ------------------------------------------------------------ + ` + expect(parseGradleVersionFromOutput(output)).toBe('8.9') + }) + + it('patch version', () => { + const output = ` + ------------------------------------------------------------ + Gradle 8.9.1 + ------------------------------------------------------------ + ` + expect(parseGradleVersionFromOutput(output)).toBe('8.9.1') + }) + + it('rc version', () => { + const output = ` + ------------------------------------------------------------ + Gradle 8.9-rc-1 + ------------------------------------------------------------ + ` + expect(parseGradleVersionFromOutput(output)).toBe('8.9-rc-1') + }) + + it('milestone version', () => { + const output = ` + ------------------------------------------------------------ + Gradle 8.0-milestone-6 + ------------------------------------------------------------ + ` + expect(parseGradleVersionFromOutput(output)).toBe('8.0-milestone-6') + }) + + it('snapshot version', () => { + const output = ` + ------------------------------------------------------------ + Gradle 8.10.2-20240828012138+0000 + ------------------------------------------------------------ + ` + expect(parseGradleVersionFromOutput(output)).toBe('8.10.2-20240828012138+0000') + }) + + it('branch version', () => { + const output = ` + ------------------------------------------------------------ + Gradle 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' + ) + }) +}) diff --git a/sources/test/jest/job-summary.test.ts b/sources/test/jest/job-summary.test.ts index 1ec3c5ce..0b40ca09 100644 --- a/sources/test/jest/job-summary.test.ts +++ b/sources/test/jest/job-summary.test.ts @@ -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 { + 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', () => { Build Scan published - `); + `) }) it('failed build', () => { const table = renderSummaryTable([failedHelpBuild]) @@ -87,7 +110,7 @@ describe('renderSummaryTable', () => { Build Scan published - `); + `) }) describe('when build scan', () => { it('publishing disabled', () => { @@ -109,7 +132,7 @@ describe('renderSummaryTable', () => { Build Scan not published - `); + `) }) it('publishing failed', () => { const table = renderSummaryTable([scanPublishFailedBuild]) @@ -130,7 +153,7 @@ describe('renderSummaryTable', () => { Build Scan publish failed - `); + `) }) }) it('multiple builds', () => { @@ -159,7 +182,7 @@ describe('renderSummaryTable', () => { Build Scan published - `); + `) }) it('truncating long requested tasks', () => { const table = renderSummaryTable([longArgsBuild]) @@ -180,11 +203,56 @@ describe('renderSummaryTable', () => { Build Scan published - `); + `) }) }) }) +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` + + + + + + + + + + + + + + + +
Gradle Root ProjectRequested TasksGradle VersionBuild OutcomeBuild Scan®
roothelp8.0 :warning::white_check_mark:Build Scan published
+ +
+ :warning: Gradle 8.0 is end-of-life +

Gradle 8.x releases receive no further fixes, security fixes included. Update to the latest Gradle version.

+

If you cannot upgrade, see the Gradle Security Subscription for options.

+
+ `) + }) + 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(`8.0 :information_source:`) + expect(table).toContain( + `

:information_source: Gradle version is out of date — consider upgrading. ` + + `See Gradle release lifecycle

` + ) + expect(table).not.toContain('
') + }) + it('adds nothing for a version inside the grace band', async () => { + const table = await renderWith(['8.2', '8.0'], [successfulHelpBuild]) + expect(table).toContain(`8.0`) + expect(table).not.toContain(':information_source:') + expect(table).not.toContain('consider upgrading') + }) +}) + describe('jobMarker', () => { const original = process.env[MATRIX_INPUT_ENV]