Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 189 additions & 0 deletions .github/workflows/java-sdk-release.yml
Original file line number Diff line number Diff line change
@@ -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 <ci@example.invalid>" 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
40 changes: 40 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<fingerprint>` 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
Expand Down
2 changes: 1 addition & 1 deletion NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
56 changes: 55 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,58 @@ subprojects {
tasks.matching { it.name == 'check' }.configureEach {
dependsOn rootProject.tasks.named('validateKeycloakRealm')
}
}
}

// ---------------------------------------------------------------------------
// 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).")
}
}
}
Loading
Loading