From 64f86fc5452ece3c66932236a6ac87436262d5f8 Mon Sep 17 00:00:00 2001 From: Gavin Sharp Date: Thu, 28 May 2026 12:17:47 -0400 Subject: [PATCH 1/4] ci: isolate Maven Central publish secrets from Gradle plugin graph Split the publish job in two: a `build-artifact` job that runs Gradle (and its plugin graph) but holds no credentials, and a credential-holding `publish` job that only runs curl/jq/gpg from the runner image. The publish job runs in a `maven-production` environment so secrets can move off the repo and behind a branch/reviewer gate. Also pin the `version` to a safe charset before propagating, switch to job/workflow-level env to deduplicate, add Gradle caching to setup-java, and remove the now- unused `.publish/prepare.sh` helper. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/ci.yml | 190 +++++++++++++++++++++++++++++++++++---- .publish/prepare.sh | 8 -- 2 files changed, 174 insertions(+), 24 deletions(-) delete mode 100755 .publish/prepare.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b33bce2..d3a7c4d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,21 +4,25 @@ on: push: workflow_dispatch: +env: + ARTIFACT_ID: phenoml-java-sdk + jobs: compile: runs-on: ubuntu-latest steps: - name: Checkout repo - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Java id: setup-jre - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: java-version: "11" distribution: "zulu" architecture: x64 + cache: gradle - name: Compile run: ./gradlew compileJava @@ -28,18 +32,20 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repo - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Java id: setup-jre - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: java-version: "11" distribution: "zulu" architecture: x64 + cache: gradle - name: Test run: ./gradlew test + tag: needs: [ compile, test ] if: github.ref == 'refs/heads/main' @@ -48,17 +54,26 @@ jobs: contents: write outputs: should_publish: ${{ steps.check.outputs.should_publish }} + version: ${{ steps.check.outputs.version }} steps: - name: Checkout repo - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - name: Extract version and check tag id: check run: | + set -euo pipefail VERSION=$(grep "^version = " build.gradle | sed "s/version = '\(.*\)'/\1/") + # build.gradle is repo-controlled but flows into downstream + # shell/URL/filesystem contexts; pin it to a safe charset before + # propagating as a job output. + if ! printf '%s' "$VERSION" | grep -Eq '^[A-Za-z0-9._+-]+$'; then + echo "Refusing to publish: version '$VERSION' contains unexpected characters" >&2 + exit 1 + fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" if git ls-remote --tags origin | grep -q "refs/tags/$VERSION"; then echo "Tag $VERSION already exists, skipping" @@ -86,29 +101,172 @@ jobs: --title "${{ steps.check.outputs.version }}" \ --generate-notes - publish: + # Isolated from publish secrets — Gradle and its plugin graph run here. + build-artifact: needs: [ compile, test, tag ] if: needs.tag.outputs.should_publish == 'true' runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout repo - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Java - id: setup-jre - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: java-version: "11" distribution: "zulu" architecture: x64 + cache: gradle + + - name: Build publishable artifacts + run: | + ./gradlew \ + assemble \ + generatePomFileForMavenPublication \ + generateMetadataFileForMavenPublication \ + -x signMavenPublication + + - name: Stage dist/ and generate SHA256SUMS + env: + VERSION: ${{ needs.tag.outputs.version }} + run: | + set -euo pipefail + mkdir -p dist + cp "build/libs/${ARTIFACT_ID}-${VERSION}.jar" dist/ + cp "build/libs/${ARTIFACT_ID}-${VERSION}-sources.jar" dist/ + cp "build/libs/${ARTIFACT_ID}-${VERSION}-javadoc.jar" dist/ + cp "build/publications/maven/pom-default.xml" "dist/${ARTIFACT_ID}-${VERSION}.pom" + if [ -f build/publications/maven/module.json ]; then + cp build/publications/maven/module.json "dist/${ARTIFACT_ID}-${VERSION}.module" + fi + ( cd dist && sha256sum * > SHA256SUMS ) + echo "Staged dist/:" + ls -la dist/ + + - name: Upload bundle artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: maven-bundle + path: dist/ + retention-days: 1 + if-no-files-found: error + + # Holds the publish secrets. Only curl/jq/gpg from the runner image run here + # — no Java toolchain, no Gradle, no third-party actions beyond download-artifact. + publish: + needs: [ tag, build-artifact ] + runs-on: ubuntu-latest + environment: maven-production + permissions: + contents: read + env: + GROUP_ID: com.phenoml.maven + CENTRAL_API: https://central.sonatype.com/api/v1/publisher + VERSION: ${{ needs.tag.outputs.version }} + MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }} + MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} + MAVEN_SIGNATURE_SECRET_KEY: ${{ secrets.MAVEN_SIGNATURE_SECRET_KEY }} + MAVEN_SIGNATURE_PASSWORD: ${{ secrets.MAVEN_SIGNATURE_PASSWORD }} + + steps: + - name: Download bundle artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: maven-bundle + path: dist + + - name: Verify SHA256SUMS + run: | + set -euo pipefail + cd dist + sha256sum -c SHA256SUMS + rm SHA256SUMS + + - name: Import signing key into ephemeral GNUPGHOME + run: | + set -euo pipefail + GNUPGHOME="$(mktemp -d "$RUNNER_TEMP/gnupg.XXXXXX")" + chmod 700 "$GNUPGHOME" + echo "GNUPGHOME=$GNUPGHOME" >> "$GITHUB_ENV" + printf '%s' "$MAVEN_SIGNATURE_SECRET_KEY" \ + | gpg --batch --pinentry-mode loopback \ + --passphrase "$MAVEN_SIGNATURE_PASSWORD" \ + --import + + - name: Sign artifacts and emit md5/sha1 sidecars + run: | + set -euo pipefail + cd dist + for f in *; do + [ -f "$f" ] || continue + gpg --batch --pinentry-mode loopback \ + --passphrase "$MAVEN_SIGNATURE_PASSWORD" \ + --detach-sign --armor \ + --output "${f}.asc" \ + "$f" + md5sum "$f" | awk '{print $1}' > "${f}.md5" + sha1sum "$f" | awk '{print $1}' > "${f}.sha1" + done + + - name: Assemble Central Portal bundle zip + run: | + set -euo pipefail + GROUP_PATH="$(echo "$GROUP_ID" | tr . /)" + DEST="bundle/${GROUP_PATH}/${ARTIFACT_ID}/${VERSION}" + mkdir -p "$DEST" + mv dist/* "$DEST/" + # zip from inside bundle/ and pass the top-level dir as the path so + # entries are flat (no leading "./") — the Portal validator expects + # entries rooted at the group path. + TOP_DIR="$(echo "$GROUP_PATH" | cut -d/ -f1)" + ( cd bundle && zip -r ../bundle.zip "$TOP_DIR" ) + echo "Bundle contents:" + unzip -l bundle.zip - - name: Publish to maven + - name: Upload bundle to Central Portal + id: upload run: | - ./gradlew sonatypeCentralUpload + set -euo pipefail + AUTH="$(printf '%s:%s' "$MAVEN_USERNAME" "$MAVEN_PASSWORD" | base64 -w 0)" + DEPLOYMENT_ID="$(curl --fail-with-body -sS -X POST \ + -H "Authorization: Bearer ${AUTH}" \ + -F "bundle=@bundle.zip;type=application/octet-stream" \ + "${CENTRAL_API}/upload?name=${ARTIFACT_ID}-${VERSION}&publishingType=AUTOMATIC" \ + | tr -d '"[:space:]')" + if [ -z "${DEPLOYMENT_ID}" ]; then + echo "Empty deployment id returned from Central Portal" >&2 + exit 1 + fi + echo "deployment_id=${DEPLOYMENT_ID}" >> "$GITHUB_OUTPUT" + echo "Upload accepted; deployment id: ${DEPLOYMENT_ID}" + + - name: Poll deployment status until terminal env: - MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }} - MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} - MAVEN_SIGNATURE_KID: ${{ secrets.MAVEN_SIGNATURE_KID }} - MAVEN_SIGNATURE_SECRET_KEY: ${{ secrets.MAVEN_SIGNATURE_SECRET_KEY }} - MAVEN_SIGNATURE_PASSWORD: ${{ secrets.MAVEN_SIGNATURE_PASSWORD }} \ No newline at end of file + DEPLOYMENT_ID: ${{ steps.upload.outputs.deployment_id }} + run: | + set -euo pipefail + AUTH="$(printf '%s:%s' "$MAVEN_USERNAME" "$MAVEN_PASSWORD" | base64 -w 0)" + for i in $(seq 1 60); do + BODY="$(curl --fail-with-body -sS -X POST \ + -H "Authorization: Bearer ${AUTH}" \ + "${CENTRAL_API}/status?id=${DEPLOYMENT_ID}")" + STATE="$(echo "${BODY}" | jq -r '.deploymentState')" + echo "[${i}] deploymentState=${STATE}" + case "${STATE}" in + PUBLISHED) + echo "Maven Central publish complete for deployment ${DEPLOYMENT_ID}" + exit 0 + ;; + FAILED) + echo "Maven Central publish FAILED:" >&2 + echo "${BODY}" | jq . >&2 + exit 1 + ;; + esac + sleep 30 + done + echo "Timed out waiting for terminal deployment state" >&2 + exit 1 diff --git a/.publish/prepare.sh b/.publish/prepare.sh deleted file mode 100755 index df3948e..0000000 --- a/.publish/prepare.sh +++ /dev/null @@ -1,8 +0,0 @@ -# Write key ring file -echo "$MAVEN_SIGNATURE_SECRET_KEY" > armored_key.asc -gpg -o publish_key.gpg --dearmor armored_key.asc - -# Generate gradle.properties file -echo "signing.keyId=$MAVEN_SIGNATURE_KID" > gradle.properties -echo "signing.secretKeyRingFile=publish_key.gpg" >> gradle.properties -echo "signing.password=$MAVEN_SIGNATURE_PASSWORD" >> gradle.properties From 9da8b05cddbfe767b979faf7d0f2d512f1bcd461 Mon Sep 17 00:00:00 2001 From: Gavin Sharp Date: Tue, 2 Jun 2026 17:37:37 -0400 Subject: [PATCH 2/4] ci: export GNUPGHOME so same-step gpg import uses the ephemeral home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plain shell assignment isn't inherited by the gpg subprocess, so the key imported into the default ~/.gnupg while GITHUB_ENV pointed the later signing step at the empty ephemeral directory — every signature failed. Export it in-step too. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3a7c4d..d1f84e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -190,6 +190,9 @@ jobs: set -euo pipefail GNUPGHOME="$(mktemp -d "$RUNNER_TEMP/gnupg.XXXXXX")" chmod 700 "$GNUPGHOME" + # export so the import below lands in the ephemeral home; GITHUB_ENV + # carries it to the later signing step. + export GNUPGHOME echo "GNUPGHOME=$GNUPGHOME" >> "$GITHUB_ENV" printf '%s' "$MAVEN_SIGNATURE_SECRET_KEY" \ | gpg --batch --pinentry-mode loopback \ From e39acfb7ff9ec4ec6745736bde0dc955df08544e Mon Sep 17 00:00:00 2001 From: Gavin Sharp Date: Tue, 2 Jun 2026 19:39:12 -0400 Subject: [PATCH 3/4] ci: bump actions/checkout v6.0.2 -> v6.0.3 Co-Authored-By: Claude Opus 4.7 --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1f84e3..821b871 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Checkout repo - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Java id: setup-jre @@ -32,7 +32,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repo - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Java id: setup-jre @@ -58,7 +58,7 @@ jobs: steps: - name: Checkout repo - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 @@ -111,7 +111,7 @@ jobs: steps: - name: Checkout repo - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Java uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 From 4082a470174a70888c9131b6d16f477c8c38ab75 Mon Sep 17 00:00:00 2001 From: Gavin Sharp Date: Thu, 25 Jun 2026 09:51:35 -0400 Subject: [PATCH 4/4] ci: bump sdk-shared-actions 1.0.2 -> 1.0.3 Picks up the bundle-openapi-spec fix to fetch specs from the public bucket. No interface change to verify-openapi-spec or the sync-fern-artifacts caller. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/ci.yml | 2 +- .github/workflows/sync-fern-artifacts.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 821b871..288711c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,7 +87,7 @@ jobs: # behind that would set should_publish=false on retry. - name: Verify bundled OpenAPI spec if: steps.check.outputs.should_publish == 'true' - uses: PhenoML/sdk-shared-actions/verify-openapi-spec@1.0.2 + uses: PhenoML/sdk-shared-actions/verify-openapi-spec@1.0.3 with: spec-path: src/main/resources/openapi/openapi.json diff --git a/.github/workflows/sync-fern-artifacts.yml b/.github/workflows/sync-fern-artifacts.yml index 234ad94..087ea5b 100644 --- a/.github/workflows/sync-fern-artifacts.yml +++ b/.github/workflows/sync-fern-artifacts.yml @@ -20,6 +20,6 @@ jobs: sync: permissions: contents: write - uses: PhenoML/sdk-shared-actions/.github/workflows/sync-fern-artifacts.yml@1.0.2 + uses: PhenoML/sdk-shared-actions/.github/workflows/sync-fern-artifacts.yml@1.0.3 with: spec-path: src/main/resources/openapi/openapi.json