diff --git a/.github/workflows/gradle-build-pr.yml b/.github/workflows/gradle-build-pr.yml index 9916e29..a938dbe 100644 --- a/.github/workflows/gradle-build-pr.yml +++ b/.github/workflows/gradle-build-pr.yml @@ -7,7 +7,7 @@ on: description: "JDK version to use" required: false type: string - default: "21" + default: "25" java-distribution: description: "JDK distribution (temurin, zulu, ...)" required: false @@ -17,7 +17,7 @@ on: description: "Gradle task(s) to run" required: false type: string - default: "clean build test" + default: "build test" runs-on: description: "JSON array of runners to use as a matrix" required: false @@ -33,16 +33,67 @@ on: required: false type: boolean default: true + paths-filters: + description: | + dorny/paths-filter YAML defining when to build. Must define a `code` + filter; the build runs when `code` matches. + required: false + type: string + default: | + code: + - '**/*.gradle' + - '**/*.gradle.kts' + - '**/gradle.properties' + - 'gradle/**' + - 'gradlew' + - 'gradlew.bat' + - 'settings.gradle' + - 'settings.gradle.kts' + - 'buildSrc/**' + - 'src/**' + - '**/*.java' + - '**/*.kt' + - '**/*.groovy' + - '**/*.scala' + - '.github/workflows/**' + force-build: + description: "Skip the path filter and always build." + required: false + type: boolean + default: false secrets: ONELITEFEATHER_MAVEN_USERNAME: required: false ONELITEFEATHER_MAVEN_PASSWORD: required: false +concurrency: + group: gradle-build-pr-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: + changes: + name: Detect changes + if: ${{ inputs.repository-owner == '' || github.repository_owner == inputs.repository-owner }} + runs-on: ubuntu-latest + outputs: + code: ${{ steps.filter.outputs.code }} + steps: + - name: Filter changed paths + id: filter + uses: dorny/paths-filter@v3 + with: + filters: ${{ inputs.paths-filters }} + + - name: Summary + run: | + echo "Code-relevant changes detected: ${{ steps.filter.outputs.code }}" + echo "force-build input: ${{ inputs.force-build }}" + build: name: Build (${{ matrix.os }}) - if: ${{ inputs.repository-owner == '' || github.repository_owner == inputs.repository-owner }} + needs: changes + if: ${{ inputs.force-build || needs.changes.outputs.code == 'true' }} runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -51,10 +102,19 @@ jobs: env: ONELITEFEATHER_MAVEN_USERNAME: ${{ secrets.ONELITEFEATHER_MAVEN_USERNAME }} ONELITEFEATHER_MAVEN_PASSWORD: ${{ secrets.ONELITEFEATHER_MAVEN_PASSWORD }} + GRADLE_OPTS: "-Dorg.gradle.parallel=true -Dorg.gradle.caching=true -Dorg.gradle.welcome=never" steps: - - name: Checkout repository + - name: Checkout uses: actions/checkout@v6 + - name: Toolchain info + shell: bash + run: | + echo "Runner : ${{ runner.os }} ${{ runner.arch }}" + echo "JDK target : ${{ inputs.java-version }} (${{ inputs.java-distribution }})" + echo "Gradle task : ${{ inputs.gradle-task }}" + echo "Debug logging: ${{ runner.debug }}" + - name: Validate Gradle wrapper if: ${{ inputs.validate-wrapper }} uses: gradle/actions/wrapper-validation@v6 @@ -67,6 +127,56 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 + with: + cache-read-only: ${{ github.ref != format('refs/heads/{0}', github.event.repository.default_branch) }} + add-job-summary: 'on-failure' + add-job-summary-as-pr-comment: 'on-failure' + + - name: Run ${{ inputs.gradle-task }} + shell: bash + run: | + if [ "${RUNNER_DEBUG:-0}" = "1" ]; then + ./gradlew ${{ inputs.gradle-task }} --info --stacktrace + else + ./gradlew ${{ inputs.gradle-task }} + fi + + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-reports-${{ matrix.os }}-jdk${{ inputs.java-version }} + path: | + **/build/reports/tests/** + **/build/test-results/** + if-no-files-found: ignore + retention-days: 14 - - name: Run ${{ inputs.gradle-task }} on ${{ matrix.os }} - run: ./gradlew ${{ inputs.gradle-task }} + test-report: + name: Aggregate test results + needs: build + if: ${{ always() && needs.build.result != 'skipped' }} + runs-on: ubuntu-latest + permissions: + checks: write + pull-requests: write + contents: read + steps: + - name: Download all test reports + uses: actions/download-artifact@v4 + with: + path: artifacts + pattern: test-reports-* + merge-multiple: true + + - name: Publish unit test results + uses: EnricoMi/publish-unit-test-result-action@v2 + if: always() + with: + junit_files: | + artifacts/**/TEST-*.xml + artifacts/**/test-results/**/*.xml + check_name: "Test results" + comment_mode: changes in failures + report_individual_runs: true + fail_on: test failures diff --git a/.github/workflows/gradle-publish.yml b/.github/workflows/gradle-publish.yml index dcc6c41..c3461b6 100644 --- a/.github/workflows/gradle-publish.yml +++ b/.github/workflows/gradle-publish.yml @@ -7,7 +7,7 @@ on: description: "JDK version to use" required: false type: string - default: "21" + default: "25" java-distribution: description: "JDK distribution (temurin, zulu, ...)" required: false @@ -39,6 +39,10 @@ on: ONELITEFEATHER_MAVEN_PASSWORD: required: true +concurrency: + group: gradle-publish-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: publish: name: Build & publish @@ -46,10 +50,20 @@ jobs: env: ONELITEFEATHER_MAVEN_USERNAME: ${{ secrets.ONELITEFEATHER_MAVEN_USERNAME }} ONELITEFEATHER_MAVEN_PASSWORD: ${{ secrets.ONELITEFEATHER_MAVEN_PASSWORD }} + GRADLE_OPTS: "-Dorg.gradle.parallel=true -Dorg.gradle.caching=true -Dorg.gradle.welcome=never" steps: - - name: Checkout repository + - name: Checkout uses: actions/checkout@v6 + - name: Toolchain info + shell: bash + run: | + echo "Runner : ${{ runner.os }} ${{ runner.arch }}" + echo "JDK target : ${{ inputs.java-version }} (${{ inputs.java-distribution }})" + echo "Build task : ${{ inputs.build-task }}" + echo "Publish task : ${{ inputs.publish-task }}" + echo "Debug logging: ${{ runner.debug }}" + - name: Validate Gradle wrapper if: ${{ inputs.validate-wrapper }} uses: gradle/actions/wrapper-validation@v6 @@ -62,9 +76,34 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 + with: + add-job-summary: 'always' - name: Build - run: ./gradlew ${{ inputs.build-task }} + shell: bash + run: | + if [ "${RUNNER_DEBUG:-0}" = "1" ]; then + ./gradlew ${{ inputs.build-task }} --info --stacktrace + else + ./gradlew ${{ inputs.build-task }} + fi - name: Publish - run: ./gradlew ${{ inputs.publish-task }} + shell: bash + run: | + if [ "${RUNNER_DEBUG:-0}" = "1" ]; then + ./gradlew ${{ inputs.publish-task }} --info --stacktrace + else + ./gradlew ${{ inputs.publish-task }} + fi + + - name: Upload build reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: publish-reports-jdk${{ inputs.java-version }} + path: | + **/build/reports/** + **/build/test-results/** + if-no-files-found: ignore + retention-days: 14 diff --git a/.github/workflows/lint-docs.yml b/.github/workflows/lint-docs.yml new file mode 100644 index 0000000..0b4c757 --- /dev/null +++ b/.github/workflows/lint-docs.yml @@ -0,0 +1,17 @@ +name: Lint docs + +on: + pull_request: + paths: + - '**/*.md' + - '**/*.markdown' + - '.markdownlint.json' + - '.lycheeignore' + - '.github/workflows/markdown-lint.yml' + - '.github/workflows/lint-docs.yml' + +jobs: + lint: + uses: ./.github/workflows/markdown-lint.yml + with: + force-lint: true diff --git a/.github/workflows/markdown-lint.yml b/.github/workflows/markdown-lint.yml new file mode 100644 index 0000000..2e8cb50 --- /dev/null +++ b/.github/workflows/markdown-lint.yml @@ -0,0 +1,106 @@ +name: Reusable - Markdown Lint + +on: + workflow_call: + inputs: + glob: + description: "markdownlint-cli2 glob pattern(s); space-separated for multiple" + required: false + type: string + default: "**/*.md" + config-file: + description: "markdownlint configuration file (relative to repo root)" + required: false + type: string + default: ".markdownlint.json" + check-links: + description: "Run lychee link checker after markdownlint" + required: false + type: boolean + default: true + lychee-args: + description: "Extra CLI args for lychee" + required: false + type: string + default: "--exclude-mail --max-redirects 5 --accept 200..=204,429 --no-progress" + runs-on: + description: "Runner image" + required: false + type: string + default: "ubuntu-latest" + paths-filters: + description: | + dorny/paths-filter YAML; must define a `docs` filter that decides whether to lint. + required: false + type: string + default: | + docs: + - '**/*.md' + - '**/*.markdown' + - '.markdownlint.json' + - '.markdownlint.yaml' + - '.markdownlint.yml' + - '.lycheeignore' + force-lint: + description: "Skip the path filter and always lint" + required: false + type: boolean + default: false + repository-owner: + description: "Restrict execution to this owner (skip on forks). Empty disables the check." + required: false + type: string + default: "OneLiteFeatherNET" + +concurrency: + group: markdown-lint-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + changes: + name: Detect markdown changes + if: ${{ inputs.repository-owner == '' || github.repository_owner == inputs.repository-owner }} + runs-on: ubuntu-latest + outputs: + docs: ${{ steps.filter.outputs.docs }} + steps: + - name: Filter changed paths + id: filter + uses: dorny/paths-filter@v3 + with: + filters: ${{ inputs.paths-filters }} + + - name: Summary + run: | + echo "Docs-relevant changes detected: ${{ steps.filter.outputs.docs }}" + echo "force-lint input: ${{ inputs.force-lint }}" + + markdownlint: + name: markdownlint + needs: changes + if: ${{ inputs.force-lint || needs.changes.outputs.docs == 'true' }} + runs-on: ${{ inputs.runs-on }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Run markdownlint-cli2 + uses: DavidAnson/markdownlint-cli2-action@v18 + with: + globs: ${{ inputs.glob }} + config: ${{ inputs.config-file }} + + link-check: + name: Lychee link check + needs: changes + if: ${{ inputs.check-links && (inputs.force-lint || needs.changes.outputs.docs == 'true') }} + runs-on: ${{ inputs.runs-on }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Lychee link checker + uses: lycheeverse/lychee-action@v2 + with: + args: ${{ inputs.lychee-args }} ${{ inputs.glob }} + fail: true diff --git a/.lycheeignore b/.lycheeignore new file mode 100644 index 0000000..7d4a35b --- /dev/null +++ b/.lycheeignore @@ -0,0 +1,2 @@ +# Internal/private hosts that lychee cannot reach from GitHub-hosted runners. +^https://outline\.onelitefeather\.dev/.* diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..6c7401c --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://raw.githubusercontent.com/DavidAnson/markdownlint/main/schema/markdownlint-config-schema.json", + "default": true, + "MD013": false, + "MD024": { "siblings_only": true }, + "MD033": { "allowed_elements": ["br", "details", "summary", "kbd"] }, + "MD041": false +} diff --git a/README.md b/README.md index 4b430fb..6a70259 100644 --- a/README.md +++ b/README.md @@ -10,17 +10,35 @@ repositories by referencing a tagged release of this repo. | Workflow | Purpose | | --- | --- | -| `.github/workflows/gradle-build-pr.yml` | Build & test a Gradle project on pull requests (matrix OS, configurable JDK). | +| `.github/workflows/gradle-build-pr.yml` | Build & test a Gradle project on pull requests across a runner matrix. Skips when no Gradle-relevant files changed; aggregates JUnit results across the matrix; auto-enables verbose logging on debug re-runs. | | `.github/workflows/gradle-publish.yml` | Build & publish a Gradle project to the OneLiteFeather Maven repository on tag pushes. | | `.github/workflows/release-please.yml` | Run [release-please](https://github.com/googleapis/release-please) for a repository. | | `.github/workflows/close-invalid-prs.yml` | Close PRs opened from a fork's default branch with a configurable message. | +| `.github/workflows/markdown-lint.yml` | Lint Markdown files with [`markdownlint-cli2`](https://github.com/DavidAnson/markdownlint-cli2-action) and check links with [`lychee`](https://github.com/lycheeverse/lychee-action). | + +## Defaults at a glance + +- Java **25** on Temurin. +- Three-OS matrix on PRs: `ubuntu-latest`, `windows-latest`, `macos-latest`. +- `gradle-build-pr` runs `build test` (no `clean`, to keep incremental caches). +- Path filter: build only runs when files under `src/`, `*.gradle*`, `buildSrc/`, JVM sources, or `.github/workflows/**` changed. +- Debug re-runs (`Re-run with debug logging`) automatically activate `--info --stacktrace`. +- Test reports are uploaded as artifacts on every run and aggregated into a unified check + PR comment. +- Concurrency cancels superseded PR runs; publish runs are never cancelled mid-flight. +- `markdown-lint` filters on `.md`/`.markdownlint*`/`.lycheeignore` paths and runs markdownlint + lychee in parallel. ## Versioning This repository is released via [release-please](https://github.com/googleapis/release-please). -Pin consumers to a tag (e.g. `@v1.0.0`) or a major (e.g. `@v1`) rather than +Pin consumers to a tag (e.g. `@v2.0.0`) or a major (e.g. `@v2`) rather than `main` for reproducible builds. +Use [Renovate](https://docs.renovatebot.com/modules/manager/github-actions/) in +your consumer repository to auto-bump the version pin. The `github-actions` +manager picks up `uses: OneLiteFeatherNET/workflows/.github/workflows/foo.yml@vX` +out of the box. A sample `renovate.json` is shipped at the root of this repo; +copy it as a starting point. + ## Usage examples ### Build a PR (Gradle) @@ -31,9 +49,35 @@ on: [pull_request] jobs: build: - uses: OneLiteFeatherNET/workflows/.github/workflows/gradle-build-pr.yml@v1 + uses: OneLiteFeatherNET/workflows/.github/workflows/gradle-build-pr.yml@v2 + secrets: inherit +``` + +Single-OS, custom JDK, force-build: + +```yaml +jobs: + build: + uses: OneLiteFeatherNET/workflows/.github/workflows/gradle-build-pr.yml@v2 + with: + java-version: "21" + runs-on: '["ubuntu-latest"]' + force-build: true + secrets: inherit +``` + +Custom path filter (must define a `code:` key): + +```yaml +jobs: + build: + uses: OneLiteFeatherNET/workflows/.github/workflows/gradle-build-pr.yml@v2 with: - java-version: "25" + paths-filters: | + code: + - 'src/**' + - 'build.gradle.kts' + - 'gradle/**' secrets: inherit ``` @@ -47,9 +91,7 @@ on: jobs: publish: - uses: OneLiteFeatherNET/workflows/.github/workflows/gradle-publish.yml@v1 - with: - java-version: "21" + uses: OneLiteFeatherNET/workflows/.github/workflows/gradle-publish.yml@v2 secrets: inherit ``` @@ -67,7 +109,7 @@ permissions: jobs: release: - uses: OneLiteFeatherNET/workflows/.github/workflows/release-please.yml@v1 + uses: OneLiteFeatherNET/workflows/.github/workflows/release-please.yml@v2 ``` ### Close invalid PRs @@ -80,11 +122,32 @@ on: jobs: close: - uses: OneLiteFeatherNET/workflows/.github/workflows/close-invalid-prs.yml@v1 + uses: OneLiteFeatherNET/workflows/.github/workflows/close-invalid-prs.yml@v2 with: protected-branch: main ``` +### Markdown lint + +```yaml +name: Lint docs +on: + pull_request: + paths: + - '**/*.md' + - '.markdownlint.json' + - '.lycheeignore' + +jobs: + lint: + uses: OneLiteFeatherNET/workflows/.github/workflows/markdown-lint.yml@v2 + with: + force-lint: true +``` + +A `.markdownlint.json` and optional `.lycheeignore` (regex per line) at the +repo root configure rules and skip-lists. + ## Required secrets Workflows that publish or read from the OneLiteFeather Maven repository expect @@ -94,9 +157,25 @@ these secrets to be available in the caller repository (and forwarded via - `ONELITEFEATHER_MAVEN_USERNAME` - `ONELITEFEATHER_MAVEN_PASSWORD` +## Test results + +For `gradle-build-pr`, JUnit XML from every matrix job is uploaded as +`test-reports--jdk` and merged by a downstream `test-report` job +that uses [`EnricoMi/publish-unit-test-result-action`](https://github.com/marketplace/actions/publish-test-results) +to post a unified check and PR comment. + +## Debugging + +When a workflow run fails, press **Re-run with debug logging** in the GitHub UI. +The reusable workflows detect `RUNNER_DEBUG=1` automatically and switch Gradle +to `--info --stacktrace`. Build summaries (test counts, build scan link) are +posted to the run summary and as PR comments via +[`gradle/actions/setup-gradle`](https://github.com/gradle/actions/blob/main/docs/setup-gradle.md). + ## Contributing - Conventional Commits are required (`feat:`, `fix:`, `chore:`, ...). +- Breaking changes use `feat!:` or `BREAKING CHANGE:` to trigger a major bump. - Releases are produced automatically by release-please on merge to `main`. ## License diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..a9b4d42 --- /dev/null +++ b/renovate.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended", + ":semanticCommits", + ":dependencyDashboard" + ], + "labels": ["dependencies"], + "schedule": ["before 06:00 on monday"], + "timezone": "Europe/Berlin", + "prHourlyLimit": 4, + "prConcurrentLimit": 10, + "packageRules": [ + { + "matchManagers": ["github-actions"], + "groupName": "GitHub Actions", + "groupSlug": "github-actions", + "semanticCommitType": "chore", + "semanticCommitScope": "deps" + }, + { + "matchManagers": ["github-actions"], + "matchUpdateTypes": ["minor", "patch", "digest"], + "automerge": true, + "platformAutomerge": true + }, + { + "matchManagers": ["github-actions"], + "matchUpdateTypes": ["major"], + "automerge": false, + "addLabels": ["breaking"] + } + ] +}