CI/CD
One pipeline, GitHub Actions, defined in .github/workflows/ci.yml. It runs on every push to any branch and can be triggered manually (workflow_dispatch). Runs for the same ref cancel each other (concurrency: ci-<ref>, cancel-in-progress: true), so only the latest push on a branch keeps running.
The jobs
flowchart LR
push["push / manual"] --> secret["secret-scan"]
push --> build["build-test\n(test + build jars)"]
push --> judge["docker-judge\n(sandbox image)"]
build --> jars["cs30-<sha> + kt-judge-<sha>\njar artifacts"]
judge --> img["judge-sandbox image\n(GHCR, pushed on main)"]
secret --> deploy["deploy-prod\n(main only)"]
jars --> deploy
img --> deploy
Four jobs. secret-scan, build-test, and docker-judge run in parallel. deploy-prod waits on all three (needs: [build-test, secret-scan, docker-judge]) and only runs on main.
secret-scan
Runs gitleaks against full history (fetch-depth: 0, then gitleaks detect --source . --no-banner --redact). The version is pinned (GITLEAKS_VERSION=8.30.1) and the binary is downloaded from the GitHub release — not scraped from “latest” — so scans are reproducible.
build-test
GitHub-hosted runner, JDK 21 (Temurin), gradle/actions/setup-gradle:
./gradlew test— all module tests../gradlew :cli:bootJar— the unified backend/CLI jar →cli/build/libs/cs30-<version>.jar../gradlew :kt-judge:bootJar— the judge jar →kt-judge/build/libs/kt-judge.jar.- Uploads test reports (14 days).
- Uploads
cs30-<sha>(fromcli/build/libs/cs30-*.jar, a glob so a version bump doesn’t break it) andkt-judge-<sha>(fromkt-judge/build/libs/kt-judge.jar), both kept 30 days. These are the exact artifactsdeploy-proddownloads.
docker-judge
Builds the judge sandbox image (kt-judge/sandbox/). A paths-filter first checks whether anything under kt-judge/sandbox/** changed; if nothing changed it does nothing. When it does build, it only pushes on main, to GitHub Container Registry, tagged ghcr.io/sjsu-cs-systems-group/judge-sandbox:latest and :<sha> (uses the GHA layer cache). The owner in the tag must be lowercase — GHCR rejects mixed case.
deploy-prod
Runs only on main, only after the other three succeed, on the self-hosted runner on production (runs-on: [self-hosted, cs30-prod-v2]), using the production GitHub environment (where PROD_DB_PASSWORD and PROD_GOOGLE_CLIENT_SECRET live). It has its own concurrency: deploy-prod with cancel-in-progress: false, so a new push to main queues behind an in-flight deploy instead of killing it mid-swap. APP_ROOT=/opt/cs30. Steps:
- Download the
cs30-<sha>andkt-judge-<sha>jar artifacts. - Sync config:
cp deploy/application.properties /opt/cs30/application.properties.cp(notinstall) on purpose — it overwrites content only and preserves the file’s server-set owner/group/mode. CI never sets permissions; the server owns them. See deployment configuration. - Write secrets:
umask 077, write/opt/cs30/cs30.envfrom the two secrets,chmod 0600. - Pull the judge image:
docker login ghcr.io(with the job’sGITHUB_TOKEN,packages: read), thendocker pull ghcr.io/sjsu-cs-systems-group/judge-sandbox:latest. Non-fatal — a pull failure logs a warning and never blocks the backend deploy. - Deploy the release:
cpboth jars intoreleases/<sha>/, flip thecurrentsymlink. Restart the judge first (non-fatal: poll:8000/healththen:8000/ready, warn but continue on failure — the backend calls the judge on startup). Then restart the backend — this is the deploy gate: poll the backend health endpoint on 443, and if it never comes up,rollback()flipscurrentback to the previous release and restarts both services. - Prune old releases, keeping the last 5.
The operator’s view of a deploy is on the deployment overview.
What controls what merges and deploys
- Feature branches get
secret-scan,build-test, and (ifkt-judge/sandbox/**changed) a judge image build — but no GHCR push and no deploy. To try a feature build, download its jar artifact from the Actions run and run it yourself. mainadditionally runsdeploy-prod. A merge to main ships to production.- Branch protection on
mainrequires a PR with at least one approval and passing checks. Becausedeploy-prodnow listssecret-scaninneeds, a leaked secret blocks the deploy directly (not only via branch protection).