Skip to main content

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.

JobTriggerRunsTimeout
smokepush / PR to main, mastere2e/smoke.spec.ts, Chromium only45 min
fullmanual workflow_dispatchwhole suite, Chromium only, --workers=2180 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.

JobRunsNotes
test-backendmvn -f backend/pom.xml test -B373 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-frontendnpm citype-checklinttesttype-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: 0full 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

AspectValue
Triggerpush 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 routingstaging → 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
:latestPushed 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 carryThe 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
Concurrencygroup 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 gatetest (tests.yml via workflow_call) runs first; both build jobs needs: test, so nothing is built or pushed when the suite is red
Build jobsbuild-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
EnvironmentsBoth 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 tagsghcr.io/jomariabejo/motorph-payroll-{backend,frontend} tagged sha-<12-hex> (immutable, what deploys) and latest (convenience only)
Config syncThe 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-stagingSSHes 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-productionneeds: deploy-staging — runs only if staging turned healthy, then deploy.sh promote sha-<tag>: the same run's exact tag, no rebuild
SafetyEach 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:

EcosystemDirectoryNotes
Maven/backendSpring 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.yml above: PRs run the smoke spec, the whole suite is manual.
  • Frontend unit coverage is one file. test-frontend runs vitest, but there is exactly one spec in frontend/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_dispatch because of its known-failing tests (see the playwright.yml section 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-report artifact (available for 30 days), unzip it, then:

    npx playwright show-report path/to/unzipped/playwright-report

    It 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.