Skip to main content

Continuous integration

Vortex offers continuous integration configurations for GitHub Actions and CircleCI that automate the process of building, testing, and deploying your site.

The workflow structure is identical for both continuous integration providers. Choose one of them and follow its setup instructions.

The continuous integration pipeline consists of multiple jobs executed in a drevops/ci-runner container to ensure consistency across runs. Each job installs the drevops/vortex-tooling scripts via scripts/vortex-tooling.sh, so the vendor/bin/vortex-* commands are available before the codebase is assembled.

Workflow structure

Local Development
═════════════════════════════════════════════════════════════════════════════════════════
Developer writes code ──► Build and test locally ──► Commit changes


Git Repository
═════════════════════════════════════════════════════════════════════════════════════════
Push to remote branch ──► Open/Update Pull Request


┌─ CI Pipeline ────────────────────────────────────────────────────────────────────────────┐
│ │
│ ┌─ Database Job (Nightly) ───────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Scheduled ──► Download production ──► Sanitize database ──► Store database cache │ │
│ │ trigger database Remove sensitive data │ │
│ │ │ │
│ └─────────────────────────────────────┬──────────────────────────────────────────────┘ │
│ │ Provides cached database │
│ ▼ │
│ ┌─ Lint Job ────────────┐ ┌─ Database Job ────────────────┐ ┌─ Audit Job ──────────┐ │
│ │ │ │ ◆ Nightly cache exists? │ │ Composer audit │ │
│ │ Build CLI container │ │ Yes ──► job succeeds │ │ (advisories) │ │
│ │ ▼ │ │ No ──► download, sanitize, │ │ ▼ │ │
│ │ Composer validate │ │ store cache │ │ Gitleaks │ │
│ │ ▼ │ └───────────────┬───────────────┘ │ (committed secrets) │ │
│ │ Composer normalize │ ▼ │ │ │
│ │ ▼ │ ┌─ Build Job ───────────────────┐ │ │ │
│ │ Hadolint │ │ Code assembly │ │ │ │
│ │ ▼ │ │ Docker, Composer deps, │ │ │ │
│ │ DCLint │ │ NPM deps, assets │ │ │ │
│ │ ▼ │ │ ▼ │ │ │ │
│ │ PHPCS │ │ Website setup │ │ │ │
│ │ ▼ │ │ Import cached DB, drush │ │ │ │
│ │ PHPStan │ │ deploy, custom scripts │ │ │ │
│ │ ▼ │ │ ▼ │ │ │ │
│ │ Rector │ │ Testing │ │ │ │
│ │ ▼ │ │ PHPUnit tests ──► Behat tests │ │ │ │
│ │ Twig CS Fixer │ └───────────────┬───────────────┘ │ │ │
│ │ ▼ │ │ │ │ │
│ │ Gherkin Lint │ │ │ │ │
│ │ ▼ │ │ │ │ │
│ │ ESLint / Stylelint │ │ │ │ │
│ │ │ │ │ │ │
│ └──────────┬────────────┘ │ │ │ │
│ │ │ │ │ │
│ ▼ ▼ │ │ │
│ ┌─ Deployment Job ─────────────────────────────────────────┐ │ │ │
│ │ │ │ │ │
│ │ Webhook Artifact Lagoon │ │ Does not gate │ │
│ │ Call URL Package artifact Run Lagoon CLI deploy │ │ deployment │ │
│ │ │ │ │ │
│ └──────────────────────────────────────────────────────────┘ └──────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────┘


Hosting Platform
═════════════════════════════════════════════════════════════════════════════════════════
◆ Environment ──No──► Sync DB from production ───┐
exists? │
│ Yes ▼
└──────────────────────────────────► drush deploy ──► Custom scripts ──► Notifications


Available Environments
═════════════════════════════════════════════════════════════════════════════════════════
┊ PR Environment ┊ Dev Staging Production
┊ (auto-removed) ┊ develop branch main branch production branch or tag

1. Lint

  • Runs in parallel with other jobs (no dependencies)
  • Builds only the CLI container (no database or other services needed)
  • Validates Composer configuration
  • Lints Dockerfiles and Docker Compose files
  • Installs development dependencies
  • Runs all code linters: PHPCS, PHPStan, Rector, Twig CS Fixer, Gherkin Lint, ESLint, Stylelint
  • Checks that Composer configuration is normalized

2. Database

  • Fetches the latest database version based on a caching strategy
  • Caches database dumps to speed up the follow-up runs

3. Build

  • Runs after the database job
  • Uses Docker Compose to set up the full environment
  • Validates Composer configuration
  • Assembles the codebase by installing dependencies
  • Provisions a website
  • Runs PHPUnit and Jest tests (first container only)
  • Checks code coverage and posts a PR comment (first container only)
  • Runs BDD tests (distributed across all containers - see Test parallelism)
  • Collects and stores test results and artifacts

