CI: what GitHub Actions runs (and what it deliberately doesn't)
Seven workflows in .github/workflows/ plus
Dependabot. The scan/test workflows trigger on pushes and pull requests
targeting main/master — a push to a feature branch runs nothing until a
PR is opened.
The one-line summary: CI scans, builds, and deploys everything —
staging first, then production automatically once staging is healthy.
A push to staging builds and deploys to staging and stops there; a push
to main goes on to production once staging is healthy.
Every push to either branch builds both images once, pushes them to GHCR, writes
the VPS env files from GitHub Secrets, deploys STAGING with a health gate
and auto-rollback, and — only if staging turned healthy — promotes the
same tag to PRODUCTION (deploy.yml) — but only after the unit suites pass
(tests.yml, called as the deploy's first job), so a red suite stops the
pipeline before an image is built. PRs get those same jobs plus an e2e
smoke test against a freshly built stack (playwright.yml). What is
still deliberately excluded: the full e2e suite and a blocking lint — see
"What is NOT in CI". The branch-level view:
../git-workflow.md.
playwright.yml — end-to-end tests
File: .github/workflows/playwright.yml
Both jobs build the real stack on the runner: docker compose up --build --wait (db + backend + frontend; pgadmin deliberately excluded), then
scripts/ci-e2e-seed.sh applies the rich
demo seed — demo/seed/ V20/V22/V24 via psql behind a temporary
tenant_id DEFAULT 1 (the files predate multi-tenancy, and their V2x
version numbers now collide with the classpath's inventory migrations, so
the demo overlay's SPRING_FLYWAY_LOCATIONS trick no longer applies) —
and finally re-runs demo-data/reset + generate as sysadmin_demo so
timesheets/leave/overtime/bonuses/payroll cover all 100 employees with
today-relative dates.
| Job | Trigger | Runs | Timeout |
|---|---|---|---|
smoke | push / PR to main, master | e2e/smoke.spec.ts, Chromium only | 45 min |
full | manual workflow_dispatch | whole suite, Chromium only, --workers=2 | 180 min |
smoke is the merge gate: API login + seeded employees grid — it proves
the images build, migrations run, the seed applies, and nginx proxies the
API. full is not a gate because the suite has documented
known-failing tests (see NEXT-SESSION.md: the employees toolbar cluster,
ewt-registry's pre-existing filter defect, the leads-filters KNOWN GAP
test) — run it manually from the Actions tab when you want the whole
picture. Both upload playwright-report/ as an artifact (30 days,
uploaded even on failure) and dump backend/frontend container logs when
they go red. The local run against the Docker stack
(../../e2e/README.md) remains the authoritative
full-suite signal until the known failures are fixed.
tests.yml — the unit/integration gate
File: .github/workflows/tests.yml
One workflow, two callers: it runs standalone on every pull request, and
deploy.yml invokes the same jobs through workflow_call before it builds
anything. That's deliberate — a deploy gate and a PR check that can disagree
about what "green" means is worse than having neither. There is no push
trigger, because deploy.yml already calls it on every push to main; adding
one would run the whole suite twice per merge.
| Job | Runs | Notes |
|---|---|---|
test-backend | mvn -f backend/pom.xml test -B | 373 tests, all passing. Three classes drive real Postgres containers via Testcontainers — ubuntu-latest supplies the Docker daemon they need. Surefire reports upload as an artifact on failure. |
test-frontend | npm ci → type-check → lint → test | type-check is tsc -b; both tsconfigs set noEmit, so it's a pure typecheck and the same one npm run build performs first. lint is continue-on-error — see below. vitest: 32 tests, green. |
The one thing that is not blocking: npm run lint reports 58 problems
(27 errors, 31 warnings) on code written before this gate existed
(react-hooks/static-components among them). Making it blocking today would
fail every deploy immediately, so it runs for visibility only. When the count
reaches zero, drop continue-on-error and add --max-warnings 0 — an
advisory lint that stays advisory forever is a lint nobody reads.
A red suite stops the pipeline before any image is built — nothing reaches GHCR, staging, or production.
Historical note: this file's previous version stated the backend had "known-failing billing tests" and that gating would block every deploy. That was measured and found untrue — the suite runs clean end to end, so the gate covers everything and nothing is excluded or quarantined.
codeql.yml — static security analysis
File: .github/workflows/codeql.yml
- Triggers: push / PR to
main/master, plus a weekly cron (30 3 * * 1, Mondays 03:30 UTC) so newly-disclosed vulnerability patterns get checked against code that hasn't changed recently. - Java job — init →
mvn -f backend/pom.xml -DskipTests compile(Temurin 21, Maven cache) → analyze. Results upload under the category/language:java-payroll-backend— the name is a leftover from when this job was a 4-way matrix over the backend plus three inventory microservices; those services moved to their own repo, and keeping the category preserves the alert history. - JavaScript/TypeScript job — a single init → analyze pass (no build
step needed for JS/TS extraction), covering
frontend/and the e2e code.
gitleaks.yml — secret scanning
File: .github/workflows/gitleaks.yml
Push / PR to main/master. Checks out with fetch-depth: 0 — full
history, not just the tip commit — so gitleaks can diff-scan the entire
push/PR range for committed credentials. Runs gitleaks/gitleaks-action@v2
with the repo GITHUB_TOKEN.
deploy.yml — build once, stage, auto-promote
File: .github/workflows/deploy.yml
| Aspect | Value |
|---|---|
| Trigger | push to main or staging, or manual workflow_dispatch (a branch guard skips every job on any other ref — leave the "Use workflow from" dropdown alone) |
| Branch routing | staging → staging only. deploy-production carries if: github.ref == 'refs/heads/main', which is the whole reason a staging branch means anything: without it the job would inherit only needs: and sail through to production. main → staging, then production once staging's health gate passes — main never skips staging, because promotion can only ship something that already booted somewhere else |
:latest | Pushed from main only. A staging build gets its sha- tag and nothing more, so it can't become what :latest — and the registry layer cache that reads from it — points at |
What staging does not carry | The VPS checkout tracks main (git pull --ff-only origin main), so compose files, the Caddyfile and deploy.sh always come from main. A staging push ships that branch's images against main's configuration: application changes are covered, infrastructure changes are not testable until they land on main |
| Concurrency | group deploy-production, cancel-in-progress: false. GitHub holds one running + at most one pending run per group and a newly queued run replaces the pending one (not a FIFO queue) — fine for rapid merges, since the newest run contains the earlier commits. The VPS side is additionally serialized by a flock in deploy.sh. |
| Test gate | test (tests.yml via workflow_call) runs first; both build jobs needs: test, so nothing is built or pushed when the suite is red |
| Build jobs | build-backend + build-frontend in parallel: buildx, GHCR login with GITHUB_TOKEN, docker/build-push-action. Layer cache reads from both GHA (type=gha) and the :latest image in GHCR (type=registry) — GitHub evicts GHA entries after 7 days idle, and the registry copy backfills that gap |
| Environments | Both deploy jobs record against a named GitHub environment (staging / production) with its URL, so the Environments tab shows what is live where. Protection rules (required reviewers) are not used — they need GitHub Pro on a private repo; staging's health gate is what stands before production |
| Image tags | ghcr.io/jomariabejo/motorph-payroll-{backend,frontend} tagged sha-<12-hex> (immutable, what deploys) and latest (convenience only) |
| Config sync | The staging job rewrites /srv/motorph/.env and .env.stage from the PROD_ENV_FILE/STAGE_ENV_FILE Secrets (via envs:, umask 077, empty-secret guards) — the VPS files are disposable pipeline artifacts |
deploy-staging | SSHes to the VPS (appleboy/ssh-action; secrets VPS_HOST/VPS_USER/VPS_SSH_KEY/optional VPS_PORT), git pull --ff-only, env sync, then deploy/deploy.sh stage deploy sha-<tag> |
deploy-production | needs: deploy-staging — runs only if staging turned healthy, then deploy.sh promote sha-<tag>: the same run's exact tag, no rebuild |
| Safety | Each deploy step pulls first, takes a pre-deploy pg_dump, gates on its backend healthcheck, and auto-rolls-back its own environment on failure — exiting non-zero, so a red run always means "look at this". A staging failure stops the pipeline before production. |
Both images build on every push regardless of what changed — the deploy
pulls both at the same sha- tag, so a conditional build would 404.
promote.yml — manual re-promote (exceptions only)
File: .github/workflows/promote.yml
Routine deploys never touch this — deploy.yml promotes automatically.
workflow_dispatch with an optional tag input: empty means "whatever
staging is currently running", an explicit sha- tag re-ships that build.
Use cases: re-promoting after a rollback, or redeploying production with
updated Secrets without a rebuild (it re-syncs the env files first). Same
SSH mechanism, health gate, auto-rollback, and concurrency group. Inputs
travel as environment variables via envs: (never template-interpolated
into the script) and are format-validated by deploy.sh.
rollback.yml — manual rollback
File: .github/workflows/rollback.yml
workflow_dispatch with an environment choice (prod/stage) and an
optional tag input; empty means "that environment's previously deployed
tag" (from .deploy-state / .deploy-state.stage). Inputs reach the VPS
as environment variables (never template-interpolated into the shell
script) and are format-validated by deploy.sh. Shares the
deploy-production concurrency group with cancel-in-progress: true, so
a rollback preempts (cancels) an in-flight or pending run instead of
risking being silently replaced in the group's single pending slot.
Flips images only — Flyway migrations are never reverted; the
old jar boots against the newer schema via
deploy/docker-compose.rollback.yml
(staging twin: docker-compose.rollback.stage.yml).
The full story, including when to restore a dump instead:
vps-guide.md.
dependabot.yml — dependency updates
File: .github/dependabot.yml
Weekly across three ecosystems/directories:
| Ecosystem | Directory | Notes |
|---|---|---|
| Maven | /backend | Spring updates grouped (org.springframework* → one PR) |
| npm | /frontend | |
| GitHub Actions | / | keeps the workflows above current |
What is NOT in CI
Say it plainly:
- No full e2e gate. See
playwright.ymlabove: PRs run the smoke spec, the whole suite is manual. - Frontend unit coverage is one file.
test-frontendruns vitest, but there is exactly one spec infrontend/src— the job's value today is the typecheck and lint, plus catching the day someone adds a failing test. - The full e2e suite is manual-only. PRs run the smoke spec; the whole
suite runs only on
workflow_dispatchbecause of its known-failing tests (see theplaywright.ymlsection above). - The deploy gate is the container healthcheck plus the smoke e2e, not a full test suite — it catches "doesn't boot" and "doesn't serve", not "computes the wrong payroll".
What to run locally before merging (details in ../testing/README.md):
# Backend tests
cd backend && mvn test
# Frontend unit tests (vitest)
cd frontend && npm test
# E2E — requires the docker stack up with ALTCHA_ENABLED=false
docker compose up -d --build
npx playwright test
Seeing what failed
-
GitHub → Actions tab — pick the workflow run; each job (e.g.
Analyze (java) - payroll-backend) has its own log. -
Playwright failures: open the run, download the
playwright-reportartifact (available for 30 days), unzip it, then:npx playwright show-report path/to/unzipped/playwright-reportIt contains per-test traces and screenshots for the retried failures.
-
CodeQL findings land in the repo's Security → Code scanning tab, filed under the per-project category, not in the Actions log.