From 2b918cbeb404dd568e7b3667429ab116e4c088c9 Mon Sep 17 00:00:00 2001 From: icepaq Date: Sat, 22 Aug 2026 14:11:01 -0700 Subject: [PATCH 1/2] ci: hand-written release pipeline for the Java SDK 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) Claude-Session: https://claude.ai/code/session_01XV1533iUUxKJxfn4FXptWz --- .fernignore | 46 +- .github/release.gradle | 51 ++ .github/workflows/ci.yml | 58 ++- .github/workflows/publish-main.yml | 747 +++++++++++++++++++++++++++++ LICENSE | 202 ++++++++ gradle.properties | 3 + 6 files changed, 1084 insertions(+), 23 deletions(-) create mode 100644 .github/release.gradle create mode 100644 .github/workflows/publish-main.yml create mode 100644 LICENSE create mode 100644 gradle.properties diff --git a/.fernignore b/.fernignore index 084a8ebb..da6f2678 100644 --- a/.fernignore +++ b/.fernignore @@ -1 +1,45 @@ -# 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 to Maven Central +# through the Sonatype Central Portal when a merge to main lands a gradle.properties +# version that is not yet on repo1.maven.org. It is meant to be the only publish path. +# +# 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, and a repository that stages the bundle to build/bundle instead of +# uploading it. Applying it with -I rather than editing build.gradle means a regeneration +# cannot silently drop it and Fern keeps ownership of the build file. +# +# 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 +gradle.properties +LICENSE diff --git a/.github/release.gradle b/.github/release.gradle new file mode 100644 index 00000000..b0b64b94 --- /dev/null +++ b/.github/release.gradle @@ -0,0 +1,51 @@ +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') + } + } + } + } + } +} 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..0dc33eee --- /dev/null +++ b/.github/workflows/publish-main.yml @@ -0,0 +1,747 @@ +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 + +permissions: + contents: read + +concurrency: + group: publish-whop-java-${{ github.ref }} + cancel-in-progress: false + +env: + 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 + 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 + +jobs: + decide: + if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + outputs: + release: ${{ steps.gate.outputs.release }} + dry: ${{ steps.gate.outputs.dry }} + version: ${{ steps.gate.outputs.version }} + 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 }} + 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" + echo "event=$EVENT dry=$dry 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 + + 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. 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" + if guard=$(python3 "${RUNNER_TEMP}/version_guard.py" "$version" "$latest" 2>&1); then + echo "$guard" + else + refuse "${VERSION_FILE} is at ${version} but Maven Central already has ${latest}. This branch was generated before that release; rebase or regenerate it before merging. ($guard)" + fi + 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}" + + published=unknown + case "$pom_status" in + 200) published=yes ;; + 404) published=no ;; + *) + refuse "Maven Central returned HTTP ${pom_status} for ${ARTIFACT_ID} ${version}; refusing to guess whether it is published." + ;; + esac + + if [ "$dry" = "true" ]; then + echo "Dry run: building, signing with a throwaway key and verifying the bundle. Nothing is uploaded." + emit release true + exit 0 + fi + + if [ "$published" = "yes" ]; then + echo "::notice::${GROUP_ID}:${ARTIFACT_ID}:${version} is already on Maven Central; nothing to publish." + 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" "$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: | + set -euo pipefail + python3 - "$VERSION" <<'PY' + import json + import os + import pathlib + import re + import sys + + version = sys.argv[1] + + version_file = pathlib.Path(os.environ["VERSION_FILE"]) + text = version_file.read_text() + text, count = re.subn( + r"(?m)^([ \t]*version[ \t]*=[ \t]*).*$", r"\g<1>" + version, text, count=1 + ) + if count == 0: + raise SystemExit("::error::no version property found in " + str(version_file)) + version_file.write_text(text) + print("stamped " + str(version_file)) + + carriers = [ + path + for path in pathlib.Path("src/main").rglob("*.java") + if "X-Fern-SDK-Version" in path.read_text() + ] + + if not carriers: + raise SystemExit( + "::error::nothing under src/main carries X-Fern-SDK-Version; the published jar would report the generator's constant" + ) + + for path in carriers: + source = path.read_text() + source, hits = re.subn( + r'("X-Fern-SDK-Version"\s*,\s*")[^"]*', r"\g<1>" + version, source + ) + if hits == 0: + raise SystemExit( + "::error::" + str(path) + " names X-Fern-SDK-Version but the rewrite matched nothing; the header shape changed" + ) + path.write_text(source) + print("stamped " + str(path)) + + metadata_path = pathlib.Path(os.environ["METADATA_FILE"]) + if metadata_path.exists(): + metadata = json.loads(metadata_path.read_text()) + for key in ("requestedVersion", "sdkVersion"): + if key in metadata: + metadata[key] = version + metadata_path.write_text(json.dumps(metadata, indent=2) + "\n") + print("stamped " + str(metadata_path)) + PY + + stale=$(grep -rn 'X-Fern-SDK-Version' src/main | grep -E '[0-9]+\.[0-9]+\.[0-9]+' | grep -Fv "$VERSION" || true) + if [ -n "$stale" ]; then + echo "::error::the source tree still reports a version other than ${VERSION}:" + printf '%s\n' "$stale" + exit 1 + fi + + - 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" <<'PY' + import sys + import xml.etree.ElementTree as ET + + pom, group, artifact, version = sys.argv[1:5] + ns = {"m": "http://maven.apache.org/POM/4.0.0"} + root = ET.parse(pom).getroot() + + + def text(tag): + node = root.find("m:" + tag, ns) + return None if node is None else (node.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") + + if problems: + raise SystemExit("::error::" + "; ".join(problems)) + + print("POM declares " + group + ":" + artifact + ":" + version + " with the metadata Central requires") + 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.release == '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 + + tag: + needs: [decide, build, publish] + if: always() && needs.decide.outputs.dry == 'false' && needs.decide.outputs.version != '' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + + - name: Tag the published version + env: + VERSION: ${{ needs.decide.outputs.version }} + run: | + set -euo pipefail + + pom_url="${CENTRAL_BASE}/${GROUP_PATH}/${ARTIFACT_ID}/${VERSION}/${ARTIFACT_ID}-${VERSION}.pom" + + found=no + for attempt in $(seq 1 20); do + status=$(curl -sS -o /dev/null -w '%{http_code}' -I "$pom_url" || echo 000) + echo "attempt ${attempt}: HTTP ${status} for ${pom_url}" + if [ "$status" = "200" ]; then + found=yes + break + fi + sleep 30 + done + + if [ "$found" != "yes" ]; then + echo "::notice::${GROUP_ID}:${ARTIFACT_ID}:${VERSION} has not appeared on Maven Central yet; nothing to tag. Re-run this workflow once it has — 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 From 95699fd66f1471cb0db1ba7dfbf244923bcf14bc Mon Sep 17 00:00:00 2001 From: icepaq <288199041+antonwhop@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:53:32 -0700 Subject: [PATCH 2/2] ci: add a GitHub Packages release target beside Maven Central MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .fernignore | 25 +- .github/release.gradle | 16 + .github/stamp-version.py | 101 ++++++ .github/workflows/publish-main.yml | 491 ++++++++++++++++++++++------- 4 files changed, 514 insertions(+), 119 deletions(-) create mode 100644 .github/stamp-version.py diff --git a/.fernignore b/.fernignore index da6f2678..528a3cc2 100644 --- a/.fernignore +++ b/.fernignore @@ -3,9 +3,19 @@ # 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 to Maven Central -# through the Sonatype Central Portal when a merge to main lands a gradle.properties -# version that is not yet on repo1.maven.org. It is meant to be the only publish path. +# 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` @@ -17,9 +27,11 @@ # # 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, and a repository that stages the bundle to build/bundle instead of -# uploading it. Applying it with -I rather than editing build.gradle means a regeneration -# cannot silently drop it and Fern keeps ownership of the build file. +# 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: @@ -41,5 +53,6 @@ .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 index b0b64b94..6260c185 100644 --- a/.github/release.gradle +++ b/.github/release.gradle @@ -44,6 +44,22 @@ gradle.projectsLoaded { 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/publish-main.yml b/.github/workflows/publish-main.yml index 0dc33eee..b26f9fb7 100644 --- a/.github/workflows/publish-main.yml +++ b/.github/workflows/publish-main.yml @@ -11,6 +11,15 @@ on: 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 @@ -20,12 +29,14 @@ concurrency: 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 @@ -34,15 +45,23 @@ env: 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: @@ -127,6 +146,8 @@ jobs: 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 @@ -138,7 +159,27 @@ jobs: dry=false fi emit dry "$dry" - echo "event=$EVENT dry=$dry ref=$GITHUB_REF" + + 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 @@ -222,56 +263,149 @@ jobs: echo "::notice::no HEAD^ to diff against; the only-version-changed check has nothing to compare." fi - 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. 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" - if guard=$(python3 "${RUNNER_TEMP}/version_guard.py" "$version" "$latest" 2>&1); then + 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 Maven Central already has ${latest}. This branch was generated before that release; rebase or regenerate it before merging. ($guard)" + 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 - 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}" + 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 - published=unknown - case "$pom_status" in - 200) published=yes ;; - 404) published=no ;; - *) - refuse "Maven Central returned HTTP ${pom_status} for ${ARTIFACT_ID} ${version}; refusing to guess whether it is published." - ;; - esac + 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, signing with a throwaway key and verifying the bundle. Nothing is uploaded." + 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 - if [ "$published" = "yes" ]; then - echo "::notice::${GROUP_ID}:${ARTIFACT_ID}:${version} is already on Maven Central; nothing to publish." + 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 @@ -293,7 +427,7 @@ jobs: run: | set -euo pipefail - for required in build.gradle settings.gradle "$INIT_SCRIPT" "$VERSION_FILE"; do + 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 @@ -319,66 +453,7 @@ jobs: - name: Sync the version stamps env: VERSION: ${{ needs.decide.outputs.version }} - run: | - set -euo pipefail - python3 - "$VERSION" <<'PY' - import json - import os - import pathlib - import re - import sys - - version = sys.argv[1] - - version_file = pathlib.Path(os.environ["VERSION_FILE"]) - text = version_file.read_text() - text, count = re.subn( - r"(?m)^([ \t]*version[ \t]*=[ \t]*).*$", r"\g<1>" + version, text, count=1 - ) - if count == 0: - raise SystemExit("::error::no version property found in " + str(version_file)) - version_file.write_text(text) - print("stamped " + str(version_file)) - - carriers = [ - path - for path in pathlib.Path("src/main").rglob("*.java") - if "X-Fern-SDK-Version" in path.read_text() - ] - - if not carriers: - raise SystemExit( - "::error::nothing under src/main carries X-Fern-SDK-Version; the published jar would report the generator's constant" - ) - - for path in carriers: - source = path.read_text() - source, hits = re.subn( - r'("X-Fern-SDK-Version"\s*,\s*")[^"]*', r"\g<1>" + version, source - ) - if hits == 0: - raise SystemExit( - "::error::" + str(path) + " names X-Fern-SDK-Version but the rewrite matched nothing; the header shape changed" - ) - path.write_text(source) - print("stamped " + str(path)) - - metadata_path = pathlib.Path(os.environ["METADATA_FILE"]) - if metadata_path.exists(): - metadata = json.loads(metadata_path.read_text()) - for key in ("requestedVersion", "sdkVersion"): - if key in metadata: - metadata[key] = version - metadata_path.write_text(json.dumps(metadata, indent=2) + "\n") - print("stamped " + str(metadata_path)) - PY - - stale=$(grep -rn 'X-Fern-SDK-Version' src/main | grep -E '[0-9]+\.[0-9]+\.[0-9]+' | grep -Fv "$VERSION" || true) - if [ -n "$stale" ]; then - echo "::error::the source tree still reports a version other than ${VERSION}:" - printf '%s\n' "$stale" - exit 1 - fi + run: python3 "$STAMP_SCRIPT" "$VERSION" "$VERSION_FILE" "$METADATA_FILE" - name: Test run: ./gradlew --no-daemon test @@ -412,18 +487,19 @@ jobs: done pom="${dir}/${ARTIFACT_ID}-${VERSION}.pom" - python3 - "$pom" "$GROUP_ID" "$ARTIFACT_ID" "$VERSION" <<'PY' + python3 - "$pom" "$GROUP_ID" "$ARTIFACT_ID" "$VERSION" build.gradle <<'PY' + import re import sys import xml.etree.ElementTree as ET - pom, group, artifact, version = sys.argv[1:5] + 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.find("m:" + tag, ns) - return None if node is None else (node.text or "").strip() + def text(tag, node=root): + found = node.find("m:" + tag, ns) + return None if found is None else (found.text or "").strip() problems = [] @@ -439,10 +515,53 @@ jobs: 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") + 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" @@ -484,7 +603,7 @@ jobs: publish: needs: [decide, build] - if: needs.decide.outputs.release == 'true' + if: needs.decide.outputs.central == 'true' runs-on: ubuntu-latest environment: ${{ needs.decide.outputs.dry == 'true' && 'dry-run' || 'maven-central' }} permissions: @@ -701,28 +820,174 @@ jobs: ;; 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] + 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 - pom_url="${CENTRAL_BASE}/${GROUP_PATH}/${ARTIFACT_ID}/${VERSION}/${ARTIFACT_ID}-${VERSION}.pom" + 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 - status=$(curl -sS -o /dev/null -w '%{http_code}' -I "$pom_url" || echo 000) - echo "attempt ${attempt}: HTTP ${status} for ${pom_url}" - if [ "$status" = "200" ]; then + echo "attempt ${attempt}:" + if resolvable; then found=yes break fi @@ -730,7 +995,7 @@ jobs: done if [ "$found" != "yes" ]; then - echo "::notice::${GROUP_ID}:${ARTIFACT_ID}:${VERSION} has not appeared on Maven Central yet; nothing to tag. Re-run this workflow once it has — this job is idempotent." + 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