From 8772f1afe512ecb746a8860315defdcb313638b7 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Wed, 20 May 2026 12:02:33 -0400 Subject: [PATCH] feat: drive releases from a single GitHub Actions workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the previous two-workflow setup (push a tag, separate publish workflow listens for it) with a single end-to-end `Release` workflow at `.github/workflows/release.yml`. The old `publish-release-from-tag.yml` is removed. Flow: 1. Actions → Release → Run workflow with `version=vX.Y.Z` and the full 40-char commit `sha` to release. 2. The job runs in the protected `release` GitHub Environment, which holds the Sonatype / GPG secrets and requires reviewer approval before anything happens. 3. After approval, the workflow validates the version and SHA, verifies the SHA is reachable from `origin/main`, runs `./gradlew check` on the pinned SHA, creates and pushes the annotated tag `vX.Y.Z` pointing at that SHA, checks out the tag, re-runs `./gradlew check`, builds artifacts, creates the GitHub Release with the SDK / agent / OTel extension jars attached, publishes to Maven Central via Sonatype, and polls Maven Central until the version is visible. The releaser must supply an explicit commit SHA (not a branch name) so that commits which land on `main` while the run is paused at the environment approval gate are NOT silently included in the release. Because publishing happens in the same workflow as the tag push, the tag push no longer needs to retrigger anything. The default `GITHUB_TOKEN` is sufficient — no GitHub App / PAT is required. Re-publish path: re-run `Release` with the same version (and any valid SHA — it's ignored once the tag exists). The workflow detects the existing tag, skips tag creation, and resumes from build/publish. GitHub Release asset uploads use `--clobber` so partial uploads from a prior failed run are replaced. The environment name is intentionally generic (`release`) rather than maven-central-specific because the gated job covers the full release flow — tag creation, GitHub Release, Sonatype publish, and Maven Central sync wait — not just the Sonatype step. `CONTRIBUTING.md` gains a Releasing section documenting the end-to-end flow, the SHA-pinning requirement, the approval gate, the re-publish behavior, the scoped environment secrets, and a note that there is no version constant to bump in source because the version is derived from git tags at build time by `generateVersion()` in `build.gradle`. `scripts/release.sh` is retained as a local fallback. --- .../workflows/publish-release-from-tag.yml | 184 -------------- .github/workflows/release.yml | 233 ++++++++++++++++++ CONTRIBUTING.md | 33 +++ 3 files changed, 266 insertions(+), 184 deletions(-) delete mode 100644 .github/workflows/publish-release-from-tag.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/publish-release-from-tag.yml b/.github/workflows/publish-release-from-tag.yml deleted file mode 100644 index ccf7a2e1..00000000 --- a/.github/workflows/publish-release-from-tag.yml +++ /dev/null @@ -1,184 +0,0 @@ -# This workflow is triggered when a new tag is pushed to main. -# It can also be run manually to re-publish a release in case it failed for some reason. -name: Publish Release From Tag - -on: - push: - tags: - - 'v*' - workflow_dispatch: - inputs: - tag: - description: 'Tag to publish (e.g., v1.0.0)' - required: true - type: string - -permissions: - contents: write - -jobs: - validate-and-publish: - name: Validate Tag and Publish Release - # we want to run ubuntu-latest but we'll pin to a specific version so workflow is reproducable - runs-on: ubuntu-24.04 - steps: - - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - fetch-depth: 0 - - - name: Determine tag - id: determine-tag - run: | - if [[ "${{ github.event_name }}" == "push" ]]; then - TAG_NAME="${{ github.ref_name }}" - else - TAG_NAME="${{ inputs.tag }}" - fi - echo "tag=$TAG_NAME" >> $GITHUB_OUTPUT - echo "Using tag: $TAG_NAME" - - - name: Validate tag format - run: | - TAG="${{ steps.determine-tag.outputs.tag }}" - - # Check if tag starts with 'v' - if [[ ! "$TAG" =~ ^v ]]; then - echo "Error: Tag '$TAG' must start with 'v'" - exit 1 - fi - - # Extract version without 'v' prefix - VERSION="${TAG#v}" - - # Check if version is valid semver (x.y.z) - if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "Error: Tag '$TAG' is not valid semver format (vx.y.z)" - exit 1 - fi - - # Check that version does not end with -SNAPSHOT - if [[ "$VERSION" =~ -SNAPSHOT$ ]]; then - echo "Error: Tag '$TAG' cannot end with '-SNAPSHOT'" - exit 1 - fi - - echo "Tag '$TAG' is valid" - - - name: Verify tag exists - run: | - TAG="${{ steps.determine-tag.outputs.tag }}" - if ! git tag -l | grep -q "^$TAG$"; then - echo "Error: Tag '$TAG' does not exist" - exit 1 - fi - echo "Tag '$TAG' exists" - - - name: Checkout tag - run: | - git checkout ${{ steps.determine-tag.outputs.tag }} - - - name: Set up JDK 17 - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.8.0 - with: - java-version: '17' - distribution: 'temurin' - - - name: Setup Gradle - uses: gradle/gradle-build-action@a8f75513eafdebd8141bd1cd4e30fcd194af8dfa # v2.12.0 - - - name: Run CI - run: ./gradlew check - - - name: Build release artifacts - run: ./gradlew build publishToMavenLocal - - - name: Find built artifacts - id: find-artifacts - run: | - TAG="${{ steps.determine-tag.outputs.tag }}" - # Strip 'v' prefix to get the actual version used by Gradle - VERSION="${TAG#v}" - - # braintrust-sdk artifacts - SDK_MAIN_JAR=$(find braintrust-sdk/build/libs -name "*-${VERSION}.jar" ! -name "*-sources.jar" ! -name "*-javadoc.jar" | head -1) - SDK_SOURCES_JAR=$(find braintrust-sdk/build/libs -name "*-${VERSION}-sources.jar" | head -1) - SDK_JAVADOC_JAR=$(find braintrust-sdk/build/libs -name "*-${VERSION}-javadoc.jar" | head -1) - - # braintrust-java-agent artifact (single fat jar, no sources/javadoc) - AGENT_JAR=$(find braintrust-java-agent/build/libs -name "braintrust-java-agent-${VERSION}.jar" | head -1) - - # braintrust-otel-extension artifact (fat jar, no sources/javadoc) - OTL_EXT_JAR=$(find braintrust-otel-extension/build/libs -name "braintrust-otel-extension-${VERSION}.jar" | head -1) - - echo "sdk-main-jar=$SDK_MAIN_JAR" >> $GITHUB_OUTPUT - echo "sdk-sources-jar=$SDK_SOURCES_JAR" >> $GITHUB_OUTPUT - echo "sdk-javadoc-jar=$SDK_JAVADOC_JAR" >> $GITHUB_OUTPUT - echo "agent-jar=$AGENT_JAR" >> $GITHUB_OUTPUT - echo "otel-ext-jar=$OTL_EXT_JAR" >> $GITHUB_OUTPUT - - echo "Found artifacts:" - echo " SDK Main JAR: $SDK_MAIN_JAR" - echo " SDK Sources JAR: $SDK_SOURCES_JAR" - echo " SDK Javadoc JAR: $SDK_JAVADOC_JAR" - echo " Agent JAR: $AGENT_JAR" - echo " OTel Extension JAR: $OTL_EXT_JAR" - - - name: Create GitHub Release - run: | - TAG="${{ steps.determine-tag.outputs.tag }}" - - # Create the release - gh release create "$TAG" \ - --generate-notes \ - --title "Release $TAG" - - # Upload SDK artifacts - for jar in \ - "${{ steps.find-artifacts.outputs.sdk-main-jar }}" \ - "${{ steps.find-artifacts.outputs.sdk-sources-jar }}" \ - "${{ steps.find-artifacts.outputs.sdk-javadoc-jar }}" \ - "${{ steps.find-artifacts.outputs.agent-jar }}" \ - "${{ steps.find-artifacts.outputs.otel-ext-jar }}"; do - if [[ -n "$jar" && -f "$jar" ]]; then - gh release upload "$TAG" "$jar" - fi - done - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Publish to Sonatype - run: |- - if [ -z "$SONATYPE_USERNAME" ]; then - echo "Error: SONATYPE_USERNAME is not set" - exit 1 - fi - if [ -z "$SONATYPE_PASSWORD" ]; then - echo "Error: SONATYPE_PASSWORD is not set" - exit 1 - fi - if [ -z "$GPG_SIGNING_KEY" ]; then - echo "Error: GPG_SIGNING_KEY is not set" - exit 1 - fi - if [ -z "$GPG_SIGNING_PASSWORD" ]; then - echo "Error: GPG_SIGNING_PASSWORD is not set" - exit 1 - fi - echo "All required credentials are set" - export -- GPG_SIGNING_KEY_ID - printenv -- GPG_SIGNING_KEY | gpg --batch --passphrase-fd 3 --import 3<<< "$GPG_SIGNING_PASSWORD" - GPG_SIGNING_KEY_ID="$(gpg --with-colons --list-keys | awk -F : -- '/^pub:/ { getline; print "0x" substr($10, length($10) - 7) }')" - ./gradlew publishAndReleaseToMavenCentral --stacktrace -PmavenCentralUsername="$SONATYPE_USERNAME" -PmavenCentralPassword="$SONATYPE_PASSWORD" --no-configuration-cache - env: - SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} - SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} - GPG_SIGNING_KEY: ${{ secrets.GPG_SIGNING_KEY }} - GPG_SIGNING_PASSWORD: ${{ secrets.GPG_SIGNING_PASSWORD }} - - - name: Wait for Maven Central sync - run: | - TAG="${{ steps.determine-tag.outputs.tag }}" - # Strip 'v' prefix to get the Maven version - VERSION="${TAG#v}" - echo "Waiting for version $VERSION to sync to Maven Central. THIS CAN TAKE MANY HOURS! Godspeed" - ./scripts/wait-for-maven.sh "$VERSION" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..d83e7aa3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,233 @@ +# Drives a release end-to-end from GitHub Actions in a single workflow. +# +# Click "Run workflow", enter a version like v1.2.3, and this will: +# 1. Validate the version (semver, no -SNAPSHOT) and the SHA. +# 2. Run ./gradlew check on the pinned SHA as a final gate. +# 3. Create and push the annotated tag vX.Y.Z pointing at the SHA +# (using GITHUB_TOKEN). +# +# The releaser must supply an explicit commit SHA (not a branch name) so +# that commits which land on main during the environment approval gate +# are NOT silently included in the release. +# 4. Build release artifacts at that tag. +# 5. Create the GitHub Release and upload the SDK / agent / OTel +# extension jars. +# 6. Publish to Maven Central via Sonatype, signed with the project +# GPG key. +# 7. Poll Maven Central until the new version is visible. +# +# Re-publishing a failed release: re-run this workflow with the same +# version. If the tag already exists, the tag-creation step is skipped +# and the rest of the pipeline runs against the existing tag. +# +# The entire job runs in the protected `release` GitHub Environment, +# which holds the Sonatype / GPG secrets and requires reviewer approval +# before any tag is pushed or any artifact is published. +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g., v1.2.3)' + required: true + type: string + sha: + description: 'Full 40-char commit SHA to tag. Required so the releaser controls exactly what ships, even if main advances during the approval gate. Ignored if the tag already exists.' + required: true + type: string + +permissions: + contents: write + +jobs: + release: + name: Release + runs-on: ubuntu-24.04 + # Gate the entire release behind a protected GitHub Environment. + # Required reviewers, deployment branch/tag rules, and the Sonatype / + # GPG secrets are configured on the environment itself in repo + # settings (Settings → Environments → release). + environment: release + steps: + - name: Validate inputs + run: | + V="${{ inputs.version }}" + if [[ ! "$V" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: version must be semver (e.g. v1.2.3)" >&2 + exit 1 + fi + if [[ "$V" == *-SNAPSHOT ]]; then + echo "Error: version cannot end with -SNAPSHOT" >&2 + exit 1 + fi + SHA="${{ inputs.sha }}" + if [[ ! "$SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "Error: sha must be a full 40-character lowercase commit SHA. Got: '$SHA'" >&2 + echo "Tip: copy the SHA from the commit page on GitHub (use the 'Copy full SHA' button)." >&2 + exit 1 + fi + + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ inputs.sha }} + fetch-depth: 0 + + - name: Verify SHA is reachable from main + run: | + SHA="${{ inputs.sha }}" + git fetch origin main --quiet + if ! git merge-base --is-ancestor "$SHA" origin/main; then + echo "Error: commit $SHA is not an ancestor of origin/main." >&2 + echo "Releases must be cut from commits that have landed on main." >&2 + exit 1 + fi + echo "Commit $SHA is reachable from origin/main." + + - name: Determine whether tag already exists + id: tag-state + run: | + TAG="${{ inputs.version }}" + git fetch --tags --quiet + if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "Tag '$TAG' already exists; will publish from the existing tag." + elif git ls-remote --tags origin | grep -q "refs/tags/${TAG}$"; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "Tag '$TAG' exists on origin but not locally; fetching." + git fetch origin "refs/tags/$TAG:refs/tags/$TAG" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "Tag '$TAG' does not exist yet; will create at $SHA." + fi + + - name: Set up JDK 17 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.8.0 + with: + java-version: '17' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/gradle-build-action@a8f75513eafdebd8141bd1cd4e30fcd194af8dfa # v2.12.0 + + - name: Run CI (pre-tag, on chosen ref) + if: steps.tag-state.outputs.exists == 'false' + run: ./gradlew check + + - name: Configure git identity + if: steps.tag-state.outputs.exists == 'false' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Create and push tag + if: steps.tag-state.outputs.exists == 'false' + run: | + TAG="${{ inputs.version }}" + SHA="${{ inputs.sha }}" + git tag -a "$TAG" -m "Release $TAG" "$SHA" + git push origin "$TAG" + + - name: Checkout tag + run: git checkout "${{ inputs.version }}" + + - name: Run CI (at tag) + run: ./gradlew check + + - name: Build release artifacts + run: ./gradlew build publishToMavenLocal + + - name: Find built artifacts + id: find-artifacts + run: | + TAG="${{ inputs.version }}" + # Strip 'v' prefix to get the actual version used by Gradle + VERSION="${TAG#v}" + + # braintrust-sdk artifacts + SDK_MAIN_JAR=$(find braintrust-sdk/build/libs -name "*-${VERSION}.jar" ! -name "*-sources.jar" ! -name "*-javadoc.jar" | head -1) + SDK_SOURCES_JAR=$(find braintrust-sdk/build/libs -name "*-${VERSION}-sources.jar" | head -1) + SDK_JAVADOC_JAR=$(find braintrust-sdk/build/libs -name "*-${VERSION}-javadoc.jar" | head -1) + + # braintrust-java-agent artifact (single fat jar, no sources/javadoc) + AGENT_JAR=$(find braintrust-java-agent/build/libs -name "braintrust-java-agent-${VERSION}.jar" | head -1) + + # braintrust-otel-extension artifact (fat jar, no sources/javadoc) + OTL_EXT_JAR=$(find braintrust-otel-extension/build/libs -name "braintrust-otel-extension-${VERSION}.jar" | head -1) + + echo "sdk-main-jar=$SDK_MAIN_JAR" >> $GITHUB_OUTPUT + echo "sdk-sources-jar=$SDK_SOURCES_JAR" >> $GITHUB_OUTPUT + echo "sdk-javadoc-jar=$SDK_JAVADOC_JAR" >> $GITHUB_OUTPUT + echo "agent-jar=$AGENT_JAR" >> $GITHUB_OUTPUT + echo "otel-ext-jar=$OTL_EXT_JAR" >> $GITHUB_OUTPUT + + echo "Found artifacts:" + echo " SDK Main JAR: $SDK_MAIN_JAR" + echo " SDK Sources JAR: $SDK_SOURCES_JAR" + echo " SDK Javadoc JAR: $SDK_JAVADOC_JAR" + echo " Agent JAR: $AGENT_JAR" + echo " OTel Extension JAR: $OTL_EXT_JAR" + + - name: Create or update GitHub Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="${{ inputs.version }}" + + # Create the release if it doesn't already exist (re-publish path). + if ! gh release view "$TAG" >/dev/null 2>&1; then + gh release create "$TAG" \ + --generate-notes \ + --title "Release $TAG" + else + echo "Release '$TAG' already exists; will upload (clobber) assets." + fi + + # Upload artifacts, clobbering any partial uploads from a prior run. + for jar in \ + "${{ steps.find-artifacts.outputs.sdk-main-jar }}" \ + "${{ steps.find-artifacts.outputs.sdk-sources-jar }}" \ + "${{ steps.find-artifacts.outputs.sdk-javadoc-jar }}" \ + "${{ steps.find-artifacts.outputs.agent-jar }}" \ + "${{ steps.find-artifacts.outputs.otel-ext-jar }}"; do + if [[ -n "$jar" && -f "$jar" ]]; then + gh release upload "$TAG" "$jar" --clobber + fi + done + + - name: Publish to Sonatype + env: + SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} + SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} + GPG_SIGNING_KEY: ${{ secrets.GPG_SIGNING_KEY }} + GPG_SIGNING_PASSWORD: ${{ secrets.GPG_SIGNING_PASSWORD }} + run: |- + if [ -z "$SONATYPE_USERNAME" ]; then + echo "Error: SONATYPE_USERNAME is not set" + exit 1 + fi + if [ -z "$SONATYPE_PASSWORD" ]; then + echo "Error: SONATYPE_PASSWORD is not set" + exit 1 + fi + if [ -z "$GPG_SIGNING_KEY" ]; then + echo "Error: GPG_SIGNING_KEY is not set" + exit 1 + fi + if [ -z "$GPG_SIGNING_PASSWORD" ]; then + echo "Error: GPG_SIGNING_PASSWORD is not set" + exit 1 + fi + echo "All required credentials are set" + export -- GPG_SIGNING_KEY_ID + printenv -- GPG_SIGNING_KEY | gpg --batch --passphrase-fd 3 --import 3<<< "$GPG_SIGNING_PASSWORD" + GPG_SIGNING_KEY_ID="$(gpg --with-colons --list-keys | awk -F : -- '/^pub:/ { getline; print "0x" substr($10, length($10) - 7) }')" + ./gradlew publishAndReleaseToMavenCentral --stacktrace -PmavenCentralUsername="$SONATYPE_USERNAME" -PmavenCentralPassword="$SONATYPE_PASSWORD" --no-configuration-cache + + - name: Wait for Maven Central sync + run: | + TAG="${{ inputs.version }}" + VERSION="${TAG#v}" + echo "Waiting for version $VERSION to sync to Maven Central. THIS CAN TAKE MANY HOURS! Godspeed" + ./scripts/wait-for-maven.sh "$VERSION" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c1e4620f..d5ab8016 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,6 +17,39 @@ Because the SDK is new and under active development, third-party contribution be - These hooks automatically run common checks for you but CI also runs the same checks before merging to the main branch is allowed - NOTE: this will overwrite existing hooks. Take backups before running +## Releasing + +Releases are driven end-to-end from a single GitHub Actions workflow. You do not need to tag locally or push tags from your machine. + +To cut a release: + +1. Make sure everything you want included is merged to `main` and CI is green. +2. Go to **Actions → Release → Run workflow**. +3. Enter: + - `version`: the release version as `vX.Y.Z` (semver, no `-SNAPSHOT`). + - `sha`: the **full 40-character commit SHA** on `main` you want to release. Copy it from the commit page on GitHub using "Copy full SHA". A branch name is intentionally not accepted — pinning to a SHA prevents commits that land on `main` during the approval gate from sneaking into the release. +4. The job runs in the protected `release` GitHub Environment and will pause for **required-reviewer approval** before doing anything. Approve from the workflow run page (or the repo's Deployments tab). +5. Once approved, the `Release` workflow will, in one job: + - Validate the version and the SHA, and verify the SHA is reachable from `origin/main`. + - Check out the pinned SHA and run `./gradlew check`. + - Create and push the annotated tag `vX.Y.Z` pointing at the SHA (using the default `GITHUB_TOKEN` — no separate bot identity is needed since the publish steps are in the same workflow). + - Check out the tag, re-run `./gradlew check`, and build release artifacts. + - Create the GitHub Release with the SDK, agent, and OTel extension jars attached. + - Publish to Maven Central via Sonatype, signed with the project GPG key. + - Poll Maven Central until the new version is visible (this can take many hours). + +The Sonatype and GPG signing secrets (`SONATYPE_USERNAME`, `SONATYPE_PASSWORD`, `GPG_SIGNING_KEY`, `GPG_SIGNING_PASSWORD`) are scoped to the `release` environment. + +The SDK version is computed from git tags at build time (see `generateVersion()` in `build.gradle`) and embedded into `braintrust.properties`, so there are no version constants to bump in source. + +### Re-publishing a failed release + +If the workflow fails partway through, re-run **Release** with the same version. The workflow detects that the tag already exists, skips tag creation, and resumes from the build/publish steps against the existing tag. GitHub Release asset uploads use `--clobber` so partial uploads from a prior run are replaced. + +### Local fallback + +`scripts/release.sh` can still create and push a tag from a clean local checkout if the Actions-driven flow is unavailable. Prefer the workflow. + ## Misc Tips ### Running a local OpenTelemetry collector