Skip to content

ci: release pipeline for the Java SDK, publishing to GitHub Packages now and Maven Central later - #2

Draft
antonwhop wants to merge 2 commits into
mainfrom
anton/release-pipeline
Draft

ci: release pipeline for the Java SDK, publishing to GitHub Packages now and Maven Central later#2
antonwhop wants to merge 2 commits into
mainfrom
anton/release-pipeline

Conversation

@antonwhop

@antonwhop antonwhop commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Why / Prompt

Java is the last of the six Fern SDKs to get a release half. Generation already works — java-release delivers #1 — but nothing in that tree can reach a registry, and the tree it delivers is not even a publishable Maven project: gradle properties on it reports version: unspecified, an empty group, and an artifact name taken from the checkout directory. The group/artifact config in the monorepo's generators.yml reaches .fern/metadata.json and nothing else.

Maven Central is blocked on things only a human with DNS control can do (see below). Internal consumers need a resolvable artifact now, so this pipeline has two targets behind one set of guards: GitHub Packages today, the Sonatype Central Portal when the namespace exists.

Based on fern-bot/2026-08-20_22-29-27_003 (#1) so the diff is only the hand-written files and CI runs against a real Java project. Merge #1 first.

Why GitHub Packages and not jars on a GitHub Release

build.gradle declares four dependencies at api scope — com.squareup.okhttp3:okhttp:5.2.1, jackson-databind:2.18.6, jackson-datatype-jdk8:2.18.6, jackson-datatype-jsr310:2.18.6. A bare jar in a flatDir repo carries no POM, so consumers resolve none of them and have to hand-copy all four and keep them in sync with every regeneration. api scope puts them on the consumer's compile classpath, so any drift is a compile error rather than a runtime surprise.

GitHub Packages is a real Maven repository: coordinates, a POM, transitive resolution, and only GITHUB_TOKEN to authenticate. The authentication requirement that makes it unsuitable for a public SDK does not matter here — internal consumers already authenticate to the org.

The coordinate is com.whop.api:whop-java, which is what Central will publish too (sdks/fern/generators.yml sets group: com.whop.api / artifact: whop-java, and gradle.properties matches). Migrating later changes a repository URL, not a dependency line.

Consuming it

repositories {
    mavenCentral()
    maven {
        name = "whopGitHubPackages"
        url = uri("https://maven.pkg.github.com/whopio/whopsdk-java")
        credentials {
            username = System.getenv("GITHUB_ACTOR")
            password = System.getenv("GITHUB_TOKEN")
        }
    }
}

dependencies {
    implementation "com.whop.api:whop-java:1.0.13"
}

Maven equivalent, in ~/.m2/settings.xml:

<server>
  <id>whop-github-packages</id>
  <username>YOUR_GITHUB_USERNAME</username>
  <password>YOUR_PAT_WITH_READ_PACKAGES</password>
</server>
<repository>
  <id>whop-github-packages</id>
  <url>https://maven.pkg.github.com/whopio/whopsdk-java</url>
</repository>

GitHub Packages has no anonymous read, even for a public repo. Inside GitHub Actions the built-in GITHUB_TOKEN covers it with permissions: packages: read and nothing else to configure. A human on a laptop needs a personal access token (classic) with the read:packages scope — the same token every GitHub Packages consumer needs, not something specific to this SDK. Publish access is never handed out: only this workflow writes, using the run's own token.

What changed

  • .github/workflows/publish-main.yml — five jobs. decide gates on the version in gradle.properties differing from what the selected registries already serve; build runs Gradle once and stages a verified bundle with no credential in scope; publish (Central) holds the signing key and portal token and runs no dependency resolver and no Gradle at all; publish-github-packages holds packages: write and no environment; tag is idempotent and independent of both publish gates. Every pull_request forces a dry run of both targets.
  • RELEASE_TARGET picks the default target (github-packages today); a workflow_dispatch input can override it to maven-central or both; a pull request always runs both. One knob to flip when Central comes online, and nothing about the Central path is removed in the meantime.
  • gradle.properties + .github/release.gradle — the Maven coordinate and the POM (name, description, url, licenses, developers, scm), plus the GitHub Packages repository, supplied without editing the generated build.gradle, so Fern keeps owning the dependency list. Applied with -I, never committed into the build. One MavenPublication, two repositories, so both registries receive byte-identical POMs.
  • .github/stamp-version.py — the version stamp (gradle.properties, every source carrying X-Fern-SDK-Version, .fern/metadata.json) as a file rather than a script inlined into one job, because both publish paths stamp the same commit.
  • .fernignore — covers all of the above plus ci.yml and LICENSE.

Guards

Everything the Central-only version had, now applied per target:

  • only-gradle.properties-changed, and only its version property
  • version regression — the self-tested comparator refuses a version behind what the registry already serves
  • registry existence — 200/404 decide, anything else refuses rather than guesses
  • version-stamp sync, asserted again inside the built jar and sources jar
  • a forced dry run on every pull request

New in this change:

  • the POM check parses build.gradle's api lines and asserts each one reaches the POM at compile scope. If Fern ever reshapes those to implementation, consumers would silently lose them from their compile classpath; now the release stops instead.
  • publish-github-packages regenerates the POM and diffs it against the one build verified before uploading, then reads the artifact back out of the registry afterwards.
  • it also asserts it cannot see MAVEN_CENTRAL_USERNAME / MAVEN_CENTRAL_PASSWORD / MAVEN_SIGNING_KEY, the same assertion the dry-run path makes.

The GitHub Packages existence check is GET /orgs/whopio/packages/maven/com.whop.api.whop-java/versions, paginated. It answers 404 {"message": "Package not found."} for a package that was never published and 403 with an explicit scope message when the token cannot read — verified against the live API, which is why 404 can be treated as "not published" rather than as a failure. Every other status refuses. If the API ever reported a false 404, GitHub Packages rejects a re-upload of an existing version rather than overwriting it, so the failure is loud and nothing is silently replaced.

Still blocked for Maven Central, and only a human can unblock it

com.whop.api has 0 artifacts on Central today — repo1.maven.org/maven2/com/whop/api/ is 404 and search.maven.org returns numFound: 0. So is com.whop. Nothing here can publish there until someone with DNS control does this:

  1. Register the namespace com.whop at central.sonatype.com (not com.whop.api). Sonatype's FAQ: "if you register com.example you will be able to publish any component under that groupId or any sub-group like com.example.mysubgroup1 or com.example.sdk.android.app." Registering com.whop covers com.whop.api and every future Whop coordinate; registering com.whop.api directly would demand a TXT record on api.whop.com instead and cover nothing else.
  2. Add the DNS TXT record on whop.com with the verification key the Portal shows. Exact-domain match — www.whop.com and maven-central.whop.com do not count.
  3. Generate a GPG key, publish the public half to keys.openpgp.org, and export the armored private half. Central rejects any deployment whose main jar, sources jar, javadoc jar or POM is unsigned; the workflow signs all four plus the Gradle module metadata.
  4. Create a GitHub Environment named maven-central with a deployment-branch policy of main only, and put four secrets in that environment: MAVEN_CENTRAL_USERNAME, MAVEN_CENTRAL_PASSWORD (a Portal user token pair, not the account password), MAVEN_SIGNING_KEY (armored private key), MAVEN_SIGNING_PASSPHRASE.

Do not create those four as repository or organization secrets. GitHub hands repository secrets to same-repo pull request runs, so a teammate could read them by editing a workflow in a PR. Two jobs enforce this themselves: a dry run and the GitHub Packages publish both fail with an explicit error if either can see them. Dry runs route to a separate dry-run environment, which GitHub auto-creates, which needs no setup, and which holds nothing.

Token/credential auth, not OIDC — the Central Portal has no OIDC trusted-publisher equivalent.

Once all four are done: flip RELEASE_TARGET to both (or maven-central), and consumers move by swapping the repository URL. The dependency line does not change.

The generated ci.yml publish job is latent, not absent

The teaching doc says Fern's Java ci.yml carries a tag-gated Maven publish. The one actually delivered in #1 does not — it is compile+test only. Decompiling com/fern/java/generators/GithubWorkflowGenerator.class out of fernapi/fern-java-sdk:4.9.1 shows why: getGithubWorkflow takes an optional registry URL, and only when one is present does it append

publish:
  needs: [ compile, test ]
  if: github.event_name == 'push' && contains(github.ref, 'refs/tags/')
  ...
  run: ./gradlew sonatypeCentralUpload    (or ./gradlew publish)
  env:
    MAVEN_USERNAME / MAVEN_PASSWORD / MAVEN_SIGNATURE_KID
    MAVEN_SIGNATURE_SECRET_KEY / MAVEN_SIGNATURE_PASSWORD

java-release has no maven output today, so the job is absent. Adding one is the obvious next step for anyone wiring a registry through Fern, and it would put credentials in the same job as Gradle's dependency resolution, firing on the very tag publish-main.yml creates. Because Fern reads .fernignore from the base branch, the entry has to land before that regeneration, not after — so ci.yml is .fernignored now, with a hand-written compile+test replacement. Verified for contrast: the generated ci.yml in whopsdk-typescript and whopsdk-python is compile+test only; whopsdk-ruby's carried the publish job, which is why #55 did the same thing.

Monorepo follow-up (not in this PR)

java-release should carry license: Apache-2.0 in its github: block, as python-release and ruby-release do. The POM here declares Apache-2.0 and this PR adds the matching LICENSE.

Correcting something I wrote in the first draft of this description: shared-fern-publish.yml does not need a java-release) arm. Falling through to npm view @whop/sdk is deliberate — .github/workflows/shared-fern-publish.yml:71-74 says so in as many words ("Java and Rust have nothing on Maven Central / crates.io yet, and Go and Swift release as git tags — those four deliberately pin to npm's version so the whole family stays in lockstep"). That is why #1 arrived stamped 1.0.11. It does not matter for what ships: publish-main.yml re-stamps gradle.properties, .fern/metadata.json and the X-Fern-SDK-Version header from the release version before it builds, and asserts afterwards that nothing in the jar still reports anything else.

