Skip to content

Pipeline Overview

The Twycis shared CI/CD setup builds container images on GitHub Actions and ships them to two long-lived environments — test and production — over an SSH connection tunnelled through Cloudflare. This page consolidates the trigger model, the shared quality gates, and the gating logic that protects each deploy job. Drill into the per-workflow pages for the full step-by-step walkthroughs.

Two environments only

This setup runs exactly two tiers: test (test.<your-domain>, deploy path /opt/app-name) and production (<your-domain>, deploy path /opt/app-name-prod). Any "acceptance" tier from older docs has been retired.

Trigger model

The environment you deploy to is decided entirely by how the pipeline is triggered:

Environment Trigger Image tag Deploy path GitHub Environment
Test Push or PR to main (plus workflow_dispatch) :${{ github.sha }} /opt/app-name test
Production Push a v* tag (plus workflow_dispatch) :${{ github.ref_name }} (the tag) /opt/app-name-prod production

Pull requests and pushes to main run the quality gates and (on push to main) deploy to test. Production deploys are deliberately gated behind a version tag — see Deploy to Production for how to cut a release.

Concurrency

The test workflow declares concurrency with cancel-in-progress: true, keyed on ${{ github.workflow }}-${{ github.ref }}. A newer push to the same branch cancels the in-flight run so the latest commit always wins.

Shared quality gates

Both workflows run the same family of gate jobs before any deploy job is allowed to start. Each gate is an independent job; the deploy job needs all of them.

Gate Job What it enforces
SonarCloud Quality Gate quality-gate / sonar Runs frontend + backend coverage, scans with SonarCloud, then verifies the Quality Gate status is OK for the exact commit SHA. Only runs when vars.SONAR_ENABLED == 'true'. See SonarCloud.
E2E results e2e-gate Reads tests/e2e/.results.json, confirms the recorded commit is an ancestor of HEAD, that no untested code changed since, and that status == passed.
Security audit security-audit Runs npm audit --audit-level=high on dependencies.
Architecture check (deploy job step) Runs node scripts/check-architecture.js to enforce module/layering rules before images are built.
Trivy scans (deploy job steps) Scans built images and the filesystem (scan-type: fs) for CRITICAL vulnerabilities; exit-code: 1 fails the deploy.

Dependabot exception

On the test workflow, the SonarCloud and E2E gates skip when the PR actor is dependabot[bot], since Dependabot PRs cannot access the required secrets and do not carry fresh E2E results.

Deploy job gating

The deploy job uses a compound if: so it only runs when every gate passed (or was legitimately skipped) and the trigger is correct.

For test (deploy-test):

if: |
  !cancelled() &&
  (needs.quality-gate.result == 'success' || needs.quality-gate.result == 'skipped') &&
  needs.e2e-gate.result == 'success' &&
  needs.security-audit.result == 'success' &&
  (github.event_name == 'push' || github.event_name == 'workflow_dispatch') &&
  github.ref == 'refs/heads/main'

For production (deploy-production), the same gate logic applies but the trigger is the v* tag, so there is no github.ref == 'refs/heads/main' clause:

if: |
  !cancelled() &&
  (needs.sonar.result == 'success' || needs.sonar.result == 'skipped') &&
  needs.e2e-gate.result == 'success' &&
  needs.security-audit.result == 'success'

Note

The (... == 'success' || ... == 'skipped') pattern on the Sonar gate is what lets SONAR_ENABLED=false repos and Dependabot PRs proceed without weakening the gate when Sonar is enabled.

Pipeline diagram

flowchart TD
    A[Push / PR to main] --> G
    B[Push v* tag] --> G
    subgraph G[Quality gates]
        direction LR
        S[SonarCloud gate]
        E[E2E results gate]
        SA[npm audit]
    end
    G --> D{All gates pass?}
    D -- no --> X[Pipeline fails]
    D -- yes --> ARCH[check-architecture.js]
    ARCH --> BUILD[Build & push images to GHCR<br/>buildx + gha cache]
    BUILD --> TRIVY[Trivy image + fs scans]
    TRIVY --> SSH[Configure SSH via Cloudflare Tunnel]
    SSH --> RSYNC[rsync nginx / scripts / docker-compose.yml]
    RSYNC --> DEPLOY[Remote deploy.sh:<br/>pull / down / migrate / sync-manual / up --wait]
    DEPLOY --> HC{Healthy?}
    HC -- no --> ROLL[Tail logs + non-zero exit]
    HC -- yes --> DONE[Deployment complete]

Where to go next