From b3b4a514f335f032be375fec0a730279e0f30883 Mon Sep 17 00:00:00 2001 From: Marius Kurgonas Date: Thu, 24 Sep 2026 17:04:15 +0100 Subject: [PATCH] chore: add automation for releasing via CircleCi --- .buildscript/release.sh | 241 ++++++++++++++++++++++++++++++++++ .buildscript/set-version.sh | 14 ++ .github/workflows/ci.yml | 2 + .github/workflows/release.yml | 92 +++++++++++++ ARCHITECTURE.md | 6 +- 5 files changed, 352 insertions(+), 3 deletions(-) create mode 100755 .buildscript/release.sh create mode 100755 .buildscript/set-version.sh create mode 100644 .github/workflows/release.yml diff --git a/.buildscript/release.sh b/.buildscript/release.sh new file mode 100755 index 00000000..432b49e2 --- /dev/null +++ b/.buildscript/release.sh @@ -0,0 +1,241 @@ +#!/bin/bash +# Release to Maven Central. The GitHub Actions workflow .github/workflows/release.yml runs the same +# steps as a manual release. See RELEASING.md. +# +# Usage: .buildscript/release.sh +# +# validate pom.xml has a release version (no -SNAPSHOT) with a CHANGELOG.md entry; the version +# is not tagged, is greater than the last tag and is not on Maven Central; the commit +# is on origin/master; the working tree is clean +# deploy build, sign, upload to the Central Portal, publish, and wait until Central reports +# PUBLISHED (skipped if the version is already on Maven Central) +# tag tag RELEASE_COMMIT (default: HEAD) and push only that tag +# github-release create the GitHub release with the version's CHANGELOG.md section as notes +# all validate, deploy, tag, github-release +# +# Environment: +# DRY_RUN=1 build and sign the bundle without uploading it (skipPublishing), and print the +# tag push and the release command instead of running them. Also allows validating +# a commit that is not on master. +# EXPECTED_VERSION fail unless pom.xml has this version (the workflow's confirmation input) +# RELEASE_COMMIT commit to tag (CI passes $GITHUB_SHA); defaults to HEAD +# MAVEN_GPG_KEY ASCII-armored private signing key (CI). Unset: your local gpg keyring signs. +# MAVEN_GPG_PASSPHRASE passphrase of that key +# GH_TOKEN GitHub token for gh (CI); otherwise your gh login is used +# GIT_REMOTE remote to fetch from and push to (default: origin) +# +# The Central Portal user token comes from the with id "central" in ~/.m2/settings.xml +# (setup-java writes it on CI). + +set -euo pipefail + +cd "$(dirname "$0")/.." + +# --- Repository configuration ------------------------------------------------------------------- +REPO_SLUG="contentful/contentful.java" +GROUP_PATH="com/contentful/java" # groupId with slashes +ARTIFACTS="java-sdk" # artifacts that must appear on Maven Central +TAG_PREFIX="v." # tags look like v.10.6.1 +MAVEN_ARGS=() # extra Maven arguments for the deploy build +# ------------------------------------------------------------------------------------------------- + +GIT_REMOTE="${GIT_REMOTE:-origin}" +DRY_RUN="${DRY_RUN:-0}" + +# The project's own , ignoring the block. +VERSION="$(sed -n -e '//,/<\/parent>/d' -e '//{s/.*\(.*\)<\/version>.*/\1/p;q;}' pom.xml)" +TAG="$TAG_PREFIX$VERSION" + +log() { printf '\n==> %s\n' "$*"; } +fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } + +# Runs a command that changes remote state, or prints it when DRY_RUN=1. +publish() { + if [[ "$DRY_RUN" == "1" ]]; then + printf '[dry-run] %s\n' "$*" + else + "$@" + fi +} + +remote_tag_sha() { + local refs + refs="$(git ls-remote "$GIT_REMOTE" "refs/tags/$TAG^{}" "refs/tags/$TAG")" \ + || fail "Could not list the tags on $GIT_REMOTE" + awk 'NR==1{print $1}' <<<"$refs" +} + +# Prints the artifacts of this version that are already on Maven Central. +on_central() { + local artifact + for artifact in $ARTIFACTS; do + if [[ "$(curl -s -o /dev/null -w '%{http_code}' \ + "https://repo1.maven.org/maven2/$GROUP_PATH/$artifact/$VERSION/$artifact-$VERSION.pom")" == "200" ]]; then + echo "$artifact" + fi + done +} + +# The CHANGELOG.md section of VERSION, without its heading. +changelog_section() { + awk -v heading="## Version [$VERSION]" ' + index($0, heading) == 1 { found = 1; next } + found && /^## / { exit } + found { print } + ' CHANGELOG.md | sed -e '/./,$!d' +} + +step_validate() { + log "Validating release $VERSION" + + [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \ + || fail "pom.xml has version '$VERSION'. A release needs X.Y.Z without -SNAPSHOT. Run .buildscript/set-version.sh X.Y.Z in a PR" + if [[ -n "${EXPECTED_VERSION:-}" && "$EXPECTED_VERSION" != "$VERSION" ]]; then + fail "You asked to release $EXPECTED_VERSION but pom.xml has $VERSION" + fi + + [[ -n "$(changelog_section)" ]] \ + || fail "CHANGELOG.md has no '## Version [$VERSION]' section (or it is empty)" + + [[ -z "$(git status --porcelain --untracked-files=no)" ]] \ + || fail "Working tree has uncommitted changes to tracked files" + + git fetch --quiet --tags --force "$GIT_REMOTE" + [[ -z "$(remote_tag_sha)" ]] || fail "Tag $TAG already exists on $GIT_REMOTE" + + local latest + latest="$(git tag -l "$TAG_PREFIX[0-9]*.[0-9]*.[0-9]*" | sed "s/^$TAG_PREFIX//" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 || true)" + if [[ -n "$latest" ]]; then + [[ "$(printf '%s\n%s\n' "$latest" "$VERSION" | sort -V | tail -1)" == "$VERSION" && "$latest" != "$VERSION" ]] \ + || fail "$VERSION is not greater than the latest tag $TAG_PREFIX$latest" + fi + + local published + published="$(on_central)" + [[ -z "$published" ]] || fail "$VERSION is already on Maven Central ($(echo $published)). Bump the version" + + local commit + commit="$(git rev-parse "${RELEASE_COMMIT:-HEAD}^{commit}")" + git fetch --quiet "$GIT_REMOTE" master + if ! git merge-base --is-ancestor "$commit" "$GIT_REMOTE/master"; then + [[ "$DRY_RUN" == "1" ]] || fail "Commit $commit is not on $GIT_REMOTE/master. Releases are made from master only" + echo "WARNING: $commit is not on $GIT_REMOTE/master (allowed for a dry run only)" + fi + + echo "OK: $VERSION (previous: ${latest:-none}) from $commit, tag $TAG" +} + +step_deploy() { + log "Deploying $VERSION to Maven Central" + + local published + published="$(on_central)" + if [[ -n "$published" && "$(echo $published)" == "$(echo $ARTIFACTS)" ]]; then + echo "$VERSION is already on Maven Central, nothing to do" + return + fi + + local args=(-B clean deploy -DskipTests "${MAVEN_ARGS[@]+"${MAVEN_ARGS[@]}"}" + "-DdeploymentName=${ARTIFACTS// /, } $VERSION" + -DautoPublish=true -DwaitUntil=PUBLISHED -DwaitMaxTime=3600) + + if [[ -n "${MAVEN_GPG_KEY:-}" ]]; then + # Sign in-process with the key from the environment; no gpg keyring or agent needed. + args+=(-Dgpg.signer=bc) + elif [[ "$DRY_RUN" == "1" ]] && ! gpg --list-secret-keys 2>/dev/null | grep -q '^sec'; then + echo "WARNING: no signing key available, the dry run skips signing" + args+=(-Dgpg.skip=true) + fi + + if [[ "$DRY_RUN" == "1" ]]; then + args+=(-DskipPublishing=true) + echo "[dry-run] the bundle is built and signed but not uploaded" + # The publishing plugin needs a "central" server entry even when it skips the upload. + if ! grep -qs 'central' "$HOME/.m2/settings.xml"; then + local settings + settings="$(mktemp)" + echo 'centraldry-rundry-run' > "$settings" + args+=(-s "$settings") + fi + fi + + ./mvnw "${args[@]}" + + if [[ "$DRY_RUN" == "1" ]]; then + echo "Signatures of the files that would be uploaded:" + find . -path '*/target/*' -name '*.asc' | sed 's/^/ /' + fi +} + +step_tag() { + local commit existing + commit="$(git rev-parse "${RELEASE_COMMIT:-HEAD}^{commit}")" + log "Tagging $commit as $TAG" + + existing="$(remote_tag_sha)" + if [[ -n "$existing" ]]; then + [[ "$existing" == "$commit" ]] || fail "Tag $TAG already exists on a different commit ($existing)" + echo "Tag $TAG already points at $commit, nothing to do" + return + fi + + if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + [[ "$(git rev-parse "refs/tags/$TAG^{commit}")" == "$commit" ]] \ + || fail "A local tag $TAG exists on a different commit. Delete it with: git tag -d $TAG" + elif [[ "$DRY_RUN" == "1" ]]; then + printf '[dry-run] git tag %s %s\n' "$TAG" "$commit" + else + git tag "$TAG" "$commit" + fi + + # Push only this tag, never --tags. + publish git push "$GIT_REMOTE" "refs/tags/$TAG" +} + +step_github_release() { + log "Publishing GitHub release $TAG" + command -v gh >/dev/null 2>&1 || fail "'gh' is required. Install it with: brew install gh" + + if gh release view "$TAG" --repo "$REPO_SLUG" >/dev/null 2>&1; then + echo "Release $TAG already exists, nothing to do" + return + fi + + local notes + notes="$(mktemp)" + changelog_section > "$notes" + if [[ "$DRY_RUN" == "1" ]]; then + echo "[dry-run] release notes:" + sed 's/^/ /' "$notes" + fi + + publish gh release create "$TAG" \ + --repo "$REPO_SLUG" \ + --title "$VERSION" \ + --notes-file "$notes" \ + --latest \ + --verify-tag + rm -f "$notes" +} + +case "${1:-}" in + validate) step_validate ;; + deploy) step_deploy ;; + tag) step_tag ;; + github-release) step_github_release ;; + all) + step_validate + step_deploy + step_tag + step_github_release + if [[ "$DRY_RUN" == "1" ]]; then + log "Dry run of $VERSION finished. Nothing was uploaded or pushed" + else + log "$REPO_SLUG $VERSION released" + fi + ;; + *) + sed -n '2,29p' "$0" | sed 's/^# \{0,1\}//' + exit 1 + ;; +esac diff --git a/.buildscript/set-version.sh b/.buildscript/set-version.sh new file mode 100755 index 00000000..bff5d072 --- /dev/null +++ b/.buildscript/set-version.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# Sets the release version in every pom.xml of the build. Run it in the release PR. +# +# Usage: .buildscript/set-version.sh X.Y.Z + +set -euo pipefail + +cd "$(dirname "$0")/.." + +VERSION="${1:-}" +[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "Usage: $0 X.Y.Z" >&2; exit 1; } + +./mvnw -B -q versions:set -DnewVersion="$VERSION" -DprocessAllModules=true -DgenerateBackupPoms=false +git status --short -- '*pom.xml' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41ac47e8..21d11fa0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,8 @@ on: branches: [master] pull_request: branches: [master] + # The Release workflow runs these tests before publishing. + workflow_call: permissions: contents: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..15170162 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,92 @@ +name: Release + +# Manual: Actions -> Release -> Run workflow (branch master), type the version from pom.xml. +# Tests, then publish to Maven Central, tag v.X.Y.Z and create the GitHub release. See RELEASING.md. + +on: + workflow_dispatch: + inputs: + version: + description: Version to release. Must equal the version in pom.xml. + required: true + type: string + dry-run: + description: Dry run. Build and sign, but upload, tag and release nothing (any branch). + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: release + cancel-in-progress: false + +jobs: + validate: + name: Validate + runs-on: ubuntu-latest + steps: + - name: Releases run from master only + if: ${{ github.ref != 'refs/heads/master' && !inputs.dry-run }} + run: | + echo "::error::Run the Release workflow on master (it ran on ${GITHUB_REF_NAME}), or tick dry run." + exit 1 + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + + - name: Validate release + env: + EXPECTED_VERSION: ${{ inputs.version }} + RELEASE_COMMIT: ${{ github.sha }} + DRY_RUN: ${{ inputs.dry-run && '1' || '0' }} + run: .buildscript/release.sh validate + + test: + name: Test + needs: validate + uses: ./.github/workflows/ci.yml + + publish: + name: Publish + needs: [validate, test] + runs-on: ubuntu-latest + # Holds the secrets below. Add required reviewers to it for a second pair of eyes. + environment: maven-central + permissions: + contents: write + env: + DRY_RUN: ${{ inputs.dry-run && '1' || '0' }} + RELEASE_COMMIT: ${{ github.sha }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + + # Writes ~/.m2/settings.xml with the "central" server read from the two env variables below. + - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + with: + distribution: temurin + java-version: "17" + cache: maven + server-id: central + server-username: CENTRAL_USERNAME + server-password: CENTRAL_PASSWORD + + - name: Publish to Maven Central + env: + CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }} + CENTRAL_PASSWORD: ${{ secrets.CENTRAL_PASSWORD }} + MAVEN_GPG_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + MAVEN_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + run: .buildscript/release.sh deploy + + - name: Tag + run: .buildscript/release.sh tag + + - name: GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: .buildscript/release.sh github-release diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 91cb3505..fc3da02e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -113,10 +113,10 @@ OkHttp 5 splits platform artifacts: JVM users get `okhttp-jvm` (included by defa - **Pre-release**: Sonatype snapshots and jitpack.io - **Versioning**: Semantic Versioning — patch = bug fixes / dependency updates; minor = new non-breaking features; major = breaking API changes - **Branching**: Trunk-based development off `master` -- **Release cadence**: On-demand +- **Release cadence**: On-demand, through the manual `Release` GitHub Actions workflow (`.github/workflows/release.yml`) - **Build**: Maven Wrapper (`./mvnw`) — no global Maven installation required - **Code coverage**: Codecov - **Checkstyle**: Enforced at `verify` phase via `checkstyle.xml` -- **GPG signing**: Required for Maven Central publication (private key managed by Contentful infrastructure team) +- **GPG signing**: Required for Maven Central publication (private key managed by Contentful infrastructure team). CI signs with the key from the `maven-central` environment's secrets. -See [CONTRIBUTING.md](./CONTRIBUTING.md) for the full release procedure. +See [RELEASING.md](./RELEASING.md) for the full release procedure.