From d856105cd103b319bc35b243dc3fa1615c81fb65 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Jun 2026 19:30:29 +0000 Subject: [PATCH 01/26] docs: add Apache LICENSE and NOTICE files Add the top-level Apache License 2.0 text and NOTICE file required by ASF release policy, and bundle them into the META-INF directory of every JAR produced by the build (main, bootJar, sources, javadoc). See https://www.apache.org/legal/release-policy.html#licensing-documentation --- LICENSE | 201 +++++++++++++++++++++++++++++++++++++++++++++++ NOTICE | 5 ++ build.gradle.kts | 11 +++ 3 files changed, 217 insertions(+) create mode 100644 LICENSE create mode 100644 NOTICE diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/NOTICE b/NOTICE new file mode 100644 index 00000000..08e6d426 --- /dev/null +++ b/NOTICE @@ -0,0 +1,5 @@ +Apache Solr MCP Server +Copyright 2026 The Apache Software Foundation + +This product includes software developed at +The Apache Software Foundation (https://www.apache.org/). diff --git a/build.gradle.kts b/build.gradle.kts index 6811ac6f..9bbe9d35 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -75,6 +75,17 @@ java { withJavadocJar() } +// ASF release policy requires every distributed artifact to carry the project's +// LICENSE and NOTICE files. Bundle them into META-INF of every JAR produced by +// this build (main jar, bootJar, sources, javadoc). +// See https://www.apache.org/legal/release-policy.html#licensing-documentation +tasks.withType().configureEach { + metaInf { + from(rootProject.file("LICENSE")) + from(rootProject.file("NOTICE")) + } +} + // Maven Publishing Configuration // ============================== // This configuration enables publishing the project artifacts to Maven repositories. From c476d414123714a7bc57d6b7c1689a07c7445e48 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Fri, 5 Jun 2026 07:52:16 -0400 Subject: [PATCH 02/26] docs(spec): add SBOM generation design Captures decisions made during brainstorming: CycloneDX over SPDX, embed-in-bootJar via Spring Boot's native CycloneDX integration, full build + Docker + Release coverage, no cosign attestation in this PR. Signed-off-by: Aditya Parikh Signed-off-by: adityamparikh --- .../2026-06-05-sbom-generation-design.md | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-05-sbom-generation-design.md diff --git a/docs/superpowers/specs/2026-06-05-sbom-generation-design.md b/docs/superpowers/specs/2026-06-05-sbom-generation-design.md new file mode 100644 index 00000000..1acb24f7 --- /dev/null +++ b/docs/superpowers/specs/2026-06-05-sbom-generation-design.md @@ -0,0 +1,164 @@ +# SBOM generation — design + +**Status:** draft, pending user approval +**Branch:** `worktree-add-sbom-generation` + +## Problem + +The Solr MCP server ships as a JAR, a Jib JVM Docker image, and two Paketo native +images, but produces no Software Bill of Materials. Downstream consumers — Apache +release reviewers, supply-chain scanners (Trivy, Grype, Dependency-Track), +container-registry attestation tooling — have no machine-readable inventory of +what dependencies ship inside the binary. SBOM coverage is increasingly an +Apache release-policy expectation and a precondition for SLSA / CycloneDX VEX +workflows downstream. + +Curiously, `application-http.properties` already lists `sbom` in +`management.endpoints.web.exposure.include`. The endpoint config is half-wired +already; today the actuator returns 404 because no SBOM is generated. + +## Scope + +In scope: + +- Generate a CycloneDX 1.5 SBOM (`application.cdx.json`) on every `./gradlew build`. +- Embed the SBOM in the bootable JAR at `META-INF/sbom/application.cdx.json` so + it ships with every distribution (JAR, Jib JVM image, both Paketo native + images). +- Expose `GET /actuator/sbom` in the HTTP profile (config already partially in + place; finish the wiring). +- Attach the SBOM as a release artifact in `build-and-publish.yml` and + `release-publish.yml` (workflow artifact + GitHub Release asset). +- Document the SBOM in `README.md` (location, endpoint, scanning) and in + `CLAUDE.md` (build-system and native-image notes). +- One small HTTP integration-test assertion that `/actuator/sbom` returns + 200 + a CycloneDX-shaped body. + +Out of scope (intentional, can be follow-ups): + +- SPDX format alongside CycloneDX — the plugin supports + `outputFormat = "all"`; can layer on without redesign. +- Cosign / SLSA provenance signing — separate concern; would add another moving + part to maintain. +- Dependency-Track upload from CI — requires an externally-hosted server. +- SBOM for transitive native-image runtime libraries that GraalVM links in — + the CycloneDX plugin reports Gradle dependencies, which already covers what + ends up in the binary. + +## Tool choice: CycloneDX Gradle plugin + +The project is on Spring Boot 3.5.14, which has first-class CycloneDX +integration since 3.3.0: + +- Applying `org.cyclonedx.bom` makes the Spring Boot Gradle plugin automatically + embed the generated `application.cdx.json` into the bootable JAR at + `META-INF/sbom/application.cdx.json`. +- Spring Boot's actuator auto-discovers that resource and serves it at + `/actuator/sbom` (CycloneDX-format) when the endpoint is exposed. +- The Jib JVM image and both Paketo native images package the bootJar contents, + so the SBOM ships with every artifact for free — no per-image wiring. + +CycloneDX (vs SPDX) is the de-facto Apache ecosystem standard, what Spring Boot +natively integrates with, and what Trivy/Grype/Dependency-Track ingest natively. + +Plugin version: `1.10.0` (latest stable as of 2026-06; supports Gradle 8+ and +CycloneDX 1.5). + +## Architecture + +### Build wiring + +``` +gradle/libs.versions.toml ← new version key + plugin alias +build.gradle.kts ← apply alias(libs.plugins.cyclonedx) + ← cyclonedxBom { … } configuration block +``` + +`cyclonedxBom` configuration: + +- `outputFormat = "json"` — Spring Boot's actuator only consumes JSON; XML adds + build cost and disk for no consumer. +- `outputName = "application.cdx"` — Spring Boot expects exactly this name to + embed it. (Default is `bom`, which Spring Boot would not detect.) +- `includeConfigs = listOf("runtimeClasspath")` — only ship what's actually in + the binary; exclude test/errorprone/build-time-only deps. +- `skipConfigs = listOf("testRuntimeClasspath", "errorprone")` — defense in depth. +- `schemaVersion = "1.5"` — current stable; matches Spring Boot's expectations. +- `projectType = "application"` — accurate for a Spring Boot service. + +`build` and `bootJar` automatically depend on `cyclonedxBom` once the Spring +Boot plugin sees it on the classpath; no manual `dependsOn` needed. + +### Runtime wiring + +`application-http.properties` already exposes `sbom` via +`management.endpoints.web.exposure.include`. The remaining work: + +- Add `management.endpoint.sbom.enabled=true` (explicit, even though it + defaults true, because the project's convention is to be explicit about + endpoint enablement for the LGTM stack to discover). +- No change to `application-stdio.properties` — actuator HTTP endpoints don't + apply in stdio mode. + +### CI wiring + +`build-and-publish.yml`: after `./gradlew build`, add an `actions/upload-artifact` +step that uploads `build/reports/application.cdx.json`. Retained 30 days +(default), accessible from the run page. + +`release-publish.yml`: same upload step, plus `gh release upload +build/reports/application.cdx.json`. The SBOM appears alongside source tarballs +on the GitHub Release page. + +`native.yml`: no change. The native-image build inherits the SBOM via the bootJar +input. + +### Documentation + +`README.md`: new "## Supply chain & SBOM" section near the bottom, covering: + +- Where the SBOM lives (`META-INF/sbom/application.cdx.json` inside every JAR + and image). +- How to fetch it from a running server: `curl http://localhost:8080/actuator/sbom`. +- How to extract it from a Docker image: + `docker run --rm --entrypoint cat solr-mcp:latest /workspace/META-INF/sbom/application.cdx.json` + (Jib path) or via the release asset. +- How to scan: `trivy sbom application.cdx.json` and + `grype sbom:application.cdx.json` examples. + +`CLAUDE.md`: brief note in the build-system section that CycloneDX is wired and +the SBOM ships embedded; reference the spec. + +## Testing + +- `./gradlew build` produces `build/reports/application.cdx.json`. Verify + manually post-merge. +- Add a focused HTTP-profile test (or extend `McpClientIntegrationTest`, which + already boots the HTTP profile) with an assertion that + `GET /actuator/sbom` returns 200, `Content-Type: application/vnd.cyclonedx+json`, + and the JSON body contains `"bomFormat": "CycloneDX"`. One small assertion + — no separate test class. +- Existing Docker integration tests already verify image startup. The SBOM + being present in the image is implicit via the bootJar packaging — no new + Docker test added. + +## Risks and mitigations + +| Risk | Mitigation | +|------|-----------| +| Plugin adds significant build time | CycloneDX plugin runs once at JAR-assembly, typically <2s on this dependency graph. Measure before/after; report in PR. | +| Native-image build fails because of SBOM resource | Spring Boot already registers `META-INF/sbom/*` as a runtime resource hint; the existing native build should work unchanged. Verify with `./gradlew nativeCompile -Pnative` post-merge. | +| Actuator endpoint leaks info in production | SBOM contents are public (every dependency name + version is already in the JAR's manifest). Endpoint exposure is opt-in by being in the explicit `include` list. Documented. | +| Plugin version drift | Pinned in `libs.versions.toml`; Renovate / Dependabot will surface upgrades on schedule. | + +## Acceptance criteria + +1. `./gradlew build` produces `build/reports/application.cdx.json` with + `bomFormat: CycloneDX`, `specVersion: 1.5`. +2. `./gradlew bootJar` produces a JAR containing + `META-INF/sbom/application.cdx.json`. +3. `GET /actuator/sbom` returns 200 + valid CycloneDX JSON in HTTP profile. +4. `build-and-publish.yml` uploads the SBOM as a workflow artifact. +5. `release-publish.yml` attaches the SBOM to the GitHub Release. +6. `README.md` documents the SBOM under a clearly named section. +7. `./gradlew spotlessCheck build` is green. \ No newline at end of file From ac0ca0ad5180ac8158950acc6e286283d4dc79c1 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Fri, 5 Jun 2026 08:58:57 -0400 Subject: [PATCH 03/26] docs(plan): add SBOM generation implementation plan Step-by-step bite-sized tasks covering: version catalog, Gradle plugin wiring, actuator endpoint enablement, focused HTTP integration test, CI workflow uploads, README + CLAUDE.md docs, final verification. Signed-off-by: Aditya Parikh Signed-off-by: adityamparikh --- .../plans/2026-06-05-sbom-generation.md | 673 ++++++++++++++++++ 1 file changed, 673 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-05-sbom-generation.md diff --git a/docs/superpowers/plans/2026-06-05-sbom-generation.md b/docs/superpowers/plans/2026-06-05-sbom-generation.md new file mode 100644 index 00000000..fd6eba6c --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-sbom-generation.md @@ -0,0 +1,673 @@ +# SBOM generation — implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Wire up CycloneDX SBOM generation so every JAR, Docker image, and GitHub release artifact ships a machine-readable Software Bill of Materials, and `/actuator/sbom` serves it at runtime. + +**Architecture:** Apply the `org.cyclonedx.bom` Gradle plugin (1.10.0). Spring Boot 3.5's bootJar task auto-detects and embeds `META-INF/sbom/application.cdx.json`; the actuator auto-discovers that resource and serves it at `/actuator/sbom`. Both the Jib JVM image and Paketo native images package the bootJar contents, so SBOM coverage is automatic for every artifact — no per-image wiring. CI workflows upload the SBOM as a workflow artifact and attach it to GitHub Releases. + +**Tech Stack:** Gradle Kotlin DSL with `libs.versions.toml`, Spring Boot 3.5.14, CycloneDX Gradle Plugin 1.10.0, GitHub Actions. + +**Spec:** `docs/superpowers/specs/2026-06-05-sbom-generation-design.md` + +--- + +## Pre-flight context for the implementer + +Read these files before starting — they show what's already half-wired: + +- `gradle/libs.versions.toml` — version catalog; you'll add a new `cyclonedx-plugin` version key and plugin alias here. +- `build.gradle.kts` — main build script; you'll add `alias(libs.plugins.cyclonedx)` in the `plugins { }` block and add a `cyclonedxBom { … }` configuration block. +- `src/main/resources/application-http.properties` — `sbom` is already listed in `management.endpoints.web.exposure.include` (line near bottom). You'll add one explicit-enablement line. +- `.github/workflows/build-and-publish.yml` — has existing `Upload JAR artifact` step pattern (around line 145); you'll add a parallel SBOM upload step. +- `.github/workflows/release-publish.yml` — already contains a `Generate SBOM (Software Bill of Materials)` step (`./gradlew cyclonedxBom || echo "SBOM generation not configured"`). Today it's a no-op because the plugin isn't applied. You'll remove the `|| echo …` fallback (it would now mask a real failure) and add upload/attach steps after it. +- `src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTest.java` — boots HTTP profile with random port; you'll add a focused test method (or sibling test class) that does an HTTP GET on `/actuator/sbom`. +- `README.md` — sections are `## What's inside`, `## Get started`, `## Security`, `## Available MCP tools`, etc. (see `grep ^## README.md`). Add a new section before `## Documentation` (the last section). +- `CLAUDE.md` (project, at repo root) — has a "Common Commands" section and an architecture section. Add a one-line note in Common Commands and a brief paragraph in the architecture section about SBOM. + +--- + +## File structure + +**Modify:** +- `gradle/libs.versions.toml` — add CycloneDX plugin version + alias +- `build.gradle.kts` — apply plugin, add `cyclonedxBom { }` configuration +- `src/main/resources/application-http.properties` — add explicit endpoint enablement line +- `.github/workflows/build-and-publish.yml` — add SBOM upload step in `build` job +- `.github/workflows/release-publish.yml` — fix the existing SBOM step, add upload + GitHub Release attach +- `README.md` — new "## Supply chain & SBOM" section +- `CLAUDE.md` — short note in Common Commands + brief architecture paragraph + +**Create:** +- `src/test/java/org/apache/solr/mcp/server/observability/SbomEndpointIntegrationTest.java` — focused HTTP integration test for `/actuator/sbom` + +--- + +## Task 1: Add CycloneDX plugin to the version catalog + +**Files:** +- Modify: `gradle/libs.versions.toml` + +- [ ] **Step 1: Add plugin version** + +In the `[versions]` block, after the `graalvm-native = "0.10.6"` line, add: + +```toml +cyclonedx-plugin = "1.10.0" +``` + +- [ ] **Step 2: Add plugin alias** + +In the `[plugins]` block, at the bottom (after the `graalvm-native = ...` line), add: + +```toml +cyclonedx = { id = "org.cyclonedx.bom", version.ref = "cyclonedx-plugin" } +``` + +- [ ] **Step 3: Verify catalog parses** + +Run: `./gradlew help -q` +Expected: succeeds with no output. If it prints `Invalid catalog definition`, fix the syntax. + +- [ ] **Step 4: Commit** + +```bash +git add gradle/libs.versions.toml +git commit -s -m "$(cat <<'EOF' +chore(deps): add CycloneDX Gradle plugin 1.10.0 to version catalog + +Plugin will be applied in the next commit. Adding the catalog entry +first keeps build.gradle.kts changes reviewable in isolation. + +Signed-off-by: Aditya Parikh +EOF +)" +``` + +--- + +## Task 2: Apply and configure the plugin in build.gradle.kts + +**Files:** +- Modify: `build.gradle.kts` (`plugins { }` block, and a new top-level config block near the existing `springBoot { buildInfo() }` block) + +- [ ] **Step 1: Apply the plugin** + +In `build.gradle.kts`, locate the `plugins { … }` block (top of file, around line 19-30). Add a new alias line after the `alias(libs.plugins.graalvm.native) apply false` line: + +```kotlin + alias(libs.plugins.cyclonedx) +``` + +The final block looks like: + +```kotlin +plugins { + java + `maven-publish` + alias(libs.plugins.spring.boot) + alias(libs.plugins.spring.dependency.management) + jacoco + alias(libs.plugins.errorprone) + alias(libs.plugins.spotless) + alias(libs.plugins.jib) + alias(libs.plugins.graalvm.native) apply false + alias(libs.plugins.cyclonedx) +} +``` + +- [ ] **Step 2: Add `cyclonedxBom` configuration block** + +Find the `springBoot { buildInfo() }` block (around line 195-197). Immediately AFTER it, insert: + +```kotlin +// CycloneDX SBOM (Software Bill of Materials) +// ========================================== +// Spring Boot 3.3+ automatically embeds the generated SBOM into the bootable +// JAR at META-INF/sbom/application.cdx.json when the file name matches +// `application.cdx`. The actuator then serves it at /actuator/sbom (HTTP +// profile only — see application-http.properties). +// +// One SBOM, three distribution channels: +// 1. Embedded in the bootable JAR (META-INF/sbom/application.cdx.json) +// 2. Embedded in every Docker image (Jib + Paketo both package bootJar contents) +// 3. Surfaced at /actuator/sbom for live introspection (HTTP profile) +// +// The `bootJar` task automatically depends on `cyclonedxBom` once the plugin +// is applied — no manual `dependsOn` wiring needed. +tasks.cyclonedxBom { + outputName.set("application.cdx") + outputFormat.set("json") + schemaVersion.set("1.5") + projectType.set("application") + includeConfigs.set(listOf("runtimeClasspath")) + skipConfigs.set(listOf("testRuntimeClasspath", "errorprone", "annotationProcessor")) +} +``` + +- [ ] **Step 3: Run formatter and build the SBOM** + +Run: +```bash +./gradlew spotlessApply +./gradlew cyclonedxBom -q +``` +Expected: both succeed. After the second command, `build/reports/application.cdx.json` exists. + +- [ ] **Step 4: Verify SBOM shape** + +Run: +```bash +test -f build/reports/application.cdx.json && \ + grep -q '"bomFormat" : "CycloneDX"' build/reports/application.cdx.json && \ + grep -q '"specVersion" : "1.5"' build/reports/application.cdx.json && \ + echo "SBOM OK" +``` +Expected: prints `SBOM OK`. If grep fails because the JSON is minified, swap `grep -q '"bomFormat":"CycloneDX"'` (no spaces). + +- [ ] **Step 5: Verify the SBOM is embedded in the bootJar** + +Run: +```bash +./gradlew bootJar -q +unzip -l build/libs/solr-mcp-*.jar | grep -F 'META-INF/sbom/application.cdx.json' +``` +Expected: one line of output showing the path exists in the JAR. + +If the file is NOT present: the bootJar task didn't pick it up. Check that `outputName` is exactly `application.cdx` (not `application.cdx.json`) — Spring Boot appends the format extension itself. + +- [ ] **Step 6: Commit** + +```bash +git add build.gradle.kts +git commit -s -m "$(cat <<'EOF' +feat(build): wire CycloneDX plugin to generate and embed SBOM + +Spring Boot 3.5's bootJar auto-embeds META-INF/sbom/application.cdx.json +when the file name matches `application.cdx`. The Jib JVM image and both +Paketo native images package the bootJar contents, so every distribution +artifact now carries an embedded CycloneDX 1.5 SBOM. + +Plugin config: +- outputFormat=json (actuator only consumes JSON) +- includeConfigs=runtimeClasspath only — test/errorprone deps excluded +- schemaVersion=1.5 + +Signed-off-by: Aditya Parikh +EOF +)" +``` + +--- + +## Task 3: Enable the /actuator/sbom endpoint explicitly + +**Files:** +- Modify: `src/main/resources/application-http.properties` + +`sbom` is already in `management.endpoints.web.exposure.include`. We're adding an explicit `enabled=true` line so the project's convention (be explicit about endpoint state) is satisfied and so any future scan reading just the properties file sees the intent. + +- [ ] **Step 1: Add the property** + +Find the line that begins with `# observability` (or `management.endpoints.web.exposure.include=...`). On a new line immediately after the `management.endpoints.web.exposure.include=...` line, add: + +```properties +management.endpoint.sbom.enabled=true +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/main/resources/application-http.properties +git commit -s -m "$(cat <<'EOF' +feat(actuator): enable /actuator/sbom endpoint explicitly + +`sbom` was already in management.endpoints.web.exposure.include; this +makes the endpoint enablement explicit so the file conveys intent +without relying on Spring Boot defaults. + +Signed-off-by: Aditya Parikh +EOF +)" +``` + +--- + +## Task 4: Add a focused HTTP integration test for /actuator/sbom + +**Files:** +- Create: `src/test/java/org/apache/solr/mcp/server/observability/SbomEndpointIntegrationTest.java` + +The test boots the HTTP profile (which mirrors `McpClientIntegrationTest`'s setup), hits `/actuator/sbom` over HTTP, and asserts the response is valid CycloneDX JSON. + +- [ ] **Step 1: Write the failing test** + +Create `src/test/java/org/apache/solr/mcp/server/observability/SbomEndpointIntegrationTest.java` with: + +```java +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.mcp.server.observability; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import org.apache.solr.mcp.server.TestcontainersConfiguration; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Verifies the CycloneDX SBOM is served at /actuator/sbom in HTTP mode. The + * SBOM is generated at build time by the cyclonedx Gradle plugin and embedded + * in the bootJar at META-INF/sbom/application.cdx.json; the actuator + * auto-discovers and serves it from there. + */ +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = {"http.security.enabled=false", "spring.docker.compose.enabled=false"}) +@ActiveProfiles("http") +@Import(TestcontainersConfiguration.class) +@Tag("integration") +@Testcontainers(disabledWithoutDocker = true) +class SbomEndpointIntegrationTest { + + @LocalServerPort + private int port; + + @Test + void sbomEndpointReturnsCycloneDxJson() throws Exception { + HttpClient client = HttpClient.newHttpClient(); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + port + "/actuator/sbom/application")) + .GET() + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.headers().firstValue("Content-Type")) + .hasValueSatisfying(ct -> assertThat(ct).contains("application/vnd.cyclonedx+json")); + assertThat(response.body()).contains("\"bomFormat\"").contains("CycloneDX"); + } +} +``` + +**Note on the URL:** Spring Boot's SBOM actuator exposes each embedded SBOM under `/actuator/sbom/{id}`. The default id for the application SBOM is `application` (derived from the file basename `application.cdx`). If the test fails with 404 because the id differs, hit `/actuator/sbom` first (an index) to discover the right id, then update the URL. + +- [ ] **Step 2: Run the test to verify it passes** + +Run: +```bash +./gradlew test --tests org.apache.solr.mcp.server.observability.SbomEndpointIntegrationTest -i +``` +Expected: PASS. If FAIL with status 404, see the note above and adjust the URL. If FAIL because of compilation, check that `assertj` is on the test classpath (it is — pulled in by `spring-boot-starter-test`). + +- [ ] **Step 3: Commit** + +```bash +git add src/test/java/org/apache/solr/mcp/server/observability/SbomEndpointIntegrationTest.java +git commit -s -m "$(cat <<'EOF' +test(observability): verify /actuator/sbom serves CycloneDX JSON + +Focused HTTP integration test that boots the http profile with the +existing TestcontainersConfiguration and asserts the SBOM endpoint +returns 200 with CycloneDX content. + +Signed-off-by: Aditya Parikh +EOF +)" +``` + +--- + +## Task 5: Upload SBOM as workflow artifact in build-and-publish.yml + +**Files:** +- Modify: `.github/workflows/build-and-publish.yml` + +- [ ] **Step 1: Add an upload step after the existing JAR upload** + +Find the step labeled `Upload JAR artifact` (around line 145-150). Immediately after it, add a new step: + +```yaml + # Upload the CycloneDX SBOM produced during the build + # build/reports/application.cdx.json is generated by the cyclonedx + # Gradle plugin and is also embedded in the bootable JAR + - name: Upload SBOM artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: solr-mcp-sbom + path: build/reports/application.cdx.json + retention-days: 30 +``` + +The `if: always()` mirrors the test-results pattern and ensures the SBOM is captured even if a downstream test fails (useful for debugging dependency-related test failures). Retention is 30 days (longer than the 7-day artifact retention) because SBOMs are useful for after-the-fact supply-chain investigation. + +- [ ] **Step 2: Commit** + +```bash +git add .github/workflows/build-and-publish.yml +git commit -s -m "$(cat <<'EOF' +ci: upload CycloneDX SBOM as workflow artifact + +Mirrors the existing JAR/test-results/coverage upload pattern. Retains +the SBOM for 30 days (vs the standard 7) since supply-chain +investigations often happen well after a build. + +Signed-off-by: Aditya Parikh +EOF +)" +``` + +--- + +## Task 6: Fix and extend the release-publish.yml SBOM step + +**Files:** +- Modify: `.github/workflows/release-publish.yml` + +The workflow already has a `Generate SBOM (Software Bill of Materials)` step that runs `./gradlew cyclonedxBom || echo "SBOM generation not configured"`. With the plugin applied, that `|| echo` fallback would mask real failures. Replace it with a strict invocation and add an upload + GitHub Release attachment. + +- [ ] **Step 1: Locate the existing step** + +In `release-publish.yml`, search for `Generate SBOM`. You'll find: + +```yaml + - name: Generate SBOM (Software Bill of Materials) + run: | + # Generate SBOM for the release + # This helps with supply chain security + ./gradlew cyclonedxBom || echo "SBOM generation not configured" +``` + +- [ ] **Step 2: Replace it with the strict invocation + upload + attach** + +Replace the step above with: + +```yaml + - name: Generate SBOM (Software Bill of Materials) + run: ./gradlew cyclonedxBom + + - name: Upload SBOM as workflow artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: solr-mcp-sbom-${{ inputs.release_version }} + path: build/reports/application.cdx.json + retention-days: 90 + + - name: Attach SBOM to GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_VERSION: ${{ inputs.release_version }} + run: | + # Rename to include the version so the asset is unambiguous on the release page + cp build/reports/application.cdx.json "solr-mcp-${RELEASE_VERSION}.cdx.json" + # --clobber lets re-runs of this workflow replace a previously uploaded SBOM + # If the v GitHub Release does not exist yet, log and continue — + # the workflow artifact above is still captured. + if gh release view "v${RELEASE_VERSION}" >/dev/null 2>&1; then + gh release upload "v${RELEASE_VERSION}" "solr-mcp-${RELEASE_VERSION}.cdx.json" --clobber + else + echo "GitHub Release v${RELEASE_VERSION} does not exist yet; SBOM available as workflow artifact only." + fi +``` + +The 90-day retention is longer than build-and-publish (30 days) because release SBOMs have an asynchronous secondary consumer: PMC members downloading them weeks after a vote. + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/release-publish.yml +git commit -s -m "$(cat <<'EOF' +ci(release): strict SBOM generation + upload + release attachment + +The existing Generate SBOM step swallowed errors with `|| echo "..."`, +masking failures now that the plugin is wired. Removes the fallback, +uploads the SBOM as a 90-day workflow artifact, and attaches it to the +v GitHub Release when one exists (graceful fallback otherwise +since the source release of record lives at dist.apache.org, not GitHub). + +Signed-off-by: Aditya Parikh +EOF +)" +``` + +--- + +## Task 7: Document SBOM in README.md + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Add a new section before `## Documentation`** + +Find the `## Documentation` section (last `##` heading in the file, around line 456). Immediately BEFORE it, insert: + +```markdown +## Supply chain & SBOM + +Every released JAR and Docker image ships a [CycloneDX](https://cyclonedx.org/) +1.5 Software Bill of Materials so downstream consumers can audit and scan the +dependency graph. + +### Where the SBOM lives + +- **Inside every JAR and image:** `META-INF/sbom/application.cdx.json` — + embedded by the Spring Boot Gradle plugin at build time. The Jib JVM image + (`solr-mcp:`) and both Paketo native images (`solr-mcp:-native-stdio`, + `solr-mcp:-native-http`) all package the bootJar contents, so the SBOM + ships with every distribution channel. +- **HTTP endpoint** (`http` profile only): `GET /actuator/sbom/application` + returns the same SBOM as `application/vnd.cyclonedx+json`. +- **GitHub Releases:** the release workflow attaches + `solr-mcp-.cdx.json` to every official ASF release. +- **CI artifacts:** every `Build and Publish` run uploads `solr-mcp-sbom` + (CycloneDX JSON) to the workflow run page; downloadable for 30 days. + +### Fetch the SBOM + +From a running HTTP-mode server: + +```bash +curl -s http://localhost:8080/actuator/sbom/application > application.cdx.json +``` + +From the local build (no server required): + +```bash +./gradlew cyclonedxBom +cat build/reports/application.cdx.json +``` + +### Scan the SBOM + +```bash +# Trivy +trivy sbom application.cdx.json + +# Grype +grype sbom:application.cdx.json +``` + +Both tools natively consume CycloneDX 1.5 and report CVEs against the +listed components. +``` + +- [ ] **Step 2: Commit** + +```bash +git add README.md +git commit -s -m "$(cat <<'EOF' +docs(readme): document SBOM location, retrieval, and scanning + +New 'Supply chain & SBOM' section covers all four distribution +channels (embedded in JAR/image, /actuator/sbom endpoint, GitHub +Release asset, CI workflow artifact) and shows trivy/grype usage. + +Signed-off-by: Aditya Parikh +EOF +)" +``` + +--- + +## Task 8: Note SBOM in CLAUDE.md + +**Files:** +- Modify: `CLAUDE.md` + +CLAUDE.md is project-level guidance for AI assistants. Two small additions: a build command and an architecture note. + +- [ ] **Step 1: Add a command line under "Common Commands"** + +Find the `## Common Commands` section. Within the fenced bash block, locate the `# Code formatting (REQUIRED before commit)` group. Immediately BEFORE that group, insert: + +```bash +# SBOM (Software Bill of Materials) +./gradlew cyclonedxBom # Generate build/reports/application.cdx.json + +``` + +(Keep the trailing blank line so the existing groups stay visually separated.) + +- [ ] **Step 2: Add an architecture note** + +Find the `### Logging Architecture` section. Immediately BEFORE it, insert a new section: + +```markdown +### SBOM Architecture + +CycloneDX SBOM generation is wired via `org.cyclonedx.bom` (`tasks.cyclonedxBom` in +`build.gradle.kts`). The Spring Boot Gradle plugin embeds the generated file +into the bootJar at `META-INF/sbom/application.cdx.json`; the actuator +auto-discovers it and serves it at `/actuator/sbom/application` in the `http` +profile (enabled in `application-http.properties`). Both the Jib JVM image and +the Paketo native images package the bootJar contents, so every distribution +artifact ships the SBOM without per-image wiring. + +Spec: [docs/superpowers/specs/2026-06-05-sbom-generation-design.md](docs/superpowers/specs/2026-06-05-sbom-generation-design.md) +``` + +- [ ] **Step 3: Commit** + +```bash +git add CLAUDE.md +git commit -s -m "$(cat <<'EOF' +docs(claude): note SBOM generation in commands + architecture + +Records the cyclonedxBom command and how the SBOM flows through +bootJar → actuator → Docker images, so future agents have the +mental model when working on related code. + +Signed-off-by: Aditya Parikh +EOF +)" +``` + +--- + +## Task 9: Final verification + +**Files:** none (verification only) + +- [ ] **Step 1: Run the full build** + +Run: +```bash +./gradlew spotlessApply build +``` +Expected: BUILD SUCCESSFUL. All tests pass. + +- [ ] **Step 2: Confirm SBOM artifacts present** + +Run: +```bash +ls -lh build/reports/application.cdx.json && \ + unzip -l build/libs/solr-mcp-*.jar | grep -F 'META-INF/sbom/application.cdx.json' +``` +Expected: file exists; one line in JAR listing for the embedded SBOM. + +- [ ] **Step 3: Inspect SBOM head** + +Run: +```bash +head -20 build/reports/application.cdx.json +``` +Expected: includes `"bomFormat" : "CycloneDX"` and `"specVersion" : "1.5"`. + +- [ ] **Step 4: Push the branch and open the PR** + +```bash +git push -u origin worktree-add-sbom-generation +gh pr create --title "feat(build): generate CycloneDX SBOM for every release artifact" --body "$(cat <<'EOF' +## Summary + +- Wires CycloneDX 1.5 SBOM generation into every build, embeds it in the + bootJar at `META-INF/sbom/application.cdx.json`, and exposes it at + `/actuator/sbom/application` in HTTP mode. +- Jib JVM image and both Paketo native images ship the SBOM for free via + bootJar packaging — no per-image wiring. +- `build-and-publish.yml` uploads the SBOM as a 30-day workflow artifact; + `release-publish.yml` uploads as a 90-day artifact and attaches it to the + matching GitHub Release. +- README documents location, retrieval, and scanning with trivy/grype. + +Spec: `docs/superpowers/specs/2026-06-05-sbom-generation-design.md` + +## Test plan + +- [x] `./gradlew build` is green +- [x] `build/reports/application.cdx.json` produced (`bomFormat: CycloneDX`, `specVersion: 1.5`) +- [x] SBOM is embedded in `build/libs/solr-mcp-*.jar` at `META-INF/sbom/application.cdx.json` +- [x] `SbomEndpointIntegrationTest` passes (`/actuator/sbom/application` returns 200 + CycloneDX JSON) +- [ ] CI green on this PR +- [ ] Manual sanity-check after merge: pull the resulting Jib image, `docker run` it with `PROFILES=http`, `curl /actuator/sbom/application` +EOF +)" +``` + +--- + +## Self-review + +**Spec coverage check:** + +- ✅ CycloneDX Gradle plugin applied — Task 1, 2 +- ✅ `outputName=application.cdx`, `outputFormat=json`, `schemaVersion=1.5` — Task 2 +- ✅ `includeConfigs=runtimeClasspath`, exclude test/errorprone — Task 2 +- ✅ Embedded in bootJar at `META-INF/sbom/application.cdx.json` — verified in Task 2 step 5 +- ✅ `/actuator/sbom` exposed by default in HTTP profile — Task 3 (and pre-existing exposure list) +- ✅ Workflow artifact in build-and-publish.yml — Task 5 +- ✅ Workflow artifact + GitHub Release asset in release-publish.yml — Task 6 +- ✅ README documents location, retrieval, scanning — Task 7 +- ✅ CLAUDE.md notes the plugin + endpoint — Task 8 +- ✅ One focused HTTP integration test asserting CycloneDX response — Task 4 +- ✅ Build green at end — Task 9 + +**Placeholder scan:** No TBD / TODO / "implement later" found. Every code/config block is complete and copyable. + +**Type/name consistency:** `application.cdx` used consistently as `outputName`; the embedded path is consistently `META-INF/sbom/application.cdx.json`; endpoint URL `/actuator/sbom/application` consistent between Task 4 (test), Task 7 (README), Task 8 (CLAUDE.md). The plugin task name `cyclonedxBom` is consistent across Tasks 2, 6, and 9. From 8e4c3c23180dcf0c2ff4bf0832b459034b169b2b Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Fri, 5 Jun 2026 08:59:34 -0400 Subject: [PATCH 04/26] chore(deps): add CycloneDX Gradle plugin 1.10.0 to version catalog Plugin will be applied in the next commit. Adding the catalog entry first keeps build.gradle.kts changes reviewable in isolation. Signed-off-by: Aditya Parikh Signed-off-by: adityamparikh --- gradle/libs.versions.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 616c2dea..60a58ce7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,6 +6,7 @@ errorprone-plugin = "5.1.0" jib = "3.5.3" spotless = "7.0.2" graalvm-native = "0.10.6" +cyclonedx-plugin = "1.10.0" # Main dependencies spring-ai = "1.1.7" @@ -112,3 +113,4 @@ errorprone = { id = "net.ltgt.errorprone", version.ref = "errorprone-plugin" } jib = { id = "com.google.cloud.tools.jib", version.ref = "jib" } spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } graalvm-native = { id = "org.graalvm.buildtools.native", version.ref = "graalvm-native" } +cyclonedx = { id = "org.cyclonedx.bom", version.ref = "cyclonedx-plugin" } From 1cf5f0f6ebd773e3819db22dc6339a1adfdef5be Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Fri, 5 Jun 2026 09:05:52 -0400 Subject: [PATCH 05/26] feat(build): generate and embed CycloneDX SBOM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply org.cyclonedx.bom Gradle plugin 2.4.1. Spring Boot 3.5's CycloneDxPluginAction auto-wires bootJar to embed the generated SBOM at META-INF/sbom/application.cdx.json, so every distribution (JAR, Jib JVM image, both Paketo native images) ships the embedded SBOM via bootJar packaging — no per-image wiring. Plugin version note: 1.10.0 breaks against Gradle 9.4 with UnsupportedOperationException (ImmutableCollection.removeAll). 2.4.1 is the latest v1.x-compatible class layout (CycloneDxPlugin / CycloneDxTask) that Spring Boot's auto-integration recognizes; v3.x renamed the classes (CyclonedxPlugin) and is incompatible until Spring Boot adopts the new shape. projectType is set explicitly to Component.Type.APPLICATION because v2.4.1 changed the property from Property to Property; Spring Boot's `.convention("application")` would store a raw String and break the task at execution time. Signed-off-by: Aditya Parikh Signed-off-by: adityamparikh --- build.gradle.kts | 28 ++++++++++++++++++++++++++++ gradle/libs.versions.toml | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 9bbe9d35..10c3c504 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,6 +16,7 @@ */ import net.ltgt.gradle.errorprone.errorprone +import org.cyclonedx.model.Component plugins { java @@ -27,6 +28,7 @@ plugins { alias(libs.plugins.spotless) alias(libs.plugins.jib) alias(libs.plugins.graalvm.native) apply false + alias(libs.plugins.cyclonedx) } // GraalVM Native Image (Opt-In) @@ -196,6 +198,32 @@ springBoot { buildInfo() } +// CycloneDX SBOM (Software Bill of Materials) +// ========================================== +// Spring Boot 3.5's CycloneDxPluginAction auto-configures this task with +// `outputName=application.cdx`, `outputFormat=json`, and arranges for the +// generated SBOM to be embedded in the bootable JAR at +// META-INF/sbom/application.cdx.json. The actuator then serves it at +// /actuator/sbom (HTTP profile only — see application-http.properties). +// +// One SBOM, three distribution channels: +// 1. Embedded in the bootable JAR (META-INF/sbom/application.cdx.json) +// 2. Embedded in every Docker image (Jib + Paketo both package bootJar contents) +// 3. Surfaced at /actuator/sbom for live introspection (HTTP profile) +// +// Why projectType is set explicitly: +// Spring Boot 3.5.14 was written against cyclonedx-plugin 1.x, where +// projectType was a Property. In v2.4.1 it became a +// Property. Spring Boot's `.convention("application")` would +// store a raw String at runtime (Gradle Property is type-erased) and break +// the task when it later expects a Component.Type. We override with the +// correct enum value before any auto-config code path can matter. +tasks.cyclonedxBom { + projectType.set(Component.Type.APPLICATION) + includeConfigs.set(listOf("runtimeClasspath")) + skipConfigs.set(listOf("testRuntimeClasspath", "errorprone", "annotationProcessor")) +} + tasks.withType { useJUnitPlatform { // Only exclude docker integration tests from regular test runs, not from dockerIntegrationTest diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 60a58ce7..6fc21873 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,7 @@ errorprone-plugin = "5.1.0" jib = "3.5.3" spotless = "7.0.2" graalvm-native = "0.10.6" -cyclonedx-plugin = "1.10.0" +cyclonedx-plugin = "2.4.1" # Main dependencies spring-ai = "1.1.7" From 6a0d39eb4f268e968c048d134c791cc5612a5a91 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Fri, 5 Jun 2026 09:06:03 -0400 Subject: [PATCH 06/26] feat(actuator): enable /actuator/sbom endpoint explicitly `sbom` was already in management.endpoints.web.exposure.include; this makes the endpoint enablement explicit so the file conveys intent without relying on Spring Boot defaults. Signed-off-by: Aditya Parikh Signed-off-by: adityamparikh --- src/main/resources/application-http.properties | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/resources/application-http.properties b/src/main/resources/application-http.properties index 24c6131c..c1b97597 100644 --- a/src/main/resources/application-http.properties +++ b/src/main/resources/application-http.properties @@ -39,6 +39,7 @@ http.security.enabled=${HTTP_SECURITY_ENABLED:true} mcp.cors.allowed-origins=${MCP_CORS_ALLOWED_ORIGINS:http://localhost:6274,http://127.0.0.1:6274} # observability management.endpoints.web.exposure.include=health,sbom,metrics,info,loggers,prometheus +management.endpoint.sbom.enabled=true # Enable @Observed annotation support for custom spans management.observations.annotations.enabled=true # Tracing Configuration From 0c0a165a7b48b124a00e4c4afddec70794eb1e69 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Fri, 5 Jun 2026 09:07:40 -0400 Subject: [PATCH 07/26] docs(spec): drop integration-test scope, document plugin-version decisions - Drop the planned SbomEndpointIntegrationTest: /actuator/sbom is stock Spring Boot functionality; our only project-specific addition is two property lines. The build itself fails if cyclonedxBom breaks (Spring Boot's bootJar auto-depends on it). - Update plugin version note to 2.4.1 and explain why both 1.10.0 (Gradle 9.4 bug) and 3.x (Spring Boot class-name change) are unsuitable. - CycloneDX schema 1.6 (plugin default) replaces the originally-noted 1.5. Signed-off-by: Aditya Parikh Signed-off-by: adityamparikh --- .../2026-06-05-sbom-generation-design.md | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/specs/2026-06-05-sbom-generation-design.md b/docs/superpowers/specs/2026-06-05-sbom-generation-design.md index 1acb24f7..00965b1e 100644 --- a/docs/superpowers/specs/2026-06-05-sbom-generation-design.md +++ b/docs/superpowers/specs/2026-06-05-sbom-generation-design.md @@ -21,7 +21,7 @@ already; today the actuator returns 404 because no SBOM is generated. In scope: -- Generate a CycloneDX 1.5 SBOM (`application.cdx.json`) on every `./gradlew build`. +- Generate a CycloneDX 1.6 SBOM (`application.cdx.json`) on every `./gradlew build`. - Embed the SBOM in the bootable JAR at `META-INF/sbom/application.cdx.json` so it ships with every distribution (JAR, Jib JVM image, both Paketo native images). @@ -62,7 +62,7 @@ CycloneDX (vs SPDX) is the de-facto Apache ecosystem standard, what Spring Boot natively integrates with, and what Trivy/Grype/Dependency-Track ingest natively. Plugin version: `1.10.0` (latest stable as of 2026-06; supports Gradle 8+ and -CycloneDX 1.5). +CycloneDX 1.6). ## Architecture @@ -83,7 +83,8 @@ build.gradle.kts ← apply alias(libs.plugins.cyclonedx) - `includeConfigs = listOf("runtimeClasspath")` — only ship what's actually in the binary; exclude test/errorprone/build-time-only deps. - `skipConfigs = listOf("testRuntimeClasspath", "errorprone")` — defense in depth. -- `schemaVersion = "1.5"` — current stable; matches Spring Boot's expectations. +- `schemaVersion` — plugin default (CycloneDX 1.6, latest stable). Spring Boot + doesn't override this convention. - `projectType = "application"` — accurate for a Spring Boot service. `build` and `bootJar` automatically depend on `cyclonedxBom` once the Spring @@ -133,11 +134,15 @@ the SBOM ships embedded; reference the spec. - `./gradlew build` produces `build/reports/application.cdx.json`. Verify manually post-merge. -- Add a focused HTTP-profile test (or extend `McpClientIntegrationTest`, which - already boots the HTTP profile) with an assertion that - `GET /actuator/sbom` returns 200, `Content-Type: application/vnd.cyclonedx+json`, - and the JSON body contains `"bomFormat": "CycloneDX"`. One small assertion - — no separate test class. +- No new integration test. `/actuator/sbom` is stock Spring Boot + functionality; the only project-specific configuration is two lines in + `application-http.properties`. A Spring Boot integration test that boots a + full context + Testcontainers Solr just to assert an actuator returns 200 + tests Spring Boot, not us. The plugin wiring is already implicitly verified: + Spring Boot's bootJar task auto-depends on `cyclonedxBom`, so if the plugin + ever breaks, `./gradlew build` fails. The remaining question — "is the SBOM + actually inside the JAR?" — is handled by the build itself succeeding and is + re-verifiable any time via `unzip -l build/libs/*.jar | grep sbom`. - Existing Docker integration tests already verify image startup. The SBOM being present in the image is implicit via the bootJar packaging — no new Docker test added. From 52347bc3004301e25c00769ebc2a3a6b7b59d19d Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Fri, 5 Jun 2026 09:08:01 -0400 Subject: [PATCH 08/26] docs(spec): drop stale 1.10.0 version reference Signed-off-by: Aditya Parikh Signed-off-by: adityamparikh --- docs/superpowers/specs/2026-06-05-sbom-generation-design.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-06-05-sbom-generation-design.md b/docs/superpowers/specs/2026-06-05-sbom-generation-design.md index 00965b1e..03f6b772 100644 --- a/docs/superpowers/specs/2026-06-05-sbom-generation-design.md +++ b/docs/superpowers/specs/2026-06-05-sbom-generation-design.md @@ -61,8 +61,8 @@ integration since 3.3.0: CycloneDX (vs SPDX) is the de-facto Apache ecosystem standard, what Spring Boot natively integrates with, and what Trivy/Grype/Dependency-Track ingest natively. -Plugin version: `1.10.0` (latest stable as of 2026-06; supports Gradle 8+ and -CycloneDX 1.6). +(See the **Plugin version constraints** subsection below for the version +trade-off — neither 1.x nor 3.x is usable today, so we pin to 2.4.1.) ## Architecture From 2cedf6471b5df260fa8ea85e04504ab3b43a1981 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Fri, 5 Jun 2026 09:08:43 -0400 Subject: [PATCH 09/26] docs(spec): inline the plugin-version constraints explanation Earlier edit lost the detail by accident. Restored as part of the Tool choice section so the spec stands on its own. Signed-off-by: Aditya Parikh Signed-off-by: adityamparikh --- .../2026-06-05-sbom-generation-design.md | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-06-05-sbom-generation-design.md b/docs/superpowers/specs/2026-06-05-sbom-generation-design.md index 03f6b772..e3ef0070 100644 --- a/docs/superpowers/specs/2026-06-05-sbom-generation-design.md +++ b/docs/superpowers/specs/2026-06-05-sbom-generation-design.md @@ -61,8 +61,24 @@ integration since 3.3.0: CycloneDX (vs SPDX) is the de-facto Apache ecosystem standard, what Spring Boot natively integrates with, and what Trivy/Grype/Dependency-Track ingest natively. -(See the **Plugin version constraints** subsection below for the version -trade-off — neither 1.x nor 3.x is usable today, so we pin to 2.4.1.) +**Plugin version: 2.4.1.** Neither the 1.x nor the 3.x line is usable today: + +- **1.10.0 (latest 1.x):** breaks against Gradle 9.4 with an + `UnsupportedOperationException` from `ImmutableCollection.removeAll` inside + `CycloneDxTask.createBom`. Verified locally. +- **3.x:** renamed the plugin/task classes (`CyclonedxPlugin`, + `CyclonedxBomTask`) and Spring Boot 3.5.14's `CycloneDxPluginAction` + auto-integration explicitly looks up the old PascalCase class + `org.cyclonedx.gradle.CycloneDxPlugin` — so applying v3 silently skips the + Spring Boot integration, breaking automatic bootJar embedding. +- **2.4.1:** last release with the v1-compatible class layout + (`CycloneDxPlugin` / `CycloneDxTask`) AND with the Gradle 9.4 bug fixed. + Deprecated `outputName`/`outputFormat`/`projectType` as Strings in favor of + type-safe alternatives; Spring Boot's auto-config still drives `outputName` + and `outputFormat` (still `Property`, just deprecated), but we + must override `projectType` explicitly with `Component.Type.APPLICATION` + because Spring Boot's `.convention("application")` would store a raw + String into `Property` and break execution. ## Architecture From 74992d6fdff90d85026c014e560bf6ee47d854ba Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Fri, 5 Jun 2026 09:09:00 -0400 Subject: [PATCH 10/26] ci: upload CycloneDX SBOM as workflow artifact Mirrors the existing JAR/test-results/coverage upload pattern. Retains the SBOM for 30 days (vs the standard 7) since supply-chain investigations often happen well after a build. Signed-off-by: Aditya Parikh Signed-off-by: adityamparikh --- .github/workflows/build-and-publish.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index 18d5d1cd..d195b769 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -149,6 +149,19 @@ jobs: path: build/libs/solr-mcp-*.jar retention-days: 7 + # Upload the CycloneDX SBOM produced during the build + # build/reports/application.cdx.json is generated by the cyclonedx + # Gradle plugin and is also embedded in the bootable JAR. Retained + # for 30 days (longer than the standard 7) because supply-chain + # investigations often happen well after a build. + - name: Upload SBOM artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: solr-mcp-sbom + path: build/reports/application.cdx.json + retention-days: 30 + # Upload JUnit test results # if: always() ensures this runs even if the build fails # This allows viewing test results for failed builds From c3b4aa8bfcb259e1308b22cf14499c2b386a1c98 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Fri, 5 Jun 2026 09:09:31 -0400 Subject: [PATCH 11/26] ci(release): strict SBOM generation + upload + release attachment The existing Generate SBOM step swallowed errors with `|| echo "..."`, masking failures now that the plugin is wired. Removes the fallback, uploads the SBOM as a 90-day workflow artifact, and attaches it to the v GitHub Release when one exists (graceful fallback otherwise since the source release of record lives at dist.apache.org, not GitHub). RELEASE_VERSION is already validated by validate-release; routing it through an env var instead of inline ${{ }} interpolation is defence-in-depth against actions-injection. Signed-off-by: Aditya Parikh Signed-off-by: adityamparikh --- .github/workflows/release-publish.yml | 38 ++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index a89f0bae..f4eb779b 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -244,10 +244,42 @@ jobs: -Djib.to.tags=${{ inputs.release_version }},latest - name: Generate SBOM (Software Bill of Materials) + # The CycloneDX plugin is wired in build.gradle.kts; bootJar already + # depends on cyclonedxBom transitively. Running it explicitly here + # ensures build/reports/application.cdx.json exists for the upload + # steps below even if the `Build project` step above used a cached + # bootJar output. + run: ./gradlew cyclonedxBom + + - name: Upload SBOM as workflow artifact + if: always() + uses: actions/upload-artifact@v4 + with: + # The release_version input is constrained by validate-release; safe to + # interpolate into the artifact name. + name: solr-mcp-sbom-${{ inputs.release_version }} + path: build/reports/application.cdx.json + retention-days: 90 + + - name: Attach SBOM to GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Pass the release_version through env so the shell sees a quoted + # variable instead of an inline ${{ }} expansion (defence-in-depth + # against actions-injection, even though release_version is already + # validated by validate-release). + RELEASE_VERSION: ${{ inputs.release_version }} run: | - # Generate SBOM for the release - # This helps with supply chain security - ./gradlew cyclonedxBom || echo "SBOM generation not configured" + # Rename to include the version so the asset is unambiguous on the release page. + cp build/reports/application.cdx.json "solr-mcp-${RELEASE_VERSION}.cdx.json" + # --clobber lets re-runs of this workflow replace a previously uploaded SBOM. + # If the v GitHub Release does not exist yet, log and continue — + # the workflow artifact above is still captured. + if gh release view "v${RELEASE_VERSION}" >/dev/null 2>&1; then + gh release upload "v${RELEASE_VERSION}" "solr-mcp-${RELEASE_VERSION}.cdx.json" --clobber + else + echo "GitHub Release v${RELEASE_VERSION} does not exist yet; SBOM available as workflow artifact only." + fi - name: Create release announcement run: | From 9d3d70b0aa30507440a3b6f82864d639bce8897e Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Fri, 5 Jun 2026 09:09:57 -0400 Subject: [PATCH 12/26] docs(readme): document SBOM location, retrieval, and scanning New 'Supply chain & SBOM' section covers all four distribution channels (embedded in JAR/image, /actuator/sbom endpoint, GitHub Release asset, CI workflow artifact) and shows trivy/grype usage. Signed-off-by: Aditya Parikh Signed-off-by: adityamparikh --- README.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/README.md b/README.md index 20915647..60b63569 100644 --- a/README.md +++ b/README.md @@ -453,6 +453,44 @@ docker run -p 8080:8080 --rm \ See [docs/specs/graalvm-native-image.md](docs/specs/graalvm-native-image.md) for the native image design and known risks. +## Supply chain & SBOM + +Every released JAR and Docker image ships a [CycloneDX](https://cyclonedx.org/) 1.6 Software Bill of Materials so downstream consumers can audit and scan the dependency graph. + +### Where the SBOM lives + +- **Inside every JAR and image:** `META-INF/sbom/application.cdx.json` — embedded by the Spring Boot Gradle plugin at build time. The Jib JVM image (`solr-mcp:`) and both Paketo native images (`solr-mcp:-native-stdio`, `solr-mcp:-native-http`) all package the bootJar contents, so the SBOM ships with every distribution channel. +- **HTTP endpoint** (`http` profile only): `GET /actuator/sbom/application` returns the same SBOM as `application/vnd.cyclonedx+json`. +- **GitHub Releases:** the release workflow attaches `solr-mcp-.cdx.json` to every official ASF release. +- **CI artifacts:** every `Build and Publish` run uploads `solr-mcp-sbom` (CycloneDX JSON) to the workflow run page; downloadable for 30 days. + +### Fetch the SBOM + +From a running HTTP-mode server: + +```bash +curl -s http://localhost:8080/actuator/sbom/application > application.cdx.json +``` + +From the local build (no server required): + +```bash +./gradlew cyclonedxBom +cat build/reports/application.cdx.json +``` + +### Scan the SBOM + +```bash +# Trivy +trivy sbom application.cdx.json + +# Grype +grype sbom:application.cdx.json +``` + +Both tools natively consume CycloneDX 1.6 and report CVEs against the listed components. + ## Documentation - [Auth0 Setup (OAuth2 configuration)](security-docs/AUTH0_SETUP.md) From e179ac36c4aea57eeec3591c5dbab93e76f39f19 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Fri, 5 Jun 2026 09:12:49 -0400 Subject: [PATCH 13/26] refactor(build): drop unnecessary cyclonedxBom configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spring Boot 3.5.14's CycloneDxPluginAction already sets outputName, outputFormat, projectType, and wires bootJar embedding — matching what Spring Initializr generates for the same dependency set. Verified that applying the plugin alone produces a valid CycloneDX 1.6 SBOM at META-INF/sbom/application.cdx.json inside the bootJar with component type=application. The earlier projectType override + includeConfigs/skipConfigs were defensive but unnecessary; let the framework defaults work. Signed-off-by: Aditya Parikh Signed-off-by: adityamparikh --- build.gradle.kts | 26 ------------ .../2026-06-05-sbom-generation-design.md | 41 ++++--------------- 2 files changed, 8 insertions(+), 59 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 10c3c504..875050d9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,7 +16,6 @@ */ import net.ltgt.gradle.errorprone.errorprone -import org.cyclonedx.model.Component plugins { java @@ -198,31 +197,6 @@ springBoot { buildInfo() } -// CycloneDX SBOM (Software Bill of Materials) -// ========================================== -// Spring Boot 3.5's CycloneDxPluginAction auto-configures this task with -// `outputName=application.cdx`, `outputFormat=json`, and arranges for the -// generated SBOM to be embedded in the bootable JAR at -// META-INF/sbom/application.cdx.json. The actuator then serves it at -// /actuator/sbom (HTTP profile only — see application-http.properties). -// -// One SBOM, three distribution channels: -// 1. Embedded in the bootable JAR (META-INF/sbom/application.cdx.json) -// 2. Embedded in every Docker image (Jib + Paketo both package bootJar contents) -// 3. Surfaced at /actuator/sbom for live introspection (HTTP profile) -// -// Why projectType is set explicitly: -// Spring Boot 3.5.14 was written against cyclonedx-plugin 1.x, where -// projectType was a Property. In v2.4.1 it became a -// Property. Spring Boot's `.convention("application")` would -// store a raw String at runtime (Gradle Property is type-erased) and break -// the task when it later expects a Component.Type. We override with the -// correct enum value before any auto-config code path can matter. -tasks.cyclonedxBom { - projectType.set(Component.Type.APPLICATION) - includeConfigs.set(listOf("runtimeClasspath")) - skipConfigs.set(listOf("testRuntimeClasspath", "errorprone", "annotationProcessor")) -} tasks.withType { useJUnitPlatform { diff --git a/docs/superpowers/specs/2026-06-05-sbom-generation-design.md b/docs/superpowers/specs/2026-06-05-sbom-generation-design.md index e3ef0070..68fb7e1f 100644 --- a/docs/superpowers/specs/2026-06-05-sbom-generation-design.md +++ b/docs/superpowers/specs/2026-06-05-sbom-generation-design.md @@ -61,24 +61,8 @@ integration since 3.3.0: CycloneDX (vs SPDX) is the de-facto Apache ecosystem standard, what Spring Boot natively integrates with, and what Trivy/Grype/Dependency-Track ingest natively. -**Plugin version: 2.4.1.** Neither the 1.x nor the 3.x line is usable today: - -- **1.10.0 (latest 1.x):** breaks against Gradle 9.4 with an - `UnsupportedOperationException` from `ImmutableCollection.removeAll` inside - `CycloneDxTask.createBom`. Verified locally. -- **3.x:** renamed the plugin/task classes (`CyclonedxPlugin`, - `CyclonedxBomTask`) and Spring Boot 3.5.14's `CycloneDxPluginAction` - auto-integration explicitly looks up the old PascalCase class - `org.cyclonedx.gradle.CycloneDxPlugin` — so applying v3 silently skips the - Spring Boot integration, breaking automatic bootJar embedding. -- **2.4.1:** last release with the v1-compatible class layout - (`CycloneDxPlugin` / `CycloneDxTask`) AND with the Gradle 9.4 bug fixed. - Deprecated `outputName`/`outputFormat`/`projectType` as Strings in favor of - type-safe alternatives; Spring Boot's auto-config still drives `outputName` - and `outputFormat` (still `Property`, just deprecated), but we - must override `projectType` explicitly with `Component.Type.APPLICATION` - because Spring Boot's `.convention("application")` would store a raw - String into `Property` and break execution. +**Plugin version: 2.4.1** — the version Spring Initializr ships for Spring +Boot 3.5.14 when you select the `sbom-cyclone-dx` dependency. ## Architecture @@ -90,21 +74,12 @@ build.gradle.kts ← apply alias(libs.plugins.cyclonedx) ← cyclonedxBom { … } configuration block ``` -`cyclonedxBom` configuration: - -- `outputFormat = "json"` — Spring Boot's actuator only consumes JSON; XML adds - build cost and disk for no consumer. -- `outputName = "application.cdx"` — Spring Boot expects exactly this name to - embed it. (Default is `bom`, which Spring Boot would not detect.) -- `includeConfigs = listOf("runtimeClasspath")` — only ship what's actually in - the binary; exclude test/errorprone/build-time-only deps. -- `skipConfigs = listOf("testRuntimeClasspath", "errorprone")` — defense in depth. -- `schemaVersion` — plugin default (CycloneDX 1.6, latest stable). Spring Boot - doesn't override this convention. -- `projectType = "application"` — accurate for a Spring Boot service. - -`build` and `bootJar` automatically depend on `cyclonedxBom` once the Spring -Boot plugin sees it on the classpath; no manual `dependsOn` needed. +No custom `cyclonedxBom { ... }` block is needed. Spring Boot's +`CycloneDxPluginAction` auto-configures `outputName = "application.cdx"`, +`outputFormat = "json"`, and `projectType = "application"` via Property +conventions, and `bootJar` automatically depends on `cyclonedxBom`. The plugin +default schema version (CycloneDX 1.6) is used as-is. This matches what +Spring Initializr generates for the same dependency set. ### Runtime wiring From 452799d5951ead20098f3defc3a8cb5343b6f4b9 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Fri, 5 Jun 2026 09:13:56 -0400 Subject: [PATCH 14/26] docs(agents): note SBOM generation in commands + architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md symlinks to AGENTS.md; edit lands on the real file. Records the cyclonedxBom command and how the SBOM flows through bootJar → actuator → Docker images, so future agents have the mental model when working on related code. Signed-off-by: Aditya Parikh Signed-off-by: adityamparikh --- AGENTS.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 4d9e30dd..072774e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,9 @@ Solr MCP Server is a Spring AI Model Context Protocol (MCP) server that enables ./gradlew test --tests "*IntegrationTest" # Run integration tests ./gradlew test jacocoTestReport # Tests with coverage report +# SBOM (Software Bill of Materials) +./gradlew cyclonedxBom # Generate build/reports/application.cdx.json + # Code formatting (REQUIRED before commit) ./gradlew spotlessApply # Apply formatting ./gradlew spotlessCheck # Check formatting @@ -115,6 +118,19 @@ Four service classes expose MCP tools via `@McpTool` annotations: Configuration files: `application-stdio.properties`, `application-http.properties` +### SBOM Architecture + +CycloneDX SBOM generation is wired by applying the `org.cyclonedx.bom` plugin +(version 2.4.1, matching what Spring Initializr ships for Spring Boot 3.5.14). +Spring Boot's `CycloneDxPluginAction` auto-configures `cyclonedxBom` and makes +the bootJar embed the result at `META-INF/sbom/application.cdx.json`; the +actuator serves it at `/actuator/sbom/application` in the `http` profile +(enabled via `application-http.properties`). Both the Jib JVM image and the +Paketo native images package the bootJar contents, so every distribution +artifact ships the SBOM without per-image wiring. + +Spec: [docs/superpowers/specs/2026-06-05-sbom-generation-design.md](docs/superpowers/specs/2026-06-05-sbom-generation-design.md) + ### Logging Architecture The STDIO transport uses stdout for JSON-RPC messages, so any stray stdout output From 21df7ac6b562f0a862601cefba7b16a99e576979 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Fri, 5 Jun 2026 09:14:15 -0400 Subject: [PATCH 15/26] style: apply spotless Signed-off-by: Aditya Parikh Signed-off-by: adityamparikh --- build.gradle.kts | 1 - 1 file changed, 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 875050d9..075fafa9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -197,7 +197,6 @@ springBoot { buildInfo() } - tasks.withType { useJUnitPlatform { // Only exclude docker integration tests from regular test runs, not from dockerIntegrationTest From 8e6f18778f5a9a348e35a3f658a5ddecf6f9d450 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Thu, 11 Jun 2026 09:26:47 -0400 Subject: [PATCH 16/26] feat(build): derive binary-release LICENSE/NOTICE from the SBOM The base LICENSE/NOTICE are correct for the source release, but the binary release (the Spring Boot fat bootJar) bundles third-party bytecode and so per https://infra.apache.org/licensing-howto.html must additionally enumerate each bundled dependency's license and lift bundled ASF dependencies' NOTICE snippets. Stacks on the CycloneDX SBOM work and reuses it as the source of dependency license data: - generateBinaryLicense: base Apache-2.0 + an appendix listing every productionRuntimeClasspath dependency with a link to its license, read from the bundled SBOM (META-INF/sbom/application.cdx.json). The SBOM resolves a license for every component, including Gradle-module-metadata-only ASF artifacts (solr-solrj/solr-api) that POM-only scanners miss, so no per-dependency list is hand-maintained. It also gates the build: a bundled module missing from the SBOM, or carrying a license not in config/license-policy.json, fails the build. - generateBinaryNotice: base NOTICE + the META-INF/NOTICE files lifted verbatim and de-duplicated from the bundled jars (the Shade ApacheNoticeResourceTransformer approach), so ASF dependency notices stay current automatically. config/license-policy.json holds the allowedLicenses set plus overrides (group:name -> SPDX id) correcting the few components CycloneDX mislabels (mcp-server-security -> Apache-2.0; ANTLR ST4/antlr-runtime -> BSD-3-Clause). Source-form jars keep the base LICENSE/NOTICE. Verified: ./gradlew build green; fat jar META-INF/LICENSE lists 158 deps (incl. SolrJ) and META-INF/NOTICE aggregates 21 upstream notices. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: adityamparikh --- AGENTS.md | 28 +++++ build.gradle.kts | 247 ++++++++++++++++++++++++++++++++++++- config/license-policy.json | 32 +++++ 3 files changed, 303 insertions(+), 4 deletions(-) create mode 100644 config/license-policy.json diff --git a/AGENTS.md b/AGENTS.md index 072774e7..b47bf9d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -224,6 +224,34 @@ buildpacks (`bootBuildImage -Pnative`). Key configuration: - **CI:** Separate `native.yml` workflow; native failures do not block JVM-path merges. - **Spec:** [docs/specs/graalvm-native-image.md](docs/specs/graalvm-native-image.md) +## Release LICENSE / NOTICE + +ASF policy requires distinct LICENSE/NOTICE for the *source* form and the *binary* +form, because the binary (Spring Boot fat `bootJar`) bundles third-party bytecode. +See [infra.apache.org/licensing-howto](https://infra.apache.org/licensing-howto.html). + +- **Source form** (thin `jar`, `-sources`, `-javadoc`): the base `LICENSE` (Apache-2.0) + and `NOTICE` at the repo root, bundled into `META-INF/` as-is. +- **Binary form** (`bootJar`): generated at build time and bundled into its `META-INF/`: + - `generateBinaryLicense` → `LICENSE` = base Apache-2.0 + an appendix listing every + bundled `productionRuntimeClasspath` dependency and a link to its license. Licenses + are read from the **CycloneDX SBOM** (`cyclonedxBom`, the same SBOM embedded at + `META-INF/sbom/application.cdx.json`), filtered to the shipped classpath. The SBOM + resolves a license for every bundled component — including Gradle-module-metadata + -only ASF artifacts such as `solr-solrj`/`solr-api` that POM-only scanners miss — so + no per-dependency list is hand-maintained. + - `generateBinaryNotice` → `NOTICE` = base NOTICE + the `META-INF/NOTICE` files lifted + verbatim (de-duplicated) from the bundled jars (Maven-Shade + `ApacheNoticeResourceTransformer` approach). +- **Gate** (`generateBinaryLicense`, run as part of `check`/`build`): fails if a bundled + dependency is missing from the SBOM, or carries a license not in + `config/license-policy.json`. That file holds the `allowedLicenses` set plus + `overrides` (group:name → SPDX id) to correct the few components CycloneDX mislabels + (e.g. `mcp-server-security` → Apache-2.0; ANTLR `ST4`/`antlr-runtime` → BSD-3-Clause). +- This builds on the SBOM generation (see **SBOM Architecture**); the SBOM remains the + machine-readable bill of materials, and LICENSE/NOTICE are the human-readable legal + artifacts derived from it. + ## Testing Structure - **Unit tests** (`*Test.java`): Mocked dependencies, fast execution. Mockito-based diff --git a/build.gradle.kts b/build.gradle.kts index 075fafa9..affff458 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,6 +16,9 @@ */ import net.ltgt.gradle.errorprone.errorprone +import org.gradle.api.artifacts.component.ModuleComponentIdentifier +import org.springframework.boot.gradle.tasks.bundling.BootJar +import java.util.zip.ZipFile plugins { java @@ -76,17 +79,253 @@ java { withJavadocJar() } -// ASF release policy requires every distributed artifact to carry the project's -// LICENSE and NOTICE files. Bundle them into META-INF of every JAR produced by -// this build (main jar, bootJar, sources, javadoc). +// LICENSE / NOTICE bundling (ASF release policy) +// ============================================== +// ASF policy requires every distributed artifact to carry LICENSE and NOTICE, and the +// correct contents differ between the *source* form and the *binary* form: +// +// - Source-form artifacts (thin `jar`, `-sources`, `-javadoc`) contain only +// ASF-authored code, so the base Apache-2.0 LICENSE + NOTICE are sufficient. +// - The *binary* artifact (the Spring Boot fat `bootJar`) bundles third-party +// bytecode. Per https://infra.apache.org/licensing-howto.html its LICENSE must +// additionally enumerate each bundled dependency and link its license, and its +// NOTICE must lift the NOTICE snippets of bundled Apache-licensed dependencies. +// +// Tooling: +// - `generateBinaryLicense` builds the binary LICENSE = base Apache-2.0 + an +// appendix derived from the CycloneDX SBOM (`cyclonedxBom`), filtered to the +// shipped runtime classpath. The SBOM already resolves a license for every +// bundled component — including Gradle-module-metadata-only ASF artifacts such +// as SolrJ that POM-only scanners miss — so no per-dependency list is hand-kept. +// It doubles as a gate: a shipped module missing from the SBOM, or carrying a +// license not in config/license-policy.json, fails the build. +// - `generateBinaryNotice` builds the binary NOTICE by aggregating the actual +// META-INF/NOTICE files embedded in the bundled jars (the Maven-Shade +// ApacheNoticeResourceTransformer approach), verbatim and de-duplicated. // See https://www.apache.org/legal/release-policy.html#licensing-documentation -tasks.withType().configureEach { + +val binaryLicenseFile = layout.buildDirectory.file("generated/license/LICENSE") +val binaryNoticeFile = layout.buildDirectory.file("generated/license/NOTICE") + +// What actually ships inside the fat jar is `productionRuntimeClasspath` — it excludes +// test/compile-only AND `developmentOnly` deps (which the bootJar does not bundle). +val shippedClasspath = configurations.named("productionRuntimeClasspath") +val runtimeArtifacts = shippedClasspath.flatMap { it.incoming.artifacts.resolvedArtifacts } + +// Assemble the binary-release LICENSE: base Apache-2.0 + an appendix of every bundled +// dependency and a link to its license, sourced from the CycloneDX SBOM. +val generateBinaryLicense by + tasks.registering { + description = + "Assembles the binary-release LICENSE (Apache-2.0 + third-party appendix from the SBOM)." + group = "documentation" + dependsOn(tasks.named("cyclonedxBom")) + val baseLicense = rootProject.file("LICENSE") + val policyFile = rootProject.file("config/license-policy.json") + val sbomFile = layout.buildDirectory.file("reports/application.cdx.json") + val artifacts = runtimeArtifacts + inputs.file(baseLicense) + inputs.file(policyFile) + inputs.file(sbomFile) + inputs.files(shippedClasspath) + outputs.file(binaryLicenseFile) + doLast { + val slurper = groovy.json.JsonSlurper() + + @Suppress("UNCHECKED_CAST") + val policy = slurper.parse(policyFile) as Map + + @Suppress("UNCHECKED_CAST") + val allowed = ((policy["allowedLicenses"] as? List) ?: emptyList()).toSet() + + @Suppress("UNCHECKED_CAST") + val overrides = (policy["overrides"] as? Map) ?: emptyMap() + + // Licenses of an SBOM component as (label, url?) pairs, de-duped by label. + // (Local lambda, not a local fun/data class — those choke the kts compiler.) + val licsOf = fun(component: Map): List> { + val out = LinkedHashMap() + + @Suppress("UNCHECKED_CAST") + (component["licenses"] as? List>)?.forEach { node -> + @Suppress("UNCHECKED_CAST") + val lo = node["license"] as? Map + if (lo != null) { + val id = lo["id"] as? String + val label = id ?: (lo["name"] as? String) ?: "Unspecified" + val url = + (lo["url"] as? String) + ?: id?.let { "https://spdx.org/licenses/$it.html" } + if (!out.containsKey(label)) out[label] = url + } else { + (node["expression"] as? String)?.let { if (!out.containsKey(it)) out[it] = null } + } + } + return out.map { it.key to it.value } + } + + @Suppress("UNCHECKED_CAST") + val sbom = slurper.parse(sbomFile.get().asFile) as Map + + @Suppress("UNCHECKED_CAST") + val components = (sbom["components"] as? List>) ?: emptyList() + val byGa = HashMap>>() + val byGav = HashMap>>() + components.forEach { c -> + val g = c["group"] as? String + val n = c["name"] as? String + if (g != null && n != null) { + val ls = licsOf(c) + byGa["$g:$n"] = ls + (c["version"] as? String)?.let { byGav["$g:$n:$it"] = ls } + } + } + + // Source of truth for what ships: the resolved runtime artifacts, as + // (group:name, version) pairs, deduped by coordinate and sorted. + val bundled = + artifacts + .get() + .mapNotNull { it.id.componentIdentifier as? ModuleComponentIdentifier } + .map { "${it.group}:${it.module}" to it.version } + .distinctBy { it.first } + .sortedBy { it.first.lowercase() } + + val notInSbom = mutableListOf() + val disallowed = mutableListOf() + val rows = StringBuilder() + bundled.forEach { (ga, version) -> + val lics: List> = + overrides[ga]?.let { listOf(it to ("https://spdx.org/licenses/$it.html" as String?)) } + ?: byGav["$ga:$version"] ?: byGa[ga] ?: emptyList() + if (lics.isEmpty()) { + notInSbom.add("$ga:$version") + return@forEach + } + lics.forEach { (label, _) -> if (label !in allowed) disallowed.add("$ga:$version -> $label") } + rows + .append("- ") + .append(ga) + .append(':') + .append(version) + .append('\n') + lics.forEach { (label, url) -> + rows.append(" License: ").append(label) + if (!url.isNullOrBlank()) rows.append(" — ").append(url) + rows.append('\n') + } + } + if (notInSbom.isNotEmpty()) { + throw GradleException( + "Bundled dependencies absent from the CycloneDX SBOM:\n" + + notInSbom.joinToString("\n") { " - $it" } + + "\nEnsure cyclonedxBom covers the runtime classpath.", + ) + } + if (disallowed.isNotEmpty()) { + throw GradleException( + "Bundled dependencies with a license not in config/license-policy.json:\n" + + disallowed.joinToString("\n") { " - $it" } + + "\nAfter verifying, add the license to allowedLicenses, or add a " + + "group:name -> SPDX-id entry to overrides if the SBOM mislabels it.", + ) + } + + val sb = StringBuilder() + sb.append(baseLicense.readText().trimEnd()).append("\n\n\n") + sb.append("=".repeat(78)).append('\n') + sb.append("APACHE SOLR MCP SERVER — THIRD-PARTY DEPENDENCY LICENSES\n") + sb.append("=".repeat(78)).append("\n\n") + sb.append( + "The binary distribution (the Spring Boot executable JAR) bundles the\n" + + "third-party dependencies listed below, derived from the bundled CycloneDX\n" + + "SBOM (META-INF/sbom/application.cdx.json). Each is provided under the license\n" + + "noted; refer to the linked license text for the full terms.\n\n", + ) + sb.append(rows) + val target = binaryLicenseFile.get().asFile + target.parentFile.mkdirs() + target.writeText(sb.toString()) + } + } + +// Assemble the binary-release NOTICE by lifting the META-INF/NOTICE files embedded in +// the bundled jars (verbatim, de-duplicated) on top of this project's own NOTICE. +val generateBinaryNotice by + tasks.registering { + description = "Assembles the binary-release NOTICE (project NOTICE + bundled dependency notices)." + group = "documentation" + val baseNotice = rootProject.file("NOTICE") + val artifacts = runtimeArtifacts + inputs.file(baseNotice) + inputs.files(shippedClasspath) + outputs.file(binaryNoticeFile) + doLast { + val noticeEntry = Regex("(^|/)META-INF/NOTICE(\\.txt|\\.md)?$", RegexOption.IGNORE_CASE) + val seen = LinkedHashSet() + val sections = StringBuilder() + artifacts + .get() + .mapNotNull { art -> + (art.id.componentIdentifier as? ModuleComponentIdentifier)?.let { it to art.file } + }.sortedBy { "${it.first.group}:${it.first.module}".lowercase() } + .forEach { (id, jar) -> + if (!jar.name.endsWith(".jar")) return@forEach + ZipFile(jar).use { zip -> + zip + .entries() + .asSequence() + .filter { noticeEntry.containsMatchIn(it.name) && !it.isDirectory } + .forEach { entry -> + val text = + zip + .getInputStream(entry) + .bufferedReader() + .readText() + .trim() + if (text.isNotEmpty() && seen.add(text)) { + sections.append("\n").append("-".repeat(78)).append('\n') + sections.append("From ${id.group}:${id.module}:${id.version}:\n\n") + sections.append(text).append('\n') + } + } + } + } + val sb = StringBuilder() + sb.append(baseNotice.readText().trimEnd()).append('\n') + if (sections.isNotEmpty()) { + sb.append("\n\n").append("=".repeat(78)).append('\n') + sb.append("NOTICES FROM BUNDLED THIRD-PARTY DEPENDENCIES (binary distribution)\n") + sb.append("=".repeat(78)).append('\n') + sb.append(sections) + } + val target = binaryNoticeFile.get().asFile + target.parentFile.mkdirs() + target.writeText(sb.toString()) + } + } + +// Source-form artifacts: base LICENSE + NOTICE. +tasks.withType().matching { it.name != "bootJar" }.configureEach { metaInf { from(rootProject.file("LICENSE")) from(rootProject.file("NOTICE")) } } +// Binary artifact (Spring Boot fat jar): generated LICENSE (with SBOM-derived appendix) +// + generated NOTICE (with lifted dependency notices). +tasks.named("bootJar") { + dependsOn(generateBinaryLicense, generateBinaryNotice) + metaInf { + from(binaryLicenseFile) + from(binaryNoticeFile) + } +} + +// Fail the build when a bundled dependency is unaccounted for (generateBinaryLicense gate). +tasks.named("check") { dependsOn(generateBinaryLicense) } + // Maven Publishing Configuration // ============================== // This configuration enables publishing the project artifacts to Maven repositories. diff --git a/config/license-policy.json b/config/license-policy.json new file mode 100644 index 00000000..6455b714 --- /dev/null +++ b/config/license-policy.json @@ -0,0 +1,32 @@ +{ + "_comment": [ + "License policy for the binary-release LICENSE appendix (generateBinaryLicense).", + "The appendix is derived from the CycloneDX SBOM, filtered to the shipped", + "productionRuntimeClasspath. The build fails if a bundled dependency carries a", + "license not in 'allowedLicenses' (review it, then add it), or if a bundled", + "dependency is missing from the SBOM. All entries below are ASF Category A or B", + "(redistributable in a binary release).", + "", + "'overrides' maps group:name -> SPDX id to correct components CycloneDX mislabels,", + "verified against their upstream project: mcp-server-security is Apache-2.0 (the", + "SBOM reports the obsolete 'Apache-1.0'); ANTLR ST4 / antlr-runtime are BSD-3-Clause", + "(the SBOM reports 'BSD-4-Clause' / 'BSD licence')." + ], + "allowedLicenses": [ + "Apache-2.0", + "MIT", + "MIT-0", + "EPL-2.0", + "BSD-2-Clause", + "BSD-3-Clause", + "GPL-2.0-with-classpath-exception", + "GNU Lesser General Public License", + "CC0-1.0", + "Public Domain" + ], + "overrides": { + "org.springaicommunity:mcp-server-security": "Apache-2.0", + "org.antlr:ST4": "BSD-3-Clause", + "org.antlr:antlr-runtime": "BSD-3-Clause" + } +} From 685ff4cf5c3fe4e82f02ad0c4526ac0b8f78d20b Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Thu, 11 Jun 2026 11:10:37 -0400 Subject: [PATCH 17/26] refactor(build): extract LICENSE/NOTICE generation to a buildSrc plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the inline LICENSE/NOTICE logic out of the root build.gradle.kts into a buildSrc convention plugin (org.apache.solr.mcp.license-notice) backed by two typed tasks: - GenerateBinaryLicense / GenerateBinaryNotice are proper DefaultTask types with @InputFile/@InputFiles/@OutputFile, so they're incremental and (being real .kt files) free of the kts-script-compiler limitations that forced the previous Pair-based workarounds — the logic now reads as plain Kotlin with data classes. - The root build.gradle.kts drops ~250 lines and three imports, and just applies `id("org.apache.solr.mcp.license-notice")`. Behaviour is unchanged: the bootJar still bundles a LICENSE with the SBOM-derived 158-dependency appendix (incl. SolrJ) and a NOTICE aggregating 21 upstream notices; source-form jars keep the base files; `check` still runs the gate. The tasks now live in buildSrc, so they can be unit-tested with Gradle TestKit. Verified: ./gradlew build green; fat-jar META-INF/LICENSE and NOTICE identical to the pre-refactor output. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: adityamparikh --- AGENTS.md | 4 + build.gradle.kts | 254 +----------------- buildSrc/build.gradle.kts | 24 ++ ....apache.solr.mcp.license-notice.gradle.kts | 101 +++++++ .../solr/mcp/build/GenerateBinaryLicense.kt | 174 ++++++++++++ .../solr/mcp/build/GenerateBinaryNotice.kt | 93 +++++++ 6 files changed, 400 insertions(+), 250 deletions(-) create mode 100644 buildSrc/build.gradle.kts create mode 100644 buildSrc/src/main/kotlin/org.apache.solr.mcp.license-notice.gradle.kts create mode 100644 buildSrc/src/main/kotlin/org/apache/solr/mcp/build/GenerateBinaryLicense.kt create mode 100644 buildSrc/src/main/kotlin/org/apache/solr/mcp/build/GenerateBinaryNotice.kt diff --git a/AGENTS.md b/AGENTS.md index b47bf9d5..4284b3aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -251,6 +251,10 @@ See [infra.apache.org/licensing-howto](https://infra.apache.org/licensing-howto. - This builds on the SBOM generation (see **SBOM Architecture**); the SBOM remains the machine-readable bill of materials, and LICENSE/NOTICE are the human-readable legal artifacts derived from it. +- **Implementation:** the `org.apache.solr.mcp.license-notice` convention plugin in + `buildSrc/` (typed `GenerateBinaryLicense` / `GenerateBinaryNotice` tasks). The root + `build.gradle.kts` only applies the plugin; the policy lives in + `config/license-policy.json`. ## Testing Structure diff --git a/build.gradle.kts b/build.gradle.kts index affff458..59445a9a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,9 +16,6 @@ */ import net.ltgt.gradle.errorprone.errorprone -import org.gradle.api.artifacts.component.ModuleComponentIdentifier -import org.springframework.boot.gradle.tasks.bundling.BootJar -import java.util.zip.ZipFile plugins { java @@ -31,6 +28,10 @@ plugins { alias(libs.plugins.jib) alias(libs.plugins.graalvm.native) apply false alias(libs.plugins.cyclonedx) + // Generates ASF source/binary LICENSE + NOTICE (buildSrc convention plugin). + // Listed after spring-boot + cyclonedx so productionRuntimeClasspath and + // cyclonedxBom exist when it wires its tasks. See buildSrc/. + id("org.apache.solr.mcp.license-notice") } // GraalVM Native Image (Opt-In) @@ -79,253 +80,6 @@ java { withJavadocJar() } -// LICENSE / NOTICE bundling (ASF release policy) -// ============================================== -// ASF policy requires every distributed artifact to carry LICENSE and NOTICE, and the -// correct contents differ between the *source* form and the *binary* form: -// -// - Source-form artifacts (thin `jar`, `-sources`, `-javadoc`) contain only -// ASF-authored code, so the base Apache-2.0 LICENSE + NOTICE are sufficient. -// - The *binary* artifact (the Spring Boot fat `bootJar`) bundles third-party -// bytecode. Per https://infra.apache.org/licensing-howto.html its LICENSE must -// additionally enumerate each bundled dependency and link its license, and its -// NOTICE must lift the NOTICE snippets of bundled Apache-licensed dependencies. -// -// Tooling: -// - `generateBinaryLicense` builds the binary LICENSE = base Apache-2.0 + an -// appendix derived from the CycloneDX SBOM (`cyclonedxBom`), filtered to the -// shipped runtime classpath. The SBOM already resolves a license for every -// bundled component — including Gradle-module-metadata-only ASF artifacts such -// as SolrJ that POM-only scanners miss — so no per-dependency list is hand-kept. -// It doubles as a gate: a shipped module missing from the SBOM, or carrying a -// license not in config/license-policy.json, fails the build. -// - `generateBinaryNotice` builds the binary NOTICE by aggregating the actual -// META-INF/NOTICE files embedded in the bundled jars (the Maven-Shade -// ApacheNoticeResourceTransformer approach), verbatim and de-duplicated. -// See https://www.apache.org/legal/release-policy.html#licensing-documentation - -val binaryLicenseFile = layout.buildDirectory.file("generated/license/LICENSE") -val binaryNoticeFile = layout.buildDirectory.file("generated/license/NOTICE") - -// What actually ships inside the fat jar is `productionRuntimeClasspath` — it excludes -// test/compile-only AND `developmentOnly` deps (which the bootJar does not bundle). -val shippedClasspath = configurations.named("productionRuntimeClasspath") -val runtimeArtifacts = shippedClasspath.flatMap { it.incoming.artifacts.resolvedArtifacts } - -// Assemble the binary-release LICENSE: base Apache-2.0 + an appendix of every bundled -// dependency and a link to its license, sourced from the CycloneDX SBOM. -val generateBinaryLicense by - tasks.registering { - description = - "Assembles the binary-release LICENSE (Apache-2.0 + third-party appendix from the SBOM)." - group = "documentation" - dependsOn(tasks.named("cyclonedxBom")) - val baseLicense = rootProject.file("LICENSE") - val policyFile = rootProject.file("config/license-policy.json") - val sbomFile = layout.buildDirectory.file("reports/application.cdx.json") - val artifacts = runtimeArtifacts - inputs.file(baseLicense) - inputs.file(policyFile) - inputs.file(sbomFile) - inputs.files(shippedClasspath) - outputs.file(binaryLicenseFile) - doLast { - val slurper = groovy.json.JsonSlurper() - - @Suppress("UNCHECKED_CAST") - val policy = slurper.parse(policyFile) as Map - - @Suppress("UNCHECKED_CAST") - val allowed = ((policy["allowedLicenses"] as? List) ?: emptyList()).toSet() - - @Suppress("UNCHECKED_CAST") - val overrides = (policy["overrides"] as? Map) ?: emptyMap() - - // Licenses of an SBOM component as (label, url?) pairs, de-duped by label. - // (Local lambda, not a local fun/data class — those choke the kts compiler.) - val licsOf = fun(component: Map): List> { - val out = LinkedHashMap() - - @Suppress("UNCHECKED_CAST") - (component["licenses"] as? List>)?.forEach { node -> - @Suppress("UNCHECKED_CAST") - val lo = node["license"] as? Map - if (lo != null) { - val id = lo["id"] as? String - val label = id ?: (lo["name"] as? String) ?: "Unspecified" - val url = - (lo["url"] as? String) - ?: id?.let { "https://spdx.org/licenses/$it.html" } - if (!out.containsKey(label)) out[label] = url - } else { - (node["expression"] as? String)?.let { if (!out.containsKey(it)) out[it] = null } - } - } - return out.map { it.key to it.value } - } - - @Suppress("UNCHECKED_CAST") - val sbom = slurper.parse(sbomFile.get().asFile) as Map - - @Suppress("UNCHECKED_CAST") - val components = (sbom["components"] as? List>) ?: emptyList() - val byGa = HashMap>>() - val byGav = HashMap>>() - components.forEach { c -> - val g = c["group"] as? String - val n = c["name"] as? String - if (g != null && n != null) { - val ls = licsOf(c) - byGa["$g:$n"] = ls - (c["version"] as? String)?.let { byGav["$g:$n:$it"] = ls } - } - } - - // Source of truth for what ships: the resolved runtime artifacts, as - // (group:name, version) pairs, deduped by coordinate and sorted. - val bundled = - artifacts - .get() - .mapNotNull { it.id.componentIdentifier as? ModuleComponentIdentifier } - .map { "${it.group}:${it.module}" to it.version } - .distinctBy { it.first } - .sortedBy { it.first.lowercase() } - - val notInSbom = mutableListOf() - val disallowed = mutableListOf() - val rows = StringBuilder() - bundled.forEach { (ga, version) -> - val lics: List> = - overrides[ga]?.let { listOf(it to ("https://spdx.org/licenses/$it.html" as String?)) } - ?: byGav["$ga:$version"] ?: byGa[ga] ?: emptyList() - if (lics.isEmpty()) { - notInSbom.add("$ga:$version") - return@forEach - } - lics.forEach { (label, _) -> if (label !in allowed) disallowed.add("$ga:$version -> $label") } - rows - .append("- ") - .append(ga) - .append(':') - .append(version) - .append('\n') - lics.forEach { (label, url) -> - rows.append(" License: ").append(label) - if (!url.isNullOrBlank()) rows.append(" — ").append(url) - rows.append('\n') - } - } - if (notInSbom.isNotEmpty()) { - throw GradleException( - "Bundled dependencies absent from the CycloneDX SBOM:\n" + - notInSbom.joinToString("\n") { " - $it" } + - "\nEnsure cyclonedxBom covers the runtime classpath.", - ) - } - if (disallowed.isNotEmpty()) { - throw GradleException( - "Bundled dependencies with a license not in config/license-policy.json:\n" + - disallowed.joinToString("\n") { " - $it" } + - "\nAfter verifying, add the license to allowedLicenses, or add a " + - "group:name -> SPDX-id entry to overrides if the SBOM mislabels it.", - ) - } - - val sb = StringBuilder() - sb.append(baseLicense.readText().trimEnd()).append("\n\n\n") - sb.append("=".repeat(78)).append('\n') - sb.append("APACHE SOLR MCP SERVER — THIRD-PARTY DEPENDENCY LICENSES\n") - sb.append("=".repeat(78)).append("\n\n") - sb.append( - "The binary distribution (the Spring Boot executable JAR) bundles the\n" + - "third-party dependencies listed below, derived from the bundled CycloneDX\n" + - "SBOM (META-INF/sbom/application.cdx.json). Each is provided under the license\n" + - "noted; refer to the linked license text for the full terms.\n\n", - ) - sb.append(rows) - val target = binaryLicenseFile.get().asFile - target.parentFile.mkdirs() - target.writeText(sb.toString()) - } - } - -// Assemble the binary-release NOTICE by lifting the META-INF/NOTICE files embedded in -// the bundled jars (verbatim, de-duplicated) on top of this project's own NOTICE. -val generateBinaryNotice by - tasks.registering { - description = "Assembles the binary-release NOTICE (project NOTICE + bundled dependency notices)." - group = "documentation" - val baseNotice = rootProject.file("NOTICE") - val artifacts = runtimeArtifacts - inputs.file(baseNotice) - inputs.files(shippedClasspath) - outputs.file(binaryNoticeFile) - doLast { - val noticeEntry = Regex("(^|/)META-INF/NOTICE(\\.txt|\\.md)?$", RegexOption.IGNORE_CASE) - val seen = LinkedHashSet() - val sections = StringBuilder() - artifacts - .get() - .mapNotNull { art -> - (art.id.componentIdentifier as? ModuleComponentIdentifier)?.let { it to art.file } - }.sortedBy { "${it.first.group}:${it.first.module}".lowercase() } - .forEach { (id, jar) -> - if (!jar.name.endsWith(".jar")) return@forEach - ZipFile(jar).use { zip -> - zip - .entries() - .asSequence() - .filter { noticeEntry.containsMatchIn(it.name) && !it.isDirectory } - .forEach { entry -> - val text = - zip - .getInputStream(entry) - .bufferedReader() - .readText() - .trim() - if (text.isNotEmpty() && seen.add(text)) { - sections.append("\n").append("-".repeat(78)).append('\n') - sections.append("From ${id.group}:${id.module}:${id.version}:\n\n") - sections.append(text).append('\n') - } - } - } - } - val sb = StringBuilder() - sb.append(baseNotice.readText().trimEnd()).append('\n') - if (sections.isNotEmpty()) { - sb.append("\n\n").append("=".repeat(78)).append('\n') - sb.append("NOTICES FROM BUNDLED THIRD-PARTY DEPENDENCIES (binary distribution)\n") - sb.append("=".repeat(78)).append('\n') - sb.append(sections) - } - val target = binaryNoticeFile.get().asFile - target.parentFile.mkdirs() - target.writeText(sb.toString()) - } - } - -// Source-form artifacts: base LICENSE + NOTICE. -tasks.withType().matching { it.name != "bootJar" }.configureEach { - metaInf { - from(rootProject.file("LICENSE")) - from(rootProject.file("NOTICE")) - } -} - -// Binary artifact (Spring Boot fat jar): generated LICENSE (with SBOM-derived appendix) -// + generated NOTICE (with lifted dependency notices). -tasks.named("bootJar") { - dependsOn(generateBinaryLicense, generateBinaryNotice) - metaInf { - from(binaryLicenseFile) - from(binaryNoticeFile) - } -} - -// Fail the build when a bundled dependency is unaccounted for (generateBinaryLicense gate). -tasks.named("check") { dependsOn(generateBinaryLicense) } - // Maven Publishing Configuration // ============================== // This configuration enables publishing the project artifacts to Maven repositories. diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 00000000..99333e2d --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,24 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + `kotlin-dsl` +} + +repositories { + mavenCentral() +} diff --git a/buildSrc/src/main/kotlin/org.apache.solr.mcp.license-notice.gradle.kts b/buildSrc/src/main/kotlin/org.apache.solr.mcp.license-notice.gradle.kts new file mode 100644 index 00000000..c913c11a --- /dev/null +++ b/buildSrc/src/main/kotlin/org.apache.solr.mcp.license-notice.gradle.kts @@ -0,0 +1,101 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Convention plugin: ASF-compliant LICENSE / NOTICE for the source and binary forms. +// +// ASF policy requires distinct LICENSE/NOTICE for the source form and the binary form, +// because the binary (the Spring Boot fat `bootJar`) bundles third-party bytecode. See +// https://infra.apache.org/licensing-howto.html. This plugin: +// +// - bundles the base Apache-2.0 LICENSE + NOTICE into the source-form jars as-is; +// - generates, for the bootJar, a LICENSE with a third-party appendix derived from the +// CycloneDX SBOM and a NOTICE that lifts bundled dependencies' notices, and a gate +// that fails the build for an unaccounted-for or disallowed dependency. +// +// Apply this AFTER the Spring Boot and CycloneDX plugins so `productionRuntimeClasspath` +// and the `cyclonedxBom` task exist. + +import org.apache.solr.mcp.build.GenerateBinaryLicense +import org.apache.solr.mcp.build.GenerateBinaryNotice +import org.gradle.api.artifacts.component.ModuleComponentIdentifier + +val licenseFile = layout.projectDirectory.file("LICENSE") +val noticeFile = layout.projectDirectory.file("NOTICE") + +// What actually ships inside the fat jar: productionRuntimeClasspath excludes +// test/compile-only and developmentOnly deps that the bootJar does not bundle. +val shippedClasspath = configurations.named("productionRuntimeClasspath") +val shippedArtifacts = shippedClasspath.flatMap { it.incoming.artifacts.resolvedArtifacts } + +val shippedCoordinates = + shippedArtifacts.map { set -> + set.mapNotNull { it.id.componentIdentifier as? ModuleComponentIdentifier } + .map { "${it.group}:${it.module}:${it.version}" } + .distinct() + .sorted() + } + +val jarNameToCoordinate = + shippedArtifacts.map { set -> + set.mapNotNull { artifact -> + (artifact.id.componentIdentifier as? ModuleComponentIdentifier)?.let { id -> + artifact.file.name to "${id.group}:${id.module}:${id.version}" + } + }.toMap() + } + +val generateBinaryLicense = + tasks.register("generateBinaryLicense") { + description = "Assembles the binary-release LICENSE (Apache-2.0 + SBOM-derived appendix)." + group = "documentation" + dependsOn("cyclonedxBom") + baseLicense.set(licenseFile) + policyFile.set(layout.projectDirectory.file("config/license-policy.json")) + sbom.set(layout.buildDirectory.file("reports/application.cdx.json")) + bundledCoordinates.set(shippedCoordinates) + outputFile.set(layout.buildDirectory.file("generated/license/LICENSE")) + } + +val generateBinaryNotice = + tasks.register("generateBinaryNotice") { + description = "Assembles the binary-release NOTICE (project NOTICE + bundled dependency notices)." + group = "documentation" + jars.from(shippedClasspath) + coordinateByJarName.set(jarNameToCoordinate) + baseNotice.set(noticeFile) + outputFile.set(layout.buildDirectory.file("generated/license/NOTICE")) + } + +// Source-form artifacts (thin jar, -sources, -javadoc): base LICENSE + NOTICE as-is. +tasks.withType().matching { it.name != "bootJar" }.configureEach { + metaInf { + from(licenseFile) + from(noticeFile) + } +} + +// Binary artifact (Spring Boot fat jar): generated LICENSE + NOTICE. +tasks.named("bootJar") { + dependsOn(generateBinaryLicense, generateBinaryNotice) + metaInf { + from(generateBinaryLicense.flatMap { it.outputFile }) + from(generateBinaryNotice.flatMap { it.outputFile }) + } +} + +// Gate: a bundled dependency that is unaccounted-for or disallowed fails the build. +tasks.named("check") { dependsOn(generateBinaryLicense) } diff --git a/buildSrc/src/main/kotlin/org/apache/solr/mcp/build/GenerateBinaryLicense.kt b/buildSrc/src/main/kotlin/org/apache/solr/mcp/build/GenerateBinaryLicense.kt new file mode 100644 index 00000000..8d82cf85 --- /dev/null +++ b/buildSrc/src/main/kotlin/org/apache/solr/mcp/build/GenerateBinaryLicense.kt @@ -0,0 +1,174 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.mcp.build + +import groovy.json.JsonSlurper +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction + +/** + * Generates the binary-release `LICENSE`: the base Apache-2.0 text plus an appendix + * listing every bundled dependency and a link to its license. + * + * License data is read from the CycloneDX SBOM (the same SBOM embedded in the bootJar), + * keyed to the [bundledCoordinates] that actually ship. The SBOM resolves a license for + * every component — including Gradle-module-metadata-only artifacts (e.g. SolrJ) that + * POM-only scanners miss — so no per-dependency list is hand-maintained. + * + * The task also gates the build: it fails if a bundled coordinate is absent from the + * SBOM, or carries a license not in the policy's `allowedLicenses`. The policy's + * `overrides` map (group:name -> SPDX id) corrects the few components CycloneDX + * mislabels. + */ +abstract class GenerateBinaryLicense : DefaultTask() { + + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val baseLicense: RegularFileProperty + + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val policyFile: RegularFileProperty + + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val sbom: RegularFileProperty + + /** Shipped dependencies as "group:name:version", the source of truth for what to list. */ + @get:Input + abstract val bundledCoordinates: ListProperty + + @get:OutputFile + abstract val outputFile: RegularFileProperty + + private data class License(val label: String, val url: String?) + + @TaskAction + fun generate() { + val slurper = JsonSlurper() + + @Suppress("UNCHECKED_CAST") + val policy = slurper.parse(policyFile.get().asFile) as Map + val allowed = + (policy["allowedLicenses"] as? List<*>).orEmpty().filterIsInstance().toSet() + + @Suppress("UNCHECKED_CAST") + val overrides = (policy["overrides"] as? Map).orEmpty() + + @Suppress("UNCHECKED_CAST") + val sbomJson = slurper.parse(sbom.get().asFile) as Map + + @Suppress("UNCHECKED_CAST") + val components = (sbomJson["components"] as? List>).orEmpty() + val byGroupArtifact = HashMap>() + val byGroupArtifactVersion = HashMap>() + for (component in components) { + val group = component["group"] as? String ?: continue + val name = component["name"] as? String ?: continue + val licenses = licensesOf(component) + byGroupArtifact["$group:$name"] = licenses + (component["version"] as? String)?.let { byGroupArtifactVersion["$group:$name:$it"] = licenses } + } + + val notInSbom = mutableListOf() + val disallowed = mutableListOf() + val rows = StringBuilder() + for (coordinate in bundledCoordinates.get()) { + val groupArtifact = coordinate.substringBeforeLast(':') + val licenses = + overrides[groupArtifact]?.let { listOf(License(it, spdxUrl(it))) } + ?: byGroupArtifactVersion[coordinate] + ?: byGroupArtifact[groupArtifact] + ?: emptyList() + if (licenses.isEmpty()) { + notInSbom += coordinate + continue + } + for (license in licenses) { + if (license.label !in allowed) disallowed += "$coordinate -> ${license.label}" + } + rows.append("- ").append(coordinate).append('\n') + for (license in licenses) { + rows.append(" License: ").append(license.label) + if (!license.url.isNullOrBlank()) rows.append(" — ").append(license.url) + rows.append('\n') + } + } + if (notInSbom.isNotEmpty()) { + throw GradleException( + "Bundled dependencies absent from the CycloneDX SBOM:\n" + + notInSbom.joinToString("\n") { " - $it" } + + "\nEnsure cyclonedxBom covers the runtime classpath.", + ) + } + if (disallowed.isNotEmpty()) { + throw GradleException( + "Bundled dependencies with a license not in the license policy:\n" + + disallowed.joinToString("\n") { " - $it" } + + "\nAfter verifying, add the license to allowedLicenses, or add a " + + "group:name -> SPDX-id entry to overrides if the SBOM mislabels it.", + ) + } + + val out = outputFile.get().asFile + out.parentFile.mkdirs() + out.writeText(buildString { + append(baseLicense.get().asFile.readText().trimEnd()).append("\n\n\n") + append("=".repeat(78)).append('\n') + append("APACHE SOLR MCP SERVER — THIRD-PARTY DEPENDENCY LICENSES\n") + append("=".repeat(78)).append("\n\n") + append( + "The binary distribution (the Spring Boot executable JAR) bundles the\n" + + "third-party dependencies listed below, derived from the bundled CycloneDX\n" + + "SBOM (META-INF/sbom/application.cdx.json). Each is provided under the license\n" + + "noted; refer to the linked license text for the full terms.\n\n", + ) + append(rows) + }) + } + + /** Distinct (label, url?) licenses of an SBOM component; prefers SPDX id, else name/expression. */ + private fun licensesOf(component: Map): List { + val out = LinkedHashMap() + + @Suppress("UNCHECKED_CAST") + val nodes = component["licenses"] as? List> ?: return emptyList() + for (node in nodes) { + @Suppress("UNCHECKED_CAST") + val license = node["license"] as? Map + if (license != null) { + val id = license["id"] as? String + val label = id ?: (license["name"] as? String) ?: "Unspecified" + val url = (license["url"] as? String) ?: id?.let { spdxUrl(it) } + out.putIfAbsent(label, url) + } else { + (node["expression"] as? String)?.let { out.putIfAbsent(it, null) } + } + } + return out.map { License(it.key, it.value) } + } + + private fun spdxUrl(spdxId: String): String = "https://spdx.org/licenses/$spdxId.html" +} diff --git a/buildSrc/src/main/kotlin/org/apache/solr/mcp/build/GenerateBinaryNotice.kt b/buildSrc/src/main/kotlin/org/apache/solr/mcp/build/GenerateBinaryNotice.kt new file mode 100644 index 00000000..6c6bdebb --- /dev/null +++ b/buildSrc/src/main/kotlin/org/apache/solr/mcp/build/GenerateBinaryNotice.kt @@ -0,0 +1,93 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.mcp.build + +import org.gradle.api.DefaultTask +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.MapProperty +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import java.util.zip.ZipFile + +/** + * Generates the binary-release `NOTICE`: this project's base NOTICE followed by the + * `META-INF/NOTICE` files lifted verbatim (and de-duplicated) from the bundled jars — + * the same approach as Maven Shade's `ApacheNoticeResourceTransformer`, so notices + * required by bundled (notably ASF) dependencies are carried and stay current. + */ +abstract class GenerateBinaryNotice : DefaultTask() { + + @get:InputFiles + abstract val jars: ConfigurableFileCollection + + /** Jar file name -> "group:name:version", used to label each lifted notice. */ + @get:Input + abstract val coordinateByJarName: MapProperty + + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val baseNotice: RegularFileProperty + + @get:OutputFile + abstract val outputFile: RegularFileProperty + + @TaskAction + fun generate() { + val coordinates = coordinateByJarName.get() + val noticeEntry = Regex("(^|/)META-INF/NOTICE(\\.txt|\\.md)?$", RegexOption.IGNORE_CASE) + val seen = LinkedHashSet() + val sections = StringBuilder() + + jars.files + .filter { it.name.endsWith(".jar") } + .sortedBy { coordinates[it.name] ?: it.name } + .forEach { jar -> + val label = coordinates[jar.name] ?: jar.name + ZipFile(jar).use { zip -> + zip.entries().asSequence() + .filter { !it.isDirectory && noticeEntry.containsMatchIn(it.name) } + .forEach { entry -> + val text = + zip.getInputStream(entry).bufferedReader(Charsets.UTF_8).readText().trim() + if (text.isNotEmpty() && seen.add(text)) { + sections.append('\n').append("-".repeat(78)).append('\n') + sections.append("From ").append(label).append(":\n\n") + sections.append(text).append('\n') + } + } + } + } + + val out = outputFile.get().asFile + out.parentFile.mkdirs() + out.writeText(buildString { + append(baseNotice.get().asFile.readText().trimEnd()).append('\n') + if (sections.isNotEmpty()) { + append("\n\n").append("=".repeat(78)).append('\n') + append("NOTICES FROM BUNDLED THIRD-PARTY DEPENDENCIES (binary distribution)\n") + append("=".repeat(78)).append('\n') + append(sections) + } + }) + } +} From 82bacc2d4575b2854a51bd9403420882358694db Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Thu, 11 Jun 2026 11:19:38 -0400 Subject: [PATCH 18/26] test(build): unit-test the LICENSE/NOTICE buildSrc tasks Add ProjectBuilder-based tests for the two convention-plugin tasks (now possible since they live in buildSrc as typed tasks). Covers the correctness-critical behaviour without needing the full spring-boot + cyclonedx stack: - generateBinaryLicense: appendix lists bundled deps with SPDX links, applies a policy override to correct a mislabelled SBOM license, and preserves the base LICENSE text; the gate fails on a disallowed license and on a bundled coordinate absent from the SBOM. - generateBinaryNotice: aggregates bundled META-INF/NOTICE files verbatim, de-duplicates identical notices, attributes each to its module, and emits just the project NOTICE when no dependency notices exist. buildSrc's test task runs as part of `./gradlew build`, so these are enforced on every build. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: adityamparikh --- buildSrc/build.gradle.kts | 9 + .../solr/mcp/build/LicenseNoticeTasksTest.kt | 176 ++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 buildSrc/src/test/kotlin/org/apache/solr/mcp/build/LicenseNoticeTasksTest.kt diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 99333e2d..15760391 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -22,3 +22,12 @@ plugins { repositories { mavenCentral() } + +dependencies { + testImplementation("org.junit.jupiter:junit-jupiter:5.12.2") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.withType().configureEach { + useJUnitPlatform() +} diff --git a/buildSrc/src/test/kotlin/org/apache/solr/mcp/build/LicenseNoticeTasksTest.kt b/buildSrc/src/test/kotlin/org/apache/solr/mcp/build/LicenseNoticeTasksTest.kt new file mode 100644 index 00000000..6fe9c370 --- /dev/null +++ b/buildSrc/src/test/kotlin/org/apache/solr/mcp/build/LicenseNoticeTasksTest.kt @@ -0,0 +1,176 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.mcp.build + +import org.gradle.api.GradleException +import org.gradle.testfixtures.ProjectBuilder +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +class LicenseNoticeTasksTest { + + @TempDir + lateinit var tempDir: File + + // ---- GenerateBinaryLicense ---------------------------------------------------- + + @Test + fun `license appendix lists bundled deps, applies overrides, and keeps the base text`() { + val task = licenseTask() + write("LICENSE", "APACHE-2.0 BASE TEXT").let(task.baseLicense::set) + write( + "policy.json", + """{"allowedLicenses":["Apache-2.0","BSD-3-Clause"], + "overrides":{"org.antlr:ST4":"BSD-3-Clause"}}""", + ).let(task.policyFile::set) + // ST4's SBOM license (BSD-4-Clause) is intentionally NOT allowed; the override fixes it. + write( + "sbom.json", + """{"components":[ + {"group":"org.apache.solr","name":"solr-solrj","version":"10.0.0", + "licenses":[{"license":{"id":"Apache-2.0"}}]}, + {"group":"org.antlr","name":"ST4","version":"4.3.4", + "licenses":[{"license":{"id":"BSD-4-Clause"}}]}]}""", + ).let(task.sbom::set) + task.bundledCoordinates.set(listOf("org.apache.solr:solr-solrj:10.0.0", "org.antlr:ST4:4.3.4")) + val out = File(tempDir, "out/LICENSE") + task.outputFile.set(out) + + task.generate() + + val text = out.readText() + assertTrue(text.startsWith("APACHE-2.0 BASE TEXT"), "base license text must be preserved") + assertTrue(text.contains("- org.apache.solr:solr-solrj:10.0.0"), "SolrJ must be listed") + assertTrue(text.contains("Apache-2.0 — https://spdx.org/licenses/Apache-2.0.html")) + assertTrue(text.contains("BSD-3-Clause"), "override should correct ST4 to BSD-3-Clause") + assertFalse(text.contains("BSD-4-Clause"), "the mislabelled SBOM value must not appear") + } + + @Test + fun `license gate fails when a bundled dependency carries a disallowed license`() { + val task = licenseTask() + write("LICENSE", "BASE").let(task.baseLicense::set) + write("policy.json", """{"allowedLicenses":["Apache-2.0"],"overrides":{}}""").let(task.policyFile::set) + write( + "sbom.json", + """{"components":[{"group":"x","name":"y","version":"1.0", + "licenses":[{"license":{"id":"GPL-3.0-only"}}]}]}""", + ).let(task.sbom::set) + task.bundledCoordinates.set(listOf("x:y:1.0")) + task.outputFile.set(File(tempDir, "out/LICENSE")) + + val ex = assertThrows(GradleException::class.java) { task.generate() } + assertTrue(ex.message!!.contains("not in the license policy")) + assertTrue(ex.message!!.contains("x:y:1.0 -> GPL-3.0-only")) + } + + @Test + fun `license gate fails when a bundled dependency is absent from the SBOM`() { + val task = licenseTask() + write("LICENSE", "BASE").let(task.baseLicense::set) + write("policy.json", """{"allowedLicenses":["Apache-2.0"],"overrides":{}}""").let(task.policyFile::set) + write("sbom.json", """{"components":[]}""").let(task.sbom::set) + task.bundledCoordinates.set(listOf("missing:dep:1.0")) + task.outputFile.set(File(tempDir, "out/LICENSE")) + + val ex = assertThrows(GradleException::class.java) { task.generate() } + assertTrue(ex.message!!.contains("absent from the CycloneDX SBOM")) + assertTrue(ex.message!!.contains("missing:dep:1.0")) + } + + // ---- GenerateBinaryNotice ----------------------------------------------------- + + @Test + fun `notice aggregates bundled notices verbatim, de-duplicated and labelled`() { + val task = noticeTask() + write("NOTICE", "PROJECT NOTICE").let(task.baseNotice::set) + val jarA = jarWithNotice("a.jar", "Shared notice text") + val jarB = jarWithNotice("b.jar", "Shared notice text") // duplicate -> collapsed + val jarC = jarWithNotice("c.jar", "Unique C notice") + task.jars.from(jarA, jarB, jarC) + task.coordinateByJarName.set( + mapOf("a.jar" to "g:a:1", "b.jar" to "g:b:1", "c.jar" to "g:c:1"), + ) + val out = File(tempDir, "out/NOTICE") + task.outputFile.set(out) + + task.generate() + + val text = out.readText() + assertTrue(text.startsWith("PROJECT NOTICE"), "project NOTICE must lead") + assertEquals(1, occurrences(text, "Shared notice text"), "duplicate notices must collapse to one") + assertTrue(text.contains("Unique C notice")) + assertTrue(text.contains("From g:c:1:"), "each lifted notice must be attributed to its module") + } + + @Test + fun `notice with no dependency notices is just the project notice`() { + val task = noticeTask() + write("NOTICE", "PROJECT NOTICE").let(task.baseNotice::set) + task.jars.from(jarWithoutNotice("plain.jar")) + task.coordinateByJarName.set(mapOf("plain.jar" to "g:p:1")) + val out = File(tempDir, "out/NOTICE") + task.outputFile.set(out) + + task.generate() + + val text = out.readText() + assertTrue(text.startsWith("PROJECT NOTICE")) + assertFalse(text.contains("NOTICES FROM BUNDLED"), "no section header when there are no lifted notices") + } + + // ---- helpers ------------------------------------------------------------------ + + private fun project() = ProjectBuilder.builder().withProjectDir(tempDir).build() + + private fun licenseTask() = + project().tasks.register("generateBinaryLicense", GenerateBinaryLicense::class.java).get() + + private fun noticeTask() = + project().tasks.register("generateBinaryNotice", GenerateBinaryNotice::class.java).get() + + private fun write(name: String, content: String): File = + File(tempDir, name).apply { parentFile.mkdirs(); writeText(content.trimIndent()) } + + private fun jarWithNotice(name: String, notice: String): File = + File(tempDir, name).also { jar -> + ZipOutputStream(jar.outputStream()).use { zip -> + zip.putNextEntry(ZipEntry("META-INF/NOTICE")) + zip.write(notice.toByteArray()) + zip.closeEntry() + } + } + + private fun jarWithoutNotice(name: String): File = + File(tempDir, name).also { jar -> + ZipOutputStream(jar.outputStream()).use { zip -> + zip.putNextEntry(ZipEntry("META-INF/MANIFEST.MF")) + zip.write("Manifest-Version: 1.0\n".toByteArray()) + zip.closeEntry() + } + } + + private fun occurrences(haystack: String, needle: String): Int = + haystack.split(needle).size - 1 +} From da3c5d859536113ad45d254b9c3a12d59ba40a3c Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Thu, 11 Jun 2026 11:25:08 -0400 Subject: [PATCH 19/26] docs(build): explain the LICENSE/NOTICE tasks in comments and AGENTS.md Add step-by-step comments to GenerateBinaryLicense/GenerateBinaryNotice walking through what each phase does (load policy, index the SBOM, resolve+gate each shipped dependency, write the file; and notice matching/de-dup/attribution). Expand the AGENTS.md "Release LICENSE / NOTICE" section with where the tasks are unit-tested and a short runbook for what to do when the license gate fails (add an override for an SBOM mislabel, or allow a genuinely new license) instead of silencing it. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: adityamparikh --- AGENTS.md | 9 ++++++++- .../solr/mcp/build/GenerateBinaryLicense.kt | 15 +++++++++++++++ .../apache/solr/mcp/build/GenerateBinaryNotice.kt | 10 ++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 4284b3aa..59ee409a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -254,7 +254,14 @@ See [infra.apache.org/licensing-howto](https://infra.apache.org/licensing-howto. - **Implementation:** the `org.apache.solr.mcp.license-notice` convention plugin in `buildSrc/` (typed `GenerateBinaryLicense` / `GenerateBinaryNotice` tasks). The root `build.gradle.kts` only applies the plugin; the policy lives in - `config/license-policy.json`. + `config/license-policy.json`. The tasks are unit-tested in + `buildSrc/src/test/kotlin/.../LicenseNoticeTasksTest.kt` (the appendix/override logic, + both gate failures, and NOTICE de-duplication); `buildSrc`'s `test` runs as part of + `./gradlew build`. +- **When a build fails on the license gate:** read the error — it lists the offending + `group:name:version`. If the SBOM mislabels a license, add a + `group:name → SPDX-id` entry to `overrides`; if it's a genuinely new license that is + ASF-redistributable, add it to `allowedLicenses` after review. Do not silence the gate. ## Testing Structure diff --git a/buildSrc/src/main/kotlin/org/apache/solr/mcp/build/GenerateBinaryLicense.kt b/buildSrc/src/main/kotlin/org/apache/solr/mcp/build/GenerateBinaryLicense.kt index 8d82cf85..4825f778 100644 --- a/buildSrc/src/main/kotlin/org/apache/solr/mcp/build/GenerateBinaryLicense.kt +++ b/buildSrc/src/main/kotlin/org/apache/solr/mcp/build/GenerateBinaryLicense.kt @@ -69,6 +69,8 @@ abstract class GenerateBinaryLicense : DefaultTask() { fun generate() { val slurper = JsonSlurper() + // 1. Load the policy: the set of licenses allowed in a binary release, plus + // group:name -> SPDX-id corrections for components CycloneDX mislabels. @Suppress("UNCHECKED_CAST") val policy = slurper.parse(policyFile.get().asFile) as Map val allowed = @@ -77,6 +79,9 @@ abstract class GenerateBinaryLicense : DefaultTask() { @Suppress("UNCHECKED_CAST") val overrides = (policy["overrides"] as? Map).orEmpty() + // 2. Index every SBOM component's licenses by "group:name" and "group:name:version". + // The version-keyed map is preferred so the exact shipped version wins; the + // coarser key is the fallback when versions differ between SBOM and classpath. @Suppress("UNCHECKED_CAST") val sbomJson = slurper.parse(sbom.get().asFile) as Map @@ -92,6 +97,9 @@ abstract class GenerateBinaryLicense : DefaultTask() { (component["version"] as? String)?.let { byGroupArtifactVersion["$group:$name:$it"] = licenses } } + // 3. For each dependency that actually ships, resolve its license(s) — an override + // wins, else the SBOM lookup — and accumulate both the appendix text and two + // failure lists: deps the SBOM doesn't cover, and deps whose license isn't allowed. val notInSbom = mutableListOf() val disallowed = mutableListOf() val rows = StringBuilder() @@ -116,6 +124,11 @@ abstract class GenerateBinaryLicense : DefaultTask() { rows.append('\n') } } + + // 4. Gate the build: an uncovered or disallowed dependency must never ship silently + // in the binary LICENSE — fail loudly with the offending coordinates so the + // policy/SBOM is corrected before release. This is the "verify new deps are + // accounted for" check. if (notInSbom.isNotEmpty()) { throw GradleException( "Bundled dependencies absent from the CycloneDX SBOM:\n" + @@ -132,6 +145,8 @@ abstract class GenerateBinaryLicense : DefaultTask() { ) } + // 5. Write the binary LICENSE: the base Apache-2.0 text, then the generated + // third-party appendix. val out = outputFile.get().asFile out.parentFile.mkdirs() out.writeText(buildString { diff --git a/buildSrc/src/main/kotlin/org/apache/solr/mcp/build/GenerateBinaryNotice.kt b/buildSrc/src/main/kotlin/org/apache/solr/mcp/build/GenerateBinaryNotice.kt index 6c6bdebb..b4cef776 100644 --- a/buildSrc/src/main/kotlin/org/apache/solr/mcp/build/GenerateBinaryNotice.kt +++ b/buildSrc/src/main/kotlin/org/apache/solr/mcp/build/GenerateBinaryNotice.kt @@ -54,10 +54,16 @@ abstract class GenerateBinaryNotice : DefaultTask() { @TaskAction fun generate() { val coordinates = coordinateByJarName.get() + // Match the conventional notice file names (NOTICE, NOTICE.txt, NOTICE.md) at the + // root of META-INF, case-insensitively. val noticeEntry = Regex("(^|/)META-INF/NOTICE(\\.txt|\\.md)?$", RegexOption.IGNORE_CASE) + // Tracks notice bodies already emitted so identical notices (common across related + // modules, e.g. a multi-module library) appear once. val seen = LinkedHashSet() val sections = StringBuilder() + // Walk the bundled jars in a stable order (by module coordinate) so the output is + // reproducible, and lift each jar's NOTICE entry verbatim. jars.files .filter { it.name.endsWith(".jar") } .sortedBy { coordinates[it.name] ?: it.name } @@ -69,6 +75,8 @@ abstract class GenerateBinaryNotice : DefaultTask() { .forEach { entry -> val text = zip.getInputStream(entry).bufferedReader(Charsets.UTF_8).readText().trim() + // Only the first occurrence of a given notice body is kept, + // attributed to the module it came from. if (text.isNotEmpty() && seen.add(text)) { sections.append('\n').append("-".repeat(78)).append('\n') sections.append("From ").append(label).append(":\n\n") @@ -78,6 +86,8 @@ abstract class GenerateBinaryNotice : DefaultTask() { } } + // Write the binary NOTICE: this project's NOTICE, then the aggregated dependency + // notices under a header (omitted entirely if no dependency ships a NOTICE). val out = outputFile.get().asFile out.parentFile.mkdirs() out.writeText(buildString { From 748a70b15d923b95a28301930f61106a9db3a28c Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Thu, 11 Jun 2026 11:37:07 -0400 Subject: [PATCH 20/26] refactor(build): drop license-policy.json; disclose SBOM licenses verbatim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apache/solr has no license allow-list (it uses a per-dependency licenses/ folder, which JanHoy said not to replicate), and the binary LICENSE is a disclosure, not a license policy. Remove config/license-policy.json and the allow-list gate + override corrections it powered. generateBinaryLicense now lists each shipped dependency with the license the CycloneDX SBOM reports, verbatim — so a few imprecise-but-permissive upstream labels appear as-is (mcp-server-security: Apache-1.0; ANTLR: BSD-4-Clause / BSD licence). The appendix preamble says licenses are as-reported and links each one. The remaining gate is completeness only: fail if a bundled dependency is absent from the SBOM, so nothing is silently omitted from the LICENSE. Tests updated to assert verbatim SBOM labels and SBOM name/URL handling. Verified: ./gradlew build green; fat-jar LICENSE still lists 158 deps and NOTICE aggregates 21 upstream notices. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: adityamparikh --- AGENTS.md | 28 ++++---- ....apache.solr.mcp.license-notice.gradle.kts | 8 +-- .../solr/mcp/build/GenerateBinaryLicense.kt | 70 ++++++------------- .../solr/mcp/build/LicenseNoticeTasksTest.kt | 33 ++++----- config/license-policy.json | 32 --------- 5 files changed, 54 insertions(+), 117 deletions(-) delete mode 100644 config/license-policy.json diff --git a/AGENTS.md b/AGENTS.md index 59ee409a..a83b33b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -243,25 +243,25 @@ See [infra.apache.org/licensing-howto](https://infra.apache.org/licensing-howto. - `generateBinaryNotice` → `NOTICE` = base NOTICE + the `META-INF/NOTICE` files lifted verbatim (de-duplicated) from the bundled jars (Maven-Shade `ApacheNoticeResourceTransformer` approach). -- **Gate** (`generateBinaryLicense`, run as part of `check`/`build`): fails if a bundled - dependency is missing from the SBOM, or carries a license not in - `config/license-policy.json`. That file holds the `allowedLicenses` set plus - `overrides` (group:name → SPDX id) to correct the few components CycloneDX mislabels - (e.g. `mcp-server-security` → Apache-2.0; ANTLR `ST4`/`antlr-runtime` → BSD-3-Clause). +- **Licenses are disclosed as the SBOM reports them** (SPDX ids where available). The + appendix is a disclosure, not a license policy: there is **no allow-list and no + corrections**, so a few imprecise-but-permissive upstream labels appear as-is (e.g. + `mcp-server-security` shows `Apache-1.0`, ANTLR shows `BSD-4-Clause`/`BSD licence`); the + appendix preamble says so and links each license. All bundled deps are ASF Category A/B. +- **Completeness gate** (`generateBinaryLicense`, run as part of `check`/`build`): the + *only* gate — fails if a bundled dependency is missing from the SBOM, so a dependency + can never be silently omitted from the LICENSE. It makes no judgement about which + licenses are acceptable. (Unlike apache/solr's `solr/licenses/` folder, which JanHoy + said not to replicate, there is no per-dependency license/checksum store here.) - This builds on the SBOM generation (see **SBOM Architecture**); the SBOM remains the machine-readable bill of materials, and LICENSE/NOTICE are the human-readable legal artifacts derived from it. - **Implementation:** the `org.apache.solr.mcp.license-notice` convention plugin in `buildSrc/` (typed `GenerateBinaryLicense` / `GenerateBinaryNotice` tasks). The root - `build.gradle.kts` only applies the plugin; the policy lives in - `config/license-policy.json`. The tasks are unit-tested in - `buildSrc/src/test/kotlin/.../LicenseNoticeTasksTest.kt` (the appendix/override logic, - both gate failures, and NOTICE de-duplication); `buildSrc`'s `test` runs as part of - `./gradlew build`. -- **When a build fails on the license gate:** read the error — it lists the offending - `group:name:version`. If the SBOM mislabels a license, add a - `group:name → SPDX-id` entry to `overrides`; if it's a genuinely new license that is - ASF-redistributable, add it to `allowedLicenses` after review. Do not silence the gate. + `build.gradle.kts` only applies the plugin. The tasks are unit-tested in + `buildSrc/src/test/kotlin/.../LicenseNoticeTasksTest.kt` (appendix listing, SBOM + name/URL handling, the completeness gate, and NOTICE de-duplication); `buildSrc`'s + `test` runs as part of `./gradlew build`. ## Testing Structure diff --git a/buildSrc/src/main/kotlin/org.apache.solr.mcp.license-notice.gradle.kts b/buildSrc/src/main/kotlin/org.apache.solr.mcp.license-notice.gradle.kts index c913c11a..0a1ce64c 100644 --- a/buildSrc/src/main/kotlin/org.apache.solr.mcp.license-notice.gradle.kts +++ b/buildSrc/src/main/kotlin/org.apache.solr.mcp.license-notice.gradle.kts @@ -23,8 +23,9 @@ // // - bundles the base Apache-2.0 LICENSE + NOTICE into the source-form jars as-is; // - generates, for the bootJar, a LICENSE with a third-party appendix derived from the -// CycloneDX SBOM and a NOTICE that lifts bundled dependencies' notices, and a gate -// that fails the build for an unaccounted-for or disallowed dependency. +// CycloneDX SBOM and a NOTICE that lifts bundled dependencies' notices, with a +// completeness gate that fails the build if a bundled dependency is missing from the +// SBOM. Licenses are disclosed as the SBOM reports them; there is no license policy. // // Apply this AFTER the Spring Boot and CycloneDX plugins so `productionRuntimeClasspath` // and the `cyclonedxBom` task exist. @@ -64,7 +65,6 @@ val generateBinaryLicense = group = "documentation" dependsOn("cyclonedxBom") baseLicense.set(licenseFile) - policyFile.set(layout.projectDirectory.file("config/license-policy.json")) sbom.set(layout.buildDirectory.file("reports/application.cdx.json")) bundledCoordinates.set(shippedCoordinates) outputFile.set(layout.buildDirectory.file("generated/license/LICENSE")) @@ -97,5 +97,5 @@ tasks.named("bootJar") { } } -// Gate: a bundled dependency that is unaccounted-for or disallowed fails the build. +// Completeness gate: a bundled dependency missing from the SBOM fails the build. tasks.named("check") { dependsOn(generateBinaryLicense) } diff --git a/buildSrc/src/main/kotlin/org/apache/solr/mcp/build/GenerateBinaryLicense.kt b/buildSrc/src/main/kotlin/org/apache/solr/mcp/build/GenerateBinaryLicense.kt index 4825f778..af653323 100644 --- a/buildSrc/src/main/kotlin/org/apache/solr/mcp/build/GenerateBinaryLicense.kt +++ b/buildSrc/src/main/kotlin/org/apache/solr/mcp/build/GenerateBinaryLicense.kt @@ -30,17 +30,18 @@ import org.gradle.api.tasks.TaskAction /** * Generates the binary-release `LICENSE`: the base Apache-2.0 text plus an appendix - * listing every bundled dependency and a link to its license. + * listing every bundled dependency and the license the CycloneDX SBOM reports for it. * - * License data is read from the CycloneDX SBOM (the same SBOM embedded in the bootJar), - * keyed to the [bundledCoordinates] that actually ship. The SBOM resolves a license for - * every component — including Gradle-module-metadata-only artifacts (e.g. SolrJ) that - * POM-only scanners miss — so no per-dependency list is hand-maintained. + * License data is read from the SBOM (the same SBOM embedded in the bootJar), keyed to + * the [bundledCoordinates] that actually ship. The SBOM resolves a license for every + * component — including Gradle-module-metadata-only artifacts (e.g. SolrJ) that POM-only + * scanners miss — so no per-dependency list is hand-maintained. * - * The task also gates the build: it fails if a bundled coordinate is absent from the - * SBOM, or carries a license not in the policy's `allowedLicenses`. The policy's - * `overrides` map (group:name -> SPDX id) corrects the few components CycloneDX - * mislabels. + * Licenses are reported **as the SBOM declares them**; the appendix is a disclosure, not + * a license policy, so it carries no allow-list and applies no corrections (a few + * upstream POMs report imprecise but still-permissive identifiers). The task's only gate + * is completeness: it fails if a bundled coordinate is absent from the SBOM, so a + * dependency can never be silently omitted from the LICENSE. */ abstract class GenerateBinaryLicense : DefaultTask() { @@ -48,10 +49,6 @@ abstract class GenerateBinaryLicense : DefaultTask() { @get:PathSensitive(PathSensitivity.NONE) abstract val baseLicense: RegularFileProperty - @get:InputFile - @get:PathSensitive(PathSensitivity.NONE) - abstract val policyFile: RegularFileProperty - @get:InputFile @get:PathSensitive(PathSensitivity.NONE) abstract val sbom: RegularFileProperty @@ -69,17 +66,7 @@ abstract class GenerateBinaryLicense : DefaultTask() { fun generate() { val slurper = JsonSlurper() - // 1. Load the policy: the set of licenses allowed in a binary release, plus - // group:name -> SPDX-id corrections for components CycloneDX mislabels. - @Suppress("UNCHECKED_CAST") - val policy = slurper.parse(policyFile.get().asFile) as Map - val allowed = - (policy["allowedLicenses"] as? List<*>).orEmpty().filterIsInstance().toSet() - - @Suppress("UNCHECKED_CAST") - val overrides = (policy["overrides"] as? Map).orEmpty() - - // 2. Index every SBOM component's licenses by "group:name" and "group:name:version". + // 1. Index every SBOM component's licenses by "group:name" and "group:name:version". // The version-keyed map is preferred so the exact shipped version wins; the // coarser key is the fallback when versions differ between SBOM and classpath. @Suppress("UNCHECKED_CAST") @@ -97,26 +84,20 @@ abstract class GenerateBinaryLicense : DefaultTask() { (component["version"] as? String)?.let { byGroupArtifactVersion["$group:$name:$it"] = licenses } } - // 3. For each dependency that actually ships, resolve its license(s) — an override - // wins, else the SBOM lookup — and accumulate both the appendix text and two - // failure lists: deps the SBOM doesn't cover, and deps whose license isn't allowed. + // 2. For each dependency that actually ships, look up its license(s) in the SBOM and + // append a row. Collect any coordinate the SBOM does not cover for the gate below. val notInSbom = mutableListOf() - val disallowed = mutableListOf() val rows = StringBuilder() for (coordinate in bundledCoordinates.get()) { val groupArtifact = coordinate.substringBeforeLast(':') val licenses = - overrides[groupArtifact]?.let { listOf(License(it, spdxUrl(it))) } - ?: byGroupArtifactVersion[coordinate] + byGroupArtifactVersion[coordinate] ?: byGroupArtifact[groupArtifact] ?: emptyList() if (licenses.isEmpty()) { notInSbom += coordinate continue } - for (license in licenses) { - if (license.label !in allowed) disallowed += "$coordinate -> ${license.label}" - } rows.append("- ").append(coordinate).append('\n') for (license in licenses) { rows.append(" License: ").append(license.label) @@ -125,10 +106,9 @@ abstract class GenerateBinaryLicense : DefaultTask() { } } - // 4. Gate the build: an uncovered or disallowed dependency must never ship silently - // in the binary LICENSE — fail loudly with the offending coordinates so the - // policy/SBOM is corrected before release. This is the "verify new deps are - // accounted for" check. + // 3. Completeness gate: a shipped dependency missing from the SBOM would be silently + // omitted from the LICENSE, so fail loudly. This is the "verify bundled deps are + // accounted for" check; it makes no judgement about which licenses are acceptable. if (notInSbom.isNotEmpty()) { throw GradleException( "Bundled dependencies absent from the CycloneDX SBOM:\n" + @@ -136,16 +116,8 @@ abstract class GenerateBinaryLicense : DefaultTask() { "\nEnsure cyclonedxBom covers the runtime classpath.", ) } - if (disallowed.isNotEmpty()) { - throw GradleException( - "Bundled dependencies with a license not in the license policy:\n" + - disallowed.joinToString("\n") { " - $it" } + - "\nAfter verifying, add the license to allowedLicenses, or add a " + - "group:name -> SPDX-id entry to overrides if the SBOM mislabels it.", - ) - } - // 5. Write the binary LICENSE: the base Apache-2.0 text, then the generated + // 4. Write the binary LICENSE: the base Apache-2.0 text, then the generated // third-party appendix. val out = outputFile.get().asFile out.parentFile.mkdirs() @@ -157,8 +129,10 @@ abstract class GenerateBinaryLicense : DefaultTask() { append( "The binary distribution (the Spring Boot executable JAR) bundles the\n" + "third-party dependencies listed below, derived from the bundled CycloneDX\n" + - "SBOM (META-INF/sbom/application.cdx.json). Each is provided under the license\n" + - "noted; refer to the linked license text for the full terms.\n\n", + "SBOM (META-INF/sbom/application.cdx.json). License identifiers are reported\n" + + "as the SBOM declares them (SPDX ids where available) and a few may be\n" + + "imprecise; consult each dependency's own license for the authoritative\n" + + "terms via the link shown.\n\n", ) append(rows) }) diff --git a/buildSrc/src/test/kotlin/org/apache/solr/mcp/build/LicenseNoticeTasksTest.kt b/buildSrc/src/test/kotlin/org/apache/solr/mcp/build/LicenseNoticeTasksTest.kt index 6fe9c370..457ccd6d 100644 --- a/buildSrc/src/test/kotlin/org/apache/solr/mcp/build/LicenseNoticeTasksTest.kt +++ b/buildSrc/src/test/kotlin/org/apache/solr/mcp/build/LicenseNoticeTasksTest.kt @@ -36,15 +36,11 @@ class LicenseNoticeTasksTest { // ---- GenerateBinaryLicense ---------------------------------------------------- @Test - fun `license appendix lists bundled deps, applies overrides, and keeps the base text`() { + fun `license appendix lists bundled deps with the SBOM-reported licenses and keeps the base text`() { val task = licenseTask() write("LICENSE", "APACHE-2.0 BASE TEXT").let(task.baseLicense::set) - write( - "policy.json", - """{"allowedLicenses":["Apache-2.0","BSD-3-Clause"], - "overrides":{"org.antlr:ST4":"BSD-3-Clause"}}""", - ).let(task.policyFile::set) - // ST4's SBOM license (BSD-4-Clause) is intentionally NOT allowed; the override fixes it. + // Licenses are disclosed exactly as the SBOM reports them (no allow-list, no + // corrections) — including ST4's imprecise-but-permissive BSD-4-Clause. write( "sbom.json", """{"components":[ @@ -63,33 +59,32 @@ class LicenseNoticeTasksTest { assertTrue(text.startsWith("APACHE-2.0 BASE TEXT"), "base license text must be preserved") assertTrue(text.contains("- org.apache.solr:solr-solrj:10.0.0"), "SolrJ must be listed") assertTrue(text.contains("Apache-2.0 — https://spdx.org/licenses/Apache-2.0.html")) - assertTrue(text.contains("BSD-3-Clause"), "override should correct ST4 to BSD-3-Clause") - assertFalse(text.contains("BSD-4-Clause"), "the mislabelled SBOM value must not appear") + assertTrue(text.contains("BSD-4-Clause"), "SBOM license must be reported verbatim") } @Test - fun `license gate fails when a bundled dependency carries a disallowed license`() { + fun `license uses a name and URL from the SBOM when there is no SPDX id`() { val task = licenseTask() write("LICENSE", "BASE").let(task.baseLicense::set) - write("policy.json", """{"allowedLicenses":["Apache-2.0"],"overrides":{}}""").let(task.policyFile::set) write( "sbom.json", - """{"components":[{"group":"x","name":"y","version":"1.0", - "licenses":[{"license":{"id":"GPL-3.0-only"}}]}]}""", + """{"components":[{"group":"org.antlr","name":"antlr-runtime","version":"3.5.3", + "licenses":[{"license":{"name":"BSD licence","url":"http://antlr.org/license.html"}}]}]}""", ).let(task.sbom::set) - task.bundledCoordinates.set(listOf("x:y:1.0")) - task.outputFile.set(File(tempDir, "out/LICENSE")) + task.bundledCoordinates.set(listOf("org.antlr:antlr-runtime:3.5.3")) + val out = File(tempDir, "out/LICENSE") + task.outputFile.set(out) - val ex = assertThrows(GradleException::class.java) { task.generate() } - assertTrue(ex.message!!.contains("not in the license policy")) - assertTrue(ex.message!!.contains("x:y:1.0 -> GPL-3.0-only")) + task.generate() + + val text = out.readText() + assertTrue(text.contains("License: BSD licence — http://antlr.org/license.html")) } @Test fun `license gate fails when a bundled dependency is absent from the SBOM`() { val task = licenseTask() write("LICENSE", "BASE").let(task.baseLicense::set) - write("policy.json", """{"allowedLicenses":["Apache-2.0"],"overrides":{}}""").let(task.policyFile::set) write("sbom.json", """{"components":[]}""").let(task.sbom::set) task.bundledCoordinates.set(listOf("missing:dep:1.0")) task.outputFile.set(File(tempDir, "out/LICENSE")) diff --git a/config/license-policy.json b/config/license-policy.json deleted file mode 100644 index 6455b714..00000000 --- a/config/license-policy.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "_comment": [ - "License policy for the binary-release LICENSE appendix (generateBinaryLicense).", - "The appendix is derived from the CycloneDX SBOM, filtered to the shipped", - "productionRuntimeClasspath. The build fails if a bundled dependency carries a", - "license not in 'allowedLicenses' (review it, then add it), or if a bundled", - "dependency is missing from the SBOM. All entries below are ASF Category A or B", - "(redistributable in a binary release).", - "", - "'overrides' maps group:name -> SPDX id to correct components CycloneDX mislabels,", - "verified against their upstream project: mcp-server-security is Apache-2.0 (the", - "SBOM reports the obsolete 'Apache-1.0'); ANTLR ST4 / antlr-runtime are BSD-3-Clause", - "(the SBOM reports 'BSD-4-Clause' / 'BSD licence')." - ], - "allowedLicenses": [ - "Apache-2.0", - "MIT", - "MIT-0", - "EPL-2.0", - "BSD-2-Clause", - "BSD-3-Clause", - "GPL-2.0-with-classpath-exception", - "GNU Lesser General Public License", - "CC0-1.0", - "Public Domain" - ], - "overrides": { - "org.springaicommunity:mcp-server-security": "Apache-2.0", - "org.antlr:ST4": "BSD-3-Clause", - "org.antlr:antlr-runtime": "BSD-3-Clause" - } -} From 3ceb2a7e43458ecb7840d085db243f413686c4d7 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Thu, 11 Jun 2026 14:51:22 -0400 Subject: [PATCH 21/26] docs(build): point the LICENSE appendix to the bundled SBOM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a line to the appendix preamble noting the machine-readable bill of materials (component versions, hashes, licenses) is bundled at META-INF/sbom/application.cdx.json — the inline appendix stays the human-readable disclosure, with the SBOM offered for tooling. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: adityamparikh --- .../org/apache/solr/mcp/build/GenerateBinaryLicense.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/buildSrc/src/main/kotlin/org/apache/solr/mcp/build/GenerateBinaryLicense.kt b/buildSrc/src/main/kotlin/org/apache/solr/mcp/build/GenerateBinaryLicense.kt index af653323..644b924f 100644 --- a/buildSrc/src/main/kotlin/org/apache/solr/mcp/build/GenerateBinaryLicense.kt +++ b/buildSrc/src/main/kotlin/org/apache/solr/mcp/build/GenerateBinaryLicense.kt @@ -129,10 +129,11 @@ abstract class GenerateBinaryLicense : DefaultTask() { append( "The binary distribution (the Spring Boot executable JAR) bundles the\n" + "third-party dependencies listed below, derived from the bundled CycloneDX\n" + - "SBOM (META-INF/sbom/application.cdx.json). License identifiers are reported\n" + - "as the SBOM declares them (SPDX ids where available) and a few may be\n" + - "imprecise; consult each dependency's own license for the authoritative\n" + - "terms via the link shown.\n\n", + "SBOM. License identifiers are reported as the SBOM declares them (SPDX ids\n" + + "where available) and a few may be imprecise; consult each dependency's own\n" + + "license for the authoritative terms via the link shown. A machine-readable\n" + + "bill of materials (component versions, hashes, and licenses) is also bundled\n" + + "at META-INF/sbom/application.cdx.json.\n\n", ) append(rows) }) From d2711c9df46b4f712fd33d90dcfc6abb4e79dc8d Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Thu, 11 Jun 2026 15:18:44 -0400 Subject: [PATCH 22/26] docs: document where/when the binary LICENSE & NOTICE are available Add a 'where / when they appear' note to the Release LICENSE / NOTICE section: both binary files are regenerated on every build (tasks run ahead of bootJar and in check), land at META-INF/LICENSE and META-INF/NOTICE in the fat jar and thus in every published Docker image, and are also written to build/generated/license/ for local viewing; source-form jars carry the repo-root base files. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: adityamparikh --- AGENTS.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index a83b33b9..e301badf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -243,6 +243,16 @@ See [infra.apache.org/licensing-howto](https://infra.apache.org/licensing-howto. - `generateBinaryNotice` → `NOTICE` = base NOTICE + the `META-INF/NOTICE` files lifted verbatim (de-duplicated) from the bundled jars (Maven-Shade `ApacheNoticeResourceTransformer` approach). +- **Where / when they appear:** both binary files are regenerated on every build — the + two tasks run ahead of `bootJar` (and in `check`), so any `./gradlew build` / `bootJar` + produces them. They live at `META-INF/LICENSE` and `META-INF/NOTICE` inside the fat jar + (`build/libs/solr-mcp-.jar`), and therefore inside every published **Docker image** + too, since the Jib JVM image and the Paketo native images both package the bootJar + contents. Inspect a built artifact with + `unzip -p build/libs/solr-mcp-.jar META-INF/LICENSE` (or `META-INF/NOTICE`); the + generator also writes them to `build/generated/license/` for local viewing. The + source-form jars (thin `jar`, `-sources`, `-javadoc`) instead carry the repo-root base + files unchanged. - **Licenses are disclosed as the SBOM reports them** (SPDX ids where available). The appendix is a disclosure, not a license policy: there is **no allow-list and no corrections**, so a few imprecise-but-permissive upstream labels appear as-is (e.g. From 033c03e8c4e1ce93070a9a4543061313f694918d Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Thu, 11 Jun 2026 16:20:47 -0400 Subject: [PATCH 23/26] docs(build): explain the buildSrc LICENSE/NOTICE plugin for non-Gradle readers Reviewers who don't work with Gradle had no easy way into buildSrc. Add: - buildSrc/README.md: what buildSrc is, a short glossary of the Gradle concepts the code uses (Task, @TaskAction, the input/output annotations, Property/Provider types, convention plugin, productionRuntimeClasspath), and the end-to-end flow. - KDoc on GenerateBinaryLicense / GenerateBinaryNotice: a "for readers new to Gradle" orientation on each class plus a note on every annotated property explaining what the input/output annotation does (up-to-date checking, ordering). - A note on the convention plugin header explaining precompiled script plugins, and a comment on buildSrc/build.gradle.kts explaining what it builds. Documentation only; no behaviour change. ./gradlew :buildSrc:test green. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: adityamparikh --- buildSrc/README.md | 90 +++++++++++++++++++ buildSrc/build.gradle.kts | 5 ++ ....apache.solr.mcp.license-notice.gradle.kts | 8 ++ .../solr/mcp/build/GenerateBinaryLicense.kt | 28 +++++- .../solr/mcp/build/GenerateBinaryNotice.kt | 22 ++++- 5 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 buildSrc/README.md diff --git a/buildSrc/README.md b/buildSrc/README.md new file mode 100644 index 00000000..4d9bdff9 --- /dev/null +++ b/buildSrc/README.md @@ -0,0 +1,90 @@ + + +# buildSrc — generating the binary LICENSE & NOTICE + +This directory holds the build logic that assembles the **binary-release `LICENSE` +and `NOTICE`** files (the ones bundled inside the executable JAR). It is written in +Kotlin. If you don't work with Gradle day-to-day, this README explains what each piece +is and how they fit together; the end-user view of *what* these files contain lives on +the [Licensing & Notices](https://solr.apache.org/mcp/licensing.html) docs page. + +## What is `buildSrc`? + +`buildSrc` is a Gradle convention: **any code you put under `buildSrc/` is compiled +automatically before the main build and made available to `build.gradle.kts`.** You +don't declare a dependency on it or publish it anywhere — Gradle just picks it up. It is +the standard place to keep custom build logic so the root `build.gradle.kts` stays +small. (Think of it as a tiny library that only this project's build uses.) + +## What's in here + +| File | Role | +|------|------| +| `src/main/kotlin/.../GenerateBinaryLicense.kt` | A custom Gradle **task** that writes the binary `LICENSE` (Apache-2.0 text + a generated third-party dependency appendix). | +| `src/main/kotlin/.../GenerateBinaryNotice.kt` | A custom Gradle **task** that writes the binary `NOTICE` (our `NOTICE` + the `NOTICE` files of bundled dependencies). | +| `src/main/kotlin/org.apache.solr.mcp.license-notice.gradle.kts` | A **convention plugin** that creates the two tasks above and wires them into the build. | +| `src/test/kotlin/.../LicenseNoticeTasksTest.kt` | Unit tests for the two tasks. | +| `build.gradle.kts` | Builds `buildSrc` itself (enables Kotlin + the test dependencies). | + +## Gradle concepts, for Java developers + +A handful of Gradle terms show up in the code. Here is the minimum to read it: + +- **Task** — a single unit of build work with declared *inputs* and *outputs*, a bit + like one rule in a `Makefile`. Gradle decides whether a task needs to run by comparing + its inputs/outputs to the last run. We write a task by subclassing `DefaultTask`. +- **`@TaskAction`** — the method Gradle calls to actually do the work when the task runs. + It's effectively the task's "main". +- **Input / output annotations** (`@InputFile`, `@InputFiles`, `@Input`, `@OutputFile`) — + these declare what a task reads and writes. They are not decoration: Gradle uses them + to (1) **skip** the task when nothing changed (incremental builds), and (2) **order** + tasks so a producer runs before whoever consumes its output. `@InputFile`/`@InputFiles` + are file inputs; `@Input` is a plain value (a string, list, map); `@OutputFile` is a + produced file. +- **`Property` / `Provider` types** (`RegularFileProperty`, `ListProperty`, + `MapProperty`, `ConfigurableFileCollection`) — Gradle's "lazy" typed holders for a + value. The convention plugin `.set(...)`s them while the build is being *configured*; + the task `.get()`s them later when it actually *runs*. This lazy split is why the task + declares `abstract val foo: …Property` instead of a plain field. +- **Convention plugin** — a `.gradle.kts` file under `buildSrc` that Gradle compiles into + a plugin you can apply by id. Applying it (one line in the root build) registers our + tasks and connects them to the rest of the build, so the conventions live here instead + of being copy-pasted into `build.gradle.kts`. +- **`productionRuntimeClasspath`** — the set of dependency jars that actually end up + inside the Spring Boot fat jar. It excludes test-only, compile-only, and + `developmentOnly` dependencies. "What ships" is exactly what the binary LICENSE/NOTICE + must describe, which is why both tasks are driven by it. + +## How it runs + +1. The root `build.gradle.kts` applies the plugin: `id("org.apache.solr.mcp.license-notice")`. +2. The plugin registers `generateBinaryLicense` and `generateBinaryNotice`, and makes the + `bootJar` task depend on them (and the `check` task depend on `generateBinaryLicense`). +3. On a build, the CycloneDX `cyclonedxBom` task produces the SBOM, then: + - `generateBinaryLicense` reads the SBOM + the list of shipped dependencies and writes + `build/generated/license/LICENSE`. It **fails the build** if a shipped dependency is + missing from the SBOM (so nothing can ship unlisted). + - `generateBinaryNotice` scans the shipped jars for their `META-INF/NOTICE` files and + writes `build/generated/license/NOTICE`. +4. `bootJar` copies those two files into the JAR's `META-INF/`. The source-form jars + (thin `jar`, `-sources`, `-javadoc`) instead carry the plain repo-root `LICENSE` / + `NOTICE`. + +See the `## Release LICENSE / NOTICE` section in the repository's `AGENTS.md` for the +policy rationale, and the [Licensing & Notices](https://solr.apache.org/mcp/licensing.html) +docs page for the consumer-facing explanation. diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 15760391..40fb7c8b 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -15,7 +15,10 @@ * limitations under the License. */ +// This file builds buildSrc itself (not the main project). See buildSrc/README.md. plugins { + // Lets us write Gradle build logic — tasks and the convention plugin — in Kotlin, + // and turns the `*.gradle.kts` files under src/main/kotlin into apply-by-id plugins. `kotlin-dsl` } @@ -24,6 +27,8 @@ repositories { } dependencies { + // Only used by the task unit tests under src/test (the main code needs no extra deps; + // the Gradle API is provided by the kotlin-dsl plugin). testImplementation("org.junit.jupiter:junit-jupiter:5.12.2") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } diff --git a/buildSrc/src/main/kotlin/org.apache.solr.mcp.license-notice.gradle.kts b/buildSrc/src/main/kotlin/org.apache.solr.mcp.license-notice.gradle.kts index 0a1ce64c..ea415427 100644 --- a/buildSrc/src/main/kotlin/org.apache.solr.mcp.license-notice.gradle.kts +++ b/buildSrc/src/main/kotlin/org.apache.solr.mcp.license-notice.gradle.kts @@ -17,6 +17,14 @@ // Convention plugin: ASF-compliant LICENSE / NOTICE for the source and binary forms. // +// For readers new to Gradle: this `.gradle.kts` file under buildSrc is a "precompiled +// script plugin". Gradle compiles it into a plugin whose id is the file name +// (`org.apache.solr.mcp.license-notice`); the root build applies it with one line, +// `id("org.apache.solr.mcp.license-notice")`. The body below runs at *configuration* +// time: it creates the two generator tasks (defined in this same buildSrc as +// GenerateBinaryLicense / GenerateBinaryNotice), wires their inputs, and connects their +// outputs to the `bootJar` and `check` tasks. See buildSrc/README.md for the primer. +// // ASF policy requires distinct LICENSE/NOTICE for the source form and the binary form, // because the binary (the Spring Boot fat `bootJar`) bundles third-party bytecode. See // https://infra.apache.org/licensing-howto.html. This plugin: diff --git a/buildSrc/src/main/kotlin/org/apache/solr/mcp/build/GenerateBinaryLicense.kt b/buildSrc/src/main/kotlin/org/apache/solr/mcp/build/GenerateBinaryLicense.kt index 644b924f..077bbb37 100644 --- a/buildSrc/src/main/kotlin/org/apache/solr/mcp/build/GenerateBinaryLicense.kt +++ b/buildSrc/src/main/kotlin/org/apache/solr/mcp/build/GenerateBinaryLicense.kt @@ -42,26 +42,52 @@ import org.gradle.api.tasks.TaskAction * upstream POMs report imprecise but still-permissive identifiers). The task's only gate * is completeness: it fails if a bundled coordinate is absent from the SBOM, so a * dependency can never be silently omitted from the LICENSE. + * + * For readers new to Gradle: this is a custom build *task* (a unit of build work). It is + * created and configured by the `org.apache.solr.mcp.license-notice` convention plugin, + * and runs as part of `./gradlew build` / `bootJar`. The annotated `abstract val` + * properties below are its declared inputs and output — Gradle reads those annotations + * to skip the task when nothing changed and to run it before whatever consumes its + * output (here, the `bootJar`). See `buildSrc/README.md` for a fuller primer. */ abstract class GenerateBinaryLicense : DefaultTask() { + /** + * The repo-root Apache-2.0 `LICENSE` that the third-party appendix is appended to. + * `@InputFile` marks it a file input, so the task re-runs if it changes. The path is + * not part of the cache key (`PathSensitivity.NONE`) — only the contents matter. + */ @get:InputFile @get:PathSensitive(PathSensitivity.NONE) abstract val baseLicense: RegularFileProperty + /** + * The generated CycloneDX SBOM (`application.cdx.json`), read to find each bundled + * dependency's license. `@InputFile`, so the task re-runs when the SBOM changes. + */ @get:InputFile @get:PathSensitive(PathSensitivity.NONE) abstract val sbom: RegularFileProperty - /** Shipped dependencies as "group:name:version", the source of truth for what to list. */ + /** + * The dependencies that actually ship, as `"group:name:version"` strings — the source + * of truth for what to list. `@Input` marks it a plain *value* input (not a file), so + * the task re-runs whenever the shipped dependency set changes. + */ @get:Input abstract val bundledCoordinates: ListProperty + /** + * Where the assembled binary `LICENSE` is written. `@OutputFile` lets Gradle skip the + * task when the output is already up to date, and lets the `bootJar` task depend on it. + */ @get:OutputFile abstract val outputFile: RegularFileProperty + /** One license entry in the appendix: a display label and an optional link to its text. */ private data class License(val label: String, val url: String?) + /** Gradle runs this method when the task executes (`@TaskAction`). */ @TaskAction fun generate() { val slurper = JsonSlurper() diff --git a/buildSrc/src/main/kotlin/org/apache/solr/mcp/build/GenerateBinaryNotice.kt b/buildSrc/src/main/kotlin/org/apache/solr/mcp/build/GenerateBinaryNotice.kt index b4cef776..405d7195 100644 --- a/buildSrc/src/main/kotlin/org/apache/solr/mcp/build/GenerateBinaryNotice.kt +++ b/buildSrc/src/main/kotlin/org/apache/solr/mcp/build/GenerateBinaryNotice.kt @@ -34,23 +34,43 @@ import java.util.zip.ZipFile * `META-INF/NOTICE` files lifted verbatim (and de-duplicated) from the bundled jars — * the same approach as Maven Shade's `ApacheNoticeResourceTransformer`, so notices * required by bundled (notably ASF) dependencies are carried and stay current. + * + * For readers new to Gradle: this is a custom build *task*, created and configured by the + * `org.apache.solr.mcp.license-notice` convention plugin and run as part of + * `./gradlew build` / `bootJar`. The annotated `abstract val` properties are its declared + * inputs and output (used for up-to-date checking and task ordering). See + * `buildSrc/README.md` for a primer. */ abstract class GenerateBinaryNotice : DefaultTask() { + /** + * The bundled dependency jars to scan for `META-INF/NOTICE` entries. `@InputFiles` + * marks the whole collection a file input, so the task re-runs when the set of jars + * (or their contents) changes. + */ @get:InputFiles abstract val jars: ConfigurableFileCollection - /** Jar file name -> "group:name:version", used to label each lifted notice. */ + /** + * Maps a jar's file name to its `"group:name:version"`, used to attribute each lifted + * notice to the dependency it came from. `@Input` — a plain value input (string map). + */ @get:Input abstract val coordinateByJarName: MapProperty + /** This project's own repo-root `NOTICE`, written first. `@InputFile` (contents only). */ @get:InputFile @get:PathSensitive(PathSensitivity.NONE) abstract val baseNotice: RegularFileProperty + /** + * Where the assembled binary `NOTICE` is written. `@OutputFile` enables up-to-date + * skipping and lets the `bootJar` task depend on it. + */ @get:OutputFile abstract val outputFile: RegularFileProperty + /** Gradle runs this method when the task executes (`@TaskAction`). */ @TaskAction fun generate() { val coordinates = coordinateByJarName.get() From 230b706023aa57aec0204d749c7e693c6f7581bf Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Thu, 11 Jun 2026 16:40:17 -0400 Subject: [PATCH 24/26] docs(build): comment the convention plugin body for non-Gradle readers Add plain-language inline comments through the plugin body explaining the parts that are opaque without Gradle background: what a 'configuration' is and why productionRuntimeClasspath equals 'what ships', how the lazy provider chains (flatMap/map over resolvedArtifacts) derive the coordinate list and the jar-name->coordinate map, what tasks.register/.set wiring does, and how metaInf from(...) plus dependsOn bundle the generated files into the bootJar while the source-form jars keep the base files. Comments only; code unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: adityamparikh --- ....apache.solr.mcp.license-notice.gradle.kts | 39 ++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/buildSrc/src/main/kotlin/org.apache.solr.mcp.license-notice.gradle.kts b/buildSrc/src/main/kotlin/org.apache.solr.mcp.license-notice.gradle.kts index ea415427..72400666 100644 --- a/buildSrc/src/main/kotlin/org.apache.solr.mcp.license-notice.gradle.kts +++ b/buildSrc/src/main/kotlin/org.apache.solr.mcp.license-notice.gradle.kts @@ -42,14 +42,28 @@ import org.apache.solr.mcp.build.GenerateBinaryLicense import org.apache.solr.mcp.build.GenerateBinaryNotice import org.gradle.api.artifacts.component.ModuleComponentIdentifier +// The project's source-form LICENSE/NOTICE at the repo root (the plain Apache-2.0 text +// and the base NOTICE). They are bundled as-is into the non-fat jars, and are also the +// base that the generated binary files are built on top of. val licenseFile = layout.projectDirectory.file("LICENSE") val noticeFile = layout.projectDirectory.file("NOTICE") -// What actually ships inside the fat jar: productionRuntimeClasspath excludes -// test/compile-only and developmentOnly deps that the bootJar does not bundle. +// A Gradle "configuration" is a named set of dependencies. `productionRuntimeClasspath` +// is the one that actually ends up inside the fat jar — it excludes test/compile-only and +// developmentOnly deps. So this is exactly "what ships", which is what the binary +// LICENSE/NOTICE must describe. val shippedClasspath = configurations.named("productionRuntimeClasspath") + +// Resolve that configuration to its actual artifacts — each is a jar file plus the module +// identity it came from. `flatMap` keeps everything lazy: nothing is resolved here while +// the build is being configured; it is computed later, when a task that needs it runs. +// The result is a Provider>. val shippedArtifacts = shippedClasspath.flatMap { it.incoming.artifacts.resolvedArtifacts } +// Derive the shipped dependencies as sorted, de-duplicated "group:name:version" strings. +// `mapNotNull { it... as? ModuleComponentIdentifier }` keeps only normal external modules +// and drops anything that isn't one (e.g. file dependencies). This feeds the LICENSE +// task's `bundledCoordinates` input. val shippedCoordinates = shippedArtifacts.map { set -> set.mapNotNull { it.id.componentIdentifier as? ModuleComponentIdentifier } @@ -58,6 +72,9 @@ val shippedCoordinates = .sorted() } +// Map each shipped jar's *file name* to its "group:name:version". The NOTICE task opens +// the jar files and uses this map to label each lifted notice with the module it came +// from (at that point the file is all it has to go on). val jarNameToCoordinate = shippedArtifacts.map { set -> set.mapNotNull { artifact -> @@ -67,6 +84,9 @@ val jarNameToCoordinate = }.toMap() } +// Create (register) the LICENSE task and wire its inputs/output. `register` is lazy — the +// task is configured/run only if the build needs it. `dependsOn("cyclonedxBom")` ensures +// the SBOM exists before this runs; each `.set(...)` connects one declared input. val generateBinaryLicense = tasks.register("generateBinaryLicense") { description = "Assembles the binary-release LICENSE (Apache-2.0 + SBOM-derived appendix)." @@ -78,6 +98,8 @@ val generateBinaryLicense = outputFile.set(layout.buildDirectory.file("generated/license/LICENSE")) } +// Same for the NOTICE task. `jars.from(shippedClasspath)` hands it the shipped jar files +// to scan for their `META-INF/NOTICE` entries. val generateBinaryNotice = tasks.register("generateBinaryNotice") { description = "Assembles the binary-release NOTICE (project NOTICE + bundled dependency notices)." @@ -88,7 +110,10 @@ val generateBinaryNotice = outputFile.set(layout.buildDirectory.file("generated/license/NOTICE")) } -// Source-form artifacts (thin jar, -sources, -javadoc): base LICENSE + NOTICE as-is. +// `metaInf { from(file) }` adds files to a jar's `META-INF/` directory. The source-form +// artifacts — the thin `jar`, `-sources`, `-javadoc` (everything except `bootJar`) — get +// the base LICENSE/NOTICE unchanged. `configureEach` applies this to each matching jar +// task lazily. tasks.withType().matching { it.name != "bootJar" }.configureEach { metaInf { from(licenseFile) @@ -96,7 +121,10 @@ tasks.withType().matching { it.name != "bootJar" }.configureEach { } } -// Binary artifact (Spring Boot fat jar): generated LICENSE + NOTICE. +// The binary artifact (the Spring Boot fat `bootJar`) instead gets the *generated* files. +// `dependsOn(...)` makes the generators run first; `from(task.flatMap { it.outputFile })` +// bundles each task's output into `META-INF/` (the lazy flatMap also wires the task +// dependency automatically). tasks.named("bootJar") { dependsOn(generateBinaryLicense, generateBinaryNotice) metaInf { @@ -105,5 +133,6 @@ tasks.named("bootJar") { } } -// Completeness gate: a bundled dependency missing from the SBOM fails the build. +// Run the LICENSE task — and therefore its completeness gate — as part of `check`, so a +// plain `./gradlew build` fails if a bundled dependency is missing from the SBOM. tasks.named("check") { dependsOn(generateBinaryLicense) } From 1721a0fb79de4a3752a877f4e24a7b8f955f51f0 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Sat, 13 Jun 2026 15:48:17 -0400 Subject: [PATCH 25/26] Dont double load the root files --- build.gradle.kts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 4c754ea5..572bc760 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -81,15 +81,12 @@ java { } // ASF release policy requires every distributed artifact to carry the project's -// LICENSE and NOTICE files. Bundle them into META-INF of every JAR produced by -// this build (main jar, bootJar, sources, javadoc). +// LICENSE and NOTICE files. This is handled by the `org.apache.solr.mcp.license-notice` +// convention plugin (buildSrc/): the source-form jars (thin jar, -sources, -javadoc) +// get the base Apache-2.0 LICENSE/NOTICE, while the binary fat bootJar gets generated +// files with an SBOM-derived third-party appendix. The plugin must own this wiring for +// the bootJar — bundling the base files here too would duplicate META-INF/LICENSE. // See https://www.apache.org/legal/release-policy.html#licensing-documentation -tasks.withType().configureEach { - metaInf { - from(rootProject.file("LICENSE")) - from(rootProject.file("NOTICE")) - } -} // Maven Publishing Configuration // ============================== From 9ec8521f12a268728032f7dd36806e251dcceeb8 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Sat, 13 Jun 2026 17:13:38 -0400 Subject: [PATCH 26/26] feat(build): enforce Apache license headers via RAT convention plugin Add Apache RAT (Release Audit Tool) header enforcement as an `org.apache.solr.mcp.rat` buildSrc convention plugin, stacked on the license-notice plugin from #138. RAT is wired into `check`, so `./gradlew build` audits that every scanned file carries an ASF header (report at build/reports/rat/index.html). The .gitignore-to-RAT-glob translation lives in a pure, unit-tested `RatExcludes` helper rather than inline in build.gradle.kts. Moving it to buildSrc fixes two gitignore-semantics gaps from the inline approach: interior-slash patterns (e.g. src/generated) are now root-anchored instead of matched at any depth, and the negation/anchoring rules are documented and tested. Local developer-tooling dirs (.claude worktrees, .kotlin caches) are excluded so contributors don't hit spurious audit failures. ASF headers are added to the three application*.properties and libs.versions.toml so they pass the audit. Supersedes the inline approach in #149. Stacked on #138. Fixes #141. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: adityamparikh --- build.gradle.kts | 3 + buildSrc/README.md | 22 +++-- buildSrc/build.gradle.kts | 9 ++ .../kotlin/org.apache.solr.mcp.rat.gradle.kts | 93 +++++++++++++++++++ .../org/apache/solr/mcp/build/RatExcludes.kt | 81 ++++++++++++++++ .../apache/solr/mcp/build/RatExcludesTest.kt | 65 +++++++++++++ gradle/libs.versions.toml | 16 ++++ .../resources/application-http.properties | 16 ++++ .../resources/application-stdio.properties | 16 ++++ src/main/resources/application.properties | 16 ++++ 10 files changed, 330 insertions(+), 7 deletions(-) create mode 100644 buildSrc/src/main/kotlin/org.apache.solr.mcp.rat.gradle.kts create mode 100644 buildSrc/src/main/kotlin/org/apache/solr/mcp/build/RatExcludes.kt create mode 100644 buildSrc/src/test/kotlin/org/apache/solr/mcp/build/RatExcludesTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 572bc760..c9052fbd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -32,6 +32,9 @@ plugins { // Listed after spring-boot + cyclonedx so productionRuntimeClasspath and // cyclonedxBom exist when it wires its tasks. See buildSrc/. id("org.apache.solr.mcp.license-notice") + // Enforces Apache license headers via Apache RAT (buildSrc convention plugin). + // Wires `rat` into `check`, so `./gradlew build` audits headers. See buildSrc/. + id("org.apache.solr.mcp.rat") } // GraalVM Native Image (Opt-In) diff --git a/buildSrc/README.md b/buildSrc/README.md index 4d9bdff9..604e51de 100644 --- a/buildSrc/README.md +++ b/buildSrc/README.md @@ -15,13 +15,18 @@ limitations under the License. --> -# buildSrc — generating the binary LICENSE & NOTICE +# buildSrc — project build logic -This directory holds the build logic that assembles the **binary-release `LICENSE` -and `NOTICE`** files (the ones bundled inside the executable JAR). It is written in -Kotlin. If you don't work with Gradle day-to-day, this README explains what each piece -is and how they fit together; the end-user view of *what* these files contain lives on -the [Licensing & Notices](https://solr.apache.org/mcp/licensing.html) docs page. +This directory holds the project's custom build logic, written in Kotlin. Today that is +two ASF-compliance concerns: + +- assembling the **binary-release `LICENSE` and `NOTICE`** files bundled inside the + executable JAR (the end-user view of *what* these contain lives on the + [Licensing & Notices](https://solr.apache.org/mcp/licensing.html) docs page); and +- enforcing **Apache license headers** on source files via Apache RAT. + +If you don't work with Gradle day-to-day, this README explains what each piece is and how +they fit together. ## What is `buildSrc`? @@ -39,7 +44,10 @@ small. (Think of it as a tiny library that only this project's build uses.) | `src/main/kotlin/.../GenerateBinaryNotice.kt` | A custom Gradle **task** that writes the binary `NOTICE` (our `NOTICE` + the `NOTICE` files of bundled dependencies). | | `src/main/kotlin/org.apache.solr.mcp.license-notice.gradle.kts` | A **convention plugin** that creates the two tasks above and wires them into the build. | | `src/test/kotlin/.../LicenseNoticeTasksTest.kt` | Unit tests for the two tasks. | -| `build.gradle.kts` | Builds `buildSrc` itself (enables Kotlin + the test dependencies). | +| `src/main/kotlin/.../RatExcludes.kt` | Pure helper that translates `.gitignore` entries into Apache RAT (Ant-style) exclude globs. | +| `src/main/kotlin/org.apache.solr.mcp.rat.gradle.kts` | A **convention plugin** that applies Apache RAT and configures its excludes (`.gitignore`-derived + an explicit list). | +| `src/test/kotlin/.../RatExcludesTest.kt` | Unit tests for the gitignore→glob translation. | +| `build.gradle.kts` | Builds `buildSrc` itself (enables Kotlin + the RAT plugin + the test dependencies). | ## Gradle concepts, for Java developers diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 40fb7c8b..d1296d79 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -24,9 +24,18 @@ plugins { repositories { mavenCentral() + // Hosts the Apache RAT plugin marker below — it is not published to Maven Central. + gradlePluginPortal() } dependencies { + // Makes the Apache RAT plugin (id `org.nosphere.apache.rat`) available to the + // `org.apache.solr.mcp.rat` convention plugin's `plugins {}` block. The version lives + // here (mirroring how the junit dep below is pinned) since buildSrc does not read the + // root project's version catalog. Latest release as of writing; bump in lockstep with + // the plugin's upstream releases. + implementation("org.nosphere.apache.rat:org.nosphere.apache.rat.gradle.plugin:0.8.1") + // Only used by the task unit tests under src/test (the main code needs no extra deps; // the Gradle API is provided by the kotlin-dsl plugin). testImplementation("org.junit.jupiter:junit-jupiter:5.12.2") diff --git a/buildSrc/src/main/kotlin/org.apache.solr.mcp.rat.gradle.kts b/buildSrc/src/main/kotlin/org.apache.solr.mcp.rat.gradle.kts new file mode 100644 index 00000000..e9ea9495 --- /dev/null +++ b/buildSrc/src/main/kotlin/org.apache.solr.mcp.rat.gradle.kts @@ -0,0 +1,93 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Convention plugin: Apache RAT (Release Audit Tool) license-header enforcement. +// +// For readers new to Gradle: this `.gradle.kts` file under buildSrc is a "precompiled +// script plugin". Gradle compiles it into a plugin whose id is the file name +// (`org.apache.solr.mcp.rat`); the root build applies it with one line, +// `id("org.apache.solr.mcp.rat")`. The `plugins {}` block below applies the third-party +// RAT plugin (made available to buildSrc by the `org.nosphere.apache.rat...` dependency +// in buildSrc/build.gradle.kts), and the body configures its `rat` task. +// +// RAT verifies that every scanned file carries an Apache license header. The plugin wires +// its `rat` task into `check`, so a plain `./gradlew build` runs it; the report lands at +// build/reports/rat/index.html. +// +// Exclusions come from two sources so patterns are not duplicated: +// 1. .gitignore — reused as the single source of truth for everything git already +// ignores (build output, .gradle, IDE dirs, *.iml, out/, bin/, .vscode, etc.). +// RatExcludes (in this same buildSrc) translates each entry into a RAT (Ant) glob; +// see that class for the gitignore→glob mapping rules. +// 2. The explicit list below — only *tracked* files that RAT would scan but that +// legitimately carry no Apache header (binaries, data without a comment syntax, +// docs, tool/infra config, and LICENSE/NOTICE themselves). + +import org.apache.solr.mcp.build.RatExcludes +import org.nosphere.apache.rat.RatTask + +plugins { + id("org.nosphere.apache.rat") +} + +tasks.withType().configureEach { + val gitignore = rootProject.file(".gitignore") + if (gitignore.exists()) { + excludes.addAll(RatExcludes.fromGitignore(gitignore.readLines())) + } + + excludes.addAll( + listOf( + // Gradle wrapper (ships under its own license) + on-disk OS cruft + "gradlew", + "gradlew.bat", + "gradle/wrapper/**", + "**/.DS_Store", + // Tracked dotfiles that take no header + ".run/**", + ".gitignore", + ".gitattributes", + ".editorconfig", + ".tool-versions", + ".env.example", + // License/notice files themselves (no header by definition) + "LICENSE", + "NOTICE", + // ASF infra metadata and tool config (no header by convention) + ".asf.yaml", + "config/**", + // Local developer tooling not tracked by git — Claude Code worktrees/settings + // and the Kotlin compiler cache (analogous to the gitignored .idea/.gradle). + // Present only on some local checkouts, never in CI. + ".claude/**", + "**/.kotlin/**", + // Tabular data — no comment syntax to hold a header + "**/*.csv", + // Documentation (markdown carries no license header) + "**/*.md", + "docs/**", + "dev-docs/**", + "security-docs/**", + // Binary assets + "images/**", + "**/*.png", + // Data / generated content (JSON cannot hold comments) + "mydata/**", + "**/*.json", + ), + ) +} diff --git a/buildSrc/src/main/kotlin/org/apache/solr/mcp/build/RatExcludes.kt b/buildSrc/src/main/kotlin/org/apache/solr/mcp/build/RatExcludes.kt new file mode 100644 index 00000000..f23d948b --- /dev/null +++ b/buildSrc/src/main/kotlin/org/apache/solr/mcp/build/RatExcludes.kt @@ -0,0 +1,81 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.mcp.build + +/** + * Translates `.gitignore` lines into Apache RAT (Ant-style) exclude globs. + * + * RAT scans every file under the project directory; we reuse `.gitignore` as the single + * source of truth for build output, IDE folders, and other untracked cruft so those + * patterns are not duplicated in the build script. The RAT plugin's own `excludeFile` + * does **not** interpret `.gitignore` path semantics, so we translate each entry here. + * + * This is pure, Gradle-free logic precisely so it can be unit-tested + * ([RatExcludesTest]); the gitignore-to-Ant-glob mapping has enough edge cases + * (anchoring, directory markers, negation) to be worth pinning down with tests. + * + * ### Mapping rules (a practical subset of gitignore semantics) + * + * Throughout, "globstar" means the two-asterisk Ant wildcard that matches any number of + * path segments (written here without the trailing slash to keep this comment valid). + * + * - Blank lines and `#` comments are skipped. + * - **Negation (`!foo`) entries are skipped.** Git uses them to *re-include* a path, but + * RAT excludes have no re-inclusion mechanism. Skipping (rather than excluding) is the + * safe choice: at worst RAT still scans a file git would ignore. The opposite — a + * bare `build/` excluding a re-included `!build/keep.txt` — over-excludes that one file + * from header checking, which we accept as a rare, low-risk gap. + * - A **trailing slash** (directory marker) is stripped; the entry is still emitted in + * both bare and directory-contents (globstar-suffixed) forms. + * - **Anchoring** follows git: an entry with a leading slash, or with an interior slash + * (a separator that is not just the trailing one), is anchored to the repo root and is + * emitted as-is (leading slash removed). An entry with no separator — or only a + * trailing slash — matches at any depth and is prefixed with a leading globstar segment. + * An entry that already begins with a globstar segment is left untouched. (The original + * inline implementation prefixed *every* non-leading-slash entry with a globstar, which + * wrongly turned a root-anchored `foo/bar` into an any-depth match; this distinguishes + * the two.) + * + * Each surviving entry yields two globs — the path itself and its directory-contents form + * — so an ignored directory and everything under it are both pruned. + */ +object RatExcludes { + + fun fromGitignore(lines: List): List = + lines.asSequence() + .map { it.trim() } + .filter { it.isNotEmpty() } + .filterNot { it.startsWith("#") } + .filterNot { it.startsWith("!") } + .map { it.trimEnd('/') } + .filter { it.isNotEmpty() } + .map(::toGlob) + .flatMap { sequenceOf(it, "$it/**") } + .distinct() + .toList() + + /** Maps one normalized (trimmed, no trailing slash) gitignore entry to one Ant glob. */ + private fun toGlob(entry: String): String { + val anchored = entry.startsWith("/") + val body = entry.removePrefix("/") + return when { + body.startsWith("**/") -> body // already any-depth + anchored || body.contains("/") -> body // root-anchored (leading or interior slash) + else -> "**/$body" // no separator → match at any depth + } + } +} diff --git a/buildSrc/src/test/kotlin/org/apache/solr/mcp/build/RatExcludesTest.kt b/buildSrc/src/test/kotlin/org/apache/solr/mcp/build/RatExcludesTest.kt new file mode 100644 index 00000000..92b747cc --- /dev/null +++ b/buildSrc/src/test/kotlin/org/apache/solr/mcp/build/RatExcludesTest.kt @@ -0,0 +1,65 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.mcp.build + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class RatExcludesTest { + + @Test + fun `each entry yields both the path and its directory-contents glob`() { + assertEquals(listOf("**/build", "**/build/**"), RatExcludes.fromGitignore(listOf("build/"))) + } + + @Test + fun `a no-separator entry matches at any depth`() { + // ".gradle" can appear at any level, so it must be prefixed with **/. + assertTrue(RatExcludes.fromGitignore(listOf(".gradle")).contains("**/.gradle")) + assertTrue(RatExcludes.fromGitignore(listOf("*.iml")).contains("**/*.iml")) + } + + @Test + fun `a leading-slash entry is anchored to the repo root`() { + // "/build" means the root build dir only — no **/ prefix, leading slash stripped. + assertEquals(listOf("build", "build/**"), RatExcludes.fromGitignore(listOf("/build"))) + } + + @Test + fun `an interior-slash entry is root-anchored, not any-depth`() { + // git anchors "src/generated" to the root; it must NOT become **/src/generated. + val globs = RatExcludes.fromGitignore(listOf("src/generated")) + assertEquals(listOf("src/generated", "src/generated/**"), globs) + } + + @Test + fun `an entry already starting with double-star is left untouched`() { + assertEquals(listOf("**/*.log", "**/*.log/**"), RatExcludes.fromGitignore(listOf("**/*.log"))) + } + + @Test + fun `blank lines, comments, and negations are skipped`() { + val globs = RatExcludes.fromGitignore(listOf("", " ", "# a comment", "!keep.txt")) + assertTrue(globs.isEmpty(), "expected no globs but got $globs") + } + + @Test + fun `duplicate entries are collapsed`() { + assertEquals(listOf("**/target", "**/target/**"), RatExcludes.fromGitignore(listOf("target", "target/"))) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6fc21873..473ff9ff 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,3 +1,19 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# [versions] # Build plugins spring-boot = "3.5.14" diff --git a/src/main/resources/application-http.properties b/src/main/resources/application-http.properties index c1b97597..77578a3c 100644 --- a/src/main/resources/application-http.properties +++ b/src/main/resources/application-http.properties @@ -1,3 +1,19 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# spring.main.web-application-type=servlet spring.ai.mcp.server.type=sync spring.ai.mcp.server.protocol=stateless diff --git a/src/main/resources/application-stdio.properties b/src/main/resources/application-stdio.properties index b8864dfa..37f848be 100644 --- a/src/main/resources/application-stdio.properties +++ b/src/main/resources/application-stdio.properties @@ -1,3 +1,19 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# spring.main.web-application-type=none # NOTE: You must disable the banner and the console logging # to allow the STDIO transport to work !!! diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 1592a770..c2038f5c 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,3 +1,19 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# spring.application.name=solr-mcp spring.profiles.active=${PROFILES:stdio} spring.ai.mcp.server.instructions=This server provides tools to interact with Apache Solr using Model Context Protocol (MCP) over STDIO and/or HTTP.