v0.1.25.52 — event replay storage and ledger helper cleanup #48
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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@0f481fcb613427c0f801b606911222b5b6f3083a # v5 | |
| with: | |
| distribution: temurin | |
| java-version: 21 | |
| cache: maven | |
| - name: Set up Python | |
| if: steps.decide.outputs.skipped == 'false' | |
| uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # 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@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # 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@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # 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@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4 | |
| with: | |
| sarif_file: 'trivy-results.sarif' | |
| category: trivy-container | |
| - name: Push image (cache hit from first build) | |
| uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # 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@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # 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 |