name: release # Stage 3: produce a deployable artifact. # # One workflow, because there is one deployable unit. `app-bootstrap` is the composition root and # the only thing a cluster runs; the adapters are leaves of that artifact, not independently # shippable services. Eight files used to answer a release tag — web-release, web-advanced-release, # websocket-release, httpclient-release, container-release, and the three that still have to live # apart (see below) — and between them they ran `verifyCleanArchitectureDependencies` six times and # `:app-bootstrap:test` four times for one release, on separate runners, with no job in any of them # able to wait on a job in another. # # Tag scheme: `v*` only. The adapter-scoped patterns (`web-v*`, `websocket-v*`, `fileserver-v*`) are # gone. They were the namespace-split bug: tagging `v1.2.3` and tagging `web-v1.2.3` ran different # sets of gates, so a release could choose which gate it cleared, and the adapter-scoped half could # not build an image because there is no per-adapter image to build. # # Two release workflows still stand outside this file, both for a mechanical reason rather than a # design one: # * jpa-release.yml — JpaReleaseRenderingTest reads that exact path and holds its PostgreSQL # matrix and promotion list to src/config/jpa/release-registry.json. # * fileserver-certification.yml — FileserverDocumentationCoverageTest requires every job id named # in docs/fileserver/support-matrix.md to be defined in a `.github/workflows/fileserver-*.yml`. # It is named "certification" rather than "release" on purpose: it certifies a storage topology # and a support matrix, it deploys nothing, and the CI/CD boundary in docs/ci-cd/boundary.md # says GitHub Actions does not deploy. # Folding either one in needs its src-side test (and, for fileserver, the support document) changed # in the same commit. Until then the image job below cannot wait on them — a stated gap. # # The image job DOES now wait on the evidence jobs in this file, which is new: while the image build # lived in its own workflow it could publish while a sibling suite was still running or already red, # because `needs:` does not reach across workflows. on: push: tags: - "v*" permissions: contents: read # Never cancel a release in flight. A half-pushed manifest is worse than a slow one, and two runs # for the same tag would race for the same registry tags. concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false jobs: # The architecture-wide verification, once. Each of the four release workflows this file replaces # ran `verifyCleanArchitectureDependencies` on its own runner, and three of them also ran the # bootstrap architecture suite; the answers were identical because the input was one commit. architecture-and-surface: runs-on: ubuntu-latest timeout-minutes: 45 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - uses: ./.github/actions/setup-gradle-java - name: Verify architecture boundaries and the published surfaces working-directory: src run: >- ./gradlew verifyCleanArchitectureDependencies verifyPublicPathSnapshot :app-bootstrap:verifyEnvKeys :app-bootstrap:test --tests 'dev.caskeleton.bootstrap.architecture.*' --no-daemon --stacktrace # Every web lane that `check` cannot reach. webCrossStackParityTest depends on `test`, # webJettyCompatTest and webFluxContractTest, so naming it runs all four — which is what # web-advanced-release.yml spent a separate 90-minute job doing by naming the three by hand. # # webAdvancedTest is here rather than in a nightly of its own. Its tests run inside # `:adapter:inbound:web:test` by design, so the lane adds exactly one thing: it fails closed when # the `web-advanced` tag selects nothing. That is worth asserting at a release and is not worth a # workflow file and a runner every night. web-stable-release-gate: runs-on: ubuntu-latest timeout-minutes: 90 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - uses: ./.github/actions/setup-gradle-java - name: Run every web lane, Stable and Advanced working-directory: src run: >- ./gradlew :adapter:inbound:web:webCrossStackParityTest :adapter:inbound:web:webNginxProxyTest :adapter:inbound:web:webAdvancedTest --no-daemon --stacktrace - name: Publish the release evidence if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2 with: name: web-release-evidence path: | src/adapter/inbound/web/build/web-contract-parity/ src/adapter/inbound/web/build/reports/tests/ if-no-files-found: error websocket-stable-release-gate: runs-on: ubuntu-latest timeout-minutes: 60 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - uses: ./.github/actions/setup-gradle-java - name: Run every websocket lane, Stable and Advanced working-directory: src run: >- ./gradlew :adapter:inbound:websocket:test :adapter:inbound:websocket:websocketJettyTest :adapter:inbound:websocket:websocketNginxTest :adapter:inbound:websocket:websocketTransportQualificationTest :adapter:inbound:websocket:websocketAdvancedTest --no-daemon --stacktrace - name: Publish the release evidence if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2 with: name: websocket-release-evidence path: src/adapter/inbound/websocket/build/reports/tests/ if-no-files-found: error # The three gRPC certification lanes. Their tests already run on every pull request — the # `grpc-inprocess`, `grpc-netty` and `grpc-fault` tags are NOT excluded from # `:grpc:grpc-testkit:test` (only `grpc-performance` is), and that task runs inside the root # `check`. So this job adds exactly what the web and WebSocket Advanced lanes above add: the lane # fails closed when its tag selects nothing, which is the one thing a tag-filtered suite inside # `test` cannot tell you. A renamed or deleted @Tag would otherwise leave the in-process, # transport and fault evidence grades claiming coverage that stopped existing. # # Release rather than nightly, for the same reason web-stable-release-gate is: these lanes need no # container and no fixed cadence — grpcNettyContractTest opens an ephemeral socket, not a broker — # so the guard is worth asserting once per tag and is not worth a runner every night. # # grpcPerformanceTest is deliberately absent. The leaf excludes it from `test` and says why: "a # measurement in the release gate is a flaky test on a shared CI runner; it runs when somebody asks # for it, by name." It is recorded as a manual entrypoint in the repository README instead. grpc-stable-release-gate: runs-on: ubuntu-latest timeout-minutes: 45 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - uses: ./.github/actions/setup-gradle-java - name: In-process contract lane working-directory: src run: ./gradlew :grpc:grpc-testkit:grpcInProcessContractTest --no-daemon --stacktrace - name: Real Netty transport lane working-directory: src run: ./gradlew :grpc:grpc-testkit:grpcNettyContractTest --no-daemon --stacktrace - name: Fault-injection lane working-directory: src run: ./gradlew :grpc:grpc-testkit:grpcFaultTest --no-daemon --stacktrace - name: Publish the gRPC release evidence if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2 with: name: grpc-release-evidence path: src/grpc/grpc-testkit/build/reports/tests/ if-no-files-found: warn # Each gate runs as its own single-line `./gradlew ` step so that a failure names the gate # rather than a folded command. The architecture dependency gate that used to end this list is now # architecture-and-surface above; it was the fourth copy of the same invocation. httpclient-release-gate: runs-on: ubuntu-latest timeout-minutes: 60 defaults: run: working-directory: src env: # A project property rather than a command-line flag, so each run command stays a plain, # verifiable task invocation while the machine-dependent bounds are still asserted. GRADLE_OPTS: -Dorg.gradle.project.performance.assertions.enabled=true steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - uses: ./.github/actions/setup-gradle-java - name: Focused module tests run: ./gradlew :adapter:outbound:httpclient:test --no-daemon --stacktrace - name: Spring 6.2 API surface lane run: ./gradlew :adapter:outbound:httpclient:spring62ApiSurfaceScan --no-daemon --stacktrace - name: Spring 7.0 compatibility lane run: ./gradlew :adapter:outbound:httpclient:spring70CompatibilityTest --no-daemon --stacktrace - name: Stable cross-transport contract suite run: ./gradlew :adapter:outbound:httpclient:httpClientStableContractTest --no-daemon --stacktrace - name: SSRF and cardinality suite run: ./gradlew :adapter:outbound:httpclient:httpClientSecurityTest --no-daemon --stacktrace - name: Event-loop blocking suite run: ./gradlew :adapter:outbound:httpclient:httpClientBlockHoundTest --no-daemon --stacktrace - name: Toxiproxy fault-injection suite run: ./gradlew :adapter:outbound:httpclient:httpClientFailureInjectionTest --no-daemon --stacktrace - name: Resource-bound performance certification run: ./gradlew :adapter:outbound:httpclient:httpClientPerformanceTest --no-daemon --stacktrace httpclient-documentation: runs-on: ubuntu-latest timeout-minutes: 15 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # actions/setup-python@v5.6.0 with: python-version: '3.12' - name: Verify documentation matches the code run: python3 scripts/verify-httpclient-docs.py app-image-release: needs: - architecture-and-surface - web-stable-release-gate - websocket-stable-release-gate - grpc-stable-release-gate - httpclient-release-gate - httpclient-documentation # Job-level, because a job that declares `permissions:` replaces the workflow set entirely: this # is the only job that writes anything anywhere, and `packages: write` stops at its boundary. permissions: contents: read packages: write runs-on: ubuntu-latest timeout-minutes: 90 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 # The builder stage inside src/Dockerfile runs this repository's Gradle wrapper to produce the # JAR that becomes the image. Validating the wrapper here checks the thing that is about to # execute, before it executes, rather than after an image already exists. # The tag is the release identity; everything below derives from it. A tag that does not parse # stops the release here, rather than producing an image named after whatever ref happened to # be checked out. # # GHCR rejects an uppercase path, and this repository's owner is mixed case — the naive # `ghcr.io/${{ github.repository }}` fails at push time with a message about the manifest # rather than about the case, so the lowercasing is explicit and the result is asserted. - name: Resolve the release coordinates env: CONFIGURED_IMAGE_NAME: ${{ vars.APP_IMAGE_NAME }} run: | set -euo pipefail readonly REGISTRY='ghcr.io' if [[ "${GITHUB_REF_TYPE}" != 'tag' ]]; then echo "::error::container-release runs only for a release tag; ref type was ${GITHUB_REF_TYPE}" exit 1 fi release_tag="${GITHUB_REF_NAME}" # Bare MAJOR.MINOR.PATCH, because src/build.gradle's release-version guard refuses a # pre-release or build suffix and the image tag must be the same string the JAR reports. if [[ ! "${release_tag}" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then echo "::error::release tag must be vMAJOR.MINOR.PATCH with no suffix; got '${release_tag}'" exit 1 fi release_version="${BASH_REMATCH[1]}" owner_path="$(printf '%s' "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')" image_name="${CONFIGURED_IMAGE_NAME:-${owner_path}/caskeleton}" image_repository="${REGISTRY}/${image_name}" if [[ "${image_repository}" != "${image_repository,,}" ]]; then echo "::error::image repository must be lowercase; got '${image_repository}'" exit 1 fi if [[ "${image_repository}" =~ [[:space:]] || "${image_repository}" == *:* ]]; then echo "::error::image repository must carry no tag and no whitespace; got '${image_repository}'" exit 1 fi { printf 'REGISTRY=%s\n' "${REGISTRY}" printf 'RELEASE_VERSION=%s\n' "${release_version}" printf 'BUILD_VERSION=%s+%s\n' "${release_version}" "${GITHUB_SHA}" printf 'IMAGE_REPOSITORY=%s\n' "${image_repository}" printf 'IMAGE_VERSION_TAG=%s\n' "${release_version}" printf 'IMAGE_REVISION_TAG=sha-%s\n' "${GITHUB_SHA}" printf 'SOURCE_URL=%s/%s\n' "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" } >> "${GITHUB_ENV}" printf 'container-release: %s -> %s:%s and %s:sha-%s\n' \ "${release_tag}" "${image_repository}" "${release_version}" \ "${image_repository}" "${GITHUB_SHA}" # Byte-identical to the install in dependency-vulnerability.yml, deliberately: the same # checksum-pinned binary at the same version scans the filesystem and the image, so the two # gates cannot disagree because one of them silently moved to a newer database schema. # # This repository installs its scanner rather than calling a scanner action, which is why no # third-party action appears in this workflow: a pinned tarball with an asserted SHA-256 is a # supply-chain claim that can be checked offline, and an action pinned to a commit is not. - name: Install pinned Trivy under RUNNER_TEMP env: TRIVY_DOWNLOAD_BASE_URL: ${{ vars.TRIVY_DOWNLOAD_BASE_URL }} run: | set -euo pipefail readonly TRIVY_VERSION='0.71.2' readonly TRIVY_SHA256_AMD64='0510e71e2fd39bf863856d499c8dc19feb4e7336546394c502a8f5cc7ab27460' readonly TRIVY_SHA256_ARM64='fe1c7106e15a5365d485b098a8c338f91e3b7ba71cb0e4963b98a3a098763cfc' readonly DOWNLOAD_BASE_URL="${TRIVY_DOWNLOAD_BASE_URL:-https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}}" case "${RUNNER_ARCH:-X64}" in X64) asset_arch='64bit' expected_sha256="${TRIVY_SHA256_AMD64}" ;; ARM64) asset_arch='ARM64' expected_sha256="${TRIVY_SHA256_ARM64}" ;; *) echo "::error::unsupported runner architecture: ${RUNNER_ARCH:-unknown}" exit 1 ;; esac install_dir="${RUNNER_TEMP}/trivy-${TRIVY_VERSION}" archive="${RUNNER_TEMP}/trivy-${TRIVY_VERSION}.tar.gz" mkdir -p "${install_dir}" curl --fail --show-error --silent --location --retry 3 \ --proto '=https' --tlsv1.2 \ "${DOWNLOAD_BASE_URL}/trivy_${TRIVY_VERSION}_Linux-${asset_arch}.tar.gz" \ --output "${archive}" printf '%s %s\n' "${expected_sha256}" "${archive}" | sha256sum -c - tar -xzf "${archive}" -C "${install_dir}" trivy chmod 0755 "${install_dir}/trivy" printf '%s\n' "${install_dir}" >> "${GITHUB_PATH}" # SOURCE_DATE_EPOCH is the commit time, not the wall clock, so the image metadata is a function # of the commit rather than of when the runner happened to pick the job up. Verified locally, # and worth stating exactly because it is easy to overclaim: BuildKit uses it for the image # config `created` field and for every history timestamp — both came back as the commit time — # and it does NOT rewrite file mtimes inside the layers. Those still carry the build time, so # two builds of the same commit agree on metadata but their layer digests still differ. # Byte-identical layers additionally need `--output type=image,rewrite-timestamp=true`, which # needs the containerd image store; that is a runner-capability change, not a flag to add # untested to the one job that publishes releases. # # The OCI `created` label comes from the same commit for the same reason: `date -u` there would # have made every rebuild a different image for no reason anybody could see. # # Both base images are already digest-pinned inside src/Dockerfile, and so is the Dockerfile # frontend in its `# syntax` directive, so nothing in this build resolves a floating tag. - name: Build the release image run: | set -euo pipefail SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)" export SOURCE_DATE_EPOCH created="$(git log -1 --format=%cI)" printf 'SOURCE_DATE_EPOCH=%s (%s)\n' "${SOURCE_DATE_EPOCH}" "${created}" DOCKER_BUILDKIT=1 docker build \ --file src/Dockerfile \ --tag "${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}" \ --tag "${IMAGE_REPOSITORY}:${IMAGE_REVISION_TAG}" \ --build-arg RELEASE_VERSION="${RELEASE_VERSION}" \ --build-arg BUILD_VERSION="${BUILD_VERSION}" \ --build-arg GIT_SHA="${GITHUB_SHA}" \ --build-arg SOURCE_URL="${SOURCE_URL}" \ --label org.opencontainers.image.created="${created}" \ src docker image inspect \ --format 'built {{.Id}} ({{.Size}} bytes, {{len .RootFS.Layers}} layers)' \ "${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}" # Generated before the blocking scan, and uploaded before it too, so the inventory of what is # in the image survives the run that refuses to publish it. An SBOM you only get on a green # build is an SBOM you cannot use to answer "what was in the one that failed". - name: Generate the image SBOM run: | set -euo pipefail trivy image \ --format cyclonedx \ --scanners license \ --output image-sbom.cdx.json \ "${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}" test -s image-sbom.cdx.json - name: Upload the image SBOM uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # actions/upload-artifact@v7.0.1 with: name: container-release-sbom path: image-sbom.cdx.json if-no-files-found: error retention-days: 90 # The same policy dependency-vulnerability.yml applies to the filesystem, applied to the thing # that actually ships: CRITICAL and HIGH block, everything else is reported. The filesystem # scan cannot see the base image's OS packages, which is most of an image's attack surface, so # a green trivy-fs has never been evidence about the artifact. # # --ignorefile is mandatory here as everywhere: .trivyignore.yaml is the single suppression # source. Each entry carries a rationale and an expiry by policy, reviewed through CODEOWNERS # (.github/dependency-vulnerability-policy.md); an inline --skip or a second ignore file would # be a suppression nobody reviews. # # This step is the reason `docker push` is further down. A vulnerable image that was pushed and # then reported is already pullable by everything that watches the tag. - name: Block High and Critical vulnerabilities in the release image run: | set -euo pipefail trivy image \ --scanners vuln,license \ --severity CRITICAL,HIGH \ --exit-code 1 \ --ignorefile .trivyignore.yaml \ "${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}" - name: Report Medium and Low vulnerabilities in the release image run: | set -euo pipefail trivy image \ --scanners vuln,license \ --severity MEDIUM,LOW \ --exit-code 0 \ --ignorefile .trivyignore.yaml \ "${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}" - name: Sign in to the container registry env: REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail printf '%s' "${REGISTRY_TOKEN}" \ | docker login "${REGISTRY}" --username "${GITHUB_ACTOR}" --password-stdin # Two tags, one digest. The semver tag is what a human reads and what a release note cites; the # sha- tag is the one that can never be moved to different content, because the git SHA it # names is the only commit that can produce it. # # Neither is what a manifest should pin. Both are mutable names in a registry: a later push can # point `1.2.3` at something else, and nothing about a tag tells a cluster it did not. The # digest recorded below is immutable by construction, and it is the field the GitOps repository # pins — the tags exist so a person can find the digest, not so a cluster can resolve one. - name: Push the release and revision tags run: | set -euo pipefail docker push "${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}" docker push "${IMAGE_REPOSITORY}:${IMAGE_REVISION_TAG}" # awk rather than `grep | head`, deliberately. Under `set -e` with `pipefail`, a grep that # matches nothing exits 1 and kills the step right here — so the explicit check below, # and its message, would never run and the failure would surface as a bare exit code. # awk exits 0 whether or not it matched, which leaves the empty case for us to report. pinned_reference="$( docker image inspect \ --format '{{range .RepoDigests}}{{println .}}{{end}}' \ "${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}" \ | awk -v prefix="${IMAGE_REPOSITORY}@sha256:" \ 'index($0, prefix) == 1 { print; exit }' )" if [[ -z "${pinned_reference}" ]]; then echo "::error::no registry digest for ${IMAGE_REPOSITORY} after push" exit 1 fi printf 'PINNED_REFERENCE=%s\n' "${pinned_reference}" >> "${GITHUB_ENV}" printf 'container-release: pushed %s\n' "${pinned_reference}" # The handoff to the GitOps repository, in a form a person and a script can both read. It is # written to the job summary as well as to an artifact because the summary is where somebody # looks first and the artifact is what survives the ninety days a release audit asks about. - name: Record the immutable image reference run: | set -euo pipefail digest="${PINNED_REFERENCE#*@}" { printf 'release_tag: %s\n' "${GITHUB_REF_NAME}" printf 'git_sha: %s\n' "${GITHUB_SHA}" printf 'image_repository: %s\n' "${IMAGE_REPOSITORY}" printf 'version_tag: %s\n' "${IMAGE_VERSION_TAG}" printf 'revision_tag: %s\n' "${IMAGE_REVISION_TAG}" printf 'digest: %s\n' "${digest}" printf 'pinned_reference: %s\n' "${PINNED_REFERENCE}" } > image-release.txt { printf '### container-release\n\n' printf 'Pin this in the GitOps manifest as the container image:\n\n' printf '```\n%s\n```\n\n' "${PINNED_REFERENCE}" printf -- '- release tag: `%s`\n' "${GITHUB_REF_NAME}" printf -- '- version tag: `%s:%s`\n' "${IMAGE_REPOSITORY}" "${IMAGE_VERSION_TAG}" printf -- '- revision tag: `%s:%s`\n' "${IMAGE_REPOSITORY}" "${IMAGE_REVISION_TAG}" } >> "${GITHUB_STEP_SUMMARY}" cat image-release.txt - name: Upload the immutable image reference uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # actions/upload-artifact@v7.0.1 with: name: container-release-image-reference path: image-release.txt if-no-files-found: error retention-days: 90