diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index de5af22..34aa2a6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,167 +1,184 @@ name: Release -# Adapter release gate for spring-boot-guard. The engine-before-adapters rule -# is mechanical: the pre-flight job refuses to run anything unless BOTH engine -# artifacts (com.guard-core:guard-core-engine and com.guard-core:guard-core at -# the lockstep version pinned in gradle/libs.versions.toml) are live on Maven -# Central. The publish job then builds with GUARD_CORE_USE_COMPOSITE=false, so -# the artifact graph proven at publish time resolves the real engine artifact -# from the repositories (mavenLocal, then Maven Central), never the composite -# checkout. +# Publishes spring-boot-guard to Maven Central (Central Portal) for a release tag. +# Nothing is built or tested for validation here: the tagged commit must +# already have passed the push workflows listed in REQUIRED_WORKFLOWS (ci.yml +# runs the build and tests against the pinned engine, and the dependency +# audit). A release can therefore never fail on a check its pull request did +# not run. # -# Bare vX.Y.z tags only (v1.0.0). The tag-consistency job also enforces the -# version flip: a tag pushed while build.gradle.kts still carries 1.0.0-SNAPSHOT -# fails with an explicit message (docs/release.md, version-flip checklist). +# Release-time facts that cannot be known earlier stay here as their own job: +# pre-flight refuses to publish unless BOTH engine artifacts +# (com.guard-core:guard-core-engine and com.guard-core:guard-core at the +# lockstep version pinned in gradle/libs.versions.toml) are live on Maven +# Central. The publish job then builds with +# GUARD_CORE_USE_COMPOSITE=false, so the artifact graph proven at publish time +# resolves the real engine artifact from the repositories (mavenLocal, then +# Maven Central), never the composite checkout. +# +# Re-drive: run this workflow by hand (Actions > Release > Run workflow) with +# the tag, for example v1.1.0. It checks out that tag, so a fixed workflow +# on master can publish an existing tag. An artifact already on Maven Central +# is skipped. Bare vX.Y.z tags only. Deployments upload as USER_MANAGED: +# release the VALIDATED deployment from the Portal UI (docs/release.md). on: - push: - tags: - - 'v*' release: types: [created] workflow_dispatch: + inputs: + tag: + description: 'Release tag to publish, for example v1.1.0' + required: true permissions: {} +concurrency: + group: release-${{ inputs.tag || github.event.release.tag_name }} + cancel-in-progress: false + +env: + REQUIRED_WORKFLOWS: >- + .github/workflows/ci.yml + jobs: - pre-flight: - name: Engine artifacts live on Maven Central + verify: + name: Tagged commit passed CI runs-on: ubuntu-latest permissions: contents: read - + actions: read + outputs: + tag: ${{ steps.tag.outputs.tag }} steps: - - name: Checkout + - name: Resolve the tag + id: tag + env: + INPUT_TAG: ${{ inputs.tag }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + tag="${INPUT_TAG:-$RELEASE_TAG}" + if ! printf '%s' "$tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::tag '$tag' is not of the form v1.2.3" + exit 1 + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" + + - name: Checkout the tag uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: refs/tags/${{ steps.tag.outputs.tag }} persist-credentials: false - - name: Verify the engine artifacts are live on Maven Central + # The project version in build.gradle.kts is the source of truth. The + # tag must equal it, and a SNAPSHOT version here means the release PR + # forgot the flip (docs/release.md, version-flip checklist). + - name: Verify the tag equals the project version + env: + TAG: ${{ steps.tag.outputs.tag }} run: | set -euo pipefail - version=$(grep -m1 '^guardCore = ' gradle/libs.versions.toml | cut -d'"' -f2) + version=$(grep -m1 'version = "' build.gradle.kts | sed 's/^.*version = "\([^"]*\)".*$/\1/') if [ -z "$version" ]; then - echo "could not read the guardCore lockstep version from gradle/libs.versions.toml" + echo "::error::could not read the project version from build.gradle.kts" exit 1 fi - for artifact in guard-core-engine guard-core; do - code=$(curl -sS -o /dev/null -w '%{http_code}' \ - "https://repo1.maven.org/maven2/com/guard-core/${artifact}/${version}/${artifact}-${version}.pom" \ - || echo 000) - if [ "$code" != "200" ]; then - echo "com.guard-core:${artifact}:${version} is NOT live on Maven Central (HTTP ${code})." - echo "Engine-before-adapters is a hard ordering rule: run the guard-core-jvm release first" - echo "(tag, GitHub release, Portal release clicks; see its docs/release.md), then re-drive" - echo "this workflow." + case "$version" in + *-SNAPSHOT) + echo "::error::project version $version is a SNAPSHOT: the release PR must flip it (docs/release.md, version-flip checklist)" exit 1 - fi - echo "ok: com.guard-core:${artifact}:${version} is live on Maven Central" - done - - test: - name: Test at tag (JDK 17) - needs: [pre-flight] - runs-on: ubuntu-latest - permissions: - contents: read - - steps: - - name: Checkout tag - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - # Same engine checkout as ci.yml (the composite default for tests). - - name: Checkout engine (guard-core-jvm @ 7711606) - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: Guard-Core/guard-core-jvm - ref: 77116062fc7d88dcb610b1f588e2bc78e63848ad - path: guard-core-jvm - persist-credentials: false - - - name: Set up JDK 17 - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 - with: - distribution: temurin - java-version: "17" + ;; + esac + if [ "${TAG#v}" != "$version" ]; then + echo "::error::tag $TAG does not match the project version $version" + exit 1 + fi + echo "ok: $TAG matches the project version $version" - # The redis-backed integration tests (rate limiting through the facade - # RateLimitManager) need Redis; they skip when it is unreachable. - - name: Start Redis + # The newest push run of each required workflow on the tagged commit + # must have succeeded. A release created right after a merge waits for + # them (up to an hour) instead of failing. + - name: Require green CI on the tagged commit + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} run: | - docker run -d --name guard-redis -p 6379:6379 redis:7-alpine - for _ in $(seq 1 30); do - if docker exec guard-redis redis-cli ping | grep -q PONG; then break; fi - sleep 1 + sha=$(git rev-parse HEAD) + for _ in $(seq 1 60); do + runs=$(gh api "repos/$REPO/actions/runs?head_sha=$sha&event=push&per_page=100" \ + --jq '[.workflow_runs | group_by(.path)[] | max_by(.run_number) | {path, status, conclusion}]') + waiting=0 + for wf in $REQUIRED_WORKFLOWS; do + run=$(jq -c --arg p "$wf" '.[] | select(.path == $p)' <<< "$runs") + if [ -z "$run" ]; then + echo "$wf: no push run on $sha yet"; waiting=1; continue + fi + status=$(jq -r .status <<< "$run"); conclusion=$(jq -r .conclusion <<< "$run") + if [ "$status" != "completed" ]; then + echo "$wf: $status"; waiting=1 + elif [ "$conclusion" != "success" ]; then + echo "::error::$wf concluded $conclusion on $sha; fix master and release a new tag" + exit 1 + else + echo "ok: $wf success on $sha" + fi + done + [ "$waiting" = "0" ] && exit 0 + sleep 60 done + echo "::error::required workflows did not finish on $sha within an hour" + exit 1 - - name: Build and test - run: ./gradlew build --no-daemon - - - name: Stop Redis - if: always() - run: docker rm -f guard-redis || true - - tag-consistency: - name: Tag matches the project version - needs: [test] + pre-flight: + name: Engine artifacts live on Maven Central + needs: [verify] runs-on: ubuntu-latest permissions: contents: read steps: - - name: Checkout tag + - name: Checkout the tag uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: refs/tags/${{ needs.verify.outputs.tag }} persist-credentials: false - # The in-file version is the source of truth (the guard-core-rs - # precedent): the release PR flips it, the tag must equal it, and a - # leftover -SNAPSHOT means the release PR forgot the flip. - - name: Verify bare tag equals the project version + - name: Verify the engine artifacts are live on Maven Central run: | set -euo pipefail - case "${GITHUB_REF_NAME}" in - v[0-9]*.[0-9]*.[0-9]*) - ;; - *) - echo "refusing non-bare tag '${GITHUB_REF_NAME}': use bare vX.Y.z (v1.0.0, no suffixes)" - exit 1 - ;; - esac - tag="${GITHUB_REF_NAME#v}" - version=$(grep -m1 'version = "' build.gradle.kts | sed 's/^.*version = "\([^"]*\)".*$/\1/') + version=$(grep -m1 '^guardCore = ' gradle/libs.versions.toml | cut -d'"' -f2) if [ -z "$version" ]; then - echo "could not read the project version from build.gradle.kts" + echo "could not read the guardCore lockstep version from gradle/libs.versions.toml" exit 1 fi - case "$version" in - *-SNAPSHOT) - echo "project version is ${version}: the release PR forgot the version flip (docs/release.md, version-flip checklist)" + for artifact in guard-core-engine guard-core; do + code=$(curl -sS -o /dev/null -w '%{http_code}' \ + "https://repo1.maven.org/maven2/com/guard-core/${artifact}/${version}/${artifact}-${version}.pom" \ + || echo 000) + if [ "$code" != "200" ]; then + echo "com.guard-core:${artifact}:${version} is NOT live on Maven Central (HTTP ${code})." + echo "Engine-before-adapters is a hard ordering rule: run the guard-core-jvm release first" + echo "(tag, GitHub release, Portal release clicks; see its docs/release.md), then re-drive" + echo "this workflow." exit 1 - ;; - esac - if [ "$tag" != "$version" ]; then - echo "tag v$tag does not match the project version $version" - exit 1 - fi - echo "ok: bare tag v$tag matches the project version $version" + fi + echo "ok: com.guard-core:${artifact}:${version} is live on Maven Central" + done publish: name: Publish to Maven Central (Central Portal) - needs: [tag-consistency] - # The tag push runs the gates; the publish runs on GitHub release creation - # and can be re-driven with workflow_dispatch after a partial failure. - if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' + needs: [verify, pre-flight] runs-on: ubuntu-latest permissions: contents: read + env: + TAG: ${{ needs.verify.outputs.tag }} steps: - - name: Checkout release tag + - name: Checkout the tag uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: refs/tags/${{ needs.verify.outputs.tag }} persist-credentials: false - name: Set up JDK 17 @@ -200,7 +217,7 @@ jobs: id: artifact run: | set -euo pipefail - version="${GITHUB_REF_NAME#v}" + version="${TAG#v}" code=$(curl -sS -o /dev/null -w '%{http_code}' \ "https://repo1.maven.org/maven2/com/guard-core/spring-boot-guard/${version}/spring-boot-guard-${version}.pom" \ || echo 000) @@ -237,7 +254,7 @@ jobs: CENTRAL_PORTAL_PASSWORD: ${{ secrets.CENTRAL_PORTAL_PASSWORD }} run: | set -euo pipefail - version="${GITHUB_REF_NAME#v}" + version="${TAG#v}" token=$(printf '%s:%s' "$CENTRAL_PORTAL_USERNAME" "$CENTRAL_PORTAL_PASSWORD" | base64 | tr -d '\n') rm -f central-bundle.zip (cd build/central-staging && zip -qr "$GITHUB_WORKSPACE/central-bundle.zip" .) @@ -285,7 +302,7 @@ jobs: - name: Summary run: | set -euo pipefail - version="${GITHUB_REF_NAME#v}" + version="${TAG#v}" echo "Publish run for spring-boot-guard ${version} finished. Operator steps:" echo " 1. Release the VALIDATED deployment in the Portal UI (central.sonatype.com, Deployments)." echo " 2. Verify repo1.maven.org serves com.guard-core:spring-boot-guard:${version}." diff --git a/docs/release.md b/docs/release.md index f5e04c3..29cb2cc 100644 --- a/docs/release.md +++ b/docs/release.md @@ -11,15 +11,15 @@ This file covers what differs here. version). The version lives in `build.gradle.kts`; the tag must equal it. - The engine lockstep pin lives in `gradle/libs.versions.toml` (`guardCore = "4.3.3"`); it moves only when the engine train moves. -- Tags are bare `vX.Y.z` only (`v1.0.0`). The `tag-consistency` job audits +- Tags are bare `vX.Y.z` only (`v1.0.0`). The `verify` job of release.yml audits this (the guard-core-rs invisible-rule incident on `refs/tags/v4.3.2` is the standing lesson). - groupId `com.guard-core`; Kotlin packages stay `com.guardcore.*`. ## Version-flip checklist (the release PR) -The workflow enforces this: tagging without the flip fails `tag-consistency` -with an explicit message. +The workflow enforces this: releasing a tag without the flip fails the +`verify` job with an explicit message. - [ ] `build.gradle.kts`: `version = "1.0.0-SNAPSHOT"` -> `version = "1.0.0"`. - [ ] `gradle/libs.versions.toml`: `guardCore` still reads the lockstep engine @@ -43,10 +43,15 @@ mechanically, not on the honor system). deployments released, repo1 answering 200 for both engine artifacts (engine docs/release.md, steps 1 and 5). 2. Merge the version-flip PR. -3. Tag: `git tag v1.0.0 && git push origin v1.0.0`. Gates run in order: - pre-flight (engine live) -> tests at tag (composite ON, same as ci.yml) -> - tag-consistency (bare tag equals the in-file version). -4. Create the GitHub release for `v1.0.0`. The publish job: +3. Tag: `git tag v1.0.0 && git push origin v1.0.0`. The tag push runs + nothing: the merge commit's push run of ci.yml (build and tests against the + pinned engine, dependency audit) is the gate. +4. Create the GitHub release for `v1.0.0`. release.yml runs no build or + test for validation. The `verify` job checks the tag is bare `vX.Y.z`, + equals the in-file version (and is not a SNAPSHOT), and that the newest + push run of ci.yml on the tagged commit succeeded (it waits up to an hour + for runs still in progress). The pre-flight job then requires both engine + artifacts live on Maven Central. Then the publish job: - verifies the four secrets are set (Portal user token + GPG key); - checks repo1 for this exact artifact and skips if already live (changed-only rerun safety); @@ -68,12 +73,13 @@ mechanically, not on the honor system). 3. No deployment left VALIDATED/PENDING in the Portal. 4. The `GUARD_CORE_JVM_REF` pin (ci.yml checkout + settings comments) moves to the released engine SHA AT TRAIN TIME, before the consumer tags: the - adapter's at-tag tests must run against the engine that actually ships + adapter's push CI must run against the engine that actually ships (the family spec pins this; a pin bump is an ordinary development PR). ## Rerun safety, failure recovery, rollback -Same rules as the engine runbook: workflow_dispatch re-drives skip anything -already live on repo1; a VALIDATED deployment you do not want gets Dropped in +Same rules as the engine runbook: a workflow_dispatch re-drive (Actions > +Release > Run workflow, `tag` input) checks that tag out with master's current +workflow and skips anything already live on repo1; a VALIDATED deployment you do not want gets Dropped in the Portal; published artifacts are immutable - no rollback after the Portal Release click, only a fix version.