diff --git a/.github/workflows/gradle-publish.yml b/.github/workflows/gradle-publish.yml index 1c33ed8..451d328 100644 --- a/.github/workflows/gradle-publish.yml +++ b/.github/workflows/gradle-publish.yml @@ -10,6 +10,18 @@ name: Gradle Package on: release: types: [ created ] + workflow_dispatch: + inputs: + ref: + description: 'Git ref (branch or tag) to build' + required: false + default: 'main' + type: string + publish_to_remote: + description: 'Publish artifacts to Sonatype OSSRH and the Gradle Plugin Portal' + required: false + default: false + type: boolean jobs: build: @@ -18,9 +30,18 @@ jobs: permissions: contents: read packages: write + env: + OSSRH_TOKEN_USERNAME: ${{ secrets.OSSRH_TOKEN_USERNAME }} + OSSRH_TOKEN_PASSWORD: ${{ secrets.OSSRH_TOKEN_PASSWORD }} + SIGNING_KEY: ${{ secrets.SIGNING_KEY }} + SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} + SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} + PUBLISH_TO_REMOTE: ${{ github.event_name == 'release' || inputs.publish_to_remote == true }} steps: - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} - name: Set up JDK 21 uses: actions/setup-java@v4 with: @@ -32,19 +53,45 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@af1da67850ed9a4cedd57bfd976089dd991e2582 # v4.0.0 + - name: Configure Gradle publishing credentials + run: | + mkdir -p "$HOME/.gradle" + + { + if [[ -n "${OSSRH_TOKEN_USERNAME}" ]]; then + echo "ossrhTokenUsername=${OSSRH_TOKEN_USERNAME}" + fi + if [[ -n "${OSSRH_TOKEN_PASSWORD}" ]]; then + echo "ossrhTokenPassword=${OSSRH_TOKEN_PASSWORD}" + fi + if [[ -n "${SIGNING_PASSWORD}" ]]; then + echo "signingPassword=${SIGNING_PASSWORD}" + fi + if [[ -n "${SIGNING_KEY}" ]]; then + echo "signingKey=${SIGNING_KEY}" + fi + # Optionally expose SIGNING_KEY_ID for workflows that delegate signing to gpg. + if [[ -n "${SIGNING_KEY_ID}" ]]; then + echo "signing.keyId=${SIGNING_KEY_ID}" + fi + } > "$HOME/.gradle/gradle.properties" + + chmod 600 "$HOME/.gradle/gradle.properties" + - name: Build with Gradle run: ./gradlew build - # The USERNAME and TOKEN need to correspond to the credentials environment variables used in - # the publishing section of your build.gradle - - name: Publish to GitHub Packages + - name: Publish to Sonatype OSSRH + if: env.PUBLISH_TO_REMOTE == 'true' run: ./gradlew publish - env: - USERNAME: ${{ github.actor }} - TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Publish to Gradle Plugin Portal + if: env.PUBLISH_TO_REMOTE == 'true' run: ./gradlew publishPlugins env: GRADLE_PUBLISH_KEY: ${{ secrets.GRADLE_PUBLISH_KEY }} - GRADLE_PUBLISH_SECRET: ${{ secrets.GRADLE_PUBLISH_SECRET }} \ No newline at end of file + GRADLE_PUBLISH_SECRET: ${{ secrets.GRADLE_PUBLISH_SECRET }} + + - name: Clean up Gradle credentials + if: always() + run: rm -f "$HOME/.gradle/gradle.properties" diff --git a/README.md b/README.md index a8aea06..2ceb479 100644 --- a/README.md +++ b/README.md @@ -158,3 +158,148 @@ tasks.checkLicenses { policiesFile = file("path/to/your/policies.json") } ``` + +## Publishing setup TODO (Sonatype OSSRH) + +Store secrets in `~/.gradle/gradle.properties`, your CI secret store, or environment +variables as noted below. The checklist assumes that the project is published through +the Sonatype OSSRH infrastructure with token-based authentication (preferred) and +falls back to legacy username/password credentials if necessary. When these +credentials (and the signing keys described below) are absent, running +`./gradlew publish` will skip the remote Sonatype repository and signing tasks so +that local verification builds continue to succeed. + +- [ ] Confirm OSSRH project access + - Sign in at with the Sonatype account that owns the + `io.github.eurofunk` groupId. If the namespace has not yet been approved, follow + the steps in the [OSSRH guide](https://central.sonatype.org/publish/publish-guide/) + to request access. + - Ensure you can see the `Staging Repositories` menu entry before attempting to publish. + +- [ ] `ossrhTokenUsername` / `OSSRH_TOKEN_USERNAME` + - From the OSSRH web UI, open **Profile → User Token** and click **Access User Token**. + Copy the generated **Token Username** and store it as the Gradle property + `ossrhTokenUsername` or environment variable `OSSRH_TOKEN_USERNAME`. + - Legacy fallback: the build still honors `ossrhUsername` / `OSSRH_USERNAME` if tokens are + not available. + +- [ ] `ossrhTokenPassword` / `OSSRH_TOKEN_PASSWORD` + - In the same dialog, copy the **Token Password** and store it as the Gradle property + `ossrhTokenPassword` or environment variable `OSSRH_TOKEN_PASSWORD`. + - Legacy fallback: provide `ossrhPassword` / `OSSRH_PASSWORD` if you must use the classic + credentials. + +- [ ] (optional) Override publishing endpoints + - Releases deploy to `https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/` by default. + Override with the Gradle property `ossrhReleasesUrl` only if Sonatype assigns a different + host for your project. + - Snapshot artifacts deploy to `https://s01.oss.sonatype.org/content/repositories/snapshots/`. + Override with `ossrhSnapshotsUrl` if required. + +- [ ] `signingKeyId` / `SIGNING_KEY_ID` *(optional)* + - Run `gpg --list-secret-keys --keyid-format=long` and copy the key ID for your publishing key (for example `ABCDEF12`). + - Provide the key ID only if you rely on the local GnuPG executable or need a stable identifier in signing reports. When Gradle loads the ASCII-armored private key directly (`signingKey`/`SIGNING_KEY`), the identifier can be omitted and Gradle derives it from the key material automatically. + +- [ ] `signingKey` / `SIGNING_KEY` + - Export the ASCII-armored private key with `gpg --armor --export-secret-keys ` (replace `` with the value above). + - Paste the full output—including the `BEGIN/END PGP PRIVATE KEY BLOCK` markers—into the Gradle property `signingKey` or environment variable `SIGNING_KEY`. A fingerprint alone is not sufficient; the entire private key block must be provided. + - The build accepts either the raw ASCII-armored export or a base64-encoded copy of that same text. Gradle checks that the payload contains a complete PGP secret key block; binary exports or truncated blocks are ignored and signing will be skipped with a warning. + +- [ ] `signingPassword` / `SIGNING_PASSWORD` + - Use the passphrase chosen when creating the GPG key (from `gpg --full-generate-key`). + - Store it as the Gradle property `signingPassword` or environment variable `SIGNING_PASSWORD`. + +- [ ] `signing.gnupg.keyName` / `SIGNING_GNUPG_KEY_NAME` *(only if using the local GPG executable)* + - If you prefer Gradle to call the local `gpg` binary, set this to the key name returned by `gpg --list-secret-keys` (for example `User Name `). + - Ensure `signing.gnupg.executable` points to the desired GPG binary and that the key is available in the local keyring or CI agent. + +### Using GitHub Actions secrets + +When the project builds in GitHub Actions, reference the organization or repository +secrets as environment variables so Gradle can pick them up automatically. Secrets +exposed via the workflow `env` section are available to every step; you can also +scope them to the publish job only. Because the signing key is multi-line, the +workflow writes the values to `~/.gradle/gradle.properties` before invoking Gradle, +which ensures the full key material (including embedded newlines or `\n` escape +sequences) is preserved. Provide the ASCII-armored key directly or a base64-encoded +copy of that text export; the workflow feeds it to Gradle exactly as provided, and +the build validates that the payload contains the full private key before enabling +signing. Invalid or binary private keys are ignored so the publish run continues +without signing. + +```yaml +jobs: + publish: + runs-on: ubuntu-latest + env: + OSSRH_TOKEN_USERNAME: ${{ secrets.OSSRH_TOKEN_USERNAME }} + OSSRH_TOKEN_PASSWORD: ${{ secrets.OSSRH_TOKEN_PASSWORD }} + SIGNING_KEY: ${{ secrets.SIGNING_KEY }} + SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} + SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} # optional; only required for gpg-based signing + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + - name: Configure Gradle publishing credentials + run: | + mkdir -p "$HOME/.gradle" + + { + if [[ -n "${OSSRH_TOKEN_USERNAME}" ]]; then + echo "ossrhTokenUsername=${OSSRH_TOKEN_USERNAME}" + fi + if [[ -n "${OSSRH_TOKEN_PASSWORD}" ]]; then + echo "ossrhTokenPassword=${OSSRH_TOKEN_PASSWORD}" + fi + if [[ -n "${SIGNING_PASSWORD}" ]]; then + echo "signingPassword=${SIGNING_PASSWORD}" + fi + if [[ -n "${SIGNING_KEY}" ]]; then + echo "signingKey=${SIGNING_KEY}" + fi + # Optionally surface SIGNING_KEY_ID for workflows that delegate signing to gpg. + if [[ -n "${SIGNING_KEY_ID}" ]]; then + echo "signing.keyId=${SIGNING_KEY_ID}" + fi + } > "$HOME/.gradle/gradle.properties" + + chmod 600 "$HOME/.gradle/gradle.properties" + - name: Publish artifacts + run: ./gradlew publish + - name: Clean up Gradle credentials + if: always() + run: rm -f "$HOME/.gradle/gradle.properties" +``` + +Gradle reads the variables listed above (and their legacy fallbacks) directly from the +environment, so no extra configuration is required. If you prefer Gradle properties +instead, write the secrets to `~/.gradle/gradle.properties` in a preceding workflow +step and delete the file afterwards to avoid leaking credentials in later jobs. The +example above performs these steps automatically. + +### Running a release test + +Use the **Gradle Package** workflow to perform a dry-run of the release +pipeline before cutting an actual GitHub release: + +1. Open **Actions → Gradle Package** in the repository UI and click + **Run workflow**. +2. Pick the branch or tag that should be exercised from the **Branch** + drop-down. The release pipeline promotes builds from the `main` + branch, which is selected by default for manual runs. Choose a + different branch only if you need to validate work-in-progress + changes before merging them into `main`. +3. Leave **Publish artifacts to Sonatype OSSRH and the Gradle Plugin Portal** + disabled to run a local verification. The workflow will build the + project, resolve signing, and skip the remote publish steps. +4. When you are satisfied with the dry-run, create a GitHub release from the + branch that should ship (typically `main`) and the workflow will execute + again with publishing enabled. + +This mirrors the branch picker shown in the GitHub release dialog and makes it +easy to verify release changes from any branch before publishing. diff --git a/build.gradle.kts b/build.gradle.kts index 6067fd7..80ba66c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,14 +1,21 @@ plugins { id("com.gradle.plugin-publish") version "1.3.1" + `maven-publish` + signing } -group = "com.eurofunk.gradle" +import java.nio.charset.StandardCharsets +import java.util.Base64 + +group = "io.github.eurofunk" version = "0.0.1" java { toolchain { languageVersion.set(JavaLanguageVersion.of(21)) } + withSourcesJar() + withJavadocJar() } repositories { @@ -40,22 +47,152 @@ gradlePlugin { } } +val ossrhTokenUsername = (findProperty("ossrhTokenUsername") as String?) + ?: System.getenv("OSSRH_TOKEN_USERNAME") +val ossrhTokenPassword = (findProperty("ossrhTokenPassword") as String?) + ?: System.getenv("OSSRH_TOKEN_PASSWORD") +val ossrhUsername = (findProperty("ossrhUsername") as String?) + ?: System.getenv("OSSRH_USERNAME") +val ossrhPassword = (findProperty("ossrhPassword") as String?) + ?: System.getenv("OSSRH_PASSWORD") + +val resolvedOssrhUsername = ossrhTokenUsername ?: ossrhUsername +val resolvedOssrhPassword = ossrhTokenPassword ?: ossrhPassword +val hasOssrhCredentials = + !resolvedOssrhUsername.isNullOrBlank() && !resolvedOssrhPassword.isNullOrBlank() + publishing { + publications { + create("mavenJava") { + from(components["java"]) + + pom { + name.set("SBOM License Plugin") + description.set("Gradle plugin for validating dependency licenses using SBOM metadata.") + url.set("https://github.com/eurofunk/sbom-license-plugin") + licenses { + license { + name.set("Apache License, Version 2.0") + url.set("https://www.apache.org/licenses/LICENSE-2.0") + } + } + developers { + developer { + id.set("eurofunk") + name.set("eurofunk Kappacher GmbH") + email.set("opensource@eurofunk.com") + organization.set("eurofunk Kappacher GmbH") + organizationUrl.set("https://www.eurofunk.com") + } + } + scm { + connection.set("scm:git:https://github.com/eurofunk/sbom-license-plugin.git") + developerConnection.set("scm:git:ssh://git@github.com/eurofunk/sbom-license-plugin.git") + url.set("https://github.com/eurofunk/sbom-license-plugin") + } + } + } + } + repositories { - mavenLocal() - repositories { + if (hasOssrhCredentials) { maven { - name = "GitHubPackages" - url = uri("https://maven.pkg.github.com/eurofunk/sbom-license-plugin") + name = "Sonatype" + val isSnapshot = version.toString().endsWith("SNAPSHOT") + val releasesRepositoryUrl = + (findProperty("ossrhReleasesUrl") as String?) + ?: "https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/" + val snapshotsRepositoryUrl = + (findProperty("ossrhSnapshotsUrl") as String?) + ?: "https://s01.oss.sonatype.org/content/repositories/snapshots/" + + url = uri(if (isSnapshot) snapshotsRepositoryUrl else releasesRepositoryUrl) + credentials { - username = project.findProperty("gpr.user") as String? ?: System.getenv("USERNAME") - password = project.findProperty("gpr.key") as String? ?: System.getenv("TOKEN") + username = resolvedOssrhUsername + password = resolvedOssrhPassword } } + } else { + logger.warn("Skipping Sonatype repository configuration because no OSSRH credentials were found.") } } } +signing { + fun normalizeSigningKey(rawKey: String?): String? { + if (rawKey.isNullOrBlank()) { + return null + } + + val trimmed = rawKey.replace("\\n", "\n").trim() + val header = "-----BEGIN PGP PRIVATE KEY BLOCK-----" + val footer = "-----END PGP PRIVATE KEY BLOCK-----" + + if (trimmed.contains(header) && trimmed.contains(footer)) { + return trimmed + } + + val base64Payload = trimmed.filterNot { it.isWhitespace() } + val decoded = runCatching { Base64.getDecoder().decode(base64Payload) } + .map { String(it, StandardCharsets.UTF_8).replace("\\n", "\n").trim() } + .getOrNull() + + if (decoded != null && decoded.contains(header) && decoded.contains(footer)) { + return decoded + } + + logger.warn( + "Skipping in-memory signing key because it does not contain a complete ASCII-armored PGP PRIVATE KEY block." + ) + + return null + } + + val signingKeyId = ((findProperty("signingKeyId") as String?) + ?: (findProperty("signing.keyId") as String?) + ?: System.getenv("SIGNING_KEY_ID") + ?: System.getenv("SIGNING_KEYID"))?.trim()?.takeIf { it.isNotEmpty() } + val signingPassword = (findProperty("signingPassword") as String?) + ?: (findProperty("signing.password") as String?) + ?: System.getenv("SIGNING_PASSWORD") + ?: System.getenv("SIGNING_PASSPHRASE") + val signingKey = normalizeSigningKey( + (findProperty("signingKey") as String?) + ?: (findProperty("signing.key") as String?) + ?: (findProperty("signingKeyBase64") as String?) + ?: (findProperty("signing.keyBase64") as String?) + ?: System.getenv("SIGNING_KEY") + ?: System.getenv("SIGNING_KEY_BASE64") + ) + val gpgKeyConfigured = project.hasProperty("signing.gnupg.keyName") || + project.hasProperty("signing.gnupg.executable") || + project.hasProperty("signing.gnupg.homeDir") || + !((findProperty("signing.gnupg.keyName") as String?) ?: System.getenv("SIGNING_GNUPG_KEY_NAME")).isNullOrBlank() || + System.getenv("SIGNING_GNUPG_EXECUTABLE") != null || + System.getenv("SIGNING_GNUPG_HOME_DIR") != null + + val hasInMemoryKeys = !signingKey.isNullOrBlank() && !signingPassword.isNullOrBlank() + + if (hasInMemoryKeys) { + if (!signingKeyId.isNullOrBlank()) { + useInMemoryPgpKeys(signingKeyId, signingKey!!, signingPassword!!) + } else { + useInMemoryPgpKeys(signingKey!!, signingPassword!!) + } + + isRequired = true + sign(publishing.publications["mavenJava"]) + } else if (gpgKeyConfigured) { + useGpgCmd() + isRequired = true + sign(publishing.publications["mavenJava"]) + } else { + isRequired = false + logger.warn("Skipping artifact signing because no signing credentials were provided.") + } +} + // Add a source set for the functional test suite val functionalTestSourceSet = sourceSets.create("functionalTest") { diff --git a/gradle.properties b/gradle.properties index 377538c..114680d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,3 @@ # This file was generated by the Gradle 'init' task. # https://docs.gradle.org/current/userguide/build_environment.html#sec:gradle_configuration_properties -org.gradle.configuration-cache=true - diff --git a/settings.gradle.kts b/settings.gradle.kts index 5f5f17e..799891f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1 +1,8 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + rootProject.name = "sbom-license-plugin"