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
Draft
ci: release pipeline for the Java SDK, publishing to GitHub Packages now and Maven Central later#2antonwhop wants to merge 2 commits into
antonwhop wants to merge 2 commits into
Conversation
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
changed the base branch from
fern-bot/2026-08-20_22-29-27_003
to
main
August 24, 2026 20:59
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why / Prompt
Java is the last of the six Fern SDKs to get a release half. Generation already works —
java-releasedelivers #1 — but nothing in that tree can reach a registry, and the tree it delivers is not even a publishable Maven project:gradle propertieson it reportsversion: unspecified, an emptygroup, and an artifact name taken from the checkout directory. Thegroup/artifactconfig in the monorepo'sgenerators.ymlreaches.fern/metadata.jsonand 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.gradledeclares four dependencies atapiscope —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 aflatDirrepo carries no POM, so consumers resolve none of them and have to hand-copy all four and keep them in sync with every regeneration.apiscope 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_TOKENto 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.ymlsetsgroup: com.whop.api/artifact: whop-java, andgradle.propertiesmatches). Migrating later changes a repository URL, not a dependency line.Consuming it
Maven equivalent, in
~/.m2/settings.xml:GitHub Packages has no anonymous read, even for a public repo. Inside GitHub Actions the built-in
GITHUB_TOKENcovers it withpermissions: packages: readand nothing else to configure. A human on a laptop needs a personal access token (classic) with theread:packagesscope — 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.decidegates on the version ingradle.propertiesdiffering from what the selected registries already serve;buildruns 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-packagesholdspackages: writeand no environment;tagis idempotent and independent of both publish gates. Everypull_requestforces a dry run of both targets.RELEASE_TARGETpicks the default target (github-packagestoday); aworkflow_dispatchinput can override it tomaven-centralorboth; a pull request always runsboth. 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 generatedbuild.gradle, so Fern keeps owning the dependency list. Applied with-I, never committed into the build. OneMavenPublication, two repositories, so both registries receive byte-identical POMs..github/stamp-version.py— the version stamp (gradle.properties, every source carryingX-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 plusci.ymlandLICENSE.Guards
Everything the Central-only version had, now applied per target:
gradle.properties-changed, and only itsversionpropertyNew in this change:
build.gradle'sapilines and asserts each one reaches the POM atcompilescope. If Fern ever reshapes those toimplementation, consumers would silently lose them from their compile classpath; now the release stops instead.publish-github-packagesregenerates the POM and diffs it against the onebuildverified before uploading, then reads the artifact back out of the registry afterwards.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 answers404 {"message": "Package not found."}for a package that was never published and403with 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.apihas 0 artifacts on Central today —repo1.maven.org/maven2/com/whop/api/is 404 andsearch.maven.orgreturnsnumFound: 0. So iscom.whop. Nothing here can publish there until someone with DNS control does this:com.whopat central.sonatype.com (notcom.whop.api). Sonatype's FAQ: "if you registercom.exampleyou will be able to publish any component under that groupId or any sub-group likecom.example.mysubgroup1orcom.example.sdk.android.app." Registeringcom.whopcoverscom.whop.apiand every future Whop coordinate; registeringcom.whop.apidirectly would demand a TXT record onapi.whop.cominstead and cover nothing else.whop.comwith the verification key the Portal shows. Exact-domain match —www.whop.comandmaven-central.whop.comdo not count.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.maven-centralwith a deployment-branch policy ofmainonly, 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-runenvironment, 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_TARGETtoboth(ormaven-central), and consumers move by swapping the repository URL. The dependency line does not change.The generated
ci.ymlpublish job is latent, not absentThe teaching doc says Fern's Java
ci.ymlcarries a tag-gated Maven publish. The one actually delivered in #1 does not — it is compile+test only. Decompilingcom/fern/java/generators/GithubWorkflowGenerator.classout offernapi/fern-java-sdk:4.9.1shows why:getGithubWorkflowtakes an optional registry URL, and only when one is present does it appendjava-releasehas 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 tagpublish-main.ymlcreates. Because Fern reads.fernignorefrom the base branch, the entry has to land before that regeneration, not after — soci.ymlis.fernignored now, with a hand-written compile+test replacement. Verified for contrast: the generatedci.ymlin 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-releaseshould carrylicense: Apache-2.0in itsgithub:block, aspython-releaseandruby-releasedo. The POM here declares Apache-2.0 and this PR adds the matchingLICENSE.Correcting something I wrote in the first draft of this description:
shared-fern-publish.ymldoes not need ajava-release)arm. Falling through tonpm view @whop/sdkis deliberate —.github/workflows/shared-fern-publish.yml:71-74says 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 stamped1.0.11. It does not matter for what ships:publish-main.ymlre-stampsgradle.properties,.fern/metadata.jsonand theX-Fern-SDK-Versionheader 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
apidependency 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.