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
databasejob - 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
buildandlintjobs - Uses the built codebase without development dependencies from the
buildstep - 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:
| Provider | Location |
|---|---|
| GitHub Actions | The Security audit workflow in .github/workflows/audit.yml |
| CircleCI | The 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 --lockedchecks the packages pinned incomposer.lockagainst 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.
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.
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 likefeature/**andbugfix/**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
| Task | First container | Other 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 Actions
- CircleCI
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' }}
- run:
name: Set test runner roles
command: |
{
echo "export VORTEX_CI_RUNNER_INDEX=${CIRCLE_NODE_INDEX:-0}"
echo "export VORTEX_CI_RUNNER_TOTAL=${CIRCLE_NODE_TOTAL:-1}"
echo "export VORTEX_CI_IS_JEST_RUNNER=$([ "${CIRCLE_NODE_INDEX:-0}" -eq 0 ] && echo 1 || echo 0)"
echo "export VORTEX_CI_IS_PHPUNIT_RUNNER=$([ "${CIRCLE_NODE_INDEX:-0}" -eq 0 ] && echo 1 || echo 0)"
echo "export VORTEX_CI_IS_SDC_DEVEL_RUNNER=$([ "${CIRCLE_NODE_INDEX:-0}" -eq 0 ] && echo 1 || echo 0)"
echo "export VORTEX_CI_IS_BEHAT_RUNNER=1"
echo "export VORTEX_CI_BEHAT_PROFILE_OFFSET=0"
} >> "${BASH_ENV}"
Each tool's step then reads its own flag as its first line:
- run:
name: Test with PHPUnit
command: |
[ "${VORTEX_CI_IS_PHPUNIT_RUNNER:-1}" = "1" ] || exit 0
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 Actions
- CircleCI
VORTEX_CI_IS_CYPRESS_RUNNER: ${{ matrix.instance == 1 }}
echo "export VORTEX_CI_IS_CYPRESS_RUNNER=$([ "${CIRCLE_NODE_INDEX:-0}" -eq 1 ] && echo 1 || echo 0)"
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 }}
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
| Container | Role | Behat profile |
|---|---|---|
| 0 | Jest, PHPUnit, coverage, SDC validation | - |
| 1 | Behat | p0 (catch-all) |
| 2 | Behat | p1 |
behat.yml stays untouched: the profiles remain numbered from p0 and no
feature file is re-tagged.
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.
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.
-
Increase the container count. See the provider-specific pages:
-
Add a profile to
behat.ymlfor each new container that runs Behat. Vortex ships withp0andp1only, and Behat fails withprofile 'p2' does not existif a container running Behat has no profile named after its derived number (its index minusVORTEX_CI_BEHAT_PROFILE_OFFSET). A container excluded from Behat needs no profile:behat.ymlp2:gherkin:cache: '/tmp/behat_gherkin_cache'filters:tags: '@smoke,@p2&&~@skipped' -
Exclude the new tag from the
p0catch-all, so its scenarios do not also run on the first container:behat.ymlp0:gherkin:cache: '/tmp/behat_gherkin_cache'filters:tags: '@smoke,~@p1&&~@p2&&~@skipped' -
Tag scenarios with
@p2to 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):
| Tool | Purpose | Variable |
|---|---|---|
| Behat | Run BDD acceptance tests | VORTEX_CI_BEHAT_IGNORE_FAILURE |
| Composer normalize | Ensure composer.json is sorted | VORTEX_CI_COMPOSER_NORMALIZE_IGNORE_FAILURE |
| Composer security audit | Check dependencies for vulnerabilities | VORTEX_CI_COMPOSER_AUDIT_IGNORE_FAILURE |
| Composer validate | Validate composer.json and lock file | VORTEX_CI_COMPOSER_VALIDATE_IGNORE_FAILURE |
| DCLint | Lint Docker Compose files | VORTEX_CI_DCLINT_IGNORE_FAILURE |
| ESLint and Stylelint | Lint JavaScript and CSS | VORTEX_CI_NODEJS_LINT_IGNORE_FAILURE |
| Gherkin Lint | Lint Behat feature files | VORTEX_CI_GHERKIN_LINT_IGNORE_FAILURE |
| Gitleaks | Scan the codebase for committed secrets | VORTEX_CI_GITLEAKS_IGNORE_FAILURE |
| Hadolint | Lint Dockerfiles for best practices | VORTEX_CI_HADOLINT_IGNORE_FAILURE |
| Jest | Run JavaScript unit tests | VORTEX_CI_JEST_IGNORE_FAILURE |
| PHPCS | Check PHP coding standards | VORTEX_CI_PHPCS_IGNORE_FAILURE |
| PHPStan | Static analysis for PHP | VORTEX_CI_PHPSTAN_IGNORE_FAILURE |
| PHPUnit | Run unit, kernel, and functional tests | VORTEX_CI_PHPUNIT_IGNORE_FAILURE |
| Rector | Check for automated refactoring rules | VORTEX_CI_RECTOR_IGNORE_FAILURE |
| SDC Devel | Validate Single Directory Components | VORTEX_CI_SDC_DEVEL_IGNORE_FAILURE |
| Twig CS Fixer | Lint Twig templates | VORTEX_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.