What the dry run cannot cover

It builds, stamps, tests, stages, verifies the coordinate, verifies every api dependency in the POM, signs the Central bundle and resolves the GitHub Packages publish task graph. It cannot cover: the real signing key (a throwaway key signs instead), the two credentials, the HTTP upload in either direction, and Central's own validation of the bundle — which is where an unregistered namespace or an unpublished public key would surface. Those failures are clean: nothing is published and nothing is tagged.

Adds the release half of the Fern pipeline for whopsdk-java: a four-job
publish workflow that ships com.whop.api:whop-java to Maven Central through
the Sonatype Central Portal, the Maven coordinate and publication metadata the
Fern Java generator does not emit, and the .fernignore that keeps all of it
alive across regenerations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XV1533iUUxKJxfn4FXptWz
Maven Central is blocked on a Sonatype namespace, a DNS TXT record on whop.com
and a signing key, none of which anyone here can create. Internal consumers need
a resolvable artifact now, and a jar attached to a GitHub Release is not one:
build.gradle declares okhttp and three Jackson modules at `api` scope, so a bare
jar in a flatDir repo resolves none of them and every consumer hand-copies four
coordinates that drift on each regeneration.

GitHub Packages gives real coordinates, a POM and transitive resolution for the
cost of a GITHUB_TOKEN. Same publication, same POM, same com.whop.api:whop-java
that Central would get — a later migration is a repository URL, not a dependency
line.

- RELEASE_TARGET selects the registry, a workflow_dispatch input overrides it,
  and every pull request forces target=both so neither path rots.
- The decide job runs the same guards against whichever registries are selected;
  the GitHub Packages existence check reads the package versions API, which
  answers 404 for a package that was never published and 403 with a scope
  message when it cannot read, and refuses on anything else.
- publish-github-packages holds packages:write and no environment. It asserts it
  cannot see the Central credentials, matches its POM against the one the build
  job verified, and reads the artifact back out of the registry afterwards.
- The build job's POM check now parses build.gradle's api dependencies and
  asserts each reaches the POM at compile scope.
- stamp-version.py is the version stamp both publish paths run.
@antonwhop antonwhop changed the title ci: hand-written release pipeline for the Java SDK ci: release pipeline for the Java SDK, publishing to GitHub Packages now and Maven Central later Aug 24, 2026
@antonwhop
antonwhop changed the base branch from fern-bot/2026-08-20_22-29-27_003 to main August 24, 2026 20:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant