Semaphore CI Testing: Fast Parallel Test Pipelines
Semaphore CI is a hosted continuous integration and delivery platform focused on speed. Its main selling points are fast build machines and first-class support for test parallelism — both within a single job and across multiple jobs.
If your test suite takes too long and you want parallel execution without complex self-hosted infrastructure, Semaphore is worth evaluating.
How Semaphore Works
Semaphore pipelines are defined in .semaphore/semaphore.yml. Unlike some CI tools where parallelism is an afterthought, Semaphore's pipeline model has parallelism built into the structure:
- Blocks contain groups of related jobs
- Jobs within a block run in parallel
- Blocks run sequentially (each block waits for the previous to complete)
This maps naturally to test workflows: run unit tests and lint in parallel, then run integration tests only after both pass.
Basic Pipeline Structure
# .semaphore/semaphore.yml
version: v1.0
name: Test Pipeline
agent:
machine:
type: e1-standard-2
os_image: ubuntu2204
blocks:
- name: Unit Tests
task:
jobs:
- name: Run Tests
commands:
- checkout
- npm ci
- npm testThe checkout command clones your repository. It's a built-in Semaphore command.
Environment Setup
Configure the environment at the task level (shared across jobs) or per-job:
blocks:
- name: Tests
task:
env_vars:
- name: NODE_ENV
value: test
- name: LOG_LEVEL
value: error
prologue:
commands:
- checkout
- nvm use 20
- npm ci
jobs:
- name: Unit Tests
commands:
- npm run test:unit
- name: Integration Tests
commands:
- npm run test:integrationThe prologue runs before every job in the task. Since unit tests and integration tests both need npm ci, putting it in the prologue avoids repeating it.
Parallel Jobs in a Block
Jobs within a block run in parallel automatically:
blocks:
- name: Test Suite
task:
prologue:
commands:
- checkout
- npm ci
jobs:
- name: Unit Tests
commands:
- npm run test:unit
- name: Linting
commands:
- npm run lint
- name: Type Check
commands:
- npx tsc --noEmitAll three jobs run simultaneously. The block completes when all jobs finish.
Test Parallelism with Test Splitting
Semaphore has built-in test splitting for distributing tests across parallel jobs.
Using Semaphore's test boosting
blocks:
- name: Jest Tests
task:
env_vars:
- name: RAILS_ENV
value: test
prologue:
commands:
- checkout
- npm ci
jobs:
- name: "Jest Tests"
parallelism: 4
commands:
- npx jest --shard=$((SEMAPHORE_JOB_INDEX + 1))/$SEMAPHORE_JOB_COUNT --ciparallelism: 4 creates 4 job instances. Semaphore provides:
$SEMAPHORE_JOB_INDEX— 0-based index (0, 1, 2, 3)$SEMAPHORE_JOB_COUNT— total (4)
Playwright parallel sharding
blocks:
- name: E2E Tests
task:
prologue:
commands:
- checkout
- npm ci
- npx playwright install --with-deps chromium
jobs:
- name: "Playwright Shards"
parallelism: 4
commands:
- npx playwright test --shard=$((SEMAPHORE_JOB_INDEX + 1))/$SEMAPHORE_JOB_COUNT
epilogue:
always:
commands:
- '[[ -d playwright-report ]] && artifact push workflow playwright-report'The epilogue runs after every job regardless of pass/fail. artifact push workflow stores the Playwright report as a Semaphore artifact.
pytest parallelism
blocks:
- name: Python Tests
task:
prologue:
commands:
- checkout
- pip install -r requirements.txt
jobs:
- name: "pytest Shards"
parallelism: 3
commands:
- |
# Split test files into shards
TEST_FILES=$(python -c "
import glob
files = sorted(glob.glob('tests/**/*.py', recursive=True))
shard = int('$SEMAPHORE_JOB_INDEX')
total = int('$SEMAPHORE_JOB_COUNT')
chunk = [files[i] for i in range(shard, len(files), total)]
print(' '.join(chunk))
")
pytest $TEST_FILES -v --junitxml=test-results/junit-$SEMAPHORE_JOB_INDEX.xml
epilogue:
always:
commands:
- test-results/publish test-results/*.xmlCaching
Semaphore has a built-in cache for fast dependency restoration:
blocks:
- name: Tests
task:
prologue:
commands:
- checkout
- cache restore node-modules-$(checksum package-lock.json)
- npm ci
- cache store node-modules-$(checksum package-lock.json) node_modules
jobs:
- name: Tests
commands:
- npm testThe cache restore and cache store commands are Semaphore built-ins. The key uses checksum of package-lock.json to invalidate when dependencies change.
Python caching
prologue:
commands:
- checkout
- cache restore pip-packages-$(checksum requirements.txt)
- pip install -r requirements.txt
- cache store pip-packages-$(checksum requirements.txt) ~/.cache/pipGo module caching
prologue:
commands:
- checkout
- cache restore go-modules-$(checksum go.sum)
- go mod download
- cache store go-modules-$(checksum go.sum) ~/go/pkg/modDatabase Services
Run databases as services alongside your tests:
blocks:
- name: Integration Tests
task:
services:
- postgres
env_vars:
- name: DATABASE_URL
value: postgresql://semaphore:semaphore@localhost/semaphore_test
prologue:
commands:
- checkout
- npm ci
jobs:
- name: Integration Tests
commands:
- npm run db:migrate
- npm run test:integrationSemaphore provides several pre-configured services:
postgres— PostgreSQL on port 5432mysql— MySQL on port 3306redis— Redis on port 6379mongodb— MongoDB on port 27017rabbitmq— RabbitMQ on port 5672elasticsearch— Elasticsearch on port 9200memcached— Memcached on port 11211cassandra— Cassandra on port 9042
Service credentials use standard defaults (documented in Semaphore docs).
Browser Testing
For browser tests, Semaphore provides machines with browsers pre-installed:
blocks:
- name: Browser Tests
task:
agent:
machine:
type: e1-standard-4 # larger machine for browser tests
os_image: ubuntu2204
prologue:
commands:
- checkout
- npm ci
- npx playwright install chromium firefox webkit
jobs:
- name: "Browser Tests"
parallelism: 3
commands:
- npx playwright test --shard=$((SEMAPHORE_JOB_INDEX + 1))/$SEMAPHORE_JOB_COUNT
epilogue:
always:
commands:
- '[[ -d playwright-report ]] && artifact push workflow playwright-report'Multi-Stage Pipelines
Define sequential stages with the after_pipeline and pipeline promotion:
# .semaphore/semaphore.yml
version: v1.0
name: CI Pipeline
agent:
machine:
type: e1-standard-2
os_image: ubuntu2204
blocks:
- name: Unit Tests
task:
jobs:
- name: Tests
commands:
- checkout
- npm ci
- npm run test:unit
- name: Integration Tests
task:
jobs:
- name: Integration
commands:
- checkout
- npm ci
- npm run test:integration
promotions:
- name: Deploy to Staging
pipeline_file: deploy-staging.yml
auto_promote:
when: "branch = 'main' AND result = 'passed'"The deploy-staging.yml pipeline runs automatically when tests pass on main.
Secrets and Environment Variables
Store secrets in Semaphore's secret management:
# Add a secret via CLI
sem create secret production-env \
-e DATABASE_URL=postgresql://prod-host/app \
-e API_KEY=your-api-keyUse in pipelines:
blocks:
- name: Integration Tests
task:
secrets:
- name: production-env
jobs:
- name: Tests
commands:
- checkout
- npm ci
- npm run test:integration # DATABASE_URL and API_KEY are availableSecrets are decrypted into environment variables for each job that requests them.
Test Reports
Semaphore supports JUnit XML test reports with the test-results command:
jobs:
- name: Tests
commands:
- checkout
- npm ci
- npx jest --ci --reporters=default --reporters=jest-junit
- '[[ $? -ne 0 ]] && true' # continue even if tests fail
epilogue:
always:
commands:
- '[[ -f junit.xml ]] && test-results publish junit.xml'Published test results appear in the Semaphore UI with pass/fail counts, duration, and test names.
Branch and Tag Filtering
Skip blocks for specific conditions:
blocks:
- name: Unit Tests
skip:
when: "branch =~ 'dependabot/.*'"
task:
jobs:
- name: Test
commands:
- npm testRun blocks only on specific conditions:
blocks:
- name: Deploy
run:
when: "branch = 'main'"
task:
jobs:
- name: Deploy
commands:
- npm run deployChoosing Machine Type
Select machine types based on your workload:
agent:
machine:
type: e1-standard-4 # 4 vCPU, 8GB RAM
os_image: ubuntu2204Available types (sizes vary):
e1-standard-2— 2 vCPU, 4GB RAM (default)e1-standard-4— 4 vCPU, 8GB RAMe1-standard-8— 8 vCPU, 16GB RAM- Mac machines available for iOS/macOS testing
For parallel jobs, a larger machine lets multiple jobs run without resource contention.
Workflow Artifacts
Store and share files between pipeline stages:
# Store artifact in one block
epilogue:
always:
commands:
- artifact push workflow build/
# Access in a later pipeline
prologue:
commands:
- artifact pull workflow build/Artifacts persist across the pipeline run and are downloadable from the Semaphore UI.
Debugging Failing Tests
When tests fail, use Semaphore's SSH session feature to debug:
# Open SSH session to a failed job
sem debug job <job-id>This gives you an interactive shell in the same environment as the failed job — useful for reproducing failures that only occur in CI.
Comparing Semaphore to Alternatives
| Feature | Semaphore | GitHub Actions | CircleCI |
|---|---|---|---|
| Native test parallelism | ✓ Built-in | Via matrix | ✓ Built-in |
| Hosted machines | ✓ Fast SSD-backed | Standard | Standard |
| Self-hosted agents | ✓ | ✓ | ✓ |
| Test analytics | Basic via XML | Via 3rd party | ✓ |
| Pipeline complexity | Moderate | Low | Moderate |
| macOS support | ✓ | ✓ | ✓ |
Semaphore's speed advantage comes from SSD-backed machines and efficient job startup. Teams that have outgrown slow builds on other platforms often see 30-50% improvement by switching.
Conclusion
Semaphore's pipeline model — parallel jobs within sequential blocks — maps well to how test suites are actually structured. Unit tests and linting run in parallel. Integration tests run after. E2E tests run after that. Expressing this in Semaphore's YAML is natural.
The built-in parallelism variables (SEMAPHORE_JOB_INDEX, SEMAPHORE_JOB_COUNT) make test sharding straightforward. Combined with the cache system and pre-configured database services, you can set up a fast, parallel test pipeline without much configuration overhead.
Start with a single block for your primary test suite, add parallelism once the suite gets slow, and configure caching to keep install times from dominating build duration.