CI quality gates
What GitHub Actions actually blocks, what it merely reports, and where to look when your PR turns red.
The short version: two things can stop a merge or a deploy — the unit and
integration suites in tests.yml, and the e2e smoke spec in playwright.yml.
Everything else on the list either scans for problems and reports them, or runs only
when a human presses a button. Nothing quarantines or excludes tests; the gates that
exist cover their whole suite.
The workflows live in .github/workflows/. All of the
automatic ones trigger on main/master only — a push to a feature branch runs
nothing until you open a PR.
| Workflow | Trigger | Gate? |
|---|---|---|
tests.yml | PR to main/master; called by deploy.yml | Yes — blocking |
playwright.yml (smoke) | push / PR to main/master | Yes — blocking |
playwright.yml (full) | manual workflow_dispatch | No — deliberately not a gate |
docs.yml | changes to the docs sources | Yes for the docs site build |
codeql.yml | push / PR + weekly cron | Advisory |
gitleaks.yml | push / PR | Advisory (fails the job on a finding) |
deploy.yml | push to main / staging | The pipeline itself |
promote.yml, rollback.yml | manual workflow_dispatch | Operator tools |
tests.yml — the unit and integration gate
File: .github/workflows/tests.yml
One definition, two callers. It runs standalone on every pull request targeting
main or master, and deploy.yml invokes
the same jobs through workflow_call before it builds anything. That is the whole
point of the arrangement: a deploy gate and a PR check that can disagree about what
"green" means is worse than having neither. There is deliberately no push
trigger — deploy.yml already calls this workflow on every push to main, so a
push trigger would run the identical suite twice per merge.
Two jobs:
test-backend — Backend (JUnit)
Temurin 21 with the Maven cache, then mvn -f backend/pom.xml test -B. The -f backend/pom.xml matters: the root pom.xml is the dead JavaFX project, and running
mvn test at the repo root silently runs nothing.
Before that there is a pre-pull step that retries docker pull postgres:16-alpine
up to three times. The tenancy tests start a real Postgres through Testcontainers
from a static initialiser with a bounded readiness timeout, so any registry problem —
a slow cold pull, or Docker Hub rate-limiting an anonymous request from a shared
runner IP — surfaces as ExceptionInInitializerError in TenantTestSupport, with
the classes that extend it reporting NoClassDefFoundError. Nothing in that output
mentions Docker or a registry, and the same commit passes locally, so it reads as a
code failure and is not one. Pulling first moves that work outside the readiness
clock and turns a registry hiccup into a retried step with an honest message. If you
change the image tag in TenantTestSupport, change it here too.
On failure the job uploads backend/target/surefire-reports/ as a 14-day
artifact. That is the first thing to download when the log is too noisy to read.
Timeout: 30 minutes. Details of the suite itself: backend-tests.md.
test-frontend — Frontend (typecheck, lint, vitest)
Node 22 with the npm cache, working-directory: frontend, then in order:
npm ci --no-audit --no-fundnpm run type-check— blocking. This istsc -b, the same checknpm run buildperforms first, so a green gate here means the production image build will not fail on types either. Type errors are what this job catches most often.npm run lint— not blocking. See below.npm test— blocking. Vitest. See frontend-tests.md.
Timeout: 20 minutes.
The deliberate non-gate: eslint. The lint step carries
continue-on-error: true, and that is a measured decision rather than an oversight.
npm run lint reports a backlog of problems on code that predates the gate —
react-hooks/static-components among them. Making it blocking on day one would fail
every deploy immediately, so it runs for visibility while the count is worked down.
The workflow's own comment states the exit condition: when the count reaches zero,
delete continue-on-error and add --max-warnings 0. An advisory lint that stays
advisory forever is a lint nobody reads.
Practical consequence: a lint regression will not turn your PR red. Open the
test-frontend job log and read the lint step yourself, or run npm run lint
locally, if you care whether you added to the backlog.
playwright.yml — end-to-end
File: .github/workflows/playwright.yml
Both jobs build the real stack on the runner and seed it before running anything:
- Write a generated
JWT_SECRETinto.env. Compose defaults it to the literal stringchange-me-in-production, whichJwtProperties.validateSecretrejects at@PostConstruct(as it does anything under 32 characters). With no.envon a runner the default applies, the backend dies during startup, and the only symptom compose reports iscontainer motorph_payroll_backend is unhealthy— nothing surfaces the real reason. Every other compose default is already CI-correct: ALTCHA off, so the API logins the helpers use work, and mailpit catching mail. docker compose up --build --wait --wait-timeout 600for db, backend and frontend. pgadmin is deliberately excluded; mailpit comes along because the backend depends on it.scripts/ci-e2e-seed.shapplies the rich demo seed and regenerates the transactional data.npx playwright install --with-deps chromium, then the tests.
| Job | Condition | Runs | Timeout |
|---|---|---|---|
smoke | any trigger except workflow_dispatch | e2e/smoke.spec.ts, Chromium only | 45 min |
full | workflow_dispatch only | the whole suite, Chromium, --workers=2 | 180 min |
smoke is the merge gate. Two tests: an API login plus totalElements == 100
on /api/employees, and the employees grid rendering that seed in a browser. Small
on purpose. What it proves is narrow but valuable — the images build, the migrations
run, the seed applies, auth works, and nginx proxies /api. What it does not prove
is that any of the business logic is right.
full is deliberately not a gate. The suite has documented known-failing tests,
so wiring it into PRs would mean a permanently red required check, which trains
everyone to ignore checks. Run it from the Actions tab when you want the whole
picture. Chromium only — firefox doubles the wall-clock for little extra signal on a
two-core runner, and --workers=2 overrides the config's workers: 1 because the
login helper already rides out the auth rate limiter across workers.
Both jobs dump the last 300 lines of backend and frontend container logs on failure,
and upload playwright-report/ as a 30-day artifact even when the run is
cancelled. To read one: download the artifact, unzip it, then
npx playwright show-report path/to/unzipped/playwright-report
Retries are 2 on CI and traces are captured on-first-retry, so a CI failure that
retried has a full trace attached; a local run at retries: 0 does not.
The authoritative full-suite signal remains a local run against the Docker stack — see e2e-playwright.md.
docs.yml — Documentation Central
File: .github/workflows/docs.yml
Builds and deploys this documentation site (docs.motorphenterprise.com) from
docs-site/. Three things happen, in order, and the build fails
if any of them does:
- The OpenAPI specs. The spec only exists at runtime — springdoc builds it by
reflecting over the controllers, and there is no committed artifact on purpose,
because a checked-in copy is stale the moment someone adds an endpoint.
SWAGGER_ENABLEDis false in stage and prod, so the only source is a dev-configured boot. Aspecjob boots the backend and runsdocs-site/scripts/fetch-openapi.mjs, which pulls one document perGroupedOpenApibean and publishes them as anopenapi-specsartifact. The group list appears in three places — the workflow,fetch-openapi.mjs, anddocusaurus.config.ts— and a coverage check fails the build if an endpoint ends up in no group at all, which is what stops a new controller from quietly vanishing from the reference. - The site.
npm run buildindocs-site/runsprepare-sitefirst:stage-docs.mjsregeneratesdocs-site/docs/from thisdocs/tree plusdocs-site/content/, thengen-api.mjsturns the specs into reference pages. The staging step fails on any unresolvable relative link indocs/that is not in itsKNOWN_BROKENallow-list. That is the gate most likely to catch you: a typo in a link from a docs page is a red build, not a broken link a reader discovers. - The curation check.
stage-docs.mjskeeps aDENYlist of pages that exist in the repo but must not be published — the demo-credentials references, the archive, the VPS guide and the hardening runbook. Inbound links to them are rewritten to GitHub rather than left dangling. The workflow gates on a check that none of that curated-out content reached the built site. If you add a page containing operator secrets, firewall posture or demo logins, add it toDENY(or mark the file with an<!-- internal -->comment in its first lines, whichstage-docs.mjsalso honours) in the same commit.
Because docs-site/docs/ is generated and gitignored, never edit it. Edit
docs/ — the single source of truth, byte-identical between GitHub and the site —
or docs-site/content/ for pages that only make sense on the website.
If the docs build is red and your change was a documentation change, the cause is almost always a link that points at a file you moved or renamed. The failure message names the source file and the target it could not resolve.
codeql.yml — static analysis
File: .github/workflows/codeql.yml
Push and 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 has not
changed recently. Two jobs: Java (init → mvn -f backend/pom.xml -DskipTests compile
→ analyze, category /language:java-payroll-backend) and JavaScript/TypeScript (no
build step needed for extraction).
The results do not go to the Security tab. Code scanning's SARIF ingest is a
GitHub Advanced Security feature; it is free on public repositories but needs a Code
Security licence on a private one, which is not available on a personal account at
all. The upload therefore failed after a completely successful analysis, and no
combination of workflow permissions changed that. Both jobs run with upload: never
and publish the SARIF as a 30-day build artifact instead —
codeql-sarif-java and codeql-sarif-javascript. To read findings, download the
artifact and open it with VS Code's SARIF Viewer extension, or
jq '.runs[].results' for a quick look.
The scan still runs on every push and PR, and the job is honestly green when the code
is clean. If the repo ever goes public, deleting the upload/output lines restores
the Security-tab integration with no other change; the category: value keeps the
alert history intact.
gitleaks.yml — secret scanning
File: .github/workflows/gitleaks.yml
Push and 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 or PR range for
committed credentials, then runs gitleaks/gitleaks-action@v2. On pull requests it
needs pull-requests: write because it lists the PR's commits through the API and
comments findings on the PR.
If this job goes red, treat the finding as real until proven otherwise, and remember that removing the secret in a follow-up commit does not remove it from history — the credential still needs rotating.
deploy.yml — how the gate reaches production
File: .github/workflows/deploy.yml
Push to staging deploys staging and stops. Push to main deploys staging, and then
— only if staging's health gate passed — promotes the same image tag to production.
main never skips staging: promotion is what makes it safe, and it can only promote
something that already booted and turned healthy somewhere else. A branch guard skips
every job on any other ref, so a manual dispatch from a feature branch does nothing.
The part that matters for this page is the first job:
test:
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging'
uses: ./.github/workflows/tests.yml
Both build jobs declare needs: test. A red suite stops the pipeline before a
single image is built — nothing reaches GHCR, staging or production. And because
it is the identical workflow file your PR ran, a PR check and a deploy gate cannot
disagree about what "green" means.
Everything after that is deployment mechanics rather than a quality gate: both images
build in parallel and push to GHCR at an immutable sha-<12-hex> tag (:latest only
from main), the VPS env files are rewritten from GitHub Secrets, and each deploy
step takes a pre-deploy database dump, gates on the backend healthcheck, and
auto-rolls-back its own environment on failure. Both deploy jobs declare the
Production GitHub environment — on the staging job that name is a secrets-scope
selector, not a claim about what it deploys, because environment-scoped secrets
resolve only in jobs declaring that same environment. Concurrency group
deploy-production with cancel-in-progress: false keeps one VPS operation at a
time.
Full walkthrough: ../deployment/ci.md and ../git-workflow.md.
promote.yml and rollback.yml — operator tools
Neither is a gate; both are workflow_dispatch only, and both share the
deploy-production concurrency group.
promote.yml re-ships an already-built tag to
production — after a rollback, or to redeploy with updated Secrets without
rebuilding. An empty tag input means "whatever staging is currently running".
rollback.yml re-deploys a previous image
tag on prod or stage; an empty tag means that environment's previously deployed
tag. It sets cancel-in-progress: true so a rollback preempts an in-flight or
pending run instead of risking being replaced in the group's single pending slot.
It flips images only — Flyway migrations are never reverted.
dependabot.yml
File: .github/dependabot.yml
Weekly across three ecosystems: Maven in /backend (with org.springframework*
grouped into one PR), npm in /frontend, and GitHub Actions in /. Dependabot PRs
run the same gates as any other PR.
What is deliberately not gated
Worth saying plainly, because "we have CI" invites the wrong assumption:
- The full e2e suite. PRs run two smoke tests. The other ~790 run only on
workflow_dispatch, because the suite has known-failing tests. - eslint.
continue-on-error, by decision, until the backlog reaches zero. - Frontend coverage generally. Vitest gates, but the frontend's unit coverage is a handful of files; almost everything else is exercised through e2e, which is not gated. See frontend-tests.md.
- Correctness at deploy time. The deploy gate is
tests.ymlplus a container healthcheck. It catches "does not compile", "does not boot" and "does not serve". It does not catch "computes the wrong payroll" — only the backend suite and a manual full e2e run do that.
Your PR just went red
Work down this list.
- Which job? Open the run from the Actions tab or the PR's checks. Each job has its own log; the failing step is expanded by default.
Backend (JUnit)— read the step's log first. If the errors areExceptionInInitializerErrorinTenantTestSupportorNoClassDefFoundErrorin classes that extend it, and the pre-pull step also complained, it is a registry problem and not your code — re-run the job. Otherwise download thesurefire-reportsartifact.Frontend (typecheck, lint, vitest)— almost alwaysnpm run type-check. Reproduce withcd frontend && npm run type-check. If the log shows lint problems but the job is green, that step is advisory and did not fail you.smoke— the stack failed to build, boot, seed or serve. The job dumps the last 300 lines of the backend and frontend container logs on failure; read those before the Playwright output. A backend that reports only "unhealthy" is usually a startup validation failure. Then downloadplaywright-report.docs— a broken relative link indocs/, or a page that should have been curated out. The message names the source file and the unresolved target.gitleaks— a credential is in the diff or in the history it scanned. Rotate it; deleting the line is not enough.- CodeQL — the job goes green on clean code and the findings are in the
codeql-sarif-*artifact, not in the Security tab.
Before pushing, the local equivalents:
cd backend && mvn test # the backend gate
cd frontend && npm run type-check # the check that fails most often
cd frontend && npm test # vitest
cd frontend && npm run lint # not gated — run it anyway
cd docs-site && npm run build # link check + curation check + site build
npx playwright test # from the repo root, stack up, ALTCHA off
Suite-by-suite detail: README.md, backend-tests.md, frontend-tests.md, e2e-playwright.md.