4. Deployment

  • Runs after successful completion of both build and lint jobs
  • Uses the built codebase without development dependencies from the build step
  • Adds required secrets and environment variables
  • Triggers a deployment using a router script - see Deployment

Security audit

Security checks run in their own workflow, separate from the pipeline above, so that a failing audit is never confused with a failing linter and can be re-run on its own:

ProviderLocation
GitHub ActionsThe Security audit workflow in .github/workflows/audit.yml
CircleCIThe audit workflow in .circleci/config.yml

The workflow runs the same 2 checks in both providers, and needs neither the application containers nor installed dependencies:

  • composer audit --locked checks the packages pinned in composer.lock against published security advisories
  • Gitleaks scans the codebase for committed secrets

Every check runs even if an earlier one failed, so a single run reports all the findings at once. The workflow fails if any of the checks failed, unless that check's _IGNORE_FAILURE variable (see Ignore tool failures) is set to 1.

It is triggered by the same pushes, pull requests and tags as the main pipeline, and can also be started on demand - in GitHub Actions from Actions → Security audit → Run workflow, and in CircleCI by re-running the audit workflow from the pipeline view.

note

Because the audit is a separate workflow, it is not a dependency of the deploy job - a failing audit does not by itself stop a deployment. To block merges and deployments on it, add its check to the repository's branch protection rules as a required status check.

Caching strategy

The database is fetched overnight by a scheduled run and cached, so the follow-up continuous integration runs on the same day reuse the cached dump instead of downloading a fresh one.

The cache key is built from a configured cache source branch (the VORTEX_CI_DB_CACHE_BRANCH variable, develop by default) and a daily timestamp - every run on any branch reads the same shared cache. If no cache exists for the current day, the previous day's cache for the same source branch is used as a fallback.

note

Database caching speeds up continuous integration runs considerably on projects with a lot of data.

For a project with a large database (over 1 GB), the database import itself may take a long time, so it may be worth packaging the database dump into a container image (overnight) or using a sanitized database dump with only the data the tests require.

Vortex supports both creating and using a database container image with embedded data. You may use MariaDB data container for Drupal with database captured as Docker layers to create an initial database image.

Other tools serve the same goal: Drush GDPR Dumper, for example, removes data during the Drush database export itself, without an intermediate database import step.

Reset the cache

If you need to force a fresh cache (e.g., to pull a new database dump outside of the regular schedule), increment the last segment of the version tag in the cache keys:

# Before
v26.8.0
# After
v26.8.1

The version tag is the Vortex release version (CalVer). Bumping only its last segment keeps a project's own cache resets from colliding with the version shipped by a future Vortex update.

Trigger conditions

Both providers build on branch pushes, pull requests, tags matching semantic version (1.2.3, 1.2.3-rc.1) or date-based (2023-04-17) patterns, and a nightly schedule that refreshes the database cache.

