Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
241 changes: 241 additions & 0 deletions .buildscript/release.sh
Original file line number Diff line number Diff line change
@@ -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 <step>
#
# 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 <server> 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 <version>, ignoring the <parent> block.
VERSION="$(sed -n -e '/<parent>/,/<\/parent>/d' -e '/<version>/{s/.*<version>\(.*\)<\/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 '<id>central</id>' "$HOME/.m2/settings.xml"; then
local settings
settings="$(mktemp)"
echo '<settings><servers><server><id>central</id><username>dry-run</username><password>dry-run</password></server></servers></settings>' > "$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
14 changes: 14 additions & 0 deletions .buildscript/set-version.sh
Original file line number Diff line number Diff line change
@@ -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'
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ on:
branches: [master]
pull_request:
branches: [master]
# The Release workflow runs these tests before publishing.
workflow_call:

permissions:
contents: read
Expand Down
92 changes: 92 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 3 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading