From 62fe1a5e1a81eef040849532295ebc3a15dfddb3 Mon Sep 17 00:00:00 2001 From: jgjesdal Date: Wed, 30 Sep 2026 10:21:14 +0200 Subject: [PATCH 1/5] build(sdk): make datahub-sdk and api-model publishable to Maven Central Port the Central tooling from the 0.2.0 prep: the maven-central-conventions plugin and the root centralBundle task. The SDK now publishes as datahub-sdk (not datahub-java-sdk), the POMs no longer import the Spring Boot BOM, and the jars carry their LICENSE. zero-allocation-hashing moves off the 0.27ea0 early-access build to 2026.0, pinned once for every module. The id hashes are unchanged. Also fix README snippets that did not compile and stale version references. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: jgjesdal --- AGENTS.md | 20 +++ NOTICE | 2 +- build.gradle | 56 ++++++- ...m.datahub.maven-central-conventions.gradle | 155 ++++++++++++++++++ datahub-api-model/LICENSE | 2 +- datahub-api-model/README.md | 11 +- datahub-api-model/build.gradle | 112 ++----------- datahub-api/build.gradle | 2 +- datahub-commons/build.gradle | 2 +- datahub-infra/build.gradle | 2 +- datahub-java-sdk/AGENTS.md | 7 +- datahub-java-sdk/LICENSE | 2 +- datahub-java-sdk/README.md | 16 +- datahub-java-sdk/build.gradle | 103 ++---------- .../datahub/sdk/services/ResourceService.java | 5 - datahub-stateless-consumer/build.gradle | 2 +- gradle.properties | 5 + 17 files changed, 292 insertions(+), 212 deletions(-) create mode 100644 buildSrc/src/main/groovy/ai.intellistream.datahub.maven-central-conventions.gradle diff --git a/AGENTS.md b/AGENTS.md index d6a76b52..5447734c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,26 @@ 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. The upload is a deliberate manual step, because +**a Central release can never be replaced or deleted**. A mistake can only be fixed by releasing +a new version. + +- Versions live in the root `gradle.properties` (`apiModelVersion`, `javaSdkVersion`). Bump both. +- Signing is skipped unless a key is configured: `-PsigningKey`/`SIGNING_KEY` (plus + `-PsigningPassword`), or `-PsigningUseGpgCommand=true`. `centralBundle` refuses to build an + unsigned bundle. +- Tag SDK releases `java-sdk-v`. The SDK and the platform are on separate version lines. + +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..b960b5f6 --- /dev/null +++ b/buildSrc/src/main/groovy/ai.intellistream.datahub.maven-central-conventions.gradle @@ -0,0 +1,155 @@ +// 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 (what CI uses) +// -PsigningUseGpgCommand=true delegate to the local gpg agent (what a laptop uses) +// 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')) +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) { + useInMemoryPgpKeys(signingKey.get(), signingPassword.getOrElse('')) + } else if (useGpgCommand) { + 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/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 From 3557013bb3ecaa285f89f18f854b628a4fdbbdf1 Mon Sep 17 00:00:00 2001 From: jgjesdal Date: Wed, 30 Sep 2026 10:46:13 +0200 Subject: [PATCH 2/5] ci(sdk): release datahub-sdk to Maven Central on a java-sdk-v tag Mirrors the Rust/Python SDK release: pushing java-sdk-vX.Y.Z checks the tag against both versions, builds and tests, then signs, uploads and publishes from the release environment. Pull requests that touch the release machinery rehearse it with a throwaway signing key. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: jgjesdal --- .github/workflows/java-sdk-release.yml | 144 +++++++++++++++++++++++++ AGENTS.md | 16 +-- 2 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/java-sdk-release.yml diff --git a/.github/workflows/java-sdk-release.yml b/.github/workflows/java-sdk-release.yml new file mode 100644 index 00000000..10e2174f --- /dev/null +++ b/.github/workflows/java-sdk-release.yml @@ -0,0 +1,144 @@ +name: Java SDK release + +# Publishes ai.intellistream:datahub-api-model and ai.intellistream:datahub-sdk to Maven Central, +# in lockstep, when a java-sdk-vX.Y.Z tag is pushed (or a GitHub Release is created with one). +# The tag is prefixed because the SDK and the platform are on separate version lines; a bare +# vX.Y.Z belongs to the platform. +on: + push: + tags: ['java-sdk-v*'] + # Rehearse the whole pipeline, signing and bundle included, 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 tag. + 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 + - .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 tag push carries a version to check against; a rehearsal has none. + if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then + if ! printf '%s' "$GITHUB_REF_NAME" | grep -Eq '^java-sdk-v[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::'$GITHUB_REF_NAME' is not a release tag (expected java-sdk-vX.Y.Z)" + exit 1 + fi + if [ "${GITHUB_REF_NAME#java-sdk-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 tag push, so this is the only test run a release gets. + - run: ./gradlew :datahub-api-model:build :datahub-java-sdk:build + + bundle: + name: Signed bundle (throwaway key) + 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 + # Signs with a key generated here, so every rehearsal exercises signing and the bundle's + # unsigned-file guard without touching the real key. The real key is used only by publish. + - run: | + set -euo pipefail + export GNUPGHOME=$(mktemp -d) + gpg --batch --passphrase '' --quick-generate-key \ + "throwaway rehearsal key " rsa2048 sign 1d + SIGNING_KEY=$(gpg --armor --export-secret-keys ci@example.invalid) \ + SIGNING_PASSWORD='' \ + ./gradlew centralBundle + - uses: actions/upload-artifact@v7 + with: + name: central-bundle-rehearsal + retention-days: 7 + path: build/central/*.zip + + publish: + name: Publish to Maven Central + runs-on: ubuntu-latest + needs: [build, bundle] + if: startsWith(github.ref, 'refs/tags/java-sdk-v') && github.repository_owner == 'IntelliStream-DataHub' + environment: release + timeout-minutes: 60 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: '25' + - uses: gradle/actions/setup-gradle@v6 + # Signing needs the real key, so the bundle is rebuilt here rather than downloaded. + - run: ./gradlew centralBundle + env: + SIGNING_KEY: ${{ secrets.SIGNING_KEY }} + SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} + # 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#java-sdk-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=@build/central/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 5447734c..43d5e7a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,15 +55,19 @@ backend-for-frontend proxy. Read it before adding a feature that touches any of **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. The upload is a deliberate manual step, because -**a Central release can never be replaced or deleted**. A mistake can only be fixed by releasing -a new version. - -- Versions live in the root `gradle.properties` (`apiModelVersion`, `javaSdkVersion`). Bump both. +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. + +- To release, set both versions in the root `gradle.properties` (`apiModelVersion`, + `javaSdkVersion`) to `X.Y.Z`, merge, and push the tag `java-sdk-vX.Y.Z`. The tag is prefixed + because the SDK and the platform are on separate version lines. +- `.github/workflows/java-sdk-release.yml` then checks the tag against both versions, builds and + tests, and signs, uploads and publishes to Central. It waits in the `release` environment for + approval, which is the human gate. On a pull request touching the release machinery it + rehearses everything except the upload, signing with a throwaway key. - Signing is skipped unless a key is configured: `-PsigningKey`/`SIGNING_KEY` (plus `-PsigningPassword`), or `-PsigningUseGpgCommand=true`. `centralBundle` refuses to build an unsigned bundle. -- Tag SDK releases `java-sdk-v`. The SDK and the platform are on separate version lines. 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. From b66abe250014e22afb856f9522c3d7152ab4312a Mon Sep 17 00:00:00 2001 From: jgjesdal Date: Wed, 30 Sep 2026 14:26:50 +0200 Subject: [PATCH 3/5] fix(build): sign with the subkey when the primary key only certifies Gradle's in-memory signer used the primary key, so a key whose primary only certifies produced signatures nothing could verify. -PsigningKeyId / SIGNING_KEY_ID selects the signing subkey. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: jgjesdal --- .github/workflows/java-sdk-release.yml | 2 ++ AGENTS.md | 3 ++- ...listream.datahub.maven-central-conventions.gradle | 12 ++++++++++-- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/java-sdk-release.yml b/.github/workflows/java-sdk-release.yml index 10e2174f..6f1c4107 100644 --- a/.github/workflows/java-sdk-release.yml +++ b/.github/workflows/java-sdk-release.yml @@ -112,6 +112,8 @@ jobs: env: SIGNING_KEY: ${{ secrets.SIGNING_KEY }} SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} + # The signing subkey's last 8 hex digits. Public, so a variable, not a secret. + SIGNING_KEY_ID: ${{ vars.SIGNING_KEY_ID }} # 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. diff --git a/AGENTS.md b/AGENTS.md index 43d5e7a0..1bcd96f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,7 +66,8 @@ deleted**. A mistake can only be fixed by releasing a new version. approval, which is the human gate. On a pull request touching the release machinery it rehearses everything except the upload, signing with a throwaway key. - Signing is skipped unless a key is configured: `-PsigningKey`/`SIGNING_KEY` (plus - `-PsigningPassword`), or `-PsigningUseGpgCommand=true`. `centralBundle` refuses to build an + `-PsigningPassword`, and `-PsigningKeyId`, the signing subkey's last 8 hex digits, when the + primary key only certifies), or `-PsigningUseGpgCommand=true`. `centralBundle` refuses to build an unsigned bundle. The step-by-step runbook (Portal account, namespace verification, key generation, upload) is 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 index b960b5f6..4af53cff 100644 --- a/buildSrc/src/main/groovy/ai.intellistream.datahub.maven-central-conventions.gradle +++ b/buildSrc/src/main/groovy/ai.intellistream.datahub.maven-central-conventions.gradle @@ -114,7 +114,8 @@ publishing { // --- Signing -------------------------------------------------------------------------- // Two supported key sources, neither of which puts a key in the repo: -// -PsigningKey / SIGNING_KEY ASCII-armoured secret key (what CI uses) +// -PsigningKey / SIGNING_KEY ASCII-armoured secret key (what CI uses), plus +// -PsigningKeyId / SIGNING_KEY_ID the signing subkey's ID when the primary only certifies // -PsigningUseGpgCommand=true delegate to the local gpg agent (what a laptop uses) // With neither set, the Sign tasks are skipped so ordinary `./gradlew build` and // publishToMavenLocal keep working unsigned. The root `centralBundle` task refuses to @@ -123,13 +124,20 @@ 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) { + if (signingKey.present && signingKeyId.present) { + useInMemoryPgpKeys(signingKeyId.get(), signingKey.get(), signingPassword.getOrElse('')) + } else if (signingKey.present) { useInMemoryPgpKeys(signingKey.get(), signingPassword.getOrElse('')) } else if (useGpgCommand) { useGpgCmd() From 4f5bdf15cf5ecbb8be7966e326b082bcd325797d Mon Sep 17 00:00:00 2001 From: jgjesdal Date: Wed, 30 Sep 2026 14:45:56 +0200 Subject: [PATCH 4/5] ci(sdk): sign releases locally with personal keys; CI verifies and uploads The release manager builds and signs the bundle on their own machine and attaches it to a java-sdk-vX.Y.Z GitHub Release. CI no longer holds a signing key. scripts/verify-central-bundle.sh checks the bundle holds exactly the expected files, that every file is signed by a key in RELEASE_SIGNERS, and that each is byte-identical to what the tagged commit builds. The workflow then uploads the verified bundle. Local gpg signing defaults to `gpg`, since not every install provides `gpg2`. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: jgjesdal --- .github/workflows/java-sdk-release.yml | 109 +++++++++++------ AGENTS.md | 37 ++++-- ...m.datahub.maven-central-conventions.gradle | 11 +- datahub-java-sdk/RELEASE_SIGNERS | 15 +++ scripts/verify-central-bundle.sh | 111 ++++++++++++++++++ 5 files changed, 237 insertions(+), 46 deletions(-) create mode 100644 datahub-java-sdk/RELEASE_SIGNERS create mode 100755 scripts/verify-central-bundle.sh diff --git a/.github/workflows/java-sdk-release.yml b/.github/workflows/java-sdk-release.yml index 6f1c4107..5a0409d5 100644 --- a/.github/workflows/java-sdk-release.yml +++ b/.github/workflows/java-sdk-release.yml @@ -1,15 +1,19 @@ name: Java SDK release # Publishes ai.intellistream:datahub-api-model and ai.intellistream:datahub-sdk to Maven Central, -# in lockstep, when a java-sdk-vX.Y.Z tag is pushed (or a GitHub Release is created with one). -# The tag is prefixed because the SDK and the platform are on separate version lines; a bare -# vX.Y.Z belongs to the platform. +# 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 java-sdk-vX.Y.Z with build/central/datahub-central-X.Y.Z.zip +# attached. The tag is prefixed because the SDK and the platform are on separate version lines. on: - push: - tags: ['java-sdk-v*'] - # Rehearse the whole pipeline, signing and bundle included, when the version or the release + 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 tag. + # skips unless this is a release. pull_request: paths: - gradle.properties @@ -17,6 +21,8 @@ on: - 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: @@ -38,7 +44,7 @@ jobs: echo "::error::apiModelVersion ($model) and javaSdkVersion ($sdk) disagree" exit 1 fi - # Only a tag push carries a version to check against; a rehearsal has none. + # 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 '^java-sdk-v[0-9]+\.[0-9]+\.[0-9]+$'; then echo "::error::'$GITHUB_REF_NAME' is not a release tag (expected java-sdk-vX.Y.Z)" @@ -62,11 +68,11 @@ jobs: distribution: temurin java-version: '25' - uses: gradle/actions/setup-gradle@v6 - # build.yml does not run on a tag push, so this is the only test run a release gets. + # 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 - bundle: - name: Signed bundle (throwaway key) + verify: + name: Verify the signed bundle runs-on: ubuntu-latest needs: versions timeout-minutes: 30 @@ -77,43 +83,80 @@ jobs: distribution: temurin java-version: '25' - uses: gradle/actions/setup-gradle@v6 - # Signs with a key generated here, so every rehearsal exercises signing and the bundle's - # unsigned-file guard without touching the real key. The real key is used only by publish. - - run: | + - name: Pick the version + run: | + set -euo pipefail + if [ "$GITHUB_EVENT_NAME" = release ]; then + echo "VERSION=${GITHUB_REF_NAME#java-sdk-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 " rsa2048 sign 1d + "throwaway rehearsal key " ed25519 sign 1d SIGNING_KEY=$(gpg --armor --export-secret-keys ci@example.invalid) \ SIGNING_PASSWORD='' \ - ./gradlew centralBundle + ./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-rehearsal + name: central-bundle retention-days: 7 - path: build/central/*.zip + path: ${{ runner.temp }}/bundle/*.zip publish: name: Publish to Maven Central runs-on: ubuntu-latest - needs: [build, bundle] - if: startsWith(github.ref, 'refs/tags/java-sdk-v') && github.repository_owner == 'IntelliStream-DataHub' + needs: [build, verify] + if: github.event_name == 'release' && github.repository_owner == 'IntelliStream-DataHub' environment: release timeout-minutes: 60 steps: - - uses: actions/checkout@v7 - - uses: actions/setup-java@v6 + - uses: actions/download-artifact@v7 with: - distribution: temurin - java-version: '25' - - uses: gradle/actions/setup-gradle@v6 - # Signing needs the real key, so the bundle is rebuilt here rather than downloaded. - - run: ./gradlew centralBundle - env: - SIGNING_KEY: ${{ secrets.SIGNING_KEY }} - SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} - # The signing subkey's last 8 hex digits. Public, so a variable, not a secret. - SIGNING_KEY_ID: ${{ vars.SIGNING_KEY_ID }} + 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. @@ -127,7 +170,7 @@ jobs: 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=@build/central/datahub-central-$version.zip" \ + -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. diff --git a/AGENTS.md b/AGENTS.md index 1bcd96f6..ef6afd2e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,17 +58,32 @@ model at the exact version built alongside it. The shared POM, signing and stagi 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. -- To release, set both versions in the root `gradle.properties` (`apiModelVersion`, - `javaSdkVersion`) to `X.Y.Z`, merge, and push the tag `java-sdk-vX.Y.Z`. The tag is prefixed - because the SDK and the platform are on separate version lines. -- `.github/workflows/java-sdk-release.yml` then checks the tag against both versions, builds and - tests, and signs, uploads and publishes to Central. It waits in the `release` environment for - approval, which is the human gate. On a pull request touching the release machinery it - rehearses everything except the upload, signing with a throwaway key. -- Signing is skipped unless a key is configured: `-PsigningKey`/`SIGNING_KEY` (plus - `-PsigningPassword`, and `-PsigningKeyId`, the signing subkey's last 8 hex digits, when the - primary key only certifies), or `-PsigningUseGpgCommand=true`. `centralBundle` refuses to build an - unsigned bundle. +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 java-sdk-vX.Y.Z build/central/datahub-central-X.Y.Z.zip`. The tag is + prefixed because the SDK and the platform are on separate version lines. + +`.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. 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 index 4af53cff..dc4f6622 100644 --- a/buildSrc/src/main/groovy/ai.intellistream.datahub.maven-central-conventions.gradle +++ b/buildSrc/src/main/groovy/ai.intellistream.datahub.maven-central-conventions.gradle @@ -114,9 +114,10 @@ publishing { // --- Signing -------------------------------------------------------------------------- // Two supported key sources, neither of which puts a key in the repo: -// -PsigningKey / SIGNING_KEY ASCII-armoured secret key (what CI uses), plus +// -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 (what a laptop uses) +// -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. @@ -140,6 +141,12 @@ signing { } 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 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/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." From 95471a5b596569ce5e61fe001ba5b89f67ce77ed Mon Sep 17 00:00:00 2001 From: jgjesdal Date: Wed, 30 Sep 2026 14:53:10 +0200 Subject: [PATCH 5/5] ci(sdk): tag Java SDK releases vX.Y.Z, like the Rust and Python SDKs Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: jgjesdal --- .github/workflows/java-sdk-release.yml | 14 +++++++------- AGENTS.md | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/java-sdk-release.yml b/.github/workflows/java-sdk-release.yml index 5a0409d5..cbd29a02 100644 --- a/.github/workflows/java-sdk-release.yml +++ b/.github/workflows/java-sdk-release.yml @@ -6,8 +6,8 @@ name: Java SDK release # # 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 java-sdk-vX.Y.Z with build/central/datahub-central-X.Y.Z.zip -# attached. The tag is prefixed because the SDK and the platform are on separate version lines. +# 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] @@ -46,11 +46,11 @@ jobs: 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 '^java-sdk-v[0-9]+\.[0-9]+\.[0-9]+$'; then - echo "::error::'$GITHUB_REF_NAME' is not a release tag (expected java-sdk-vX.Y.Z)" + 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#java-sdk-v}" != "$sdk" ]; then + if [ "${GITHUB_REF_NAME#v}" != "$sdk" ]; then echo "::error::tag $GITHUB_REF_NAME does not match javaSdkVersion $sdk" exit 1 fi @@ -87,7 +87,7 @@ jobs: run: | set -euo pipefail if [ "$GITHUB_EVENT_NAME" = release ]; then - echo "VERSION=${GITHUB_REF_NAME#java-sdk-v}" >> "$GITHUB_ENV" + 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 @@ -166,7 +166,7 @@ jobs: CENTRAL_TOKEN_PASSWORD: ${{ secrets.CENTRAL_TOKEN_PASSWORD }} run: | set -euo pipefail - version=${GITHUB_REF_NAME#java-sdk-v} + 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" \ diff --git a/AGENTS.md b/AGENTS.md index ef6afd2e..dde0f00c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,8 +69,8 @@ To release `X.Y.Z`: 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 java-sdk-vX.Y.Z build/central/datahub-central-X.Y.Z.zip`. The tag is - prefixed because the SDK and the platform are on separate version lines. +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