The exact branch filters differ per provider:

  • GitHub Actions builds pushes to long-lived branches (production, main, master, develop, release/**, hotfix/**, project/**) and pull requests from any branch, so short-lived branches like feature/** and bugfix/** trigger builds through their pull requests - see GitHub Actions.
  • CircleCI builds every pushed branch, and restricts only the deployment job to a configured branch list - see CircleCI.

Test parallelism

The build job runs across multiple parallel containers (2 by default) to speed up test execution. Since each container runs the full build and provision steps, the test workload is distributed to make the best use of each container.

Code linting runs in a separate lint job and is not affected by test parallelism settings.

What runs where

TaskFirst containerOther containers
Jest tests-
PHPUnit tests-
Code coverage check and PR comment-
Single Directory Component validation-
Behat tests✓ (profile p0)✓ (profile p1, p2, ...)

Everything except Behat runs exclusively on the first container to avoid duplicate work. Behat tests run on all containers using profile-based distribution.

Choosing which container runs what

Each tool reads a VORTEX_CI_IS_<TOOL>_RUNNER variable that decides whether it runs on the current container. All of them are declared together at the top of the build job, so the whole distribution is visible and editable in one place:

.github/workflows/build-test-deploy.yml
env:
VORTEX_CI_RUNNER_INDEX: ${{ strategy.job-index }}
VORTEX_CI_RUNNER_TOTAL: ${{ strategy.job-total }}
VORTEX_CI_IS_JEST_RUNNER: ${{ matrix.instance == 0 || strategy.job-total == 1 }}
VORTEX_CI_IS_PHPUNIT_RUNNER: ${{ matrix.instance == 0 || strategy.job-total == 1 }}
VORTEX_CI_IS_SDC_DEVEL_RUNNER: ${{ matrix.instance == 0 || strategy.job-total == 1 }}
VORTEX_CI_IS_BEHAT_RUNNER: true
VORTEX_CI_BEHAT_PROFILE_OFFSET: 0

Each tool's step then reads its own flag:

- name: Test with PHPUnit
if: ${{ env.VORTEX_CI_IS_PHPUNIT_RUNNER == 'true' }}

VORTEX_CI_RUNNER_INDEX and VORTEX_CI_RUNNER_TOTAL carry the current container's index and the container count under the same names on both providers. VORTEX_CI_BEHAT_PROFILE_OFFSET is subtracted from the container index to derive the Behat profile number - see Giving a tool its own container.

To run a tool of your own on a specific container, add one more flag alongside the others and reference it from your step:

.github/workflows/build-test-deploy.yml
VORTEX_CI_IS_CYPRESS_RUNNER: ${{ matrix.instance == 1 }}

Giving a tool its own container

Start by adding a container for the tool to move onto - the default configuration has containers 0 and 1 only. Then point the tool's flag at the new container and exclude that container from Behat. With a third container added, that is (GitHub Actions shown - mirror the conditions in CircleCI's Set test runner roles step):

VORTEX_CI_IS_PHPUNIT_RUNNER: ${{ matrix.instance == 2 }}
VORTEX_CI_IS_BEHAT_RUNNER: ${{ matrix.instance != 2 }}
warning

Point the flag at a container that exists. A flag whose condition matches no container disables the tool everywhere, and nothing reports it - the steps are skipped and the job still passes.

Behat derives its profile number from the container index minus VORTEX_CI_BEHAT_PROFILE_OFFSET, and p0 is the catch-all that runs every scenario without a @pX tag. With the dedicated container at the end, the offset stays 0: the containers before it keep their profiles.

To dedicate a leading container instead - for example, pinning every once-only tool to container 0 and keeping Behat off it - raise the offset by one for each leading container excluded from Behat, so the first Behat container still selects the p0 catch-all:

VORTEX_CI_IS_JEST_RUNNER: ${{ matrix.instance == 0 }}
VORTEX_CI_IS_PHPUNIT_RUNNER: ${{ matrix.instance == 0 }}
VORTEX_CI_IS_SDC_DEVEL_RUNNER: ${{ matrix.instance == 0 }}
VORTEX_CI_IS_BEHAT_RUNNER: ${{ matrix.instance != 0 }}
VORTEX_CI_BEHAT_PROFILE_OFFSET: 1
ContainerRoleBehat profile
0Jest, PHPUnit, coverage, SDC validation-
1Behatp0 (catch-all)
2Behatp1

behat.yml stays untouched: the profiles remain numbered from p0 and no feature file is re-tagged.

warning

When VORTEX_CI_BEHAT_PROFILE is unset, the offset expresses one contiguous block of Behat containers starting at the offset. A Behat container below the offset derives a negative profile name and the run fails. A gap inside the block leaves the profile at the gap's position unselected, and its scenarios silently stop running. An explicitly set VORTEX_CI_BEHAT_PROFILE bypasses the derivation entirely.

Balancing Behat tests

Because the first container handles Jest, PHPUnit and coverage in addition to Behat tests, it has more work to do than the other containers. To keep the overall build time low, assign more Behat scenarios to the non-first containers.

Behat scenarios are assigned to containers using profile tags. A scenario without a profile tag runs on the first container; tag it @p1 to move it to the second container; tag it @smoke to run it on every container:

Scenario: Quick smoke test (untagged, stays on the first container)
Given I go to the homepage
Then I should see "Welcome"

@p1
Scenario: Full content workflow (runs on the second container)
Given I am logged in as a content editor
...

When only one container is available, all scenarios run there regardless of tags.

tip

As a rule of thumb, keep lightweight scenarios on the first container and move heavier or more numerous scenarios to additional containers (@p1, @p2, etc.). This keeps the total build time closer to the duration of the longest single container rather than the sum of all tests.

Note that @smoke scenarios - used to check that Behat itself is configured and works correctly - run on every runner by design, so keep them few and fast.

See Behat > Profiles for how the shipped profiles map tags to runners.

Adding more containers

Raising the container count takes two changes that must stay in step - the container count itself, and a matching Behat profile for every new container.

  1. Increase the container count. See the provider-specific pages:

  2. Add a profile to behat.yml for each new container that runs Behat. Vortex ships with p0 and p1 only, and Behat fails with profile 'p2' does not exist if a container running Behat has no profile named after its derived number (its index minus VORTEX_CI_BEHAT_PROFILE_OFFSET). A container excluded from Behat needs no profile:

    behat.yml
    p2:
    gherkin:
    cache: '/tmp/behat_gherkin_cache'
    filters:
    tags: '@smoke,@p2&&~@skipped'
  3. Exclude the new tag from the p0 catch-all, so its scenarios do not also run on the first container:

    behat.yml
    p0:
    gherkin:
    cache: '/tmp/behat_gherkin_cache'
    filters:
    tags: '@smoke,~@p1&&~@p2&&~@skipped'
  4. Tag scenarios with @p2 to move them onto the new container.

Scenarios tagged @smoke run on every container by design, so they stay out of the balancing arithmetic.

Maintenance

Pin SSH host keys

Strict host-key checking is disabled by default for the SSH connections the pipeline makes to the hosting provider. To enable it, add the verified host keys as a VORTEX_SSH_KNOWN_HOSTS variable in your provider's settings - on GitHub Actions as a repository variable mapped into the workflow env block, on CircleCI as an environment variable with multiple entries joined with \n. The steps that load the SSH keys skip their known_hosts file by default; set the VORTEX_FETCH_DB_SSH_KNOWN_HOSTS (database fetch) and VORTEX_DEPLOY_SSH_KNOWN_HOSTS (deployment) variables to pin those host keys too.

Enable debug mode

To get verbose output when troubleshooting build failures, enable debug mode by setting the VORTEX_DEBUG variable to 1 in your CI provider's settings.

Runner disk space

Hosted runners come with a fixed amount of disk space, and running out of it is easy to misread: the runner is terminated from the outside, the step that was running never reports an error, and the failure looks like a hang rather than a disk problem. If a build dies during provisioning without reporting an error, suspect the disk first.

The most reliable fix is to reduce what has to fit: a sanitized dump or a database container image instead of a full dump.

  • On GitHub Actions, Vortex prints the disk state in every run and can reclaim space held by preinstalled toolchains - see GitHub Actions > Runner disk space.
  • On CircleCI, disk space is not tied to the resource class, so upgrading it adds CPU and memory but no extra room for the build.

Update CI runner image

The CI jobs run inside the drevops/ci-runner container - a Docker image specifically designed for CI job execution. It provides a consistent, reproducible environment with 25+ pre-installed tools:

  • PHP & Node.js - PHP 8.4, Node.js, Composer, npm, Yarn
  • Docker tools - Docker, Docker Compose, Docker Buildx
  • Code quality - ShellCheck, shfmt, Bats testing framework
  • Utilities - Git, curl, rsync, jq, and more

Using this image ensures all CI runs have identical tooling, eliminating environment inconsistencies between local development and CI. It also speeds up builds by avoiding repetitive installation of common tools.

To update to a newer version, change the image tag in your CI configuration file. The image follows CalVer versioning (e.g., 26.8.0) with monthly releases.

Ignore tool failures

Sometimes you may want to allow builds to pass despite linter and test failures.

Set the corresponding VORTEX_CI_*_IGNORE_FAILURE variable to 1 to ignore failures (but still run the tool and see the results in the logs):

ToolPurposeVariable
BehatRun BDD acceptance testsVORTEX_CI_BEHAT_IGNORE_FAILURE
Composer normalizeEnsure composer.json is sortedVORTEX_CI_COMPOSER_NORMALIZE_IGNORE_FAILURE
Composer security auditCheck dependencies for vulnerabilitiesVORTEX_CI_COMPOSER_AUDIT_IGNORE_FAILURE
Composer validateValidate composer.json and lock fileVORTEX_CI_COMPOSER_VALIDATE_IGNORE_FAILURE
DCLintLint Docker Compose filesVORTEX_CI_DCLINT_IGNORE_FAILURE
ESLint and StylelintLint JavaScript and CSSVORTEX_CI_NODEJS_LINT_IGNORE_FAILURE
Gherkin LintLint Behat feature filesVORTEX_CI_GHERKIN_LINT_IGNORE_FAILURE
GitleaksScan the codebase for committed secretsVORTEX_CI_GITLEAKS_IGNORE_FAILURE
HadolintLint Dockerfiles for best practicesVORTEX_CI_HADOLINT_IGNORE_FAILURE
JestRun JavaScript unit testsVORTEX_CI_JEST_IGNORE_FAILURE
PHPCSCheck PHP coding standardsVORTEX_CI_PHPCS_IGNORE_FAILURE
PHPStanStatic analysis for PHPVORTEX_CI_PHPSTAN_IGNORE_FAILURE
PHPUnitRun unit, kernel, and functional testsVORTEX_CI_PHPUNIT_IGNORE_FAILURE
RectorCheck for automated refactoring rulesVORTEX_CI_RECTOR_IGNORE_FAILURE
SDC DevelValidate Single Directory ComponentsVORTEX_CI_SDC_DEVEL_IGNORE_FAILURE
Twig CS FixerLint Twig templatesVORTEX_CI_TWIG_CS_FIXER_IGNORE_FAILURE

Configure deployment skip conditions

Deployments can be skipped for specific branches or pull requests while their CI checks keep running - see Deployment > Skipping deployments.