diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..deac24a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,254 @@ +# Publishes logback-logtail to Maven Central from GitHub Actions, the way logtail-js and logtail-python +# release from theirs. Maven Central has no trusted publishing, so the publish job runs in the +# "maven-central" environment and reads real credentials from its secrets: +# MAVEN_CENTRAL_USERNAME, MAVEN_CENTRAL_PASSWORD a Central Portal user token (central.sonatype.com/usertoken) +# GPG_PRIVATE_KEY, GPG_PASSPHRASE the ASCII-armored signing key and its passphrase +# Keep required reviewers on that environment: approving the environment prompt is the release gate. +# +# Release: Actions → Release → Run workflow from main, pick patch or minor, approve the environment prompt. +# The workflow bumps the version in pom.xml and the two example projects, builds, commits "vX.Y.Z", tags it, +# pushes both, deploys the signed bundle to the Central Portal and waits until the Portal reports it +# published, then creates a GitHub release with auto-generated notes for the tag; edit the notes afterwards +# if needed. Propagation to repo1.maven.org and search.maven.org follows on its own and can take a few +# hours. The dependency snippet in the docs article is updated by hand. +# +# Retry: if a release failed after the version commit was pushed, run "retry" right away from main. It bumps +# nothing, rebuilds the tagged commit, deploys with the components the Portal already published left out of +# the bundle, and creates the GitHub release if it is still missing. Running patch or minor again would +# release the next version instead. +# +# Dry run: bumps in place without committing, uploads a real deployment with auto-publish OFF, waits for the +# Portal to validate it (credentials, signatures, key on a keyserver, bundle contents) and drops it through +# the Publisher API, so nothing is ever published. Every push that touches this file runs one, so a change to +# the workflow proves itself on its branch before it reaches main; a dry run can also be dispatched from any +# branch. +name: Release + +on: + push: + paths: + - .github/workflows/release.yml + workflow_dispatch: + inputs: + release: + description: "patch or minor: bump, tag and publish. retry: finish a release that failed halfway." + type: choice + options: [patch, minor, retry] + required: true + dry_run: + description: "Dry run: bump and build, let the Portal validate the deployment, publish nothing" + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: release + cancel-in-progress: false + +defaults: + run: + shell: bash + +env: + # A push runs the dry run of a patch release; a dispatch does what its inputs say + RELEASE: ${{ inputs.release || 'patch' }} + DRY_RUN: ${{ github.event_name == 'push' || inputs.dry_run }} + +jobs: + verify: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Set up JDK + uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: 8 + cache: maven + + - name: Build with Maven + uses: nick-fields/retry@v4 + with: + timeout_seconds: 300 + max_attempts: 5 + command: mvn -B clean package --file pom.xml + env: + BETTER_STACK_SOURCE_TOKEN: ${{ secrets.BETTER_STACK_SOURCE_TOKEN }} + BETTER_STACK_INGESTING_HOST: ${{ secrets.BETTER_STACK_INGESTING_HOST }} + + build: + name: Bump and build + needs: verify + runs-on: ubuntu-latest + permissions: + contents: write # pushes the version commit and tag + outputs: + version: ${{ steps.version.outputs.version }} + + steps: + - name: Releases run from main only + if: ${{ env.DRY_RUN != 'true' && github.ref != 'refs/heads/main' }} + run: | + echo "::error::Dispatch the release from main, not from $GITHUB_REF_NAME." + exit 1 + + - uses: actions/checkout@v7 + with: + fetch-depth: 0 # the version check looks at the tags on HEAD + + - name: Set up JDK + uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: 21 + cache: maven + + - name: Bump version + id: version + run: | + python3 - <<'EOF' + import os, re, subprocess + + pom, example_pom, example_gradle = 'pom.xml', 'examples/maven/pom.xml', 'examples/gradle/build.gradle' + current = re.search(r'^ (\d+\.\d+\.\d+)$', open(pom).read(), re.M).group(1) + assert '%s' % current in open(example_pom).read(), 'examples/maven/pom.xml does not use the current version %s' % current + assert 'logback-logtail:%s' % current in open(example_gradle).read(), 'examples/gradle/build.gradle does not use the current version %s' % current + tagged = 'v' + current in subprocess.check_output(['git', 'tag', '--points-at', 'HEAD'], text=True).split() + + release, dry_run = os.environ['RELEASE'], os.environ['DRY_RUN'] == 'true' + if release == 'retry': + assert tagged, 'retry only finishes a release whose version commit v%s is HEAD' % current + version = current + else: + assert dry_run or not tagged, 'HEAD is already released as v%s, there is nothing new to release' % current + major, minor, patch = map(int, current.split('.')) + version = '%d.%d.0' % (major, minor + 1) if release == 'minor' else '%d.%d.%d' % (major, minor, patch + 1) + for path, line in ((pom, ' %s'), (example_pom, '%s'), (example_gradle, 'logback-logtail:%s')): + text = open(path).read() + open(path, 'w').write(text.replace(line % current, line % version, 1)) + + print('%s -> %s' % (current, version)) + open(os.environ['GITHUB_OUTPUT'], 'a').write('version=%s\n' % version) + EOF + + - name: Build + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + mvn -B clean verify -P release -Dmaven.test.skip=true -Dgpg.skip=true + ls target/logback-logtail-$VERSION.jar target/logback-logtail-$VERSION-sources.jar target/logback-logtail-$VERSION-javadoc.jar + + - name: Commit and tag + if: ${{ env.DRY_RUN != 'true' && env.RELEASE != 'retry' }} + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -am "v$VERSION" + git tag -a "v$VERSION" -m "v$VERSION" + git push origin main "v$VERSION" + + release: + name: Publish + needs: build + runs-on: ubuntu-latest + environment: maven-central + timeout-minutes: 60 # the Portal gets up to 30 minutes to publish + permissions: + contents: write # creates the GitHub release + env: + VERSION: ${{ needs.build.outputs.version }} + MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + MAVEN_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + + steps: + - uses: actions/checkout@v7 + with: + # A release deploys the version commit the build job tagged; a dry run deploys the commit it was run on + ref: ${{ env.DRY_RUN == 'true' && github.sha || format('refs/tags/v{0}', needs.build.outputs.version) }} + + # Writes the Portal token into settings.xml under the "central" server id the pom uses and imports the + # signing key into a keyring of its own; the gpg plugin reads the passphrase from MAVEN_GPG_PASSPHRASE. + # Nothing is restored from the Actions cache in this job. + - name: Set up JDK, Maven Central credentials and the signing key + uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: 21 + server-id: central + server-username-env-var: MAVEN_CENTRAL_USERNAME + server-password-env-var: MAVEN_CENTRAL_PASSWORD + gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }} + gpg-passphrase-env-var: MAVEN_GPG_PASSPHRASE + + - name: Dry run - bump in place + if: ${{ env.DRY_RUN == 'true' }} + run: mvn -B -q org.codehaus.mojo:versions-maven-plugin:2.22.0:set -DnewVersion="$VERSION" -DgenerateBackupPoms=false + + - name: Dry run - make sure auto-publish is off + if: ${{ env.DRY_RUN == 'true' }} + run: | + # A literal in the plugin configuration would win over the flag and publish the dry run for + # real, so the effective pom has to show it off before anything is uploaded + mvn -B -q -P release help:effective-pom -Doutput=effective-pom.xml -DautoPublish=false + python3 - <<'EOF' + import sys, xml.etree.ElementTree as ET + + ns = '{http://maven.apache.org/POM/4.0.0}' + plugins = [p for p in ET.parse('effective-pom.xml').getroot().iter(ns + 'plugin') if p.findtext(ns + 'artifactId') == 'central-publishing-maven-plugin'] + values = [p.findtext(ns + 'configuration/' + ns + 'autoPublish') for p in plugins] + print('central-publishing-maven-plugin autoPublish in the effective pom:', values) + if not values or values != ['false'] * len(values): + sys.exit('::error::-DautoPublish=false is not effective, refusing to upload') + EOF + + - name: Dry run - let the Portal validate the deployment + if: ${{ env.DRY_RUN == 'true' }} + id: dry_run + run: | + # With auto-publish off the plugin stops once the Portal reports the deployment validated, which is + # every check short of publishing. The deployment id is kept so that the next step can drop it. + set +e + mvn -B clean deploy -P release -Dmaven.test.skip=true -DautoPublish=false -DdeploymentName="Dry run of $VERSION from $GITHUB_REF_NAME" | tee mvn.log + status=${PIPESTATUS[0]} + set -e + echo "id=$(grep -oE 'deploymentId: [0-9a-f-]{36}' mvn.log | head -1 | cut -d' ' -f2)" >> "$GITHUB_OUTPUT" + exit "$status" + + - name: Dry run - drop the deployment + if: ${{ always() && steps.dry_run.outputs.id != '' }} + env: + DEPLOYMENT_ID: ${{ steps.dry_run.outputs.id }} + run: | + curl --fail --silent --show-error --request DELETE \ + --header "Authorization: Bearer $(printf '%s:%s' "$MAVEN_CENTRAL_USERNAME" "$MAVEN_CENTRAL_PASSWORD" | base64 --wrap=0)" \ + "https://central.sonatype.com/api/v1/publisher/deployment/$DEPLOYMENT_ID" + echo "Dropped deployment $DEPLOYMENT_ID, nothing was published." + + - name: Publish to Maven Central + if: ${{ env.DRY_RUN != 'true' }} + run: | + # The pom turns auto-publish on and waits until the Portal reports the deployment published. On a + # retry, components the Portal already published are left out of the bundle. + mvn -B clean deploy -P release -Dmaven.test.skip=true -DdeploymentName="logback-logtail $VERSION" -DignorePublishedComponents="${{ env.RELEASE == 'retry' }}" + + - name: Create GitHub release + if: ${{ env.DRY_RUN != 'true' }} + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + if gh release view "v$VERSION" > /dev/null 2>&1; then + echo "Release v$VERSION already exists." + else + # --verify-tag: only ever attach to the tag the build job pushed, never create one here. + gh release create "v$VERSION" --verify-tag --generate-notes + fi + echo "Published https://central.sonatype.com/artifact/com.logtail/logback-logtail/$VERSION" + echo "It reaches https://repo1.maven.org/maven2/com/logtail/logback-logtail/$VERSION/ on its own; update the docs article snippet by hand." diff --git a/examples/gradle/build.gradle b/examples/gradle/build.gradle index f7266af..b41fb3f 100644 --- a/examples/gradle/build.gradle +++ b/examples/gradle/build.gradle @@ -7,7 +7,7 @@ repositories { } dependencies { - implementation 'com.logtail:logback-logtail:0.3.6' + implementation 'com.logtail:logback-logtail:0.3.7' implementation 'ch.qos.logback:logback-classic:1.2.11' implementation 'ch.qos.logback:logback-core:1.2.11' implementation 'com.fasterxml.jackson.core:jackson-databind:2.13.5' diff --git a/examples/maven/pom.xml b/examples/maven/pom.xml index 48bfaef..a1eb1e4 100644 --- a/examples/maven/pom.xml +++ b/examples/maven/pom.xml @@ -26,7 +26,7 @@ com.logtail logback-logtail - 0.3.6 + 0.3.7 ch.qos.logback diff --git a/pom.xml b/pom.xml index e1bda28..4f73d50 100644 --- a/pom.xml +++ b/pom.xml @@ -11,7 +11,7 @@ com.logtail logback-logtail jar - 0.3.6 + 0.3.7 ${project.groupId}:${project.artifactId} Logback Java appender for sending logs to BetterStack.com @@ -47,6 +47,7 @@ yyyy-MM-dd UTF-8 UTF-8 + true @@ -180,7 +181,7 @@ org.apache.maven.plugins maven-gpg-plugin - 3.0.1 + 3.2.8 --batch @@ -202,11 +203,13 @@ org.sonatype.central central-publishing-maven-plugin - 0.6.0 + 0.11.0 true central - true + + ${autoPublish} + published