diff --git a/.github/workflows/java-sdk-release.yml b/.github/workflows/java-sdk-release.yml new file mode 100644 index 00000000..cbd29a02 --- /dev/null +++ b/.github/workflows/java-sdk-release.yml @@ -0,0 +1,189 @@ +name: Java SDK release + +# Publishes ai.intellistream:datahub-api-model and ai.intellistream:datahub-sdk to Maven Central, +# in lockstep. Each release is signed on the release manager's own machine with their personal +# key; this workflow never holds a signing key. It checks the signed bundle and uploads it. +# +# To release: set both versions in gradle.properties to X.Y.Z and merge; on that commit run +# ./gradlew centralBundle -PsigningUseGpgCommand=true +# then publish a GitHub Release tagged vX.Y.Z with build/central/datahub-central-X.Y.Z.zip attached. +# Every published release runs this, so vX.Y.Z tags belong to the Java SDK. +on: + release: + types: [published] + # Rehearse the whole pipeline, with a throwaway signing key, when the version or the release + # machinery changes, so it is not first exercised during an actual release. The publish job + # skips unless this is a release. + pull_request: + paths: + - gradle.properties + - build.gradle + - buildSrc/src/main/groovy/ai.intellistream.datahub.maven-central-conventions.gradle + - datahub-api-model/build.gradle + - datahub-java-sdk/build.gradle + - datahub-java-sdk/RELEASE_SIGNERS + - scripts/verify-central-bundle.sh + - .github/workflows/java-sdk-release.yml + +permissions: + contents: read + +jobs: + versions: + name: Tag matches the versions + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - run: | + set -euo pipefail + model=$(grep -m1 '^apiModelVersion=' gradle.properties | cut -d= -f2) + sdk=$(grep -m1 '^javaSdkVersion=' gradle.properties | cut -d= -f2) + echo "apiModelVersion=$model javaSdkVersion=$sdk ref=${GITHUB_REF_NAME:-}" + # The SDK's POM pins api-model at the version built alongside it. + if [ "$model" != "$sdk" ]; then + echo "::error::apiModelVersion ($model) and javaSdkVersion ($sdk) disagree" + exit 1 + fi + # Only a release carries a version to check against; a rehearsal has none. + if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then + if ! printf '%s' "$GITHUB_REF_NAME" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::'$GITHUB_REF_NAME' is not a release tag (expected vX.Y.Z)" + exit 1 + fi + if [ "${GITHUB_REF_NAME#v}" != "$sdk" ]; then + echo "::error::tag $GITHUB_REF_NAME does not match javaSdkVersion $sdk" + exit 1 + fi + fi + + build: + name: Build and test + runs-on: ubuntu-latest + needs: versions + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: '25' + - uses: gradle/actions/setup-gradle@v6 + # build.yml does not run on a release, so this is the only test run a release gets. + - run: ./gradlew :datahub-api-model:build :datahub-java-sdk:build + + verify: + name: Verify the signed bundle + runs-on: ubuntu-latest + needs: versions + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: '25' + - uses: gradle/actions/setup-gradle@v6 + - name: Pick the version + run: | + set -euo pipefail + if [ "$GITHUB_EVENT_NAME" = release ]; then + echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV" + echo "VERSION_ARGS=" >> "$GITHUB_ENV" + else + # A snapshot stages under timestamped file names that differ build to build, so the + # rehearsal builds at the release version the snapshot is heading for. + v=$(grep -m1 '^javaSdkVersion=' gradle.properties | cut -d= -f2) + echo "VERSION=${v%-SNAPSHOT}" >> "$GITHUB_ENV" + echo "VERSION_ARGS=-PjavaSdkVersion=${v%-SNAPSHOT} -PapiModelVersion=${v%-SNAPSHOT}" >> "$GITHUB_ENV" + fi + # What the tagged source builds to, unsigned. The bundle must match it byte for byte. + - name: Build the expected artifacts + run: | + set -euo pipefail + ./gradlew clean \ + :datahub-api-model:publishMavenJavaPublicationToCentralStagingRepository \ + :datahub-java-sdk:publishMavenJavaPublicationToCentralStagingRepository $VERSION_ARGS + cp -r build/central-staging "$RUNNER_TEMP/expected" + - name: Download the bundle attached to the release + if: github.event_name == 'release' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/bundle" + gh release download "$GITHUB_REF_NAME" --pattern 'datahub-central-*.zip' --dir "$RUNNER_TEMP/bundle" + [ "$(ls "$RUNNER_TEMP/bundle" | wc -l)" = 1 ] \ + || { echo "::error::expected exactly one datahub-central-*.zip on the release"; exit 1; } + cp datahub-java-sdk/RELEASE_SIGNERS "$RUNNER_TEMP/signers" + echo "PUBKEYS=" >> "$GITHUB_ENV" + # Stands in for the release manager's local signing, so the rehearsal exercises the same + # verification a release gets, against a key that exists only in this job. + - name: Sign a rehearsal bundle with a throwaway key + if: github.event_name != 'release' + run: | + set -euo pipefail + export GNUPGHOME=$(mktemp -d) + gpg --batch --passphrase '' --quick-generate-key \ + "throwaway rehearsal key " ed25519 sign 1d + SIGNING_KEY=$(gpg --armor --export-secret-keys ci@example.invalid) \ + SIGNING_PASSWORD='' \ + ./gradlew centralBundle $VERSION_ARGS + mkdir -p "$RUNNER_TEMP/bundle" + cp build/central/*.zip "$RUNNER_TEMP/bundle/" + fpr=$(gpg --list-keys --with-colons ci@example.invalid | awk -F: '/^fpr/{print $10; exit}') + echo "$fpr throwaway rehearsal key" > "$RUNNER_TEMP/signers" + gpg --armor --export ci@example.invalid > "$RUNNER_TEMP/pubkeys.asc" + echo "PUBKEYS=$RUNNER_TEMP/pubkeys.asc" >> "$GITHUB_ENV" + - name: Verify + run: | + ./scripts/verify-central-bundle.sh "$RUNNER_TEMP"/bundle/*.zip "$VERSION" \ + "$RUNNER_TEMP/expected" "$RUNNER_TEMP/signers" $PUBKEYS + # The publish job uploads this artifact, not the release asset, so the bundle that + # reaches Central is the one verified here even if the asset is replaced meanwhile. + - uses: actions/upload-artifact@v7 + with: + name: central-bundle + retention-days: 7 + path: ${{ runner.temp }}/bundle/*.zip + + publish: + name: Publish to Maven Central + runs-on: ubuntu-latest + needs: [build, verify] + if: github.event_name == 'release' && github.repository_owner == 'IntelliStream-DataHub' + environment: release + timeout-minutes: 60 + steps: + - uses: actions/download-artifact@v7 + with: + name: central-bundle + # Central has no OIDC trusted publishing, so this takes a Portal user token. AUTOMATIC + # publishes as soon as validation passes; the release environment is the human gate. + # A Central release can never be replaced or deleted. + - name: Upload and wait for Central to publish + env: + CENTRAL_TOKEN_USER: ${{ secrets.CENTRAL_TOKEN_USER }} + CENTRAL_TOKEN_PASSWORD: ${{ secrets.CENTRAL_TOKEN_PASSWORD }} + run: | + set -euo pipefail + version=${GITHUB_REF_NAME#v} + auth=$(printf '%s:%s' "$CENTRAL_TOKEN_USER" "$CENTRAL_TOKEN_PASSWORD" | base64 -w0) + api=https://central.sonatype.com/api/v1/publisher + id=$(curl -sS --fail-with-body -H "Authorization: Bearer $auth" \ + -F "bundle=@datahub-central-$version.zip" \ + "$api/upload?name=datahub-java-sdk-$version&publishingType=AUTOMATIC") + echo "deployment $id" + # Validation plus the push to Central usually takes minutes; allow up to 45. + for _ in $(seq 1 90); do + status=$(curl -sS --fail-with-body -X POST -H "Authorization: Bearer $auth" \ + "$api/status?id=$id") + state=$(printf '%s' "$status" | jq -r .deploymentState) + echo "state: $state" + case "$state" in + PUBLISHED) exit 0 ;; + FAILED) printf '%s\n' "$status" | jq .; exit 1 ;; + esac + sleep 30 + done + echo "::error::deployment $id did not reach PUBLISHED in time; check the Portal" + exit 1 diff --git a/AGENTS.md b/AGENTS.md index d6a76b52..dde0f00c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,46 @@ as the authoritative store, validation before anything goes async, one type labe on the frontend, calling datahub-api directly rather than extending the console's backend-for-frontend proxy. Read it before adding a feature that touches any of those. +## Releasing the published artifacts + +`datahub-api-model` and `datahub-java-sdk` are the only modules published as Maven coordinates: +`ai.intellistream:datahub-api-model` and `ai.intellistream:datahub-sdk` (the SDK's artifactId is +**not** its Gradle project name). They are released in lockstep, because the SDK's POM pins the +model at the exact version built alongside it. The shared POM, signing and staging setup is the +`maven-central-conventions` plugin in `buildSrc`. `./gradlew centralBundle` stages both modules +and zips the Maven Central deployment bundle. **A Central release can never be replaced or +deleted**. A mistake can only be fixed by releasing a new version. + +Each release is signed on the release manager's own machine with their **personal** key; CI +never holds a signing key. The keys allowed to sign are the primary fingerprints in +`datahub-java-sdk/RELEASE_SIGNERS`, so adding a release manager is a reviewed change to that file. + +To release `X.Y.Z`: + +1. Set both versions in the root `gradle.properties` (`apiModelVersion`, `javaSdkVersion`) to + `X.Y.Z` and merge. +2. On that merged commit, with no `-P` version overrides, run + `./gradlew centralBundle -PsigningUseGpgCommand=true`. It signs through your local gpg agent; + add `-Psigning.gnupg.keyName=` if you hold more than one key. +3. `gh release create vX.Y.Z build/central/datahub-central-X.Y.Z.zip`. Every published GitHub + Release runs the release workflow, so `vX.Y.Z` tags and releases belong to the Java SDK. + +`.github/workflows/java-sdk-release.yml` then checks the tag against both versions, and builds and +tests. `scripts/verify-central-bundle.sh` then verifies the attached bundle: it holds exactly the +expected files, every file is signed by a listed key, and every file is byte-identical to what +the tagged commit builds. The build is reproducible across JDK vendors, so a bundle built from any +other commit or version fails. The job then waits in the `release` environment for approval, and +uploads and publishes the verified bundle to Central. On a pull request that touches the release +machinery, the workflow rehearses everything except the upload, signing with a throwaway key. + +`centralBundle` refuses to build an unsigned bundle. Besides `-PsigningUseGpgCommand=true` it +takes an in-memory key, `-PsigningKey`/`SIGNING_KEY` with `-PsigningPassword`, which is what the +rehearsal uses. With a key whose primary only certifies, it also needs `-PsigningKeyId`, the +signing subkey's last 8 hex digits. + +The step-by-step runbook (Portal account, namespace verification, key generation, upload) is +kept locally and is not checked in. Ask the maintainer for it. + ## Build Commands ```bash diff --git a/NOTICE b/NOTICE index 1bd3cee9..be26dcd5 100644 --- a/NOTICE +++ b/NOTICE @@ -129,7 +129,7 @@ lz4-java 1.11.0 Resolved from the `at.yawk.lz4` coordinates, a republished build of the original artifact. -zero-allocation-hashing 0.27ea0 +zero-allocation-hashing 2026.0 Copyright Higher Frequency Trading Apache License 2.0 diff --git a/build.gradle b/build.gradle index 03f4f221..e01d9305 100644 --- a/build.gradle +++ b/build.gradle @@ -53,4 +53,58 @@ subprojects { tasks.matching { it.name == 'check' }.configureEach { dependsOn rootProject.tasks.named('validateKeycloakRealm') } -} \ No newline at end of file +} + +// --------------------------------------------------------------------------- +// Maven Central release bundle. +// +// The Central Portal takes a single zip laid out as a Maven repository and validates it +// as one deployment, so the two published modules (datahub-api-model and datahub-sdk, +// released in lockstep) stage into ONE shared directory and ship as ONE bundle. Gradle +// does not upload: that is a separate manual step, deliberately, so nothing reaches a +// permanent, undeletable Central release by accident. +// --------------------------------------------------------------------------- +def centralPublishTasks = [ + ':datahub-api-model:publishMavenJavaPublicationToCentralStagingRepository', + ':datahub-java-sdk:publishMavenJavaPublicationToCentralStagingRepository', +] + +// Wipe the staging dir first: it is keyed by version, so without this a re-run after a +// version bump would ship the previous version's files alongside the new ones. +tasks.register('clearCentralStaging', Delete) { + delete layout.buildDirectory.dir('central-staging') +} + +subprojects { + tasks.matching { it.name == 'publishMavenJavaPublicationToCentralStagingRepository' } + .configureEach { dependsOn rootProject.tasks.named('clearCentralStaging') } +} + +tasks.register('centralBundle', Zip) { + group = 'publishing' + description = 'Stage datahub-api-model + datahub-sdk and zip the Maven Central deployment bundle.' + dependsOn centralPublishTasks + + def stagingDir = layout.buildDirectory.dir('central-staging') + from stagingDir + // Gradle writes no maven-metadata.xml for releases, but exclude it defensively: the + // Portal rejects a bundle carrying files it did not expect. + exclude '**/maven-metadata*' + + archiveFileName = "datahub-central-${providers.gradleProperty('javaSdkVersion').getOrElse('0.3.0-SNAPSHOT')}.zip" + destinationDirectory = layout.buildDirectory.dir('central') + + // Central rejects an unsigned deployment, and the Sign tasks skip silently when no key + // is configured. Fail here instead, where the fix is obvious. + doFirst { + def jars = fileTree(stagingDir) { include '**/*.jar', '**/*.pom' }.files + def unsigned = jars.findAll { !new File(it.path + '.asc').exists() } + if (unsigned) { + throw new GradleException( + "Maven Central requires a PGP signature for every published file; these have none:\n" + + unsigned.collect { " ${it.name}" }.join('\n') + + "\nConfigure a signing key (-PsigningKey / SIGNING_KEY, or " + + "-PsigningUseGpgCommand=true).") + } + } +} diff --git a/buildSrc/src/main/groovy/ai.intellistream.datahub.maven-central-conventions.gradle b/buildSrc/src/main/groovy/ai.intellistream.datahub.maven-central-conventions.gradle new file mode 100644 index 00000000..dc4f6622 --- /dev/null +++ b/buildSrc/src/main/groovy/ai.intellistream.datahub.maven-central-conventions.gradle @@ -0,0 +1,170 @@ +// Publishing conventions for the two artifacts that leave this repo as Maven coordinates: +// ai.intellistream:datahub-api-model and ai.intellistream:datahub-sdk. Everything Maven +// Central demands of a release lives here so the two modules stay identical; each module +// supplies only its own version property and POM name/description. +// +// Central's release requirements (https://central.sonatype.org/publish/requirements/): +// - sources + javadoc jars alongside the main jar +// - POM with name, description, url, licence, developer and scm entries +// - a detached PGP signature (.asc) for every published file +// - md5/sha1 checksums (Gradle writes these when publishing to a maven repository) +// +// Publishing is deliberately a two-step, offline-first flow: `publish...ToCentralStaging` +// writes a real Maven layout into the ROOT build dir, and the root `centralBundle` task +// zips both modules' output into the single deployment bundle the Central Portal API +// takes. Nothing is uploaded by Gradle; the upload is a separate, manual step. + +plugins { + id 'maven-publish' + id 'signing' +} + +group = 'ai.intellistream' + +// Central rejects a release without these two. +java { + withSourcesJar() + withJavadocJar() +} + +// Ship the module's LICENSE inside every jar. Licence scanners read jar metadata as well as +// POMs, and a consumer who vendors or shades the jar keeps the notice by default rather than +// having to remember it. (slf4j is the cautionary case: MIT, but undeclared in its POM, so +// scanners routinely report it as unknown.) +tasks.withType(Jar).configureEach { + from(layout.projectDirectory.file('LICENSE')) { into 'META-INF' } +} + +publishing { + publications { + mavenJava(MavenPublication) { + from components.java + + // Pin resolved versions into the published POM. Several dependencies are + // version-managed by the Spring Boot BOM at build time and would otherwise be + // published with no version at all, which breaks resolution for the non-Spring + // consumers these artifacts exist for. + versionMapping { + usage('java-api') { fromResolutionResult() } + usage('java-runtime') { fromResolutionResult() } + } + + pom { + // name/description are set per module; the rest is Central boilerplate. + url = 'https://github.com/IntelliStream-DataHub/datahub-platform' + // Apache-2.0, NOT the repository's AGPL-3.0. These two modules are the + // only ones a third party links into their own application, and a copyleft + // client library would be unusable for most of them; it also matches the + // Python and Rust SDKs. Each module carries its own LICENSE file, which is + // what makes the exception to the root LICENSE explicit. Both must stay + // Apache-2.0 together: datahub-sdk puts datahub-api-model on every + // consumer's compile classpath, so relicensing only one achieves nothing. + licenses { + license { + name = 'Apache License, Version 2.0' + url = 'https://www.apache.org/licenses/LICENSE-2.0.txt' + distribution = 'repo' + } + } + developers { + developer { + id = 'intellistream' + name = 'Intellistream AS' + organization = 'Intellistream AS' + organizationUrl = 'https://intellistream.ai' + } + } + scm { + url = 'https://github.com/IntelliStream-DataHub/datahub-platform' + connection = 'scm:git:https://github.com/IntelliStream-DataHub/datahub-platform.git' + developerConnection = 'scm:git:git@github.com:IntelliStream-DataHub/datahub-platform.git' + } + } + } + } + + repositories { + // Local staging directory in the ROOT build dir, shared by both modules, so one zip + // covers the whole release. Not a remote: the Central Portal takes an uploaded bundle. + maven { + name = 'centralStaging' + url = rootProject.layout.buildDirectory.dir('central-staging') + } + + // Optional remote registry for pre-release builds that should not go to Central. + // No default: a hardcoded host would advertise one nobody outside the project can + // resolve from. Supply -PmavenPublishUrl, and credentials via -PmavenPublishUser / + // -PmavenPublishToken or MAVEN_PUBLISH_USER / MAVEN_PUBLISH_TOKEN. + def mavenPublishUrl = providers.gradleProperty('mavenPublishUrl').orNull + if (mavenPublishUrl) { + maven { + name = 'remote' + url = mavenPublishUrl + credentials { + username = providers.gradleProperty('mavenPublishUser') + .orElse(providers.environmentVariable('MAVEN_PUBLISH_USER')).getOrNull() + password = providers.gradleProperty('mavenPublishToken') + .orElse(providers.environmentVariable('MAVEN_PUBLISH_TOKEN')).getOrNull() + } + authentication { basic(BasicAuthentication) } + } + } + } +} + +// --- Signing -------------------------------------------------------------------------- +// Two supported key sources, neither of which puts a key in the repo: +// -PsigningKey / SIGNING_KEY ASCII-armoured secret key (CI's rehearsal), plus +// -PsigningKeyId / SIGNING_KEY_ID the signing subkey's ID when the primary only certifies +// -PsigningUseGpgCommand=true delegate to the local gpg agent: how releases are +// signed, on the release manager's own machine +// With neither set, the Sign tasks are skipped so ordinary `./gradlew build` and +// publishToMavenLocal keep working unsigned. The root `centralBundle` task refuses to +// produce an unsigned bundle, so a forgotten key fails the release rather than Central. +def signingKey = providers.gradleProperty('signingKey') + .orElse(providers.environmentVariable('SIGNING_KEY')) +def signingPassword = providers.gradleProperty('signingPassword') + .orElse(providers.environmentVariable('SIGNING_PASSWORD')) +// The signing subkey's short ID (its last 8 hex digits; Gradle rejects the 16-digit form). +// Required when the primary key only certifies, which is the recommended layout: without it +// the in-memory signer uses the primary key and produces a signature nothing can verify. +def signingKeyId = providers.gradleProperty('signingKeyId') + .orElse(providers.environmentVariable('SIGNING_KEY_ID')) +def useGpgCommand = providers.gradleProperty('signingUseGpgCommand') + .orElse(providers.environmentVariable('SIGNING_USE_GPG_COMMAND')) + .map { it.toBoolean() }.getOrElse(false) +def signingConfigured = signingKey.present || useGpgCommand + +signing { + if (signingKey.present && signingKeyId.present) { + useInMemoryPgpKeys(signingKeyId.get(), signingKey.get(), signingPassword.getOrElse('')) + } else if (signingKey.present) { + useInMemoryPgpKeys(signingKey.get(), signingPassword.getOrElse('')) + } else if (useGpgCommand) { + // Gradle calls `gpg2` by default, which only some installs provide; GnuPG 2 installs as + // `gpg` everywhere. -Psigning.gnupg.executable still overrides it, and + // -Psigning.gnupg.keyName= picks a key when there is more than one. + if (!project.hasProperty('signing.gnupg.executable')) { + project.ext['signing.gnupg.executable'] = 'gpg' + } + useGpgCmd() + } + sign publishing.publications.mavenJava +} + +tasks.withType(Sign).configureEach { + onlyIf { signingConfigured } +} + +// --- Keep the Spring Boot BOM out of the published POM --------------------------------- +// Both modules apply io.spring.dependency-management purely for build-time version +// management, but by default that plugin copies the imported spring-boot-dependencies BOM +// into the generated POM's . A consumer of these deliberately +// framework-free artifacts would then import the entire Spring Boot platform and have +// versions forced on ~everything in their build. versionMapping above already writes a +// concrete version onto every dependency, so the BOM import buys the POM nothing. +pluginManager.withPlugin('io.spring.dependency-management') { + dependencyManagement { + generatedPomCustomization { enabled = false } + } +} diff --git a/datahub-api-model/LICENSE b/datahub-api-model/LICENSE index d6456956..69624d54 100644 --- a/datahub-api-model/LICENSE +++ b/datahub-api-model/LICENSE @@ -187,7 +187,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2026 Intellistream AS Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/datahub-api-model/README.md b/datahub-api-model/README.md index 42510f53..292fc22d 100644 --- a/datahub-api-model/README.md +++ b/datahub-api-model/README.md @@ -1,8 +1,9 @@ # datahub-api-model The **lean, framework-free wire-contract** module. It owns the request/response DTOs, -form models and envelopes that make up the `datahub-api` REST contract — the types -referenced by the Feign `DatahubApi` interface — and nothing else. +form models and envelopes that make up the `datahub-api` REST contract, and nothing else. +Published as `ai.intellistream:datahub-api-model`, the model the Java SDK +(`ai.intellistream:datahub-sdk`) is built on. It is the new **bottom of the build graph**, below `datahub-commons`: @@ -41,12 +42,14 @@ contract jars. | Dependency | Why | Scope | |---|---|---| -| `com.fasterxml.jackson.core:jackson-databind` (Jackson 2) | JSON mapping, custom (de)serializers | `api` | +| `tools.jackson.core:jackson-databind` (Jackson 3) | JSON mapping, custom (de)serializers | `api` | | `tools.jackson.dataformat:jackson-dataformat-xml` (Jackson 3) | one `@JacksonXmlElementWrapper` on `DataWrapper` | `compileOnly` | | `jakarta.validation:jakarta.validation-api` | constraint annotations + custom validators | `api` | | `io.swagger.core.v3:swagger-annotations-jakarta` | `@Schema` on DTOs | `api` | | `net.openhft:zero-allocation-hashing` | xxHash on `Resource`/`IdCollection`/`PolicyType` | `api` | | `org.slf4j:slf4j-api` | one `@Slf4j` helper | `api` | +| `org.apache.arrow:arrow-format` | FlatBuffers classes of the Arrow IPC format, for the binary datapoint frames | `api` | +| `com.github.luben:zstd-jni` | `ZstdPayloadCodec`; each consumer supplies the native library | `compileOnly` | | Lombok | accessors/builders | compile-time only | Explicitly **absent**: Spring, OpenFeign, Vault, Pulsar, JPA, the JDK-HttpClient @@ -57,7 +60,7 @@ Spring code to the artifact, and `bootJar` is disabled. ### XML is server-only Only `DataWrapper` carries a single Jackson-3 XML annotation, used by the web apps that -serve `application/xml`. It is `compileOnly` here so consumers (a future SDK) don't drag +serve `application/xml`. It is `compileOnly` here so consumers (the Java SDK) don't drag in woodstox; `datahub-commons` keeps `jackson-dataformat-xml` at runtime for the server. ### Two decouplings vs. the pre-extraction code diff --git a/datahub-api-model/build.gradle b/datahub-api-model/build.gradle index 927df244..2e8fb116 100644 --- a/datahub-api-model/build.gradle +++ b/datahub-api-model/build.gradle @@ -1,10 +1,9 @@ // datahub-api-model — the lean, framework-free wire-contract module. // // Holds the request/response DTOs, form models and envelopes that make up the -// datahub-api REST contract (the types referenced by the Feign `DatahubApi` -// interface). It sits BELOW datahub-commons: datahub-commons depends on this -// module with `api`, so every existing import of these types across the -// platform keeps resolving unchanged. +// datahub-api REST contract, the types the Java SDK and the server share. It sits +// BELOW datahub-commons: datahub-commons depends on this module with `api`, so every +// existing import of these types across the platform keeps resolving unchanged. // // Dependency policy: NO frameworks in the produced artifact. Only zero-/tiny- // transitive contract jars — Jackson (JSON), Jakarta Bean Validation annotations, @@ -21,8 +20,7 @@ plugins { id 'ai.intellistream.datahub.java-library-conventions' id 'org.springframework.boot' id 'io.spring.dependency-management' - id 'maven-publish' - id 'signing' + id 'ai.intellistream.datahub.maven-central-conventions' } dependencies { @@ -45,7 +43,7 @@ dependencies { api 'io.swagger.core.v3:swagger-annotations-jakarta:2.2.55' // xxHash helper used by Resource / IdCollection / ResourceFilter / PolicyType. - api 'net.openhft:zero-allocation-hashing:0.27ea0' + api "net.openhft:zero-allocation-hashing:${zeroAllocHashingVersion}" // One @Slf4j helper (TextValidator) needs the SLF4J facade. api 'org.slf4j:slf4j-api' @@ -74,95 +72,15 @@ bootJar { enabled = false } jar { enabled = true; archiveClassifier = '' } // --- Publishing ----------------------------------------------------------------- -// Published so an out-of-tree consumer (the Java SDK) can resolve these wire types as -// a versioned Maven artifact: ai.intellistream:datahub-api-model. -group = 'ai.intellistream' -version = providers.gradleProperty('apiModelVersion').getOrElse('0.1.0-SNAPSHOT') - -// Ship -sources and -javadoc jars so SDK developers get IDE navigation + API docs. -java { - withSourcesJar() - withJavadocJar() -} - -publishing { - publications { - mavenJava(MavenPublication) { - from components.java - // Pin resolved versions into the published POM. Some deps are version-managed by - // the Spring Boot BOM at build time and would otherwise be published with no - // version, breaking resolution for non-Spring consumers (e.g. the Java SDK). - versionMapping { - usage('java-api') { fromResolutionResult() } - usage('java-runtime') { fromResolutionResult() } - } - pom { - name = 'DataHub API Model' - description = 'Lean, framework-free wire-contract types for the DataHub Platform REST API.' - url = 'https://github.com/IntelliStream-DataHub/datahub-platform' - licenses { - // Apache-2.0, not the platform's AGPL. A client library has to be safe to - // link into someone else's application, and a copyleft one is not. - license { - name = 'The Apache License, Version 2.0' - url = 'https://www.apache.org/licenses/LICENSE-2.0.txt' - distribution = 'repo' - } - } - developers { - developer { - id = 'intellistream' - name = 'IntelliStream AS' - url = 'https://intellistream.ai' - } - } - scm { - connection = 'scm:git:https://github.com/IntelliStream-DataHub/datahub-platform.git' - developerConnection = 'scm:git:ssh://git@github.com/IntelliStream-DataHub/datahub-platform.git' - url = 'https://github.com/IntelliStream-DataHub/datahub-platform' - } - } - } - } - repositories { - // No public Maven release yet, so there is no default registry here: a hardcoded - // one would advertise a host nobody outside the project can resolve from. Supply a - // target to publish remotely: - // - // ./gradlew :datahub-api-model:publish \ - // -PmavenPublishUrl=https://example.org/api/packages//maven - // - // Credentials come from -PmavenPublishUser / -PmavenPublishToken, or the - // MAVEN_PUBLISH_USER / MAVEN_PUBLISH_TOKEN environment variables, so no secrets - // live in the repo. Without the URL only publishToMavenLocal is available, which is - // how out-of-tree consumers try the artifact today. - def mavenPublishUrl = providers.gradleProperty('mavenPublishUrl').orNull - if (mavenPublishUrl) { - maven { - name = 'remote' - url = mavenPublishUrl - credentials { - username = providers.gradleProperty('mavenPublishUser') - .orElse(providers.environmentVariable('MAVEN_PUBLISH_USER')).getOrNull() - password = providers.gradleProperty('mavenPublishToken') - .orElse(providers.environmentVariable('MAVEN_PUBLISH_TOKEN')).getOrNull() - } - authentication { basic(BasicAuthentication) } - } - } - } -} - -// Signing. Maven Central rejects unsigned artifacts, but a normal build and -// publishToMavenLocal must not need a key, so signing is required only when one is -// configured. Supply it as -PsigningInMemoryKey (an ASCII-armoured secret key) plus -// -PsigningInMemoryKeyPassword, or via ORG_GRADLE_PROJECT_ equivalents in CI. -signing { - def signingKey = providers.gradleProperty('signingInMemoryKey').orNull - def signingPassword = providers.gradleProperty('signingInMemoryKeyPassword').orNull - required { signingKey != null } - if (signingKey != null) { - useInMemoryPgpKeys(signingKey, signingPassword) - sign publishing.publications.mavenJava +// Published to Maven Central as ai.intellistream:datahub-api-model, in lockstep with the +// SDK. The group, licence/scm/developer POM metadata, sources+javadoc jars, the +// LICENSE inside each jar, signing and the staging repository all come from the +// maven-central-conventions plugin; `./gradlew centralBundle` builds the deployment. +version = providers.gradleProperty('apiModelVersion').getOrElse('0.3.0-SNAPSHOT') + +publishing.publications.mavenJava { + pom { + name = 'DataHub API Model' + description = 'Lean, framework-free wire-contract types for the DataHub Platform REST API.' } } diff --git a/datahub-api/build.gradle b/datahub-api/build.gradle index 8bb5283b..50acafa3 100644 --- a/datahub-api/build.gradle +++ b/datahub-api/build.gradle @@ -29,7 +29,7 @@ dependencies { testImplementation project(':datahub-infra') implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.1.1' - implementation 'net.openhft:zero-allocation-hashing:0.27ea0' + implementation "net.openhft:zero-allocation-hashing:${zeroAllocHashingVersion}" // Decompresses the binary datapoint frames to validate them (api-model's ZstdPayloadCodec is // compile-only there; each consumer of it supplies the native library). implementation 'com.github.luben:zstd-jni:1.5.7-17' diff --git a/datahub-commons/build.gradle b/datahub-commons/build.gradle index c31f41fc..2d4ef0de 100644 --- a/datahub-commons/build.gradle +++ b/datahub-commons/build.gradle @@ -33,7 +33,7 @@ dependencies { implementation "tools.jackson.dataformat:jackson-dataformat-xml:${jacksonVersion}" implementation "tools.jackson.core:jackson-databind:${jacksonVersion}" - implementation 'net.openhft:zero-allocation-hashing:0.27ea0' + implementation "net.openhft:zero-allocation-hashing:${zeroAllocHashingVersion}" // UUID v7 implementation 'com.fasterxml.uuid:java-uuid-generator:5.2.0' diff --git a/datahub-infra/build.gradle b/datahub-infra/build.gradle index 284b9c19..9cdb4ed7 100644 --- a/datahub-infra/build.gradle +++ b/datahub-infra/build.gradle @@ -76,7 +76,7 @@ dependencies { implementation "tools.jackson.dataformat:jackson-dataformat-xml:${jacksonVersion}" implementation "tools.jackson.core:jackson-databind:${jacksonVersion}" - implementation 'net.openhft:zero-allocation-hashing:0.27ea0' + implementation "net.openhft:zero-allocation-hashing:${zeroAllocHashingVersion}" implementation 'com.clickhouse:client-v2:0.10.0' implementation 'com.clickhouse:clickhouse-http-client:0.10.0' diff --git a/datahub-java-sdk/AGENTS.md b/datahub-java-sdk/AGENTS.md index b5b34f7a..7eb3874b 100644 --- a/datahub-java-sdk/AGENTS.md +++ b/datahub-java-sdk/AGENTS.md @@ -94,6 +94,7 @@ Thin, synchronous Java client for the DataHub Platform REST API, published as JWT as a static token (the SDK bakes the token into the client, hence per-request wrappers). Changes to `DatahubConfig`, `TokenProvider`, or service signatures ripple there. - Out-of-tree consumers install the artifact with `publishToMavenLocal`; there is no - public Maven release yet. `publish` targets whatever `-PmavenPublishUrl` names, and has - no default. The version comes from the `javaSdkVersion` Gradle property - (default `0.1.0-SNAPSHOT`). + public Maven release yet. The remote `publish` repository exists only when + `-PmavenPublishUrl` names one; `centralBundle` (root project) stages to a local directory + for Maven Central. The version comes from the `javaSdkVersion` Gradle property (default + `0.3.0-SNAPSHOT`), and the Maven artifactId is `datahub-sdk`, not the Gradle project name. diff --git a/datahub-java-sdk/LICENSE b/datahub-java-sdk/LICENSE index d6456956..69624d54 100644 --- a/datahub-java-sdk/LICENSE +++ b/datahub-java-sdk/LICENSE @@ -187,7 +187,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2026 Intellistream AS Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/datahub-java-sdk/README.md b/datahub-java-sdk/README.md index 380ea6f4..cacc7bd0 100644 --- a/datahub-java-sdk/README.md +++ b/datahub-java-sdk/README.md @@ -44,8 +44,8 @@ DatahubClient client = DatahubClient.create( "https://keycloak.intellistream.ai/realms/datahub/protocol/openid-connect/token") .build()); -DataWrapper resources = client.resources().getById(5677892L); -resources.getItems().forEach(System.out::println); +DataWrapper nodes = client.resources().getById(5677892L); +nodes.getItems().forEach(System.out::println); ``` ### Authentication @@ -104,7 +104,10 @@ TimeseriesFilter criteria = new TimeseriesFilter(); criteria.setDataSetId(List.of(IdCollection.createFromExternalId("plant_a"))); criteria.setName(List.of("Pump*", "Valve*")); // wildcards, OR-ed criteria.setUnit(List.of("kg/hr", "deg_*")); -criteria.setMetadata(Map.of("owner", "plant-a", "health", null)); +Map metadata = new HashMap<>(); +metadata.put("owner", "plant-a"); +metadata.put("health", null); // Map.of rejects null values +criteria.setMetadata(metadata); client.timeseries().filter(criteria).getItems().forEach(System.out::println); ``` @@ -213,7 +216,7 @@ the HTTP status. ```java try { - client.resources().create(resources); + client.resources().create(resources, null); } catch (DatahubApiException e) { Problem p = e.problem(); switch (p.slug()) { // "duplicate", "dataset-forbidden", … @@ -326,9 +329,10 @@ here and installing both artifacts to the local Maven repository: ``` Then add `mavenLocal()` to that project's repositories and depend on -`ai.intellistream:datahub-sdk:0.1.0-SNAPSHOT` (or whatever `javaSdkVersion` you built with). +`ai.intellistream:datahub-sdk:0.3.0-SNAPSHOT` (or whatever `javaSdkVersion` you built with). -To publish to a Maven repository of your own, pass its URL; there is deliberately no default: +To also publish to a Maven repository of your own, pass its URL; there is deliberately no default +(`centralBundle`, for Maven Central, stages to a local directory instead): ```bash ./gradlew :datahub-api-model:publish :datahub-java-sdk:publish \ diff --git a/datahub-java-sdk/RELEASE_SIGNERS b/datahub-java-sdk/RELEASE_SIGNERS new file mode 100644 index 00000000..525d5602 --- /dev/null +++ b/datahub-java-sdk/RELEASE_SIGNERS @@ -0,0 +1,15 @@ +# Keys allowed to sign releases of ai.intellistream:datahub-sdk and +# ai.intellistream:datahub-api-model. +# +# Each release is signed on the release manager's own machine with their personal key. +# The release workflow refuses a bundle signed by any key not listed here, so adding a +# release manager is a reviewed change to this file. +# +# This is also the list to check a downloaded artifact against: +# gpg --verify datahub-sdk-X.Y.Z.jar.asc datahub-sdk-X.Y.Z.jar +# should report a good signature whose primary key fingerprint is below. +# +# One line per signer: the primary key fingerprint (40 hex digits), then who holds it. +# A signer rotates signing subkeys without changing this file. + +906FF6D8E0B2AEE4606A6278243C23A826F4BAB8 Jostein Gjesdal diff --git a/datahub-java-sdk/build.gradle b/datahub-java-sdk/build.gradle index e60d13ee..27c26c3f 100644 --- a/datahub-java-sdk/build.gradle +++ b/datahub-java-sdk/build.gradle @@ -12,8 +12,7 @@ plugins { id 'ai.intellistream.datahub.java-library-conventions' id 'org.springframework.boot' id 'io.spring.dependency-management' - id 'maven-publish' - id 'signing' + id 'ai.intellistream.datahub.maven-central-conventions' } dependencies { @@ -37,92 +36,18 @@ bootJar { enabled = false } jar { enabled = true; archiveClassifier = '' } // --- Publishing ----------------------------------------------------------------- -// Published so external consumers can resolve the client as a versioned Maven artifact: -// ai.intellistream:datahub-sdk (depending on ai.intellistream:datahub-api-model). -group = 'ai.intellistream' -version = providers.gradleProperty('javaSdkVersion').getOrElse('0.1.0-SNAPSHOT') - -java { - withSourcesJar() - withJavadocJar() -} - -publishing { - publications { - mavenJava(MavenPublication) { - from components.java - // Pin BOM-managed versions into the published POM so non-Spring consumers resolve. - versionMapping { - usage('java-api') { fromResolutionResult() } - usage('java-runtime') { fromResolutionResult() } - } - pom { - name = 'DataHub SDK' - description = 'Java client for the DataHub Platform REST API.' - url = 'https://github.com/IntelliStream-DataHub/datahub-platform' - licenses { - // Apache-2.0, not the platform's AGPL. A client library has to be safe to - // link into someone else's application, and a copyleft one is not. - license { - name = 'The Apache License, Version 2.0' - url = 'https://www.apache.org/licenses/LICENSE-2.0.txt' - distribution = 'repo' - } - } - developers { - developer { - id = 'intellistream' - name = 'IntelliStream AS' - url = 'https://intellistream.ai' - } - } - scm { - connection = 'scm:git:https://github.com/IntelliStream-DataHub/datahub-platform.git' - developerConnection = 'scm:git:ssh://git@github.com/IntelliStream-DataHub/datahub-platform.git' - url = 'https://github.com/IntelliStream-DataHub/datahub-platform' - } - } - } - } - repositories { - // No public Maven release yet, so there is no default registry here: a hardcoded - // one would advertise a host nobody outside the project can resolve from. Supply a - // target to publish remotely: - // - // ./gradlew :datahub-java-sdk:publish \ - // -PmavenPublishUrl=https://example.org/api/packages//maven - // - // Credentials come from -PmavenPublishUser / -PmavenPublishToken, or the - // MAVEN_PUBLISH_USER / MAVEN_PUBLISH_TOKEN environment variables, so no secrets - // live in the repo. Without the URL only publishToMavenLocal is available, which is - // how out-of-tree consumers try the artifact today. - def mavenPublishUrl = providers.gradleProperty('mavenPublishUrl').orNull - if (mavenPublishUrl) { - maven { - name = 'remote' - url = mavenPublishUrl - credentials { - username = providers.gradleProperty('mavenPublishUser') - .orElse(providers.environmentVariable('MAVEN_PUBLISH_USER')).getOrNull() - password = providers.gradleProperty('mavenPublishToken') - .orElse(providers.environmentVariable('MAVEN_PUBLISH_TOKEN')).getOrNull() - } - authentication { basic(BasicAuthentication) } - } - } - } -} - -// Signing. Maven Central rejects unsigned artifacts, but a normal build and -// publishToMavenLocal must not need a key, so signing is required only when one is -// configured. Supply it as -PsigningInMemoryKey (an ASCII-armoured secret key) plus -// -PsigningInMemoryKeyPassword, or via ORG_GRADLE_PROJECT_ equivalents in CI. -signing { - def signingKey = providers.gradleProperty('signingInMemoryKey').orNull - def signingPassword = providers.gradleProperty('signingInMemoryKeyPassword').orNull - required { signingKey != null } - if (signingKey != null) { - useInMemoryPgpKeys(signingKey, signingPassword) - sign publishing.publications.mavenJava +// Published to Maven Central as ai.intellistream:datahub-sdk, in lockstep with the +// model. The group, licence/scm/developer POM metadata, sources+javadoc jars, the +// LICENSE inside each jar, signing and the staging repository all come from the +// maven-central-conventions plugin; `./gradlew centralBundle` builds the deployment. +version = providers.gradleProperty('javaSdkVersion').getOrElse('0.3.0-SNAPSHOT') + +publishing.publications.mavenJava { + // The Maven coordinate is datahub-sdk, not the Gradle project name datahub-java-sdk — + // that is what the SDK docs tell consumers to depend on. Do not let it default. + artifactId = 'datahub-sdk' + pom { + name = 'DataHub SDK' + description = 'Java client for the DataHub Platform REST API.' } } diff --git a/datahub-java-sdk/src/main/java/ai/intellistream/datahub/sdk/services/ResourceService.java b/datahub-java-sdk/src/main/java/ai/intellistream/datahub/sdk/services/ResourceService.java index ab5f14cd..c3be22a9 100644 --- a/datahub-java-sdk/src/main/java/ai/intellistream/datahub/sdk/services/ResourceService.java +++ b/datahub-java-sdk/src/main/java/ai/intellistream/datahub/sdk/services/ResourceService.java @@ -11,7 +11,6 @@ import ai.intellistream.datahub.models.RelatedResourcesForm; import ai.intellistream.datahub.models.Asset; import ai.intellistream.datahub.models.NodeModel; -import ai.intellistream.datahub.models.Resource; import ai.intellistream.datahub.models.ResourceRetreiver; import ai.intellistream.datahub.models.UpdateRelForm; import ai.intellistream.datahub.models.UpdateResourceForm; @@ -116,10 +115,6 @@ public DataWrapper search(SearchBody search) { return http.post("/resources/search", search, nodes); } - /** - * POST /resources/create — create resources, optionally with relations between them. - * Returns the created graph (nodes as {@link Resource}, relations as {@link EdgeProxy}). - */ /** * POST /resources/create. Any creatable node kind rides one call — an {@link Asset} with a * geoLocation, a {@code DataSetModel}, a {@code Timeseries} next to the assets it measures — diff --git a/datahub-stateless-consumer/build.gradle b/datahub-stateless-consumer/build.gradle index 1e9f7498..c298a42d 100644 --- a/datahub-stateless-consumer/build.gradle +++ b/datahub-stateless-consumer/build.gradle @@ -53,7 +53,7 @@ dependencies { exclude group: 'org.apache.logging.log4j', module: 'log4j-slf4j-impl' } - implementation 'net.openhft:zero-allocation-hashing:0.27ea0' + implementation "net.openhft:zero-allocation-hashing:${zeroAllocHashingVersion}" implementation 'at.yawk.lz4:lz4-java:1.12.0' implementation 'com.github.luben:zstd-jni:1.5.7-17' diff --git a/gradle.properties b/gradle.properties index 351338be..b87f5869 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,6 +20,11 @@ javaSdkVersion=0.3.0-SNAPSHOT # --- Shared library versions --- pulsarVersion=4.0.11 +# xxHash for node/external ids. Pinned centrally because the ids it produces are PERSISTED: +# every module must hash identically, and a version that changed the algorithm would orphan +# existing rows. Also published as an api dependency of datahub-api-model, so it must be a +# stable release, not the 0.27ea0 early-access build it replaced. +zeroAllocHashingVersion=2026.0 # Parser generator for the events filter expression language (datahub-commons). # The `antlr` plugin ships with Gradle itself, so only the library version lives here. antlrVersion=4.13.2 diff --git a/scripts/verify-central-bundle.sh b/scripts/verify-central-bundle.sh new file mode 100755 index 00000000..4646c280 --- /dev/null +++ b/scripts/verify-central-bundle.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# +# Check a locally signed Maven Central bundle before it is uploaded. +# +# ./scripts/verify-central-bundle.sh [public-key-file] +# +# Releases of datahub-sdk and datahub-api-model are signed on the release manager's own +# machine with their personal key, and CI uploads the result. So before anything reaches +# Central, where a release can never be replaced or deleted, this proves four things: +# +# 1. The bundle holds exactly the files a release should: both modules, at , +# nothing else. Central rejects files it does not expect. +# 2. Every jar, POM and module file carries a detached signature. +# 3. Every signature is good and was made by a key in : a primary key +# fingerprint per line, so a signer can rotate subkeys without a change here. The +# keys are fetched from the public keyservers, which is also where Central looks, so a +# key that is not published fails here instead of at Central. Only a plain good +# signature counts: gpg exits 0 for a revoked or expired key too, so the exit code is +# not enough. +# 4. The signed files are byte-identical to , which CI builds from +# the tagged commit. A valid signature only proves a release manager signed +# something; this proves it was the tagged source. The build is reproducible across +# JDK vendors, so any difference is a real one. +# +# [public-key-file] imports keys from a file instead of the keyservers. The pull-request +# rehearsal uses it for its throwaway key. + +set -euo pipefail + +if [ $# -lt 4 ]; then + sed -n '2,6p' "$0" >&2 + exit 2 +fi +bundle=$1 version=$2 expected=$3 signers=$4 pubkeys=${5:-} + +fail() { echo "::error::$*" >&2; exit 1; } + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +export GNUPGHOME=$work/gnupg +mkdir -m 700 "$GNUPGHOME" + +# --- 1. Contents --------------------------------------------------------------------- +[ "$(basename "$bundle")" = "datahub-central-$version.zip" ] \ + || fail "bundle is $(basename "$bundle"), expected datahub-central-$version.zip" +unzip -q "$bundle" -d "$work/bundle" + +mapfile -t files < <(cd "$work/bundle" && find . -type f | sed 's|^\./||' | sort) +[ ${#files[@]} -gt 0 ] || fail "bundle is empty" +for f in "${files[@]}"; do + [[ $f =~ ^ai/intellistream/(datahub-api-model|datahub-sdk)/$version/[^/]+$ ]] \ + || fail "unexpected path in bundle: $f" + [[ $f =~ \.(jar|pom|module)(\.(asc|md5|sha1|sha256|sha512))?$|\.asc\.(md5|sha1|sha256|sha512)$ ]] \ + || fail "unexpected file in bundle: $f" +done + +mapfile -t artifacts < <(printf '%s\n' "${files[@]}" | grep -E '\.(jar|pom|module)$') +mapfile -t wanted < <(cd "$expected" && find . -type f \( -name '*.jar' -o -name '*.pom' -o -name '*.module' \) \ + | sed 's|^\./||' | sort) +[ ${#wanted[@]} -gt 0 ] || fail "no expected artifacts under $expected" +missing=$(comm -13 <(printf '%s\n' "${artifacts[@]}") <(printf '%s\n' "${wanted[@]}")) +extra=$(comm -23 <(printf '%s\n' "${artifacts[@]}") <(printf '%s\n' "${wanted[@]}")) +[ -z "$missing" ] || fail "bundle is missing: $missing" +[ -z "$extra" ] || fail "bundle has artifacts the tagged source does not build: $extra" + +# Central checks these too; failing here names the file. +for f in "${files[@]}"; do + case $f in + *.md5) algo=md5sum ;; + *.sha1) algo=sha1sum ;; + *.sha256) algo=sha256sum ;; + *.sha512) algo=sha512sum ;; + *) continue ;; + esac + [ "$($algo "$work/bundle/${f%.*}" | cut -d' ' -f1)" = "$(tr -d ' \n' < "$work/bundle/$f")" ] \ + || fail "checksum mismatch: $f" +done + +# --- 2 and 3. Signatures --------------------------------------------------------------- +mapfile -t allowed < <(grep -Ev '^\s*(#|$)' "$signers" | awk '{print toupper($1)}') +[ ${#allowed[@]} -gt 0 ] || fail "$signers lists no signers" + +if [ -n "$pubkeys" ]; then + gpg --batch --quiet --import "$pubkeys" +else + for fpr in "${allowed[@]}"; do + gpg --batch --quiet --keyserver hkps://keyserver.ubuntu.com --recv-keys "$fpr" 2>/dev/null \ + || gpg --batch --quiet --keyserver hkps://keys.openpgp.org --recv-keys "$fpr" 2>/dev/null \ + || echo "warning: signer $fpr is on neither keyserver" >&2 + done +fi + +for f in "${artifacts[@]}"; do + [ -f "$work/bundle/$f.asc" ] || fail "no signature for $f" + status=$(gpg --batch --status-fd 1 --verify "$work/bundle/$f.asc" "$work/bundle/$f" 2>/dev/null || true) + grep -q '^\[GNUPG:\] GOODSIG ' <<<"$status" \ + || fail "$f: not a good signature from a current key ($(grep -oE '^\[GNUPG:\] (BAD|ERR|EXP|EXPKEY|REVKEY)SIG' <<<"$status" | cut -d' ' -f2 | sort -u | tr '\n' ' '))" + primary=$(awk '$2 == "VALIDSIG" {print $NF}' <<<"$status") + subkey=$(awk '$2 == "VALIDSIG" {print $3}' <<<"$status") + printf '%s\n' "${allowed[@]}" | grep -qx "$primary" \ + || fail "$f: signed by $primary, which is not in $signers" + echo "signed $f (subkey $subkey, $(grep -i "^$primary" "$signers" | cut -d' ' -f2- | sed 's/^ *//'))" +done + +# --- 4. Built from the tagged source ---------------------------------------------------- +for f in "${artifacts[@]}"; do + cmp -s "$work/bundle/$f" "$expected/$f" \ + || fail "$f differs from what the tagged commit builds. Rebuild the bundle from that commit with gradle.properties unchanged and no -P version overrides." +done + +echo "OK: ${#artifacts[@]} artifacts, all signed by listed signers and identical to the tagged build."