Skip to content

Build and Push Docker Image #40

Build and Push Docker Image

Build and Push Docker Image #40

Workflow file for this run

name: Build and Push Docker Image
on:
release:
types: [published]
workflow_dispatch:
# Default to read-all at top level; each job below escalates only the narrow
# scopes it actually needs (contents:write for benchmark history,
# packages:write + security-events:write for the Docker push + SARIF upload,
# packages:read for the smoke test pull).
permissions: read-all
jobs:
# Performance regression gate. Runs the benchmark profile three times,
# medians the results, then compares against the benchmark-data rolling
# history median. On regression (> 25% worse on any headline metric),
# fails the release before any Docker build runs. The rolling baseline keeps
# a single unusually fast shared-runner sample from becoming the only release
# comparison point; baseline.json remains updated for reference/bootstrap.
#
# Bypass: include `[benchmark-skip]` anywhere in the release notes body
# (for test-only / infra-only releases where benchmarks only measure
# environmental noise — precedent: v0.1.25.9, .10, .11 were all
# legitimately benchmark-skip'd).
#
# On successful non-bypass gate, atomically updates baseline.json
# with the new numbers and commits to main.
benchmark-gate:
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: write # needed to push updated baseline.json
outputs:
skipped: ${{ steps.decide.outputs.skipped }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.release.tag_name || github.ref }}
# Decide whether to run the gate. Skip for workflow_dispatch (manual
# Docker re-builds don't need regression check — the tag is already
# pinned). Skip also if [benchmark-skip] in release notes.
- name: Decide skip
id: decide
env:
EVENT: ${{ github.event_name }}
RELEASE_BODY: ${{ github.event.release.body }}
run: |
if [ "$EVENT" != "release" ]; then
echo "Event is '$EVENT' (not 'release') — skipping benchmark gate."
echo "skipped=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if echo "$RELEASE_BODY" | grep -qF "[benchmark-skip]"; then
echo "Release notes contain [benchmark-skip] — skipping benchmark gate."
echo "skipped=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Running benchmark gate."
echo "skipped=false" >> "$GITHUB_OUTPUT"
- name: Set up JDK 21
if: steps.decide.outputs.skipped == 'false'
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5
with:
distribution: temurin
java-version: 21
cache: maven
- name: Set up Python
if: steps.decide.outputs.skipped == 'false'
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.12'
# Reactor install first (release-workflow memory gotcha: benchmarks
# need the new version in ~/.m2 before -Pbenchmark can resolve it).
- name: Install reactor
if: steps.decide.outputs.skipped == 'false'
run: |
mvn -B install -DskipTests --file cycles-protocol-service/pom.xml
rm -rf cycles-protocol-service/cycles-protocol-service-api/target/surefire-reports
- name: Benchmark trials (3×)
if: steps.decide.outputs.skipped == 'false'
run: |
for i in 1 2 3; do
mvn -B test -Pbenchmark \
--file cycles-protocol-service/pom.xml \
-pl cycles-protocol-service-api
python3 scripts/parse-benchmarks.py \
cycles-protocol-service/cycles-protocol-service-api/target/surefire-reports \
--trial-of $i \
--tag "${{ github.event.release.tag_name || '' }}" \
> /tmp/trial${i}.json
echo "--- trial $i ---"
cat /tmp/trial${i}.json
rm -rf cycles-protocol-service/cycles-protocol-service-api/target/surefire-reports
done
- name: Median-aggregate
if: steps.decide.outputs.skipped == 'false'
run: |
python3 scripts/median-benchmarks.py /tmp/trial1.json /tmp/trial2.json /tmp/trial3.json \
> /tmp/current.json
echo '--- release candidate ---'
cat /tmp/current.json
# History + baseline live on the `benchmark-data` branch (not main).
# Branch protection on main rejects bot pushes; the dedicated
# non-protected branch pattern avoids needing bypass config or PAT
# secrets. See benchmarks/README.md on main for the full rationale.
- name: Fetch benchmark-data branch (worktree)
if: steps.decide.outputs.skipped == 'false'
run: |
git fetch origin benchmark-data:benchmark-data
git worktree add bench-data benchmark-data
- name: Regression check
if: steps.decide.outputs.skipped == 'false'
shell: bash
run: |
# pipefail so the non-zero exit from check-regression (on
# regression) isn't swallowed by `tee`.
set -o pipefail
python3 scripts/check-regression.py release \
--current /tmp/current.json \
--baseline bench-data/benchmarks/baseline.json \
--history bench-data/benchmarks/history.jsonl \
--window 7 \
--threshold 0.25 \
| tee /tmp/gate-summary.md
- name: Attach summary to job
if: steps.decide.outputs.skipped == 'false' && always()
run: |
if [ -f /tmp/gate-summary.md ]; then
cat /tmp/gate-summary.md >> "$GITHUB_STEP_SUMMARY"
fi
# On successful gate, the new median becomes the baseline for the
# next release. Also appended to history.jsonl for trend continuity.
# All writes go to the benchmark-data branch; main is untouched.
- name: Update baseline + history on benchmark-data
if: steps.decide.outputs.skipped == 'false'
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
cp /tmp/current.json bench-data/benchmarks/baseline.json
cat /tmp/current.json >> bench-data/benchmarks/history.jsonl
echo "" >> bench-data/benchmarks/history.jsonl
cd bench-data
git add benchmarks/baseline.json benchmarks/history.jsonl
if git diff --cached --quiet; then
echo "no baseline/history changes to commit"
exit 0
fi
git commit -m "chore(bench): update baseline for ${{ github.event.release.tag_name }}
[skip ci]"
git push origin benchmark-data
build-and-push:
needs: benchmark-gate
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
security-events: write # for Trivy SARIF upload to Security tab
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Extract version from POM
id: version
run: |
VERSION=$(sed -n 's/.*<revision>\(.*\)<\/revision>.*/\1/p' cycles-protocol-service/pom.xml | tr -d ' ')
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Detected version: $VERSION"
- name: Log in to GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
# Two-phase build: load image locally for Trivy first, then push only
# if scan passes. Second build-push-action call is a cache hit (~5-10s
# rebuild + push) thanks to type=gha cache from the first call.
- name: Build image (no push, load for scan)
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
with:
context: .
push: false
load: true
tags: ghcr.io/runcycles/cycles-server:${{ steps.version.outputs.version }}
build-args: |
APP_VERSION=${{ steps.version.outputs.version }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Trivy vulnerability scan
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: ghcr.io/runcycles/cycles-server:${{ steps.version.outputs.version }}
severity: 'HIGH,CRITICAL'
ignore-unfixed: true
format: 'sarif'
output: 'trivy-results.sarif'
exit-code: '1'
# In `format: sarif` mode the action builds an all-severities report
# by default, so `exit-code: 1` trips on ANY fixable finding and the
# `severity` filter above is ignored for gating. Limit the SARIF to
# the declared severities so the gate (and the Security-tab report)
# honor HIGH,CRITICAL only — a fixable MEDIUM should not block the
# release. See AUDIT.md (jackson-databind CVE-2026-54515).
limit-severities-for-sarif: true
- name: Upload Trivy SARIF to Security tab
if: always()
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
sarif_file: 'trivy-results.sarif'
category: trivy-container
- name: Push image (cache hit from first build)
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
with:
context: .
push: true
tags: |
ghcr.io/runcycles/cycles-server:${{ steps.version.outputs.version }}
ghcr.io/runcycles/cycles-server:latest
build-args: |
APP_VERSION=${{ steps.version.outputs.version }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Set package visibility to public
run: |
gh api \
--method PUT \
-H "Accept: application/vnd.github+json" \
/orgs/runcycles/packages/container/cycles-server/versions \
|| true
# Set the container package to public so unauthenticated pulls work
gh api \
--method PUT \
-H "Accept: application/vnd.github+json" \
/orgs/runcycles/packages/container/cycles-server \
-f visibility=public \
|| echo "Note: Could not set package visibility. Set it manually in GitHub package settings."
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Smoke-test the *published* image, not a rebuild. Catches failure
# modes that unit/integration tests can't see — wrong entry point,
# missing files in the image, startup script typos, multi-arch
# manifest issues.
smoke-test-published:
needs: build-and-push
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
timeout-minutes: 8
steps:
- name: Log in to GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create network and start redis
run: |
docker network create smoke-net
docker run -d --name smoke-redis --network smoke-net --network-alias redis redis:7-alpine
for i in $(seq 1 10); do
if docker exec smoke-redis redis-cli ping >/dev/null 2>&1; then
echo "redis ready"
break
fi
sleep 1
done
- name: Run the published cycles-server image
env:
VERSION: ${{ needs.build-and-push.outputs.version }}
run: >
docker run -d
--name smoke-server
--network smoke-net
-p 7878:7878
-e REDIS_HOST=redis
-e REDIS_PORT=6379
-e REDIS_PASSWORD=
-e ADMIN_API_KEY=smoke-test-admin-key
ghcr.io/runcycles/cycles-server:${VERSION}
- name: Wait for /actuator/health/readiness
run: |
for i in $(seq 1 30); do
code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:7878/actuator/health/readiness || echo "000")
if [ "$code" = "200" ]; then
echo "cycles-server ready after $((i*2))s"
curl -s http://localhost:7878/actuator/health/readiness
echo
exit 0
fi
echo "waiting ($i/30) — status=$code"
sleep 2
done
echo "ERROR: cycles-server did not become ready in 60s"
docker logs smoke-server | tail -50
exit 1
- name: Smoke probe — admin dual-auth allowlist works
run: |
# /v1/reservations is in the admin-dual-auth allowlist (v0.1.25.8)
# and REQUIRED tenant under admin. Missing tenant should return 400
# INVALID_REQUEST with a specific message, not HTML / 500.
body=$(curl -s -H "X-Admin-API-Key: smoke-test-admin-key" http://localhost:7878/v1/reservations)
echo "Body: $body"
echo "$body" | python -c "
import sys, json
d = json.loads(sys.stdin.read())
assert d.get('error') == 'INVALID_REQUEST', f'unexpected: {d}'
assert 'tenant' in d.get('message', '').lower(), f'unexpected message: {d}'
"
- name: Smoke probe — missing API key returns structured 401
run: |
body=$(curl -s http://localhost:7878/v1/reservations)
echo "Body: $body"
echo "$body" | python -c "
import sys, json
d = json.loads(sys.stdin.read())
assert d.get('error') == 'UNAUTHORIZED', f'unexpected: {d}'
"
- name: Capture logs on failure
if: failure()
run: docker logs smoke-server > smoke-server.log 2>&1 || true
- name: Upload logs on failure
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: smoke-test-logs
path: smoke-server.log
retention-days: 14
- name: Tear down
if: always()
run: |
docker rm -f smoke-server smoke-redis || true
docker network rm smoke-net || true