Skip to main content

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.

WorkflowTriggerGate?
tests.ymlPR to main/master; called by deploy.ymlYes — blocking
playwright.yml (smoke)push / PR to main/masterYes — blocking
playwright.yml (full)manual workflow_dispatchNo — deliberately not a gate
docs.ymlchanges to the docs sourcesYes for the docs site build
codeql.ymlpush / PR + weekly cronAdvisory
gitleaks.ymlpush / PRAdvisory (fails the job on a finding)
deploy.ymlpush to main / stagingThe pipeline itself
promote.yml, rollback.ymlmanual workflow_dispatchOperator 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 triggerdeploy.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:

  1. npm ci --no-audit --no-fund
  2. npm run type-checkblocking. This is tsc -b, the same check npm run build performs 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.
  3. npm run lintnot blocking. See below.
  4. npm testblocking. 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:

  1. Write a generated JWT_SECRET into .env. Compose defaults it to the literal string change-me-in-production, which JwtProperties.validateSecret rejects at @PostConstruct (as it does anything under 32 characters). With no .env on a runner the default applies, the backend dies during startup, and the only symptom compose reports is container 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.
  2. docker compose up --build --wait --wait-timeout 600 for db, backend and frontend. pgadmin is deliberately excluded; mailpit comes along because the backend depends on it.
  3. scripts/ci-e2e-seed.sh applies the rich demo seed and regenerates the transactional data.
  4. npx playwright install --with-deps chromium, then the tests.
JobConditionRunsTimeout
smokeany trigger except workflow_dispatche2e/smoke.spec.ts, Chromium only45 min
fullworkflow_dispatch onlythe whole suite, Chromium, --workers=2180 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:

  1. 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_ENABLED is false in stage and prod, so the only source is a dev-configured boot. A spec job boots the backend and runs docs-site/scripts/fetch-openapi.mjs, which pulls one document per GroupedOpenApi bean and publishes them as an openapi-specs artifact. The group list appears in three places — the workflow, fetch-openapi.mjs, and docusaurus.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.
  2. The site. npm run build in docs-site/ runs prepare-site first: stage-docs.mjs regenerates docs-site/docs/ from this docs/ tree plus docs-site/content/, then gen-api.mjs turns the specs into reference pages. The staging step fails on any unresolvable relative link in docs/ that is not in its KNOWN_BROKEN allow-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.
  3. The curation check. stage-docs.mjs keeps a DENY list 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 to DENY (or mark the file with an <!-- internal --> comment in its first lines, which stage-docs.mjs also 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: 0full 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.yml plus 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.

  1. 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.
  2. Backend (JUnit) — read the step's log first. If the errors are ExceptionInInitializerError in TenantTestSupport or NoClassDefFoundError in 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 the surefire-reports artifact.
  3. Frontend (typecheck, lint, vitest) — almost always npm run type-check. Reproduce with cd 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.
  4. 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 download playwright-report.
  5. docs — a broken relative link in docs/, or a page that should have been curated out. The message names the source file and the unresolved target.
  6. gitleaks — a credential is in the diff or in the history it scanned. Rotate it; deleting the line is not enough.
  7. 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.