Skip to main content
Version: v2

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 on the CI provider's standard environment - a CircleCI convenience image or a GitHub-hosted runner - with the required tooling installed at job time.

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 ──────────────────────────────────────────────────────────────────────────────┐
│ │
│ The Lint, Test and Build jobs run in parallel. Deployment runs after all three pass. │
│ │
│ Lint Job │
│ Build CLI container ──► Composer validate / normalize ──► Hadolint ──► │
│ DCLint ──► PHPCS ──► PHPStan ──► Rector ──► Twig CS Fixer ──► │
│ Gherkin Lint ──► ESLint / Stylelint │
│ │
│ Test Job │
│ Restore the database cache. On the first build of the day (cache miss), each runner │
│ downloads the production database and exports a clean pre-provision dump; only the │
│ primary runner stores it in the cache for the rest of the day's builds. │
│ ▼ │
│ Build Docker ──► Composer deps ──► NPM deps ──► Assets │
│ ▼ │
│ Import cached database ──► drush deploy ──► custom deploy steps │
│ ▼ │
│ PHPUnit tests ──► Behat tests │
│ │
│ Build Job │
│ Assemble and validate the deployable artifact │
│ │
│ Deployment Job (after Lint, Test and Build pass) │
│ Webhook URL ──► artifact package ──► Lagoon webhook ──► Docker container image │
│ │
└────────────────────────────────────────────────────────────────────────────────────────────┘


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

  • Runs in parallel with the lint and build jobs
  • Fetches the database on the first build of the day and caches it for the rest of the day's builds (only one runner stores the cache when running in parallel)
  • 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

3. Build

  • Runs in parallel with the lint and test jobs (no dependencies)
  • Uses Docker Compose to build the production stack
  • For artifact-based hosting (e.g. Acquia), exports the built codebase for the deployment job
  • For image-based hosting (e.g. Lagoon), a successful build confirms that the production images are deployable

4. Deployment

  • Runs after successful completion of the lint, test, and build jobs
  • Uses the built codebase without development dependencies from the build job
  • 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 on the first continuous integration run of the day and cached so that the remaining runs on the same day reuse the cached database dump.

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.

In case of a project with a large database >1GB, the database import itself may take a long time, so it may be worth looking into either packaging the database dump into a container image or using a sanitized database dump with only the required data for the tests.

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 before the daily cache refreshes), 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.

  • Push events to the following branches:
    • production, main, master, develop
    • feature/*, bugfix/*
    • release/*, hotfix/* (semantic version or date-based, e.g., release/1.2.3, hotfix/2023-04-17)
    • project/*
    • ci*
  • Pull requests to these branches
  • Tags matching semantic version (1.2.3, 1.2.3-rc.1) or date-based (2023-04-17) patterns

Test parallelism

The test job runs across multiple parallel containers (2 by default) to speed up test execution. Since each container runs the full provision and test 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 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

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.

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

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:
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 simply skipped and the job still passes.

Behat derives its profile name from the container index, and p0 is the catch-all that runs every scenario without a @pX tag. Excluding container 0 from Behat would therefore leave p0 without a container and those scenarios silently untested, so profile numbers count from the first container that runs Behat instead: VORTEX_CI_BEHAT_PROFILE_OFFSET is subtracted from the container index to pick the profile. Raise it by one for every leading container excluded from Behat, and the first Behat container still selects p0:

VORTEX_CI_IS_PHPUNIT_RUNNER: ${{ matrix.instance == 0 }}
VORTEX_CI_IS_BEHAT_RUNNER: ${{ matrix.instance != 0 }}
VORTEX_CI_BEHAT_PROFILE_OFFSET: 1

Balancing Behat tests

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

Behat scenarios are assigned to containers using profile tags. Tag a scenario with @p0 to run it on the first container or @p1 to run it on the second:

@p0
Scenario: Quick smoke test
Given I go to the homepage
Then I should see "Welcome"

@p1
Scenario: Full content workflow
Given I am logged in as a content editor
...

Scenarios without a profile tag default to the first container. When only one container is available, all scenarios run regardless of tags.

tip

As a rule of thumb, keep lightweight or smoke-test scenarios on the first container (@p0) 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.

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

Vortex keeps the build within that budget:

  • The test and build jobs print df -h in a Report disk usage step, so the disk state is on the record for every run.
  • The Docker build cache and dangling images are pruned once the stack is up.
  • The database dump is removed from the runner as soon as it has been copied into the container, so only one copy is held during provisioning.

Reclaim the preinstalled toolchains

On GitHub Actions, the test and build jobs can remove the preinstalled toolchains no Vortex job uses - GHCup, Swift, PowerShell, .NET and CodeQL - before doing anything else. In the measurement above this freed about 15 GB in around 14 seconds, taking the job from roughly 13 GB of headroom to 28 GB.

The removal is destructive to the runner for the rest of the job, so it is off by default. Turn it on by setting the VORTEX_CI_FREE_DISK_SPACE variable to 1 in Settings → Secrets and variables → Actions → Variables. Leave it unset on a project whose workflow runs a step that needs Haskell, Swift, PowerShell, .NET or CodeQL.

note

Every figure here comes from one runner image at one point in time, and GitHub reissues that image regularly. Read the Report disk usage output of a recent run for the current numbers - if the reclaimed amount has shrunk, the path list needs revisiting.

Free even more space

With VORTEX_CI_FREE_DISK_SPACE enabled, the Android SDK is the single largest remaining tree at about 10 GB, but removing it takes around 50 seconds. It ships commented out in the workflow for that reason - uncomment the line in the Free up disk space on the runner step of both the test and build jobs to enable it:

set -- "$@" /usr/local/lib/android

When space still runs out

If a build dies during provisioning without reporting an error, suspect the disk and read the Report disk usage output. 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, a larger runner also comes with more disk. 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.

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.