diff --git a/.github/scripts/create-central-bundle.sh b/.github/scripts/create-central-bundle.sh new file mode 100755 index 0000000..c01a328 --- /dev/null +++ b/.github/scripts/create-central-bundle.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "usage: $0 " >&2 + exit 1 +fi + +version=$1 +bundle=$2 +staging_root="target/central-bundle-staging" +destination="${staging_root}/io/rstream/rstream/${version}" + +if [[ ! "$version" =~ ^[0-9]+(\.[0-9]+){2}$ ]]; then + echo "invalid release version: ${version}" >&2 + exit 1 +fi +if [[ -e "$staging_root" || -e "$bundle" ]]; then + echo "Maven Central bundle output already exists" >&2 + exit 1 +fi + +mkdir -p "$destination" "$(dirname "$bundle")" +artifacts=( + "target/rstream-${version}.pom" + "target/rstream-${version}.jar" + "target/rstream-${version}-sources.jar" + "target/rstream-${version}-javadoc.jar" +) +for artifact in "${artifacts[@]}"; do + for source in "$artifact" "${artifact}.asc"; do + if [[ ! -f "$source" ]]; then + echo "signed Maven artifact is missing: ${source}" >&2 + exit 1 + fi + cp "$source" "$destination/" + done +done + +for artifact in "$destination"/*; do + md5sum "$artifact" | awk '{print $1}' > "${artifact}.md5" + sha1sum "$artifact" | awk '{print $1}' > "${artifact}.sha1" + sha256sum "$artifact" | awk '{print $1}' > "${artifact}.sha256" + sha512sum "$artifact" | awk '{print $1}' > "${artifact}.sha512" +done + +bundle=$(cd "$(dirname "$bundle")" && pwd)/$(basename "$bundle") +( + cd "$staging_root" + find io -type f -print | LC_ALL=C sort | zip -q "$bundle" -@ +) diff --git a/.github/scripts/publish-maven-central.sh b/.github/scripts/publish-maven-central.sh new file mode 100755 index 0000000..3ae45b6 --- /dev/null +++ b/.github/scripts/publish-maven-central.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "usage: $0 " >&2 + exit 1 +fi + +version=$1 +bundle=$2 +repository_url="https://repo1.maven.org/maven2/io/rstream/rstream/${version}" +work_directory=$(mktemp -d) +trap 'rm -rf "$work_directory"' EXIT + +"$(dirname "$0")/verify-central-bundle.sh" "$version" "$bundle" +unzip -q "$bundle" -d "$work_directory/bundle" +candidate_directory="${work_directory}/bundle/io/rstream/rstream/${version}" + +verify_publication() { + local attempts=$1 + local files=( + "rstream-${version}.pom" + "rstream-${version}.pom.asc" + "rstream-${version}.jar" + "rstream-${version}.jar.asc" + "rstream-${version}-sources.jar" + "rstream-${version}-sources.jar.asc" + "rstream-${version}-javadoc.jar" + "rstream-${version}-javadoc.jar.asc" + ) + local attempt filename + for ((attempt = 1; attempt <= attempts; attempt++)); do + local complete=true + for filename in "${files[@]}"; do + if ! curl --fail --silent --show-error --location \ + --output "${work_directory}/${filename}" "${repository_url}/${filename}"; then + complete=false + break + fi + if ! cmp --silent "${candidate_directory}/${filename}" "${work_directory}/${filename}"; then + echo "published Maven artifact differs from candidate: ${filename}" >&2 + exit 1 + fi + done + if [[ "$complete" == true ]]; then + return 0 + fi + if ((attempt < attempts)); then + sleep 10 + fi + done + return 1 +} + +publication_status=$(curl --silent --location --output /dev/null --write-out '%{http_code}' \ + "${repository_url}/rstream-${version}.pom") +if [[ "$publication_status" == 200 ]]; then + if ! verify_publication 12; then + echo "existing Maven Central release is incomplete" >&2 + exit 1 + fi + exit 0 +fi +if [[ "$publication_status" != 404 ]]; then + echo "Maven Central availability check returned HTTP ${publication_status}" >&2 + exit 1 +fi + +: "${MAVEN_CENTRAL_USERNAME:?MAVEN_CENTRAL_USERNAME is required}" +: "${MAVEN_CENTRAL_PASSWORD:?MAVEN_CENTRAL_PASSWORD is required}" +authorization=$(printf '%s:%s' "$MAVEN_CENTRAL_USERNAME" "$MAVEN_CENTRAL_PASSWORD" | base64 | tr -d '\n') +printf '::add-mask::%s\n' "$authorization" +deployment_id=$(curl --fail --silent --show-error \ + --header "Authorization: Bearer ${authorization}" \ + --form "bundle=@${bundle};type=application/octet-stream" \ + "https://central.sonatype.com/api/v1/publisher/upload?publishingType=AUTOMATIC&name=rstream-${version}") +if [[ ! "$deployment_id" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]]; then + echo "Maven Central returned an invalid deployment ID" >&2 + exit 1 +fi + +for _ in {1..90}; do + status=$(curl --fail --silent --show-error --request POST \ + --header "Authorization: Bearer ${authorization}" \ + "https://central.sonatype.com/api/v1/publisher/status?id=${deployment_id}") + state=$(jq -r '.deploymentState' <<<"$status") + case "$state" in + PUBLISHED) + break + ;; + FAILED) + jq '.errors' <<<"$status" >&2 + exit 1 + ;; + PENDING | VALIDATING | VALIDATED | PUBLISHING) + sleep 10 + ;; + *) + echo "unexpected Maven Central deployment state: ${state}" >&2 + exit 1 + ;; + esac +done +if [[ "$state" != PUBLISHED ]]; then + echo "Maven Central deployment did not reach PUBLISHED" >&2 + exit 1 +fi +if ! verify_publication 60; then + echo "Maven Central release did not become publicly verifiable" >&2 + exit 1 +fi diff --git a/.github/scripts/verify-central-bundle.sh b/.github/scripts/verify-central-bundle.sh new file mode 100755 index 0000000..dd95cd8 --- /dev/null +++ b/.github/scripts/verify-central-bundle.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "usage: $0 " >&2 + exit 1 +fi + +version=$1 +bundle=$2 +prefix="io/rstream/rstream/${version}" + +if [[ ! "$version" =~ ^[0-9]+(\.[0-9]+){2}$ ]]; then + echo "invalid release version: ${version}" >&2 + exit 1 +fi +if [[ ! -f "$bundle" ]]; then + echo "Maven Central bundle is missing: ${bundle}" >&2 + exit 1 +fi + +unzip -tq "$bundle" >/dev/null +if unzip -Z1 "$bundle" | awk -v prefix="${prefix}/" ' + /^\// || /(^|\/)\.\.($|\/)/ || index($0, prefix) != 1 { invalid = 1 } + END { exit invalid } +'; then + : +else + echo "Maven Central bundle contains an unsafe path" >&2 + exit 1 +fi + +required=( + "rstream-${version}.pom" + "rstream-${version}.jar" + "rstream-${version}-sources.jar" + "rstream-${version}-javadoc.jar" +) +entries=$(unzip -Z1 "$bundle") +for filename in "${required[@]}"; do + for suffix in "" .asc; do + expected="${prefix}/${filename}${suffix}" + if ! grep -Fxq "$expected" <<<"$entries"; then + echo "Maven Central bundle is missing ${expected}" >&2 + exit 1 + fi + done +done diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 046e588..87d8f4d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,33 +1,102 @@ -name: Publish +name: Promote stable release + on: - release: - types: - - published workflow_dispatch: + inputs: + release_tag: + description: Reviewed candidate tag to publish + required: true + type: string + permissions: - contents: read + actions: read + contents: write + +concurrency: + group: stable-release + cancel-in-progress: false + jobs: publish: + name: Publish approved Maven Central bundle + if: ${{ github.actor == vars.CI_ALLOWED_ACTOR }} + environment: stable-release runs-on: ubuntu-latest - environment: maven-central steps: + - name: Find matching release candidate + id: release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.release_tag }} + run: | + set -euo pipefail + if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+(\.[0-9]+){2}$ ]]; then + echo "invalid release tag: ${RELEASE_TAG}" >&2 + exit 1 + fi + if [[ "$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" --jq '.draft')" != true ]]; then + echo "GitHub release ${RELEASE_TAG} must still be a draft" >&2 + exit 1 + fi + version=${RELEASE_TAG#v} + latest_tag=$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq '.tag_name' 2>/dev/null || true) + if [[ -n "$latest_tag" && "$(printf '%s\n' "${latest_tag#v}" "$version" | sort -V | tail -n 1)" != "$version" ]]; then + echo "refusing to promote ${RELEASE_TAG} after newer release ${latest_tag}" >&2 + exit 1 + fi + tag_ref=$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${RELEASE_TAG}") + tag_sha=$(jq -r '.object.sha' <<<"$tag_ref") + if [[ "$(jq -r '.object.type' <<<"$tag_ref")" == tag ]]; then + tag_sha=$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${tag_sha}" --jq '.object.sha') + fi + candidate_run=$(gh api --method GET \ + "repos/${GITHUB_REPOSITORY}/actions/workflows/release-candidate.yml/runs" \ + -f branch="$RELEASE_TAG" -f event=push -f status=success -f per_page=20 \ + --jq ".workflow_runs | map(select(.head_sha == \"${tag_sha}\")) | first | .id") + if [[ -z "$candidate_run" || "$candidate_run" == null ]]; then + echo "no successful release candidate run matches ${RELEASE_TAG}" >&2 + exit 1 + fi + { + echo "tag=${RELEASE_TAG}" + echo "version=${version}" + echo "run_id=${candidate_run}" + } >> "$GITHUB_OUTPUT" - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: + ref: ${{ steps.release.outputs.tag }} persist-credentials: false - - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - distribution: temurin - java-version: "21" - cache: maven - server-id: central - server-username: MAVEN_CENTRAL_USERNAME - server-password: MAVEN_CENTRAL_PASSWORD - gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }} - gpg-passphrase: MAVEN_GPG_PASSPHRASE - - name: Publish to Maven Central + name: release-candidate-${{ steps.release.outputs.version }} + path: candidate + repository: ${{ github.repository }} + run-id: ${{ steps.release.outputs.run_id }} + github-token: ${{ github.token }} + - name: Verify candidate integrity + shell: bash + env: + VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + (cd candidate && sha256sum --check SHA256SUMS) + ./.github/scripts/verify-central-bundle.sh "$VERSION" candidate/central-bundle.zip + - name: Publish and verify Maven Central bundle shell: bash env: - MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} - MAVEN_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} - run: mvn -B -Prelease deploy + MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + VERSION: ${{ steps.release.outputs.version }} + run: ./.github/scripts/publish-maven-central.sh "$VERSION" candidate/central-bundle.zip + - name: Publish GitHub release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --draft=false --latest + if [[ "$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" --jq '.draft')" != false ]]; then + echo "GitHub release is still a draft: ${RELEASE_TAG}" >&2 + exit 1 + fi diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml new file mode 100644 index 0000000..b14cff4 --- /dev/null +++ b/.github/workflows/release-candidate.yml @@ -0,0 +1,64 @@ +name: Build release candidate + +on: + push: + tags: + - "v*" + +permissions: + contents: read + +concurrency: + group: release-candidate-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + name: Build signed Maven Central bundle + if: ${{ github.actor == vars.CI_ALLOWED_ACTOR }} + environment: maven-central + runs-on: ubuntu-latest + steps: + - name: Validate release tag + shell: bash + run: | + set -euo pipefail + version="${GITHUB_REF_NAME#v}" + if [[ ! "$version" =~ ^[0-9]+(\.[0-9]+){2}$ ]]; then + echo "invalid release version: ${version}" >&2 + exit 1 + fi + printf 'VERSION=%s\n' "$version" >> "$GITHUB_ENV" + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5 + with: + distribution: temurin + java-version: "21" + cache: maven + gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }} + gpg-passphrase: MAVEN_GPG_PASSPHRASE + - name: Build signed deployment bundle + shell: bash + env: + MAVEN_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + run: | + set -euo pipefail + project_version=$(mvn -B help:evaluate -Dexpression=project.version -q -DforceStdout) + if [[ "$project_version" != "$VERSION" ]]; then + echo "tag version ${VERSION} does not match project version ${project_version}" >&2 + exit 1 + fi + mvn -B -Prelease verify + mkdir -p candidate + ./.github/scripts/create-central-bundle.sh "$VERSION" candidate/central-bundle.zip + ./.github/scripts/verify-central-bundle.sh "$VERSION" candidate/central-bundle.zip + (cd candidate && sha256sum -- central-bundle.zip > SHA256SUMS) + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: release-candidate-${{ env.VERSION }} + path: candidate + if-no-files-found: error + compression-level: 0 + retention-days: 90 diff --git a/release-please-config.json b/release-please-config.json index 1af5690..7f730c6 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -3,6 +3,8 @@ "pull-request-header": "New release created", "packages": { ".": { + "draft": true, + "force-tag-creation": true, "release-type": "maven", "package-name": "io.rstream:rstream", "component": "rstream", diff --git a/src/test/java/io/rstream/ReleaseWorkflowTest.java b/src/test/java/io/rstream/ReleaseWorkflowTest.java new file mode 100644 index 0000000..25db4ae --- /dev/null +++ b/src/test/java/io/rstream/ReleaseWorkflowTest.java @@ -0,0 +1,36 @@ +package io.rstream; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; + +final class ReleaseWorkflowTest { + private static String read(String path) throws IOException { + return Files.readString(Path.of(path)); + } + + @Test + void releaseCandidateDoesNotPublish() throws IOException { + String workflow = read(".github/workflows/release-candidate.yml"); + assertThat(workflow).contains("actions/upload-artifact@"); + assertThat(workflow).doesNotContain("workflow_dispatch:"); + assertThat(workflow).doesNotContain("publisher/upload"); + } + + @Test + void promotionPublishesGitHubReleaseLast() throws IOException { + String workflow = read(".github/workflows/publish.yml"); + assertThat(workflow).contains("environment: stable-release"); + assertThat(workflow.indexOf("Publish GitHub release")) + .isGreaterThan(workflow.indexOf("Publish and verify Maven Central bundle")); + } + + @Test + void releasePleaseCreatesTaggedDraft() throws IOException { + String config = read("release-please-config.json"); + assertThat(config).contains("\"draft\": true", "\"force-tag-creation\": true"); + } +}