Skip to content
Merged
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: 129 additions & 112 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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" .)
Expand Down Expand Up @@ -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}."
Expand Down
26 changes: 16 additions & 10 deletions docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
Expand All @@ -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.
Loading