CI/CD
Two workflows:
.github/workflows/ci.yml— builds and tests every push, and deploysmainto production. It publishes nothing: no jars outside Actions artifacts, no images to GHCR..github/workflows/release.yml— publishes both jars and the judge sandbox image when you push av*tag. It deploys nothing.
Everything published carries the version of the tag that built it.
CI runs on every push to any branch and can be triggered manually (workflow_dispatch). It skips docs-only pushes (paths-ignore: docs/**, handled by docs.yml) and tag pushes (tags-ignore: '**', handled by release.yml). 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 ONLY)"]
build --> jars["cs30-<sha> + kt-judge-<sha>\njar artifacts (30 days)"]
secret --> deploy["deploy-prod\n(main only)"]
jars --> deploy
judge --> deploy
ghcr["judge-sandbox:latest\n(GHCR — published by release.yml)"] -.->|pulled at deploy| 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.
The dashed edge is the sandbox image. deploy-prod still pulls it, but no job here produces it.
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/) and never pushes it. A paths-filter first checks whether anything under kt-judge/sandbox/** changed; if nothing changed it does nothing. When it does build, it tags the result judge-sandbox:ci, a local name thrown away with the runner.
This job is a build check — it catches a broken Dockerfile before the PR merges. It has no packages: write permission and never logs in to GHCR, so it cannot publish even by mistake. Publishing a release does that. Both jobs share the same type=gha layer cache, so the release build reuses these layers when the sandbox hasn’t changed.
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.:latestonly moves when a release is published, so this pull can fetch an image older than the commit being deployed. See sandbox image timing. - 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.
Publishing a release
release.yml runs on a v* tag push (or workflow_dispatch with a version input). It publishes both jars and the judge sandbox image. It has no deploy step, so publishing a release does not change what production runs.
git tag v1.2.3 && git push origin v1.2.3
Three jobs, each waiting on the one before it. The release is built as a draft and only made visible after the image push succeeds, so a failed Docker build cannot leave jars published without a matching sandbox image:
flowchart LR
jars["publish-jars\ntest + build → DRAFT release"] --> img["publish-sandbox-image\npush judge-sandbox to GHCR"]
img --> pub["publish-release\nundraft — visible here, and only here"]
publish-jars
- Resolve the version from
${GITHUB_REF_NAME#v}(or the dispatch input, passed throughenv:so it can’t be interpolated into the shell) and reject anything that isn’t1.2.3/1.2.3-rc1. ./gradlew test :cli:bootJar :kt-judge:bootJar -PreleaseVersion=<version>. That property is the only thing that overrides the modules’1.0-SNAPSHOT; it also lands in each jar’sImplementation-Version, so you can identify a jar withunzip -p cs30.jar META-INF/MANIFEST.MF. Tests run first, so a failing test means no release at all.gh release create --draft --generate-noteswithcs30.jar,kt-judge.jarandSHA256SUMS.txt. A draft is invisible on the Releases page and its assets are not downloadable.
publish-sandbox-image
Builds kt-judge/sandbox/ and pushes to GHCR. It builds every time, with no paths-filter: a release needs an image for its own version even if the sandbox hasn’t changed in months. Three tags:
| Tag | Purpose |
|---|---|
:v1.2.3 |
Immutable. A release always resolves to the same image. |
:<sha> |
Traces an image back to its commit. |
:latest |
What deploy-prod pulls. Skipped for prereleases. |
Prereleases skip :latest on purpose. deploy-prod pulls :latest on every merge to main, so an -rc tag claiming it would push an untested sandbox into production without anyone deploying it.
publish-release
Runs gh release edit --draft=false. The release becomes visible and downloadable here and nowhere earlier. --draft=false is the only flag passed, so a prerelease stays a prerelease.
If any job fails the release stays a draft, so nothing is ever half-visible. Retrying means deleting the draft first (gh release delete v1.2.3), because gh release create refuses to overwrite an existing release, draft or not. Delete the remote tag too if you want to reuse the version number.
Consuming a release
Asset names are fixed and carry no version, so the download URL stays the same across releases. The repo is public, so no token is needed:
curl -LO https://github.com/SJSU-CS-systems-group/cs30/releases/latest/download/cs30.jar
A tag with a suffix (v1.2.3-rc1) publishes as a prerelease, so it stays out of /releases/latest.
Sandbox image timing
Only a release publishes the sandbox image. Merging a sandbox change to main builds it in docker-judge but pushes it nowhere, so deploy-prod keeps pulling the old :latest and production keeps running the old sandbox.
Change the sandbox, tag a release. Otherwise the change never reaches production. Jar-only changes deploy on merge as before.
The trade is that jars and sandbox image carrying the same version are built from the same commit and stay together.
What controls what merges, deploys and publishes
- Feature branches get
secret-scan,build-test, and (ifkt-judge/sandbox/**changed) a judge image build — no GHCR push, no deploy. To try a feature build, download its jar artifact from the Actions run and run it yourself. mainadditionally runsdeploy-prod. A merge ships the jars to production and publishes nothing — no GitHub Release, no GHCR image.- A
v*tag publishes both jars and the sandbox image, and deploys nothing.ci.ymlignores tag pushes (tags-ignore: '**'), so tagging does not run the pipeline twice. - Branch protection on
mainrequires a PR with at least one approval and passing checks. Becausedeploy-prodlistssecret-scaninneeds, a leaked secret blocks the deploy directly (not only via branch protection).
Merging deploys. Tagging publishes. Neither does the other.