diff --git a/.fernignore b/.fernignore
index 084a8ebb..528a3cc2 100644
--- a/.fernignore
+++ b/.fernignore
@@ -1 +1,58 @@
-# Specify files that shouldn't be modified by Fern
+# Files Fern must not modify or delete when regenerating this repo.
+#
+# Fern prunes anything it did not generate, and it reads this file from the pull request's
+# base branch — so an entry only protects a file once it is on main.
+#
+# publish-main.yml is hand-written: it releases com.whop.api:whop-java when a merge to main
+# lands a gradle.properties version that is not yet published, and it is meant to be the
+# only publish path. It has two targets behind one set of guards — GitHub Packages
+# (whopio-internal, authenticated with the run's own GITHUB_TOKEN, no namespace to
+# register) and the Sonatype Central Portal (public, blocked on DNS verification and a
+# signing key). RELEASE_TARGET sets the default, a workflow_dispatch input overrides it,
+# and every pull request rehearses both so neither path rots while the other is the live
+# one.
+#
+# stamp-version.py is that workflow's version stamp: gradle.properties, every source file
+# carrying X-Fern-SDK-Version, and .fern/metadata.json. It is a file rather than a script
+# inlined into one job because both publish paths build the same commit and have to stamp
+# it identically.
+#
+# gradle.properties is the version of record and the release trigger. The Fern Java
+# generator writes no version and no group into build.gradle at all — `gradle properties`
+# on the delivered tree reports `version: unspecified`, an empty group and an artifact name
+# taken from the checkout directory — so without this file the project has no Maven
+# coordinate. Gradle reads `group` and `version` from gradle.properties natively, which
+# keeps the coordinate out of the generated build.gradle and lets Fern keep owning the
+# dependency list there.
+#
+# release.gradle is the Gradle init script that adds the MavenPublication: the POM (name,
+# description, url, licenses, developers, scm) that Maven Central requires, the whop-java
+# archive name, a repository that stages the bundle to build/bundle instead of uploading
+# it, and the GitHub Packages repository the internal release publishes to. Applying it
+# with -I rather than editing build.gradle means a regeneration cannot silently drop it
+# and Fern keeps ownership of the build file — including the four `api` dependencies,
+# which are the reason a real POM and not a bare jar is what has to reach consumers.
+#
+# ci.yml is hand-written for a security reason, not a stylistic one. The Fern Java
+# generator's GithubWorkflowGenerator emits a tag-gated `publish` job — `if:
+# github.event_name == 'push' && contains(github.ref, 'refs/tags/')`, running `./gradlew
+# publish` or `./gradlew sonatypeCentralUpload` with MAVEN_USERNAME, MAVEN_PASSWORD,
+# MAVEN_SIGNATURE_KID, MAVEN_SIGNATURE_SECRET_KEY and MAVEN_SIGNATURE_PASSWORD in the same
+# environment as Gradle's dependency resolution — whenever the generator is configured
+# with a Maven registry. The java-release group in the monorepo's sdks/fern/generators.yml
+# has no maven output today, so the ci.yml Fern delivered in whopsdk-java#1 is compile+test
+# only. Adding that output is the obvious next step for anyone wiring Maven publishing
+# through Fern, and it would introduce a second unreviewed publish path that also fires on
+# the tag publish-main.yml creates. Because Fern reads this file from the base branch, the
+# entry has to exist before that regeneration, not after it. Ours is the generated file
+# with the publish job that cannot appear, modernised off actions/setup-java@v1.
+#
+# LICENSE: Fern does not carry over a LICENSE, and the POM this repo publishes declares
+# Apache-2.0 — the same license the python-release and ruby-release groups pass to Fern.
+# Without this entry the first regeneration would delete the file the POM points at.
+.github/workflows/ci.yml
+.github/workflows/publish-main.yml
+.github/release.gradle
+.github/stamp-version.py
+gradle.properties
+LICENSE
diff --git a/.github/release.gradle b/.github/release.gradle
new file mode 100644
index 00000000..6260c185
--- /dev/null
+++ b/.github/release.gradle
@@ -0,0 +1,67 @@
+gradle.projectsLoaded {
+ gradle.rootProject { root ->
+ root.afterEvaluate {
+ root.base.archivesName = 'whop-java'
+
+ root.publishing {
+ publications {
+ maven(MavenPublication) {
+ groupId = root.group
+ artifactId = 'whop-java'
+ version = root.version
+ from root.components.java
+
+ pom {
+ name = 'Whop Java SDK'
+ description = 'The official Java SDK for the Whop API.'
+ url = 'https://github.com/whopio/whopsdk-java'
+ licenses {
+ license {
+ name = 'Apache License, Version 2.0'
+ url = 'https://www.apache.org/licenses/LICENSE-2.0.txt'
+ }
+ }
+ developers {
+ developer {
+ id = 'whop'
+ name = 'Whop'
+ email = 'support@whop.com'
+ organization = 'Whop'
+ organizationUrl = 'https://whop.com'
+ }
+ }
+ scm {
+ url = 'https://github.com/whopio/whopsdk-java'
+ connection = 'scm:git:https://github.com/whopio/whopsdk-java.git'
+ developerConnection = 'scm:git:ssh://git@github.com/whopio/whopsdk-java.git'
+ }
+ }
+ }
+ }
+
+ repositories {
+ maven {
+ name = 'bundle'
+ url = root.layout.buildDirectory.dir('bundle')
+ }
+
+ // Same publication, same POM, same coordinate as Central — only the
+ // transport differs, so a consumer who later moves to Central changes
+ // a repository URL and not a dependency line. Credentials are read
+ // from the environment rather than a property so that the empty
+ // default keeps `-I` usable for bundle staging, where the task that
+ // needs them is never invoked; Gradle only demands them when
+ // publishMavenPublicationToGitHubPackagesRepository actually runs.
+ maven {
+ name = 'gitHubPackages'
+ url = System.getenv('GITHUB_PACKAGES_URL') ?: 'https://maven.pkg.github.com/whopio/whopsdk-java'
+ credentials {
+ username = System.getenv('GITHUB_ACTOR') ?: ''
+ password = System.getenv('GITHUB_TOKEN') ?: ''
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/.github/stamp-version.py b/.github/stamp-version.py
new file mode 100644
index 00000000..31123c1e
--- /dev/null
+++ b/.github/stamp-version.py
@@ -0,0 +1,101 @@
+"""Stamp one version across every file in the tree that reports one.
+
+Two jobs publish the same commit to two registries, so the stamp has to be a single
+artifact both can run rather than a script inlined into one of them. It is deliberately
+fail-loud: Fern owns every file it touches, so a rename or a reshaped header should stop
+a release rather than ship a jar that reports the generator's constant on the wire.
+"""
+
+import json
+import pathlib
+import re
+import sys
+
+HEADER = "X-Fern-SDK-Version"
+
+
+def fail(message):
+ raise SystemExit("::error::" + message)
+
+
+def stamp_version_file(path, version):
+ text = path.read_text()
+ text, count = re.subn(
+ r"(?m)^([ \t]*version[ \t]*=[ \t]*).*$", r"\g<1>" + version, text, count=1
+ )
+ if count == 0:
+ fail("no version property found in " + str(path))
+ path.write_text(text)
+ print("stamped " + str(path))
+
+
+def stamp_sources(version):
+ carriers = [
+ path
+ for path in pathlib.Path("src/main").rglob("*.java")
+ if HEADER in path.read_text()
+ ]
+
+ if not carriers:
+ fail(
+ "nothing under src/main carries "
+ + HEADER
+ + "; the published jar would report the generator's constant"
+ )
+
+ for path in carriers:
+ source = path.read_text()
+ source, hits = re.subn(
+ r'("' + HEADER + r'"\s*,\s*")[^"]*', r"\g<1>" + version, source
+ )
+ if hits == 0:
+ fail(
+ str(path)
+ + " names "
+ + HEADER
+ + " but the rewrite matched nothing; the header shape changed"
+ )
+ path.write_text(source)
+ print("stamped " + str(path))
+
+
+def stamp_metadata(path, version):
+ if not path.exists():
+ return
+ metadata = json.loads(path.read_text())
+ for key in ("requestedVersion", "sdkVersion"):
+ if key in metadata:
+ metadata[key] = version
+ path.write_text(json.dumps(metadata, indent=2) + "\n")
+ print("stamped " + str(path))
+
+
+def assert_no_stale_version(version):
+ semver = re.compile(r"\d+\.\d+\.\d+")
+ stale = []
+ for path in pathlib.Path("src/main").rglob("*.java"):
+ for number, line in enumerate(path.read_text().splitlines(), 1):
+ if HEADER in line and semver.search(line) and version not in line:
+ stale.append(str(path) + ":" + str(number) + ": " + line.strip())
+
+ if stale:
+ fail(
+ "the source tree still reports a version other than "
+ + version
+ + ":\n"
+ + "\n".join(stale)
+ )
+
+
+def main():
+ version, version_file, metadata_file = sys.argv[1:4]
+
+ stamp_version_file(pathlib.Path(version_file), version)
+ stamp_sources(version)
+ stamp_metadata(pathlib.Path(metadata_file), version)
+ assert_no_stale_version(version)
+ print("every version stamp reports " + version)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 190eed59..95a9404d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,42 +1,56 @@
-name: ci
+name: CI
-on: [push]
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+permissions:
+ contents: read
concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: false
+ group: ci-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ JAVA_VERSION: "17"
jobs:
compile:
runs-on: ubuntu-latest
-
steps:
- - name: Checkout repo
- uses: actions/checkout@v4
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
+ with:
+ persist-credentials: false
- - name: Set up Java
- id: setup-jre
- uses: actions/setup-java@v1
+ - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961
with:
- java-version: "11"
- architecture: x64
+ distribution: temurin
+ java-version: ${{ env.JAVA_VERSION }}
+
+ - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb
+ with:
+ validate-wrappers: true
- name: Compile
- run: ./gradlew compileJava
+ run: ./gradlew --no-daemon compileJava
test:
- needs: [ compile ]
+ needs: [compile]
runs-on: ubuntu-latest
steps:
- - name: Checkout repo
- uses: actions/checkout@v4
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
+ with:
+ persist-credentials: false
+
+ - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961
+ with:
+ distribution: temurin
+ java-version: ${{ env.JAVA_VERSION }}
- - name: Set up Java
- id: setup-jre
- uses: actions/setup-java@v1
+ - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb
with:
- java-version: "11"
- architecture: x64
+ validate-wrappers: true
- name: Test
- run: ./gradlew test
+ run: ./gradlew --no-daemon test
diff --git a/.github/workflows/publish-main.yml b/.github/workflows/publish-main.yml
new file mode 100644
index 00000000..b26f9fb7
--- /dev/null
+++ b/.github/workflows/publish-main.yml
@@ -0,0 +1,1012 @@
+name: Release
+
+on:
+ push:
+ branches: [main]
+ paths: ["gradle.properties"]
+ pull_request:
+ workflow_dispatch:
+ inputs:
+ dry_run:
+ description: "Build, sign with a throwaway key and verify the bundle without uploading"
+ type: boolean
+ default: true
+ target:
+ description: "Which registry to release to"
+ type: choice
+ default: default
+ options:
+ - default
+ - github-packages
+ - maven-central
+ - both
+
+permissions:
+ contents: read
+
+concurrency:
+ group: publish-whop-java-${{ github.ref }}
+ cancel-in-progress: false
+
+env:
+ RELEASE_TARGET: github-packages
+ GROUP_ID: com.whop.api
+ ARTIFACT_ID: whop-java
+ PLACEHOLDER_VERSION: 0.0.0
+ GROUP_PATH: com/whop/api
+ VERSION_FILE: gradle.properties
+ METADATA_FILE: .fern/metadata.json
+ STAMP_SCRIPT: .github/stamp-version.py
+ RESOURCE_DIR: src/main/java/com/whop/api/resources
+ MIN_RESOURCE_MODULES: "60"
+ INIT_SCRIPT: .github/release.gradle
+ BUNDLE_DIR: build/bundle
+ JAVA_VERSION: "17"
+ CENTRAL_BASE: https://repo1.maven.org/maven2
+ PORTAL_BASE: https://central.sonatype.com/api/v1/publisher
+ PUBLISHING_TYPE: AUTOMATIC
+ PACKAGES_API: https://api.github.com/orgs/whopio/packages/maven
+ PACKAGES_BASE: https://maven.pkg.github.com/whopio/whopsdk-java
+
+jobs:
+ decide:
+ if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: read
+ outputs:
+ release: ${{ steps.gate.outputs.release }}
+ dry: ${{ steps.gate.outputs.dry }}
+ version: ${{ steps.gate.outputs.version }}
+ target: ${{ steps.gate.outputs.target }}
+ central: ${{ steps.gate.outputs.central }}
+ packages: ${{ steps.gate.outputs.packages }}
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
+ with:
+ fetch-depth: 2
+ persist-credentials: false
+
+ - name: Write the version-regression guard
+ run: |
+ set -euo pipefail
+ cat > "${RUNNER_TEMP}/version_guard.py" <<'PY'
+ import re
+ import sys
+
+
+ def parse(raw):
+ match = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)(?:[-.]([0-9A-Za-z.-]+))?", raw)
+ if match is None:
+ raise SystemExit("unparseable version: " + raw)
+ core = tuple(int(part) for part in match.group(1, 2, 3))
+ return core, match.group(4)
+
+
+ def rank(pre):
+ if pre is None:
+ return (1,)
+ parts = []
+ for chunk in pre.split("."):
+ if chunk.isdigit():
+ parts.append((0, int(chunk), ""))
+ else:
+ parts.append((1, 0, chunk))
+ return (0, parts)
+
+
+ candidate_core, candidate_pre = parse(sys.argv[1])
+ latest_core, latest_pre = parse(sys.argv[2])
+
+ if (candidate_core, rank(candidate_pre)) < (latest_core, rank(latest_pre)):
+ raise SystemExit(sys.argv[1] + " is behind " + sys.argv[2])
+
+ print(sys.argv[1] + " is at or ahead of " + sys.argv[2])
+ PY
+
+ - name: Self-test the version-regression guard
+ run: |
+ set -euo pipefail
+ failures=0
+
+ check() {
+ if python3 "${RUNNER_TEMP}/version_guard.py" "$1" "$2" >/dev/null 2>&1; then
+ actual=allow
+ else
+ actual=block
+ fi
+
+ if [ "$actual" = "$3" ]; then
+ echo "ok: $1 vs $2 -> $actual"
+ else
+ echo "::error::version guard: $1 vs $2 expected $3, got $actual"
+ failures=$((failures + 1))
+ fi
+ }
+
+ check 1.0.0 1.0.0 allow
+ check 1.0.1 1.0.0 allow
+ check 1.0.0 1.0.1 block
+ check 0.0.10 0.0.9 allow
+ check 0.0.9 0.0.10 block
+ check 1.2.0 1.10.0 block
+ check 1.10.0 1.2.0 allow
+ check 2.0.0 1.99.99 allow
+ check 1.0.0-rc.1 0.9.9 allow
+ check 1.0.0-rc.1 1.0.0 block
+ check 1.0.0 1.0.0-rc.1 allow
+ check 1.0.0-rc.2 1.0.0-rc.1 allow
+ check 1.0.0-rc.1 1.0.0-rc.2 block
+
+ [ "$failures" -eq 0 ]
+
+ - name: Decide whether to release
+ id: gate
+ env:
+ EVENT: ${{ github.event_name }}
+ DRY_RUN: ${{ github.event.inputs.dry_run }}
+ TARGET_INPUT: ${{ github.event.inputs.target }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ set -euo pipefail
+
+ emit() { echo "$1=$2" >> "$GITHUB_OUTPUT"; }
+
+ if [ "$EVENT" = "pull_request" ] || [ "$DRY_RUN" = "true" ]; then
+ dry=true
+ else
+ dry=false
+ fi
+ emit dry "$dry"
+
+ if [ "$EVENT" = "pull_request" ]; then
+ target=both
+ elif [ -n "${TARGET_INPUT:-}" ] && [ "$TARGET_INPUT" != "default" ]; then
+ target="$TARGET_INPUT"
+ else
+ target="$RELEASE_TARGET"
+ fi
+
+ case "$target" in
+ github-packages) want_central=false; want_packages=true ;;
+ maven-central) want_central=true; want_packages=false ;;
+ both) want_central=true; want_packages=true ;;
+ *)
+ echo "::error::'${target}' is not a release target. Use github-packages, maven-central or both."
+ exit 1
+ ;;
+ esac
+
+ emit target "$target"
+ echo "event=$EVENT dry=$dry target=$target ref=$GITHUB_REF"
+
+ refuse() {
+ if [ "$dry" = "true" ]; then
+ echo "::warning::[dry] a real release would stop here: $*"
+ return 0
+ fi
+ echo "::error::$*"
+ exit 1
+ }
+
+ if [ ! -f "$VERSION_FILE" ]; then
+ echo "::notice::${VERSION_FILE} does not exist — this is not a tree this workflow can release."
+ emit release false
+ emit version ""
+ exit 0
+ fi
+
+ version=$(sed -n 's/^[[:space:]]*version[[:space:]]*=[[:space:]]*//p' "$VERSION_FILE" | head -1 | tr -d '[:space:]')
+
+ if [ -z "$version" ]; then
+ echo "::error::${VERSION_FILE} has no version property."
+ exit 1
+ fi
+
+ echo "${VERSION_FILE} version: $version"
+ emit version "$version"
+
+ if ! printf '%s' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$'; then
+ echo "::error::'$version' is not a version this workflow will publish."
+ exit 1
+ fi
+
+ if printf '%s' "$version" | grep -qiE 'SNAPSHOT'; then
+ echo "::error::'$version' is a snapshot; the Central Portal release path does not accept snapshots."
+ exit 1
+ fi
+
+ declared_group=$(sed -n 's/^[[:space:]]*group[[:space:]]*=[[:space:]]*//p' "$VERSION_FILE" | head -1 | tr -d '[:space:]')
+ if [ "$declared_group" != "$GROUP_ID" ]; then
+ echo "::error::${VERSION_FILE} declares group '${declared_group}' but this workflow publishes '${GROUP_ID}'."
+ exit 1
+ fi
+
+ if [ "$version" = "$PLACEHOLDER_VERSION" ]; then
+ if [ "$dry" = "true" ]; then
+ echo "::notice::[dry] ${PLACEHOLDER_VERSION} is the unreleased placeholder and a real release would stop here. Continuing so this run still exercises the build, the stamps, the bundle and the signing."
+ else
+ echo "::notice::${VERSION_FILE} is still at the ${PLACEHOLDER_VERSION} placeholder; there is nothing to release. The first release is a deliberate one-line pull request that sets a real version — Maven Central never lets it be replaced."
+ emit release false
+ exit 0
+ fi
+ fi
+
+ if [ "$EVENT" = "pull_request" ]; then
+ enforce_diff=false
+ else
+ enforce_diff=true
+ fi
+
+ if git rev-parse --verify --quiet HEAD^ >/dev/null; then
+ changed=$(git diff --name-only HEAD^ HEAD)
+ body=$(git diff -U0 HEAD^ HEAD -- "$VERSION_FILE" | grep -E '^[+-]' | grep -vE '^(\+\+\+|---)' || true)
+
+ if [ -z "$body" ]; then
+ other=0
+ else
+ other=$(printf '%s\n' "$body" | grep -cvE '^[+-][[:space:]]*version[[:space:]]*=' || true)
+ fi
+
+ if [ "$changed" = "$VERSION_FILE" ] && [ "$other" -eq 0 ]; then
+ echo "HEAD changes ${VERSION_FILE}'s version property and nothing else."
+ else
+ summary="a release must change ${VERSION_FILE} and nothing but its version property. Changed: $(printf '%s' "$changed" | tr '\n' ' ')"
+ if [ "$enforce_diff" = "true" ]; then
+ refuse "$summary"
+ else
+ echo "::notice::[$EVENT] this is not a release commit, so the diff is informational: ${summary}"
+ fi
+ fi
+ else
+ echo "::notice::no HEAD^ to diff against; the only-version-changed check has nothing to compare."
+ fi
+
+ guard_against() {
+ if guard=$(python3 "${RUNNER_TEMP}/version_guard.py" "$version" "$2" 2>&1); then
+ echo "$guard"
+ else
+ refuse "${VERSION_FILE} is at ${version} but $1 already has $2. This branch was generated before that release; rebase or regenerate it before merging. ($guard)"
+ fi
+ }
+
+ central_published=unknown
+ if [ "$want_central" = "true" ]; then
+ metadata_url="${CENTRAL_BASE}/${GROUP_PATH}/${ARTIFACT_ID}/maven-metadata.xml"
+ metadata_status=$(curl -sS -o "${RUNNER_TEMP}/maven-metadata.xml" -w '%{http_code}' "$metadata_url" || echo 000)
+ echo "Maven Central returned HTTP ${metadata_status} for ${metadata_url}"
+
+ latest=""
+ case "$metadata_status" in
+ 200)
+ latest=$(python3 -c 'import re,sys; text=open(sys.argv[1]).read(); m=re.search(r"([^<]+)", text) or re.search(r"([^<]+)", text); print(m.group(1) if m else "")' "${RUNNER_TEMP}/maven-metadata.xml")
+ if [ -z "$latest" ]; then
+ refuse "maven-metadata.xml for ${GROUP_ID}:${ARTIFACT_ID} carries neither nor ."
+ fi
+ ;;
+ 404)
+ echo "::warning::${GROUP_ID}:${ARTIFACT_ID} has never been published to Maven Central. Releasing ${version} claims the coordinate permanently — Maven Central does not allow deleting or replacing a released version."
+ ;;
+ *)
+ refuse "Maven Central returned HTTP ${metadata_status} for maven-metadata.xml; refusing to guess the published version."
+ ;;
+ esac
+
+ if [ -n "$latest" ]; then
+ echo "Latest on Maven Central: $latest"
+ guard_against "Maven Central" "$latest"
+ fi
+
+ pom_url="${CENTRAL_BASE}/${GROUP_PATH}/${ARTIFACT_ID}/${version}/${ARTIFACT_ID}-${version}.pom"
+ pom_status=$(curl -sS -o /dev/null -w '%{http_code}' -I "$pom_url" || echo 000)
+ echo "Maven Central returned HTTP ${pom_status} for ${pom_url}"
+
+ case "$pom_status" in
+ 200) central_published=yes ;;
+ 404) central_published=no ;;
+ *)
+ refuse "Maven Central returned HTTP ${pom_status} for ${ARTIFACT_ID} ${version}; refusing to guess whether it is published."
+ ;;
+ esac
+ fi
+
+ packages_published=unknown
+ if [ "$want_packages" = "true" ]; then
+ : > "${RUNNER_TEMP}/package_versions.txt"
+ page=1
+ package_status=200
+
+ while :; do
+ versions_url="${PACKAGES_API}/${GROUP_ID}.${ARTIFACT_ID}/versions?per_page=100&page=${page}"
+ package_status=$(curl -sS -o "${RUNNER_TEMP}/versions.json" -w '%{http_code}' \
+ --header "Accept: application/vnd.github+json" \
+ --header "X-GitHub-Api-Version: 2022-11-28" \
+ --header "Authorization: Bearer ${GITHUB_TOKEN}" \
+ "$versions_url" || echo 000)
+ echo "GitHub Packages returned HTTP ${package_status} for ${versions_url}"
+
+ if [ "$package_status" != "200" ]; then
+ break
+ fi
+
+ count=$(python3 - "${RUNNER_TEMP}/versions.json" "${RUNNER_TEMP}/package_versions.txt" <<'PY'
+ import json
+ import sys
+
+ page = json.load(open(sys.argv[1]))
+ with open(sys.argv[2], "a") as sink:
+ for item in page:
+ sink.write(item["name"] + "\n")
+ print(len(page))
+ PY
+ )
+
+ if [ "$count" -lt 100 ]; then
+ break
+ fi
+ page=$((page + 1))
+ done
+
+ case "$package_status" in
+ 200)
+ published_versions=$(sort -u "${RUNNER_TEMP}/package_versions.txt")
+ echo "Published on GitHub Packages:"
+ printf '%s\n' "$published_versions"
+
+ if printf '%s\n' "$published_versions" | grep -Fxq "$version"; then
+ packages_published=yes
+ else
+ packages_published=no
+ fi
+
+ while IFS= read -r published_version; do
+ [ -n "$published_version" ] || continue
+ guard_against "GitHub Packages" "$published_version"
+ done <<< "$published_versions"
+ ;;
+ 404)
+ packages_published=no
+ echo "::warning::${GROUP_ID}:${ARTIFACT_ID} has never been published to GitHub Packages. A 404 here is the registry saying the package does not exist — an unauthorized read answers 403 with a scope message, and this step refuses on anything that is not 200 or 404."
+ ;;
+ *)
+ refuse "the GitHub Packages API returned HTTP ${package_status} for ${GROUP_ID}.${ARTIFACT_ID}; refusing to guess whether ${version} is published."
+ ;;
+ esac
+ fi
+
+ if [ "$dry" = "true" ]; then
+ echo "Dry run: building, staging and verifying the bundle for ${target}. Nothing is uploaded."
+ emit release true
+ emit central "$want_central"
+ emit packages "$want_packages"
+ exit 0
+ fi
+
+ release_central=false
+ release_packages=false
+
+ if [ "$want_central" = "true" ]; then
+ if [ "$central_published" = "yes" ]; then
+ echo "::notice::${GROUP_ID}:${ARTIFACT_ID}:${version} is already on Maven Central; nothing to publish there."
+ else
+ release_central=true
+ fi
+ fi
+
+ if [ "$want_packages" = "true" ]; then
+ if [ "$packages_published" = "yes" ]; then
+ echo "::notice::${GROUP_ID}:${ARTIFACT_ID}:${version} is already on GitHub Packages; nothing to publish there."
+ else
+ release_packages=true
+ fi
+ fi
+
+ emit central "$release_central"
+ emit packages "$release_packages"
+
+ if [ "$release_central" = "false" ] && [ "$release_packages" = "false" ]; then
+ emit release false
+ exit 0
+ fi
+
+ emit release true
+
+ build:
+ needs: decide
+ if: needs.decide.outputs.release == 'true'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
+ with:
+ persist-credentials: false
+
+ - name: Refuse to build a tree Fern did not fill in
+ run: |
+ set -euo pipefail
+
+ for required in build.gradle settings.gradle "$INIT_SCRIPT" "$STAMP_SCRIPT" "$VERSION_FILE"; do
+ if [ ! -f "$required" ]; then
+ echo "::error::${required} is missing; this tree cannot produce a release bundle."
+ exit 1
+ fi
+ done
+
+ count=$(find "$RESOURCE_DIR" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l)
+ echo "${RESOURCE_DIR}: ${count} resource packages"
+ if [ "$count" -lt "$MIN_RESOURCE_MODULES" ]; then
+ echo "::error::only ${count} resource packages, expected at least ${MIN_RESOURCE_MODULES}. Fern emits an empty SDK and exits 0 when it cannot parse the spec, and a Maven Central release cannot be recalled."
+ exit 1
+ fi
+
+ - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961
+ with:
+ distribution: temurin
+ java-version: ${{ env.JAVA_VERSION }}
+
+ - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb
+ with:
+ validate-wrappers: true
+
+ - name: Sync the version stamps
+ env:
+ VERSION: ${{ needs.decide.outputs.version }}
+ run: python3 "$STAMP_SCRIPT" "$VERSION" "$VERSION_FILE" "$METADATA_FILE"
+
+ - name: Test
+ run: ./gradlew --no-daemon test
+
+ - name: Stage the Maven bundle
+ run: ./gradlew --no-daemon -I "$INIT_SCRIPT" publishMavenPublicationToBundleRepository
+
+ - name: Verify the staged bundle
+ env:
+ VERSION: ${{ needs.decide.outputs.version }}
+ run: |
+ set -euo pipefail
+
+ dir="${BUNDLE_DIR}/${GROUP_PATH}/${ARTIFACT_ID}/${VERSION}"
+ if [ ! -d "$dir" ]; then
+ echo "::error::${dir} does not exist; Gradle staged the wrong coordinate."
+ find "$BUNDLE_DIR" -maxdepth 6 -type d || true
+ exit 1
+ fi
+
+ find "$BUNDLE_DIR" -name 'maven-metadata.xml*' -delete
+
+ for suffix in ".jar" "-sources.jar" "-javadoc.jar" ".pom"; do
+ file="${dir}/${ARTIFACT_ID}-${VERSION}${suffix}"
+ if [ ! -s "$file" ]; then
+ echo "::error::${file} is missing or empty. Maven Central requires the main, sources and javadoc jars plus a POM."
+ ls -l "$dir" || true
+ exit 1
+ fi
+ echo "present: $(ls -l "$file")"
+ done
+
+ pom="${dir}/${ARTIFACT_ID}-${VERSION}.pom"
+ python3 - "$pom" "$GROUP_ID" "$ARTIFACT_ID" "$VERSION" build.gradle <<'PY'
+ import re
+ import sys
+ import xml.etree.ElementTree as ET
+
+ pom, group, artifact, version, build_file = sys.argv[1:6]
+ ns = {"m": "http://maven.apache.org/POM/4.0.0"}
+ root = ET.parse(pom).getroot()
+
+
+ def text(tag, node=root):
+ found = node.find("m:" + tag, ns)
+ return None if found is None else (found.text or "").strip()
+
+
+ problems = []
+ for tag, expected in (("groupId", group), ("artifactId", artifact), ("version", version)):
+ if text(tag) != expected:
+ problems.append(tag + " is " + repr(text(tag)) + ", expected " + repr(expected))
+
+ for tag in ("name", "description", "url"):
+ if not text(tag):
+ problems.append(tag + " is empty; Maven Central rejects the deployment")
+
+ for tag in ("licenses", "developers", "scm"):
+ if root.find("m:" + tag, ns) is None:
+ problems.append(tag + " is absent; Maven Central rejects the deployment")
+
+ declared = {}
+ for node in root.findall("m:dependencies/m:dependency", ns):
+ key = text("groupId", node) + ":" + text("artifactId", node)
+ declared[key] = (text("version", node), text("scope", node))
+
+ print("POM dependencies:")
+ for key in sorted(declared):
+ print(" " + key + ":" + str(declared[key][0]) + " (" + str(declared[key][1]) + ")")
+
+ expected_api = re.findall(
+ r"""(?m)^\s*api\s+['"]([^'":]+):([^'":]+):([^'"]+)['"]""",
+ open(build_file).read(),
+ )
+ if not expected_api:
+ problems.append(build_file + " declares no api dependencies; the parser no longer matches")
+
+ for dep_group, dep_artifact, dep_version in expected_api:
+ key = dep_group + ":" + dep_artifact
+ if key not in declared:
+ problems.append(
+ key
+ + " is an api dependency in "
+ + build_file
+ + " but is absent from the POM; consumers would not resolve it"
+ )
+ continue
+ found_version, scope = declared[key]
+ if found_version != dep_version:
+ problems.append(key + " is " + str(found_version) + " in the POM, " + dep_version + " in " + build_file)
+ if scope != "compile":
+ problems.append(
+ key
+ + " is scope "
+ + str(scope)
+ + " in the POM; an api dependency has to reach the consumer's compile classpath"
+ )
+
+ if problems:
+ raise SystemExit("::error::" + "; ".join(problems))
+
+ print(
+ "POM declares "
+ + group + ":" + artifact + ":" + version
+ + " with the metadata Central requires and all "
+ + str(len(expected_api))
+ + " api dependencies at compile scope"
+ )
+ PY
+
+ unzip -p "${dir}/${ARTIFACT_ID}-${VERSION}-sources.jar" com/whop/api/core/ClientOptions.java > "${RUNNER_TEMP}/ClientOptions.java"
+ if ! grep -q "\"X-Fern-SDK-Version\", \"${VERSION}\"" "${RUNNER_TEMP}/ClientOptions.java"; then
+ echo "::error::the sources jar does not report ${VERSION} on the wire."
+ grep -n 'X-Fern-SDK-Version' "${RUNNER_TEMP}/ClientOptions.java" || true
+ exit 1
+ fi
+
+ rm -rf "${RUNNER_TEMP}/jar"
+ mkdir -p "${RUNNER_TEMP}/jar"
+ unzip -q "${dir}/${ARTIFACT_ID}-${VERSION}.jar" -d "${RUNNER_TEMP}/jar"
+
+ carriers=$(grep -rlaF 'X-Fern-SDK-Version' "${RUNNER_TEMP}/jar" || true)
+ if [ -z "$carriers" ]; then
+ echo "::error::no class in the main jar names X-Fern-SDK-Version. The header moved, and the stamp step is no longer stamping what ships."
+ exit 1
+ fi
+
+ echo "compiled carriers of X-Fern-SDK-Version:"
+ printf '%s\n' "$carriers"
+
+ while IFS= read -r carrier; do
+ if ! grep -qaF "$VERSION" "$carrier"; then
+ echo "::error::${carrier} names X-Fern-SDK-Version but does not carry ${VERSION}; the published jar would report a different version on the wire."
+ exit 1
+ fi
+ done <<< "$carriers"
+
+ echo "bundle verified:"
+ find "$BUNDLE_DIR" -type f | sort
+
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
+ with:
+ name: bundle
+ path: ${{ env.BUNDLE_DIR }}/
+ if-no-files-found: error
+ retention-days: 7
+
+ publish:
+ needs: [decide, build]
+ if: needs.decide.outputs.central == 'true'
+ runs-on: ubuntu-latest
+ environment: ${{ needs.decide.outputs.dry == 'true' && 'dry-run' || 'maven-central' }}
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
+ with:
+ name: bundle
+ path: bundle/
+
+ - name: Refuse a dry run that can see the release credentials
+ env:
+ DRY: ${{ needs.decide.outputs.dry }}
+ HAS_PORTAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME != '' }}
+ HAS_PORTAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD != '' }}
+ HAS_SIGNING_KEY: ${{ secrets.MAVEN_SIGNING_KEY != '' }}
+ run: |
+ set -euo pipefail
+
+ echo "dry=${DRY} portal_username=${HAS_PORTAL_USERNAME} portal_password=${HAS_PORTAL_PASSWORD} signing_key=${HAS_SIGNING_KEY}"
+
+ if [ "$DRY" = "true" ]; then
+ if [ "$HAS_PORTAL_USERNAME" = "true" ] || [ "$HAS_PORTAL_PASSWORD" = "true" ] || [ "$HAS_SIGNING_KEY" = "true" ]; then
+ echo "::error::a dry run can read the release credentials. They must exist only as environment secrets on the maven-central environment, never as repository or organization secrets — repository secrets are handed to same-repo pull request runs, so anyone who can open a pull request could exfiltrate them."
+ exit 1
+ fi
+ echo "no release credential is reachable from this dry run."
+ exit 0
+ fi
+
+ missing=""
+ [ "$HAS_PORTAL_USERNAME" = "true" ] || missing="${missing} MAVEN_CENTRAL_USERNAME"
+ [ "$HAS_PORTAL_PASSWORD" = "true" ] || missing="${missing} MAVEN_CENTRAL_PASSWORD"
+ [ "$HAS_SIGNING_KEY" = "true" ] || missing="${missing} MAVEN_SIGNING_KEY"
+
+ if [ -n "$missing" ]; then
+ echo "::error::the maven-central environment is missing:${missing}"
+ exit 1
+ fi
+
+ echo "every release credential is present."
+
+ - name: Match the artifact against the checksums the build job staged
+ run: |
+ set -euo pipefail
+
+ checked=0
+ while IFS= read -r checksum; do
+ artifact="${checksum%.sha1}"
+ if [ ! -f "$artifact" ]; then
+ echo "::error::${checksum} has no ${artifact} beside it."
+ exit 1
+ fi
+ expected=$(tr -d '[:space:]' < "$checksum")
+ actual=$(sha1sum "$artifact" | cut -d' ' -f1)
+ if [ "$expected" != "$actual" ]; then
+ echo "::error::${artifact} is sha1:${actual} but the build job staged sha1:${expected}. Refusing to sign bytes the build job never verified."
+ exit 1
+ fi
+ checked=$((checked + 1))
+ done < <(find bundle -name '*.sha1')
+
+ if [ "$checked" -eq 0 ]; then
+ echo "::error::the downloaded bundle carries no checksums."
+ exit 1
+ fi
+ echo "${checked} artifacts match the checksums the build job staged"
+
+ - name: Sign the bundle
+ env:
+ DRY: ${{ needs.decide.outputs.dry }}
+ VERSION: ${{ needs.decide.outputs.version }}
+ SIGNING_KEY: ${{ secrets.MAVEN_SIGNING_KEY }}
+ SIGNING_PASSPHRASE: ${{ secrets.MAVEN_SIGNING_PASSPHRASE }}
+ run: |
+ set -euo pipefail
+
+ GNUPGHOME=$(mktemp -d)
+ chmod 700 "$GNUPGHOME"
+ export GNUPGHOME
+
+ if [ "$DRY" = "true" ]; then
+ echo "::warning::signing with a throwaway key generated in this runner. A real release signs with Whop's Maven Central key, and that substitution is the one thing this rehearsal cannot cover."
+ gpg --batch --pinentry-mode loopback --passphrase "" \
+ --quick-generate-key "Whop Dry Run " rsa3072 sign 0
+ SIGNING_PASSPHRASE=""
+ else
+ printf '%s\n' "$SIGNING_KEY" | gpg --batch --import
+ fi
+
+ fingerprint=$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr:/ {print $10; exit}')
+ if [ -z "$fingerprint" ]; then
+ echo "::error::no secret key is available to sign with."
+ exit 1
+ fi
+ echo "signing with ${fingerprint}"
+
+ signed=0
+ while IFS= read -r file; do
+ gpg --batch --yes --pinentry-mode loopback --passphrase "$SIGNING_PASSPHRASE" \
+ --local-user "$fingerprint" --armor --detach-sign --output "${file}.asc" "$file"
+ signed=$((signed + 1))
+ done < <(find bundle -type f ! -name '*.asc' ! -name '*.md5' ! -name '*.sha1' ! -name '*.sha256' ! -name '*.sha512')
+
+ if [ "$signed" -eq 0 ]; then
+ echo "::error::there was nothing in the bundle to sign."
+ exit 1
+ fi
+ echo "signed ${signed} artifacts"
+
+ while IFS= read -r signature; do
+ if ! gpg --batch --verify "$signature" "${signature%.asc}" >/dev/null 2>&1; then
+ echo "::error::${signature} does not verify against ${signature%.asc}"
+ exit 1
+ fi
+ done < <(find bundle -name '*.asc')
+ echo "every signature verifies"
+
+ for suffix in ".jar" "-sources.jar" "-javadoc.jar" ".pom"; do
+ required="bundle/${GROUP_PATH}/${ARTIFACT_ID}/${VERSION}/${ARTIFACT_ID}-${VERSION}${suffix}.asc"
+ if [ ! -s "$required" ]; then
+ echo "::error::${required} is missing. Maven Central rejects a deployment whose main, sources or javadoc jar or POM is unsigned."
+ exit 1
+ fi
+ done
+
+ (cd bundle && zip -qr "${RUNNER_TEMP}/central-bundle.zip" .)
+ echo "bundle: $(ls -l "${RUNNER_TEMP}/central-bundle.zip")"
+ unzip -Z1 "${RUNNER_TEMP}/central-bundle.zip" | sort
+
+ - name: Upload the bundle to the Sonatype Central Portal
+ if: needs.decide.outputs.dry == 'false'
+ env:
+ VERSION: ${{ needs.decide.outputs.version }}
+ PORTAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }}
+ PORTAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }}
+ run: |
+ set -euo pipefail
+
+ auth=$(printf '%s:%s' "$PORTAL_USERNAME" "$PORTAL_PASSWORD" | base64 -w0)
+
+ status=$(curl -sS -o "${RUNNER_TEMP}/upload.txt" -w '%{http_code}' \
+ --request POST \
+ --header "Authorization: Bearer ${auth}" \
+ --form "bundle=@${RUNNER_TEMP}/central-bundle.zip" \
+ "${PORTAL_BASE}/upload?name=${ARTIFACT_ID}-${VERSION}&publishingType=${PUBLISHING_TYPE}" || echo 000)
+
+ if [ "$status" != "201" ]; then
+ echo "::error::the Central Portal returned HTTP ${status} for the upload."
+ cat "${RUNNER_TEMP}/upload.txt" || true
+ exit 1
+ fi
+
+ deployment=$(tr -d '[:space:]' < "${RUNNER_TEMP}/upload.txt")
+ echo "deployment=${deployment}"
+ echo "DEPLOYMENT_ID=${deployment}" >> "$GITHUB_ENV"
+
+ terminal=""
+ for attempt in $(seq 1 120); do
+ code=$(curl -sS -o "${RUNNER_TEMP}/status.json" -w '%{http_code}' \
+ --request POST \
+ --header "Authorization: Bearer ${auth}" \
+ "${PORTAL_BASE}/status?id=${deployment}" || echo 000)
+
+ if [ "$code" != "200" ]; then
+ echo "::warning::status poll ${attempt} returned HTTP ${code}"
+ sleep 15
+ continue
+ fi
+
+ state=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("deploymentState",""))' "${RUNNER_TEMP}/status.json")
+ echo "poll ${attempt}: ${state}"
+
+ case "$state" in
+ PUBLISHED)
+ terminal=PUBLISHED
+ break
+ ;;
+ FAILED)
+ terminal=FAILED
+ break
+ ;;
+ VALIDATED)
+ if [ "$PUBLISHING_TYPE" = "USER_MANAGED" ]; then
+ terminal=VALIDATED
+ break
+ fi
+ ;;
+ esac
+
+ sleep 15
+ done
+
+ cat "${RUNNER_TEMP}/status.json" || true
+
+ case "$terminal" in
+ PUBLISHED)
+ echo "::notice::${GROUP_ID}:${ARTIFACT_ID}:${VERSION} is published."
+ ;;
+ VALIDATED)
+ echo "::notice::deployment ${deployment} validated and is waiting for a human to release it at https://central.sonatype.com/publishing/deployments"
+ ;;
+ FAILED)
+ echo "::error::deployment ${deployment} failed validation. Dropping it so the version can be retried."
+ curl -sS -o /dev/null -w 'drop http=%{http_code}\n' \
+ --request DELETE \
+ --header "Authorization: Bearer ${auth}" \
+ "${PORTAL_BASE}/deployment/${deployment}" || true
+ exit 1
+ ;;
+ *)
+ echo "::error::deployment ${deployment} did not reach a terminal state. It is left in place — resolve it at https://central.sonatype.com/publishing/deployments before re-running, because a second upload of the same version will not supersede it."
+ exit 1
+ ;;
+ esac
+
+ publish-github-packages:
+ needs: [decide, build]
+ if: needs.decide.outputs.packages == 'true'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
+ with:
+ persist-credentials: false
+
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
+ with:
+ name: bundle
+ path: bundle/
+
+ - name: Refuse a run that can see the Maven Central credentials
+ env:
+ HAS_PORTAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME != '' }}
+ HAS_PORTAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD != '' }}
+ HAS_SIGNING_KEY: ${{ secrets.MAVEN_SIGNING_KEY != '' }}
+ run: |
+ set -euo pipefail
+
+ echo "portal_username=${HAS_PORTAL_USERNAME} portal_password=${HAS_PORTAL_PASSWORD} signing_key=${HAS_SIGNING_KEY}"
+
+ if [ "$HAS_PORTAL_USERNAME" = "true" ] || [ "$HAS_PORTAL_PASSWORD" = "true" ] || [ "$HAS_SIGNING_KEY" = "true" ]; then
+ echo "::error::this job can read the Maven Central release credentials. They belong to the maven-central environment and nothing else — this job joins no environment, so seeing them means they were created as repository or organization secrets, which GitHub hands to same-repo pull request runs."
+ exit 1
+ fi
+
+ echo "no Maven Central credential is reachable from this job."
+
+ - name: Report the coordinate and the artifact set the build job verified
+ env:
+ VERSION: ${{ needs.decide.outputs.version }}
+ run: |
+ set -euo pipefail
+
+ dir="bundle/${GROUP_PATH}/${ARTIFACT_ID}/${VERSION}"
+ echo "coordinate: ${GROUP_ID}:${ARTIFACT_ID}:${VERSION}"
+ echo "repository: ${PACKAGES_BASE}"
+ echo "artifacts:"
+ find "$dir" -type f ! -name '*.md5' ! -name '*.sha1' ! -name '*.sha256' ! -name '*.sha512' | sort
+ echo "POM:"
+ cat "${dir}/${ARTIFACT_ID}-${VERSION}.pom"
+
+ - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961
+ with:
+ distribution: temurin
+ java-version: ${{ env.JAVA_VERSION }}
+
+ - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb
+ with:
+ validate-wrappers: true
+
+ - name: Sync the version stamps
+ env:
+ VERSION: ${{ needs.decide.outputs.version }}
+ run: python3 "$STAMP_SCRIPT" "$VERSION" "$VERSION_FILE" "$METADATA_FILE"
+
+ - name: Match the POM against the one the build job verified
+ env:
+ VERSION: ${{ needs.decide.outputs.version }}
+ run: |
+ set -euo pipefail
+
+ ./gradlew --no-daemon -I "$INIT_SCRIPT" generatePomFileForMavenPublication
+
+ staged="bundle/${GROUP_PATH}/${ARTIFACT_ID}/${VERSION}/${ARTIFACT_ID}-${VERSION}.pom"
+ if ! diff -u "$staged" build/publications/maven/pom-default.xml; then
+ echo "::error::this job would publish a POM the build job never verified. The build job asserts the coordinate, the Central metadata and every api dependency against the generated build.gradle; a POM that differs here means the two jobs did not see the same tree."
+ exit 1
+ fi
+
+ echo "the POM this job will publish is the one the build job verified"
+
+ - name: Rehearse the upload
+ if: needs.decide.outputs.dry == 'true'
+ env:
+ GITHUB_PACKAGES_URL: ${{ env.PACKAGES_BASE }}
+ run: |
+ set -euo pipefail
+ echo "::warning::dry run — resolving the publish task graph without uploading. The HTTP transport to ${PACKAGES_BASE} is the only part of this path a dry run cannot cover."
+ ./gradlew --no-daemon -I "$INIT_SCRIPT" publishMavenPublicationToGitHubPackagesRepository --dry-run
+
+ - name: Publish to GitHub Packages
+ if: needs.decide.outputs.dry == 'false'
+ env:
+ GITHUB_PACKAGES_URL: ${{ env.PACKAGES_BASE }}
+ GITHUB_ACTOR: ${{ github.actor }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: ./gradlew --no-daemon -I "$INIT_SCRIPT" publishMavenPublicationToGitHubPackagesRepository
+
+ - name: Verify what the registry now serves
+ if: needs.decide.outputs.dry == 'false'
+ env:
+ VERSION: ${{ needs.decide.outputs.version }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ set -euo pipefail
+
+ pom_url="${PACKAGES_BASE}/${GROUP_PATH}/${ARTIFACT_ID}/${VERSION}/${ARTIFACT_ID}-${VERSION}.pom"
+ status=$(curl -sSL -o "${RUNNER_TEMP}/published.pom" -w '%{http_code}' \
+ --header "Authorization: Bearer ${GITHUB_TOKEN}" "$pom_url" || echo 000)
+ echo "GitHub Packages returned HTTP ${status} for ${pom_url}"
+
+ if [ "$status" != "200" ]; then
+ echo "::error::${GROUP_ID}:${ARTIFACT_ID}:${VERSION} is not resolvable from ${PACKAGES_BASE} after the upload."
+ exit 1
+ fi
+
+ staged="bundle/${GROUP_PATH}/${ARTIFACT_ID}/${VERSION}/${ARTIFACT_ID}-${VERSION}.pom"
+ if ! diff -u "$staged" "${RUNNER_TEMP}/published.pom"; then
+ echo "::error::the POM the registry serves is not the POM the build job verified."
+ exit 1
+ fi
+
+ echo "::notice::${GROUP_ID}:${ARTIFACT_ID}:${VERSION} resolves from ${PACKAGES_BASE} and serves the POM the build job verified."
+
+ tag:
+ needs: [decide, build, publish, publish-github-packages]
+ if: always() && needs.decide.outputs.dry == 'false' && needs.decide.outputs.version != ''
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ packages: read
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
+
+ - name: Tag the published version
+ env:
+ VERSION: ${{ needs.decide.outputs.version }}
+ TARGET: ${{ needs.decide.outputs.target }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ set -euo pipefail
+
+ central_pom="${CENTRAL_BASE}/${GROUP_PATH}/${ARTIFACT_ID}/${VERSION}/${ARTIFACT_ID}-${VERSION}.pom"
+ packages_pom="${PACKAGES_BASE}/${GROUP_PATH}/${ARTIFACT_ID}/${VERSION}/${ARTIFACT_ID}-${VERSION}.pom"
+
+ resolvable() {
+ local status
+ if [ "$TARGET" != "maven-central" ]; then
+ status=$(curl -sSL -o /dev/null -w '%{http_code}' \
+ --header "Authorization: Bearer ${GITHUB_TOKEN}" "$packages_pom" || echo 000)
+ echo " GitHub Packages: HTTP ${status}"
+ if [ "$status" = "200" ]; then
+ return 0
+ fi
+ fi
+
+ if [ "$TARGET" != "github-packages" ]; then
+ status=$(curl -sS -o /dev/null -w '%{http_code}' -I "$central_pom" || echo 000)
+ echo " Maven Central: HTTP ${status}"
+ if [ "$status" = "200" ]; then
+ return 0
+ fi
+ fi
+
+ return 1
+ }
+
+ found=no
+ for attempt in $(seq 1 20); do
+ echo "attempt ${attempt}:"
+ if resolvable; then
+ found=yes
+ break
+ fi
+ sleep 30
+ done
+
+ if [ "$found" != "yes" ]; then
+ echo "::notice::${GROUP_ID}:${ARTIFACT_ID}:${VERSION} is not resolvable from any registry this run targeted; nothing to tag. Re-run this workflow once it is — this job is idempotent."
+ exit 0
+ fi
+
+ if git ls-remote --exit-code --tags origin "refs/tags/v${VERSION}" >/dev/null 2>&1; then
+ echo "v${VERSION} is already tagged."
+ exit 0
+ fi
+
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git tag -a "v${VERSION}" -m "${ARTIFACT_ID} ${VERSION}"
+
+ git push origin "refs/tags/v${VERSION}" || \
+ git ls-remote --exit-code --tags origin "refs/tags/v${VERSION}" >/dev/null
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 00000000..d6456956
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 00000000..c58bc52d
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,3 @@
+group=com.whop.api
+version=0.0.0
+org.gradle.jvmargs=-Xmx4g