Skip to content

feat(magento): say when a run is waiting for a worker (#818) #2974

feat(magento): say when a run is waiting for a worker (#818)

feat(magento): say when a run is waiting for a worker (#818) #2974

Workflow file for this run

# See DUR009 (test pyramid), DUR008 (PHP-FIG coding standards).
name: CI
# One run per ref: a second push to the same branch cancels the run the first one started.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main, master]
# A prise commit changes no code: testing main again for it only queues runners ahead of the
# pull requests. Push only: on pull_request `ci-ok` is the required check and must exist.
paths-ignore:
- '.worktrees/prises/**'
- '.worktrees/PRISES.md'
pull_request:
jobs:
qa:
# Was the required status check on `main` until 2026-09-16; `ci-ok` below is now. Its steps
# are what `loop/guardrails/verify.sh` mirrors: change one, change the other.
name: Core · CS + tests (PHP ${{ matrix.php-version }})
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
php-version: ['8.2', '8.3', '8.4', '8.5']
steps:
- uses: actions/checkout@v7
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
# ext-grpc: required by WorkflowTaskRunner, WorkflowTaskProcessor and TemporalHistoryCursor (DUR027).
extensions: mbstring, sqlite3, pdo_sqlite, grpc
# The 8.2 leg measures coverage while it tests: it was a job of its own that re-ran
# this leg's suite (#348 audit, 4).
coverage: ${{ matrix.php-version == '8.2' && 'pcov' || 'none' }}
ini-values: pcov.directory=.
- name: Cache Composer downloads
uses: actions/cache@v6
with:
path: ~/.cache/composer
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock', '**/composer.json') }}
restore-keys: composer-${{ runner.os }}-
- name: Install Composer dependencies
run: composer install --no-interaction --prefer-dist --no-progress
- name: PHP-CS-Fixer (dry-run)
run: composer cs:check
- name: PHPUnit
if: matrix.php-version != '8.2'
run: composer test -- --exclude-testsuite temporal --fail-on-skipped
# rector/rector ships its own nikic/php-parser and PCOV loads the project's: the rector
# suite runs without coverage (phpunit.xml says why).
- name: PHPUnit with coverage, red below the line threshold
if: matrix.php-version == '8.2'
env:
# The figure on the day the threshold landed (#348). Raise it with the coverage;
# never lower it to get to green.
MIN_LINE_COVERAGE: '76'
# The default shell is `bash -e` without pipefail: a red PHPUnit would hide behind tee.
run: |
set -o pipefail
vendor/bin/phpunit --exclude-testsuite rector,temporal --fail-on-skipped \
--coverage-text --only-summary-for-coverage-text | tee coverage.txt
vendor/bin/phpunit --testsuite rector --fail-on-skipped
lines=$(sed -n 's/^ *Lines: *\([0-9.]*\)%.*/\1/p' coverage.txt)
echo "Lines: ${lines}% (minimum ${MIN_LINE_COVERAGE}%)"
awk -v l="$lines" -v m="$MIN_LINE_COVERAGE" 'BEGIN { exit !(l != "" && l >= m) }'
symfony-matrix:
# The `qa` job installs the composer.lock resolution: it only ever sees one Symfony line.
# Yet the only compatibility bug found so far — `#[\Override]` on a method that Symfony
# 6.4's DataCollector does not declare, fatal from PHP 8.3 on — was only visible at the
# *crossing* of an old Symfony line and a recent PHP. Hence an explicit matrix rather
# than a cartesian product: every entry is an edge that has a reason to exist.
name: Symfony · package (${{ matrix.label }} / PHP ${{ matrix.php-version }})
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
# 6.4 LTS at the library's PHP floor.
- { php-version: '8.2', symfony: '6.4.*', deps: 'lowest', label: '6.4 lowest' }
# 6.4 LTS under a recent PHP: the crossing that bites.
- { php-version: '8.4', symfony: '6.4.*', deps: 'highest', label: '6.4 highest' }
# *Installable* floor of the 7 line. The constraint allows ^7.0, but
# 7.0.x through 7.3.x are end of life and blocked by Composer's security
# advisories: pinning them here would test nothing, it would fail to resolve.
- { php-version: '8.2', symfony: '7.4.*', deps: 'lowest', label: '7.4 lowest' }
# 7.4 LTS under a recent PHP: what the lock resolves today.
- { php-version: '8.4', symfony: '7.4.*', deps: 'highest', label: '7.4 highest' }
# Line 8: requires PHP >= 8.4, so never reachable locally on 8.2.
- { php-version: '8.4', symfony: '8.0.*', deps: 'lowest', label: '8.0 lowest' }
- { php-version: '8.5', symfony: '8.*', deps: 'highest', label: '8.x highest' }
steps:
- uses: actions/checkout@v7
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
extensions: mbstring, sqlite3, pdo_sqlite, grpc
coverage: none
# Flex constrains *all* symfony/* dependencies, transitive ones included, to the
# requested line. Without it every package would have to be listed by hand, and the
# monorepo root no longer requires any of them directly.
- name: Cache Composer downloads
uses: actions/cache@v6
with:
path: ~/.cache/composer
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock', '**/composer.json') }}
restore-keys: composer-${{ runner.os }}-
- name: Pin the Symfony line (Flex)
run: |
composer global config --no-plugins allow-plugins.symfony/flex true
composer global require --no-progress --no-scripts --no-plugins symfony/flex
composer config --no-plugins allow-plugins.symfony/flex true
# Partial update: only the Symfony dimension moves. An unfiltered `--prefer-lowest`
# would lower phpunit and phpstan too, and would make the matrix fail on something
# other than what it measures.
- name: Resolve the requested Symfony line
env:
SYMFONY_REQUIRE: ${{ matrix.symfony }}
run: |
composer update --no-interaction --prefer-dist --no-progress \
${{ matrix.deps == 'lowest' && '--prefer-lowest' || '' }} 'symfony/*'
- name: Symfony line actually installed
run: composer show symfony/messenger --no-ansi | head -n 3
- name: PHPUnit
run: composer test
# PHPStan is the tool that caught `#[\Override]`: without it, the matrix would only
# see the problem if a test loaded the offending class.
- name: PHPStan
run: composer phpstan
laravel:
# The counterpart of the Symfony matrix, for the other host family. Same principle: one
# entry per *edge* that has a reason to exist, not a cartesian product.
#
# What this matrix measures and no other one can: `gplanchat/durable-laravel` builds on
# `illuminate/*`, whose lines 11, 12 and 13 do not ask for the same PHP — 13 requires ^8.3
# when the repository's baseline is 8.2 — and whose contracts move from one line to the
# next. The `unit` suite only proves the line in the lock.
#
# ⚠ This package does **not** require `illuminate/queue`, and that is not an oversight:
# Laravel 11+ pulls `symfony/process ^7.2` in with it, which would make it irreconcilable
# with the Symfony 6.4 line the neighbouring matrix still tests. The guard against the
# `sync` queue therefore reads the driver name from the configuration rather than the
# connection class.
name: Laravel · package (${{ matrix.label }} / PHP ${{ matrix.php-version }})
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
# 11 at the PHP floor common to both.
- { php-version: '8.2', laravel: '11.*', deps: 'lowest', label: '11 lowest' }
# 11 under a recent PHP: the crossing that bites, as for Symfony 6.4.
- { php-version: '8.4', laravel: '11.*', deps: 'highest', label: '11 highest' }
# 12 at the floor.
- { php-version: '8.2', laravel: '12.*', deps: 'lowest', label: '12 lowest' }
# 12 under a recent PHP: what the lock resolves today.
- { php-version: '8.4', laravel: '12.*', deps: 'highest', label: '12 highest' }
# 13 requires PHP ^8.3: never reachable at the repository's floor.
- { php-version: '8.4', laravel: '13.*', deps: 'lowest', label: '13 lowest' }
- { php-version: '8.5', laravel: '13.*', deps: 'highest', label: '13 highest' }
steps:
- uses: actions/checkout@v7
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
# grpc as in the Symfony matrix: the root requires the Temporal bridge, which
# requires ext-grpc. Without it, `composer update` fails before having resolved
# anything of Illuminate — the matrix would measure the absence of an extension.
extensions: mbstring, sqlite3, pdo_sqlite, grpc
coverage: none
# Partial update, same reason as on the Symfony side: only the Laravel dimension
# moves. An unfiltered `--prefer-lowest` would lower phpunit and phpstan, and the
# matrix would fail on something other than what it measures.
- name: Resolve the requested Laravel line
run: |
composer require --no-update --no-interaction --dev \
"illuminate/cache:${{ matrix.laravel }}" \
"illuminate/database:${{ matrix.laravel }}"
composer update --no-interaction --prefer-dist --no-progress \
${{ matrix.deps == 'lowest' && '--prefer-lowest' || '' }} 'illuminate/*'
- name: Laravel line actually installed
run: composer show illuminate/database --no-ansi | head -n 3
- name: PHPUnit (the integration package and the Illuminate bridge)
run: vendor/bin/phpunit --testsuite laravel
- name: PHPStan
run: composer phpstan
laravel-bench:
# The Laravel bench's probe (#360): `config/durable.php` → the core's Nexus registry → the
# handler, through the very method the Nexus worker calls, with no cluster (the job's DSN
# names a closed port). Then the round trip (#666), against a dev server started below. The
# `laravel` job above tests the package; this one tests the application that installs it,
# from `laravel/`'s own lock.
name: Laravel · bench, Nexus probe
runs-on: ubuntu-latest
timeout-minutes: 15
env:
# Read at boot, `package:discover` included: the bench's backend is temporal.
DURABLE_DSN: 'temporal://127.0.0.1:7999?namespace=probe&nexus_task_queue=q&tls=0'
# Without a `.env`, the bench's cache is the database one; the probe needs no database.
CACHE_STORE: array
steps:
- uses: actions/checkout@v7
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
# The bench's floor, the PHP its README runs the probe with.
php-version: '8.2'
extensions: mbstring, sqlite3, pdo_sqlite, curl, grpc
coverage: none
- name: Cache Composer downloads
uses: actions/cache@v6
with:
path: ~/.cache/composer
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock', '**/composer.json') }}
restore-keys: composer-${{ runner.os }}-
- name: Composer install (laravel/)
working-directory: laravel
run: composer install --no-interaction --prefer-dist --no-progress
- name: Probe — the bench serves delivery/schedule and delivery/ship
working-directory: laravel
run: php probe-nexus.php
# #666: the same two operations, now served by artisan's workers to the root's demo harness
# on a start-dev server. It needs the root's vendor/ and Durable's search attributes, which
# the bench writes on every start.
- name: Composer install (root, for the demo harness)
run: composer install --no-interaction --prefer-dist --no-progress
- name: Start a Temporal dev server
run: |
docker run -d --name temporal-dev -p 7233:7233 temporalio/temporal:1.9.1 \
server start-dev --ip 0.0.0.0 --port 7233 --namespace durable-test --headless \
--search-attribute DurableWorkflowName=Keyword --search-attribute DurableExecutionId=Keyword
timeout 60 bash -c 'until nc -z 127.0.0.1 7233; do sleep 1; done'
- name: PHPUnit — the bench serves delivery against a server
working-directory: laravel
timeout-minutes: 10
env:
DURABLE_DSN: 'temporal://127.0.0.1:7233?namespace=durable-test&nexus_task_queue=laravel-bench-nexus&workflow_task_queue=laravel-bench-workflows&tls=0'
run: vendor/bin/phpunit --testdox --fail-on-skipped
filament:
# gplanchat/durable-filament (#712, #899), outside the root graph: Filament pulls
# laravel/framework, which requires Symfony ^7 and would break the 6.4 legs of
# symfony-matrix. A throwaway project installs it through path repositories, on each
# Filament line, with the Temporal and Illuminate bridges so a test that needs one (#871)
# cannot skip: durable-laravel only suggests the Illuminate bridge (#847).
name: Filament · package (${{ matrix.label }} / PHP ${{ matrix.php-version }})
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
include:
- { php-version: '8.2', filament: '^3.3', deps: 'lowest', label: 'Filament 3 lowest' }
- { php-version: '8.4', filament: '^4.0', deps: 'highest', label: 'Filament 4 highest' }
- { php-version: '8.5', filament: '^4.0', deps: 'highest', label: 'Filament 4 highest' }
steps:
- uses: actions/checkout@v7
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
# grpc: the Temporal bridge is installed, as in the Laravel bench.
extensions: mbstring, sqlite3, pdo_sqlite, curl, grpc, intl
coverage: none
# Composer's cache directory depends on the runner (#767): ask Composer instead of guessing.
# No lock file here, so the key also carries the ISO week (#910): the cache renews weekly.
- name: Composer cache directory
id: composer-cache
run: |
echo "dir=$(composer config --global cache-dir)" >> "$GITHUB_OUTPUT"
echo "week=$(date -u +%G-W%V)" >> "$GITHUB_OUTPUT"
- name: Cache Composer
uses: actions/cache@v6
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: filament-${{ matrix.php-version }}-${{ hashFiles('src/**/composer.json') }}-${{ steps.composer-cache.outputs.week }}
restore-keys: filament-${{ matrix.php-version }}-
- name: A project that installs the package
run: |
mkdir -p /tmp/filament-app
cat > /tmp/filament-app/composer.json <<JSON
{
"minimum-stability": "dev",
"prefer-stable": true,
"repositories": [
{ "type": "path", "url": "${{ github.workspace }}/src/Durable" },
{ "type": "path", "url": "${{ github.workspace }}/src/DurableLaravel" },
{ "type": "path", "url": "${{ github.workspace }}/src/Bridge/Illuminate" },
{ "type": "path", "url": "${{ github.workspace }}/src/GrpcClient" },
{ "type": "path", "url": "${{ github.workspace }}/src/Bridge/Temporal" },
{ "type": "path", "url": "${{ github.workspace }}/src/DurableFilament" }
],
"require": {
"filament/filament": "${{ matrix.filament }}",
"gplanchat/durable-filament": "@dev",
"gplanchat/durable-bridge-illuminate": "@dev",
"gplanchat/durable-bridge-temporal": "@dev",
"phpunit/phpunit": "^11.5"
},
"autoload-dev": {
"psr-4": { "Gplanchat\\\\Durable\\\\Filament\\\\Tests\\\\": "${{ github.workspace }}/src/DurableFilament/tests/" }
}
}
JSON
# Only the Filament dimension moves, as in the laravel job: an unfiltered `--prefer-lowest`
# would lower phpunit and the bridge's dependencies too. The first update writes the lock
# the partial one starts from. `--with-dependencies` lets filament/* drag livewire and the
# rest of its own tree down with it; without it they stay locked at their highest and hold
# filament up. The floor is 3.3.53 and livewire 3.8.3: every version below is blocked by a
# Packagist security advisory (filament/forms <=3.3.52, livewire <=3.8.2).
- name: Composer update
working-directory: /tmp/filament-app
run: |
composer update --no-interaction --prefer-dist --no-progress
composer update --no-interaction --prefer-dist --no-progress \
${{ matrix.deps == 'lowest' && '--prefer-lowest --with-dependencies' || '' }} 'filament/*'
- name: Filament line actually installed, and the bridges
working-directory: /tmp/filament-app
run: |
for package in filament/filament livewire/livewire laravel/framework gplanchat/durable-bridge-temporal gplanchat/durable-bridge-illuminate; do
composer show "$package" --no-ansi | grep -E '^(name|versions) '
done
test -e vendor/gplanchat/durable-bridge-temporal
test -e vendor/gplanchat/durable-bridge-illuminate
- name: Start a Temporal dev server
run: |
docker run -d --name temporal-dev -p 7233:7233 temporalio/temporal:1.9.1 \
server start-dev --ip 0.0.0.0 --port 7233 --namespace durable-test --headless \
--search-attribute DurableWorkflowName=Keyword --search-attribute DurableExecutionId=Keyword
timeout 60 bash -c 'until nc -z 127.0.0.1 7233; do sleep 1; done'
- name: PHPUnit (src/DurableFilament/tests)
working-directory: /tmp/filament-app
env:
DURABLE_DSN: 'temporal://127.0.0.1:7233?namespace=durable-test&tls=0'
run: vendor/bin/phpunit -c "$GITHUB_WORKSPACE/src/DurableFilament/phpunit.xml" --bootstrap vendor/autoload.php --display-skipped --fail-on-skipped
sylius-shop:
# The only check in the repository that really renders a page. Everything else proves the
# pieces are correct; this one proves that, assembled, they display something.
#
# Outside the main matrix, and for two reasons there is no way around: Sylius Standard 2.2
# requires PHP ^8.3 when the repository's baseline is 8.2, and the shop needs a real MySQL.
# It is precisely the latter that revealed two faults SQLite was swallowing — a boolean
# bound without a type, and an upsert that counted the rows affected by an UPDATE.
name: Sylius · dashboard, rendered
runs-on: ubuntu-latest
timeout-minutes: 45
services:
mysql:
image: mysql:8.4
env:
MYSQL_ALLOW_EMPTY_PASSWORD: 1
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping -h 127.0.0.1"
--health-interval=5s
--health-timeout=5s
--health-retries=20
steps:
- uses: actions/checkout@v7
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
# 8.3: Sylius Standard 2.2's floor. The plugin itself stays at >=8.2.
php-version: '8.3'
# grpc: the shop serves a Nexus operation, and the Temporal bridge depends on it.
# Its dashboard, for its part, still does not need it — that is DUR037, and
# `temporal.journal: false` is what keeps the two apart.
extensions: intl, gd, pdo_mysql, mbstring, zip, curl, sodium, exif, fileinfo, grpc
coverage: none
# The repository's packages come in through `path` repositories: the shop reads
# `../src`, so it tests the code of this commit and not a published version.
- name: Cache Composer downloads
uses: actions/cache@v6
with:
path: ~/.cache/composer
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock', '**/composer.json') }}
restore-keys: composer-${{ runner.os }}-
- name: Composer install (sylius/)
working-directory: sylius
run: composer install --no-interaction --prefer-dist --no-progress
# The shop's layers: value objects, the Payments port's use case and the factories
# that translate a Nexus payload. No kernel, no database, so it runs before both and
# fails in seconds when it fails.
- name: PHPUnit — the shop's own layers
working-directory: sylius
run: vendor/bin/phpunit tests/Unit
- name: Test database
working-directory: sylius
env:
APP_ENV: test
run: |
bin/console doctrine:database:create --if-not-exists
bin/console doctrine:schema:create
- name: PHPUnit — the dashboard is rendered, and a timer is drained by durable:worker
working-directory: sylius
env:
APP_ENV: test
run: vendor/bin/phpunit tests/Functional
# #665: the shop serves stock and calls billing through its own workers, with the root's
# demo harness on the other side: the root's vendor/ for the harness, and a start-dev
# server, since serving Nexus needs one with Nexus on.
- name: Composer install (root, for the demo harness)
run: composer install --no-interaction --prefer-dist --no-progress
- name: Start a Temporal dev server
run: |
docker run -d --name temporal-dev -p 7233:7233 temporalio/temporal:1.9.1 \
server start-dev --ip 0.0.0.0 --port 7233 --namespace durable-test --headless
timeout 60 bash -c 'until nc -z 127.0.0.1 7233; do sleep 1; done'
# Its own database, shared by the demo and demo_caller profiles as in demo/run.sh; the
# test creates the schema and the stock it reserves.
- name: PHPUnit — the shop serves and calls Nexus operations
working-directory: sylius
timeout-minutes: 10
env:
DURABLE_TEMPORAL_DSN: 'temporal://127.0.0.1:7233?namespace=durable-test&nexus_task_queue=shop-bench-nexus&tls=0'
DATABASE_URL: 'mysql://root@127.0.0.1/sylius_nexus?serverVersion=8&charset=utf8mb4'
run: vendor/bin/phpunit --group nexus-bench --fail-on-skipped tests/Integration
# #671: the admin page of a real run whose Nexus operation is in flight on this server.
# The functional step above skips it, having no server yet; here it cannot skip.
- name: PHPUnit — the run page shows a Nexus operation in flight
working-directory: sylius
timeout-minutes: 5
env:
APP_ENV: test
DURABLE_TEMPORAL_DSN: 'temporal://127.0.0.1:7233?namespace=durable-test&tls=0'
run: vendor/bin/phpunit --fail-on-skipped tests/Functional/TheRunPageShowsANexusOperationInFlightTest.php
magento-matrix:
# The counterpart of the Symfony matrix, for the other foreign-container host. Same
# principle: every entry is an **edge that has a reason**, not a cartesian product. What
# the job proves is the resolution — that the module's constraint (`php >=8.2`,
# `magento/framework ^103.0`) is honest against what each Mage-OS line requires. What it
# does not prove is the boot: installing the distribution costs ~1 GB and several minutes
# per entry, and that is the integration job's work, not a matrix's.
#
# The guard discriminates, verified locally before being written here: 2.2.0 on PHP 8.2
# resolves (code 0), 3.4.0 on PHP 8.2 fails naming the cause — "requires php ~8.3.0||~8.4.0||
# ~8.5.0 -> your php version (8.2.33) does not satisfy that requirement".
name: Magento · package resolves (Mage-OS ${{ matrix.mageos }} / PHP ${{ matrix.php-version }})
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
# The oldest line that still accepts the module's PHP floor.
# It puts `magento/framework: ^103.0` to the test from below.
- { php-version: '8.2', mageos: '1.3.0' }
# The bench's pin, at the PHP floor: the pair on which every measurement in
# this change was made.
- { php-version: '8.2', mageos: '2.2.0' }
# Top of the 2 line under a recent PHP.
- { php-version: '8.4', mageos: '2.3.0' }
# The edge that bites: Mage-OS 3 refuses PHP 8.2 while the module allows it.
# 8.3 is therefore this line's *reachable* floor, not the module's.
- { php-version: '8.3', mageos: '3.4.0' }
# The most recent on the most recent.
- { php-version: '8.5', mageos: '3.4.0' }
steps:
- uses: actions/checkout@v7
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
# What the metapackage demands of the host; `magento/check-php-extensions.sh` is
# the reference list for it, kept up to date by the bench.
extensions: bcmath, ctype, curl, dom, gd, iconv, intl, mbstring, openssl, pdo_mysql, simplexml, soap, sodium, xsl, zip
coverage: none
# A throwaway project rather than the bench: `magento/` carries a lock pinned to a
# single line, and a matrix reusing it would test only that line.
- name: Resolution project
run: |
mkdir -p /tmp/resolve
cat > /tmp/resolve/composer.json <<JSON
{
"minimum-stability": "dev",
"prefer-stable": true,
"repositories": [
{ "type": "composer", "url": "https://repo.mage-os.org/" },
{ "type": "path", "url": "${{ github.workspace }}/src/DurableModule", "options": { "symlink": false } },
{ "type": "path", "url": "${{ github.workspace }}/src/Durable", "options": { "symlink": false } },
{ "type": "path", "url": "${{ github.workspace }}/src/GrpcClient", "options": { "symlink": false } },
{ "type": "path", "url": "${{ github.workspace }}/src/Bridge/Temporal", "options": { "symlink": false } }
],
"require": {
"mage-os/product-community-edition": "${{ matrix.mageos }}",
"gplanchat/durable-magento": "@dev"
},
"config": {
"allow-plugins": {
"mage-os/composer-dependency-version-audit-plugin": false,
"mage-os/magento-composer-installer": true,
"mage-os/inventory-composer-installer": true,
"laminas/laminas-dependency-plugin": true
}
}
}
JSON
# `--dry-run`: the resolution is what we test, the download is not.
# The path-repository audit plugin is disabled for the same reason as on the bench —
# `gplanchat/durable` is both published and provided by path, and here the path is the
# source of truth.
- name: Does the Mage-OS line accept the module?
working-directory: /tmp/resolve
run: composer update --dry-run --no-interaction --no-audit --no-progress
magento-without-temporal:
# The module only *suggests* the Temporal bridge, so it has to install without it (#725).
# The matrix only resolves, and the bench carries the bridge: this is the one job where a
# bridge type reachable from a reflected constructor, or a preference the DI compiler
# cannot see, fails the build. Services and install flags are magento-boot's.
name: Magento · installs without the Temporal bridge (Mage-OS 2.2.0 / PHP 8.2)
runs-on: ubuntu-latest
timeout-minutes: 45
services:
mysql:
image: mysql:8.4
env:
MYSQL_ROOT_PASSWORD: magento
MYSQL_DATABASE: magento
MYSQL_USER: magento
MYSQL_PASSWORD: magento
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping -h 127.0.0.1"
--health-interval=5s --health-timeout=5s --health-retries=20
opensearch:
image: opensearchproject/opensearch:2.12.0
env:
discovery.type: single-node
DISABLE_SECURITY_PLUGIN: 'true'
OPENSEARCH_JAVA_OPTS: '-Xms512m -Xmx512m'
ports:
- 9200:9200
options: >-
--health-cmd="curl -sf http://localhost:9200/_cluster/health || exit 1"
--health-interval=10s --health-timeout=5s --health-retries=30
steps:
- uses: actions/checkout@v7
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: bcmath, ctype, curl, dom, gd, iconv, intl, mbstring, openssl, pdo_mysql, simplexml, soap, sodium, xsl, zip
ini-values: memory_limit=-1
coverage: none
# Composer's cache directory depends on the runner (#767): ask Composer instead of guessing.
- name: Composer cache directory
id: composer-cache
run: echo "dir=$(composer config --global cache-dir)" >> "$GITHUB_OUTPUT"
- name: Cache Composer
uses: actions/cache@v6
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: magento-no-temporal-${{ hashFiles('magento/composer.lock') }}
restore-keys: magento-no-temporal-
- name: MySQL accepts triggers
run: mysql -h 127.0.0.1 -uroot -pmagento -e "SET GLOBAL log_bin_trust_function_creators = 1;"
# A throwaway project on the bench's pin, with no path repository for
# src/Bridge/Temporal: the bridge cannot be pulled in.
- name: Project without the bridge
run: |
mkdir -p /tmp/no-temporal
cat > /tmp/no-temporal/composer.json <<JSON
{
"minimum-stability": "dev",
"prefer-stable": true,
"repositories": [
{ "type": "composer", "url": "https://repo.mage-os.org/" },
{ "type": "path", "url": "${{ github.workspace }}/src/DurableModule", "options": { "symlink": false } },
{ "type": "path", "url": "${{ github.workspace }}/src/Durable", "options": { "symlink": false } }
],
"require": {
"mage-os/product-community-edition": "2.2.0",
"gplanchat/durable-magento": "@dev"
},
"autoload": {
"psr-4": { "Magento\\\\Setup\\\\": "setup/src/Magento/Setup/" },
"files": [ "app/etc/NonComposerComponentRegistration.php" ]
},
"config": {
"allow-plugins": {
"mage-os/composer-dependency-version-audit-plugin": false,
"mage-os/magento-composer-installer": true,
"mage-os/inventory-composer-installer": true,
"laminas/laminas-dependency-plugin": true
}
}
}
JSON
# `--no-autoloader`: the autoload `files` entry comes with the skeleton, deployed below.
- name: Composer install
working-directory: /tmp/no-temporal
run: composer install --no-interaction --prefer-dist --no-progress --no-autoloader
# Same reason as magento-boot: the skeleton comes from mage-os/magento2-base.
- name: Deploy the Magento skeleton
working-directory: /tmp/no-temporal
run: |
cp -Rn vendor/mage-os/magento2-base/. . || true
composer dump-autoload --no-interaction
test -f bin/magento
chmod +x bin/magento
# `if`, not `! cmd`: bash's errexit ignores a negated command.
- name: The bridge is absent
working-directory: /tmp/no-temporal
run: |
if composer show gplanchat/durable-bridge-temporal >/dev/null 2>&1; then exit 1; fi
test ! -e vendor/gplanchat/durable-bridge-temporal
php -r 'require "vendor/autoload.php"; exit(class_exists("Gplanchat\\Bridge\\Temporal\\Http\\Psr18Http") ? 1 : 0);'
- name: Magento install
working-directory: /tmp/no-temporal
run: |
bin/magento setup:install \
--base-url=http://127.0.0.1:8080/ \
--db-host=127.0.0.1 --db-name=magento --db-user=magento --db-password=magento \
--admin-firstname=Durable --admin-lastname=Ops --admin-email=durable@example.com \
--admin-user=durable --admin-password='Durable123!' \
--language=en_US --currency=USD --timezone=UTC --use-rewrites=1 \
--search-engine=opensearch --opensearch-host=127.0.0.1 --opensearch-port=9200 \
--backend-frontname=admin --no-interaction
bin/magento module:status Gplanchat_DurableModule | tee /tmp/status.txt
grep -q 'Module is enabled' /tmp/status.txt
- name: setup:di:compile
working-directory: /tmp/no-temporal
run: bin/magento setup:di:compile
# No DSN, no bridge: the module runs in memory and says so.
- name: The module answers in memory
working-directory: /tmp/no-temporal
run: bin/magento durable:health
magento-boot:
# The counterpart of "Sylius shop" for the other host: the job that
# really **boots** Magento. It exists because until now every claim about this module
# rested on a transcript produced by hand on a workstation's bench — and the Mage-OS × PHP
# matrix, for its part, only proves the resolution.
#
# It is expensive: the distribution weighs ~1 GB and the install takes minutes. That is
# why it is matrixed on PHP alone, over the one Mage-OS line pinned by the tracked lock of
# `magento/`: the floor the bench was measured on, and 8.4, which a merchant on Magento
# 2.4.8 runs and the lock accepts (Mage-OS 2.2.0 requires ~8.2.0||~8.3.0||~8.4.0).
name: Magento · bench + dashboard (Mage-OS 2.2.0 / PHP ${{ matrix.php-version }})
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
php-version: ['8.2', '8.4']
services:
mysql:
image: mysql:8.4
env:
MYSQL_ROOT_PASSWORD: magento
MYSQL_DATABASE: magento
MYSQL_USER: magento
MYSQL_PASSWORD: magento
ports:
- 3306:3306
# `log_bin_trust_function_creators`: without it the install dies on
# "You do not have the SUPER privilege … CREATE TRIGGER". The bench sets it the same way.
options: >-
--health-cmd="mysqladmin ping -h 127.0.0.1"
--health-interval=5s --health-timeout=5s --health-retries=20
opensearch:
image: opensearchproject/opensearch:2.12.0
env:
discovery.type: single-node
# The name 2.12 understands — the bench set it this way, and it is the setting
# we know works. `plugins.security.disabled` is ignored here.
DISABLE_SECURITY_PLUGIN: 'true'
OPENSEARCH_JAVA_OPTS: '-Xms512m -Xmx512m'
ports:
- 9200:9200
options: >-
--health-cmd="curl -sf http://localhost:9200/_cluster/health || exit 1"
--health-interval=10s --health-timeout=5s --health-retries=30
steps:
- uses: actions/checkout@v7
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
extensions: bcmath, ctype, curl, dom, gd, iconv, intl, mbstring, openssl, pdo_mysql, simplexml, soap, sodium, xsl, zip
ini-values: memory_limit=-1
coverage: none
# Composer's cache directory depends on the runner (#767): ask Composer instead of guessing.
- name: Composer cache directory
id: composer-cache
run: echo "dir=$(composer config --global cache-dir)" >> "$GITHUB_OUTPUT"
# Without this cache, every execution re-downloads the distribution.
- name: Cache Composer
uses: actions/cache@v6
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: magento-boot-${{ hashFiles('magento/composer.lock') }}
restore-keys: magento-boot-
- name: MySQL accepts triggers
run: mysql -h 127.0.0.1 -uroot -pmagento -e "SET GLOBAL log_bin_trust_function_creators = 1;"
# `magento/`'s `path` repositories read `../src`: the bench installs **this commit**,
# not a published version. That is what makes this job able to say something about
# the module.
- name: Composer install (magento/)
working-directory: magento
run: composer install --no-interaction --prefer-dist --no-progress
# The module's static analysis lives **here**, and not in the job that carries it for
# the other packages. The reason is the one that made this job exist: the module talks
# to classes the repository does not install, and the only machine that has them is
# the one that has just paid for installing the distribution. The alternative — stubs
# written by hand for the sixteen Magento classes the module touches — would not have
# made an error: it would have hidden some, because an approximate stub makes the
# analysis agree with itself.
#
# ⚠ `--ignore-platform-req=ext-grpc`: the Temporal bridge requires the extension
# outright, and this job does not install it. Analysing does not need it — the `Grpc\`
# classes come from the Composer package, and here the bridge is only *scanned*, never
# run nor analysed.
- name: Composer install (root, for the analysis tools)
run: composer install --no-interaction --prefer-dist --no-progress --ignore-platform-req=ext-grpc
- name: PHPStan (src/DurableModule, against the real Magento classes)
run: composer phpstan-magento
- name: Psalm (src/DurableModule, against the real Magento classes)
run: composer psalm-magento
# ⚠ The skeleton is not in the repository — `magento/.gitignore` keeps only the overlay
# there, and the development workstation had obtained it once through `create-project`.
# After a bare `composer install`, `bin/magento` therefore does not exist.
# `mage-os/magento2-base` carries those files; we deploy them the way the installer
# plugin would, without overwriting what the overlay has already laid down (`-n`).
- name: Deploy the Magento skeleton
working-directory: magento
run: |
cp -Rn vendor/mage-os/magento2-base/. . || true
test -f bin/magento
chmod +x bin/magento
- name: Magento install
working-directory: magento
run: |
bin/magento setup:install \
--base-url=http://127.0.0.1:8080/ \
--db-host=127.0.0.1 --db-name=magento --db-user=magento --db-password=magento \
--admin-firstname=Durable --admin-lastname=Ops --admin-email=durable@example.com \
--admin-user=durable --admin-password='Durable123!' \
--language=en_US --currency=USD --timezone=UTC --use-rewrites=1 \
--search-engine=opensearch --opensearch-host=127.0.0.1 --opensearch-port=9200 \
--backend-frontname=admin --no-interaction
- name: The module is there
working-directory: magento
run: |
# Two modules, and the distinction is the point: `Gplanchat_DurableModule` is the
# published package — it declares no workflow. `Gplanchat_DurableProbe` is the
# **bench**'s module, which carries the demonstration and the probes, and which will
# never reach a user. It is the one that provides `durable:demo` to the next step.
bin/magento module:enable Gplanchat_DurableModule Gplanchat_DurableProbe
# The run page step below logs into the admin; two-factor auth would stop it at the
# setup screen. Every enabled *TwoFactorAuth module goes, dependents included.
tfa=$(bin/magento module:status --enabled | grep -E '_[A-Za-z]*TwoFactorAuth$' || true)
if [ -n "$tfa" ]; then bin/magento module:disable $tfa; fi
bin/magento setup:upgrade --keep-generated
bin/magento module:status Gplanchat_DurableModule | tee /tmp/status.txt
grep -q 'Module is enabled' /tmp/status.txt
# The assertion worth the price of the job: a workflow written once runs inside
# Magento, and its activities come from its contract's attributes, not copied strings.
- name: A workflow runs inside it
working-directory: magento
run: |
bin/magento durable:demo ORD-4242 | tee /tmp/demo.txt
grep -q "notify:charge:ORD-4242" /tmp/demo.txt
grep -q "durable.demo.charge" /tmp/demo.txt
# Without a DSN, the screen must **say** why it is empty rather than look broken.
- name: The admin screen responds
working-directory: magento
run: |
php -S 127.0.0.1:8080 -t pub/ phpserver/router.php > /tmp/httpd.log 2>&1 &
php -r 'usleep(5000000);'
curl -sf --max-time 120 http://127.0.0.1:8080/admin -o /tmp/login.html
grep -qi 'Magento Admin' /tmp/login.html
# From here on, with a cluster: the admin finds a run by the execution id the
# application started it with (#514), and that id may hold a slash. The steps above
# stay as they are, since they assert the page without a DSN.
- name: Install Temporal CLI
uses: temporalio/setup-temporal@v0
# No search attribute is registered: `search_attributes` stays off in env.php, and the
# execution id travels in the memo, which needs none.
- name: Start a Temporal dev server
timeout-minutes: 3
run: |
temporal server start-dev --headless --port 7233 --namespace default --log-level warn > /tmp/temporal.log 2>&1 &
timeout 60 bash -c 'until nc -z 127.0.0.1 7233; do sleep 1; done'
timeout 60 bash -c 'until temporal workflow list --namespace default --limit 1 >/dev/null 2>&1; do sleep 1; done'
# This job has no ext-grpc, hence gRPC over curl, named rather than left to `auto`.
- name: Point Magento at the cluster
working-directory: magento
run: |
php -r '$f = "app/etc/env.php"; $c = include $f; $c["durable"]["temporal"]["dsn"] = "temporal://127.0.0.1:7233?namespace=default&tls=0&transport=grpc-curl"; file_put_contents($f, "<?php\nreturn " . var_export($c, true) . ";\n");'
bin/magento config:set admin/security/use_form_key 0
bin/magento config:set admin/captcha/enable 0
bin/magento cache:flush
- name: Seed a run whose execution id holds a slash
working-directory: magento
timeout-minutes: 2
run: timeout 60 bin/magento durable:demo:start order/4242
# `findRun()` asks the cluster with DescribeWorkflowExecution, so the run is there as soon
# as it is started: no worker, no visibility delay. The page title repeats the requested
# id even when nothing is found, so the assertion reads what only a found run renders.
- name: The admin opens the run by its execution id
working-directory: magento
timeout-minutes: 3
run: |
nc -z 127.0.0.1 8080 || { php -S 127.0.0.1:8080 -t pub/ phpserver/router.php >> /tmp/httpd.log 2>&1 & timeout 30 bash -c 'until nc -z 127.0.0.1 8080; do sleep 1; done'; }
jar=/tmp/admin.cookies
# The login form posts to the page it is served on (`action=""`), so the URL the GET
# ends on is where the credentials go.
login_url=$(curl -sS -L --max-time 60 -c $jar -b $jar -o /tmp/login.html -w '%{url_effective}' http://127.0.0.1:8080/admin)
form_key=$(grep -oE '<input[^>]*name="form_key"[^>]*>' /tmp/login.html | head -n1 | grep -oE 'value="[^"]+"' | sed -E 's/value="([^"]+)"/\1/' || true)
action=$(grep -oE '<form[^>]*id="login-form"[^>]*' /tmp/login.html | grep -oE 'action="[^"]+"' | sed -E 's/action="([^"]+)"/\1/' || true)
action=${action:-$login_url}
if [ -z "$form_key" ]; then echo "No form_key on the login page ($login_url)." >&2; cat /tmp/login.html; tail -n 50 /tmp/httpd.log; exit 1; fi
curl -s --max-time 60 -c $jar -b $jar -L -o /tmp/after-login.html \
--data-urlencode "form_key=$form_key" --data-urlencode 'login[username]=durable' --data-urlencode 'login[password]=Durable123!' \
"$action"
if grep -q 'id="login-form"' /tmp/after-login.html; then echo 'The admin login failed.' >&2; cat /tmp/after-login.html; exit 1; fi
curl -s --max-time 60 -c $jar -b $jar -o /tmp/run.html 'http://127.0.0.1:8080/admin/durable/process/view/?run_id=order%2F4242'
if ! grep -q '<td>order/4242</td>' /tmp/run.html || ! grep -q 'Backend run' /tmp/run.html; then
echo 'The run page did not render the run.' >&2
cat /tmp/run.html; tail -n 50 /tmp/httpd.log; tail -n 50 /tmp/temporal.log
exit 1
fi
# #667: Magento calls the three demo services through Nexus. The root's demo harness serves
# them on the same dev server and namespace, over curl as everything in this job; the
# bench's journal worker advances OrderNexusWorkflow.
- name: The demo harness serves stock, billing and delivery
timeout-minutes: 2
run: |
php tests/integration/Temporal/nexus-demo-harness.php 127.0.0.1:7233 default --transport=grpc-curl > /tmp/harness.log 2>&1 &
harness=$!
echo "$harness" > /tmp/harness.pid
# Fails at once if the harness dies before it is ready, rather than after the minute.
timeout 60 bash -c "until grep -qx ready /tmp/harness.log; do kill -0 $harness 2>/dev/null || exit 1; sleep 1; done" || { cat /tmp/harness.log; exit 1; }
# durable:demo:nexus exits non-zero unless the order was shipped; the order of the calls is
# read from the run's history, not from the command's output.
- name: Magento calls verify, schedule, reserve, then charge and ship
working-directory: magento
timeout-minutes: 5
run: |
bin/magento durable:worker --role=journal > /tmp/magento-worker.log 2>&1 &
order="MAG-NX-${GITHUB_RUN_ID}"
if ! timeout 180 bin/magento durable:demo:nexus "$order" 1200 MUG_BLUE=1 --timeout=150; then
tail -n 50 /tmp/magento-worker.log; tail -n 50 /tmp/harness.log; exit 1
fi
calls=$(temporal workflow show --namespace default -w "durable-${order}" -o json \
| jq -r '[.events[] | select(.eventType == "EVENT_TYPE_NEXUS_OPERATION_SCHEDULED") | .nexusOperationScheduledEventAttributes | "\(.service)/\(.operation)"] | join(" ")')
echo "calls: $calls"
test "$calls" = 'billing/verify delivery/schedule stock/reserve billing/charge delivery/ship' \
|| { tail -n 50 /tmp/magento-worker.log; tail -n 50 /tmp/harness.log; exit 1; }
echo "$order" > /tmp/nexus-order
# #672: the admin shows a run's Nexus operations. The harness stops (SIGTERM deletes its
# endpoints), billing's endpoint comes back toward a queue nobody polls, and a new order's
# verify stays in flight while the admin opens its page, then the shipped order's.
- name: The admin shows a Nexus operation in flight, then settled ones
working-directory: magento
timeout-minutes: 3
run: |
kill -TERM "$(cat /tmp/harness.pid)" || { echo "The #667 harness is already gone." >&2; cat /tmp/harness.log; exit 1; }
timeout 30 bash -c 'while temporal operator nexus endpoint get --name demo-business-billing >/dev/null 2>&1; do sleep 1; done'
temporal operator nexus endpoint create --name demo-business-billing --target-namespace default --target-task-queue nobody-polls
held="MAG-INFLIGHT-${GITHUB_RUN_ID}"
timeout 20 bin/magento durable:demo:nexus "$held" 1200 MUG_BLUE=1 --timeout=5 || true
jar=/tmp/admin.cookies
curl -s --max-time 60 -c $jar -b $jar -o /tmp/held.html "http://127.0.0.1:8080/admin/durable/process/view/?run_id=${held}"
for shown in '<code>demo-business-billing</code>' '<td>billing</td>' '<td>verify</td>' 'in_flight">in flight<'; do
grep -qF "$shown" /tmp/held.html || { echo "The run page lacks: $shown" >&2; cat /tmp/held.html; tail -n 50 /tmp/magento-worker.log; exit 1; }
done
curl -s --max-time 60 -c $jar -b $jar -o /tmp/shipped.html "http://127.0.0.1:8080/admin/durable/process/view/?run_id=$(cat /tmp/nexus-order)"
test "$(grep -oF 'completed">completed<' /tmp/shipped.html | wc -l)" -eq 5 \
|| { echo 'The shipped order does not show its five operations completed.' >&2; cat /tmp/shipped.html; exit 1; }
# #669: now Magento serves. The probe module lists a billing handler in its di.xml and a
# workflow fulfilling charge; a second, prefixed harness routes billing to the bench's
# Nexus queue (the DSN names none, so durable-workflows) and calls it. The journal
# worker of the step above runs the fulfilling workflow.
- name: Magento serves billing to the demo harness
working-directory: magento
timeout-minutes: 5
run: |
harness="php ../tests/integration/Temporal/nexus-demo-harness.php 127.0.0.1:7233 default --prefix=mg- --transport=grpc-curl"
$harness --bench=billing@durable-workflows > /tmp/harness-mg.log 2>&1 &
pid=$!
timeout 60 bash -c "until grep -qx ready /tmp/harness-mg.log; do kill -0 $pid 2>/dev/null || exit 1; sleep 1; done" || { cat /tmp/harness-mg.log; exit 1; }
bin/magento durable:worker --role=nexus > /tmp/magento-nexus.log 2>&1 &
logs() { tail -n 50 /tmp/magento-nexus.log; tail -n 50 /tmp/magento-worker.log; tail -n 50 /tmp/harness-mg.log; }
# Answers only the probe's handler and workflow give, not the harness's own.
verify=$(timeout 120 $harness --call=billing/verify --input='{"order":"MAG-SV-1","amount":1200,"currency":"USD"}') || { logs; exit 1; }
verify=$(tail -n 1 <<< "$verify")
echo "verify: $verify"
test "$(jq -c . <<< "$verify")" = '{"accepted":false,"reason":"the Magento bench bills in EUR, not USD"}' || { logs; exit 1; }
charge=$(timeout 120 $harness --call=billing/charge --input='{"order":"MAG-SV-2","amount":1200,"currency":"EUR"}') || { logs; exit 1; }
charge=$(tail -n 1 <<< "$charge")
echo "charge: $charge"
test "$(jq -c . <<< "$charge")" = '{"receipt":"MAGENTO-BENCH-EUR-MAG-SV-2","charged":1200}' || { logs; exit 1; }
static-analysis:
# Was the required status check on `main` until 2026-09-16; `ci-ok` below is now. Its steps
# are what `loop/guardrails/verify.sh` mirrors: change one, change the other.
name: Core · static analysis (PHPStan + Psalm)
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
# grpc: the Temporal bridge now requires it outright, `composer install`
# fails without it.
php-version: '8.2'
extensions: mbstring, sqlite3, pdo_sqlite, grpc
coverage: none
- name: Cache Composer downloads
uses: actions/cache@v6
with:
path: ~/.cache/composer
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock', '**/composer.json') }}
restore-keys: composer-${{ runner.os }}-
- name: Install Composer dependencies
run: composer install --no-interaction --prefer-dist --no-progress
- name: Tool versions (composer.lock)
run: |
composer show phpstan/phpstan --no-ansi | head -n 5
composer show vimeo/psalm --no-ansi | head -n 5
php vendor/bin/phpstan --version
php vendor/bin/psalm --version
- name: PHPStan (src/, tests/)
run: composer phpstan
- name: Psalm (src/, tests/)
run: composer psalm
symfony-sample:
name: Symfony · bench, PHPUnit (${{ matrix.label }} / PHP ${{ matrix.php-version }})
runs-on: ubuntu-latest
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
php-version: ['8.2', '8.3', '8.4', '8.5']
symfony: ['7.4.*', '8.0.*']
# Symfony 8 requires PHP 8.4+: no 8.0 entry below 8.4.
exclude:
- php-version: '8.2'
symfony: '8.0.*'
- php-version: '8.3'
symfony: '8.0.*'
# Adds the job name's label to every combination of its line.
include:
- { symfony: '7.4.*', label: '7.4' }
- { symfony: '8.0.*', label: '8.0' }
env:
SYMFONY_REQUIRE: ${{ matrix.symfony }}
steps:
- uses: actions/checkout@v7
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
# ext-grpc: the Temporal bridge requires it outright, and the 8.0 leg resolves
# (composer update) instead of installing from the lock — so the platform
# constraint is checked there.
extensions: mbstring, sqlite3, pdo_sqlite, grpc
coverage: none
# 7.4: install, so the versioned composer.lock stays exercised (and scanned by Dependabot).
# 8.0: update, SYMFONY_REQUIRE only acting at resolution time, outside the lock.
- name: Composer (symfony/, Symfony ${{ matrix.symfony }})
working-directory: symfony
run: composer ${{ matrix.symfony == '7.4.*' && 'install' || 'update' }} --no-interaction --prefer-dist --no-progress
- name: PHPUnit (symfony/)
working-directory: symfony
run: composer test
conformance-sql:
# The eight SQL conformance classes against real servers (#327). In `qa` they open in-memory
# SQLite, which answers some questions differently: MySQL counts *changed* rows on an
# UPDATE, and a re-link that SQLite accepted violated the primary key there.
name: Core · SQL conformance (${{ matrix.database }})
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
include:
- { database: 'MySQL 8.4', dsn: 'mysql://root:root@127.0.0.1:3306/durable_test' }
- { database: 'PostgreSQL 16', dsn: 'pgsql://postgres:root@127.0.0.1:5432/durable_test' }
services:
mysql:
image: mysql:8.4
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: durable_test
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping -h 127.0.0.1 -proot"
--health-interval=5s
--health-timeout=5s
--health-retries=20
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: root
POSTGRES_DB: durable_test
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready -U postgres"
--health-interval=5s
--health-timeout=5s
--health-retries=20
steps:
- uses: actions/checkout@v7
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
extensions: mbstring, pdo_mysql, pdo_pgsql, grpc
coverage: none
- name: Cache Composer downloads
uses: actions/cache@v6
with:
path: ~/.cache/composer
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock', '**/composer.json') }}
restore-keys: composer-${{ runner.os }}-
- name: Install Composer dependencies
run: composer install --no-interaction --prefer-dist --no-progress
# --fail-on-skipped: a suite that quietly skips against the server proves nothing.
- name: PHPUnit (the DBAL and Illuminate conformance classes)
env:
DURABLE_TEST_DSN: ${{ matrix.dsn }}
run: vendor/bin/phpunit --fail-on-skipped --display-skipped --filter 'ConformanceTest' tests/unit/Bridge/Dbal tests/unit/Bridge/Illuminate
guide-follows:
# The bench that follows the guide (#363): a fresh `symfony/skeleton`, this commit's
# packages, the getting-started blocks applied verbatim in both languages, and the first
# workflow run to completion. #276 shipped a guide whose container did not compile; the
# benches did not notice, because none of them is built from the guide.
name: Docs · getting-started guide, followed verbatim
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
coverage: none
- name: Follow the guide
run: bin/guide-follows.sh
temporal-integration:
name: Temporal · ext-grpc, bench, TLS
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v7
- name: Setup PHP (ext-grpc required)
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: mbstring, sqlite3, pdo_sqlite, grpc
coverage: none
- name: Cache Composer downloads
uses: actions/cache@v6
with:
path: ~/.cache/composer
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock', '**/composer.json') }}
restore-keys: composer-${{ runner.os }}-
# The root `integration` suite (#344): ninety tests that skipped in every job because
# none set DURABLE_TEMPORAL_ADDRESS. They expect `temporal server start-dev`, not the
# auto-setup image below: Nexus enabled, a `durable-test` namespace, two custom search
# attributes and the HTTP gateway. Its own ports, so it can run beside compose.
- name: Install Composer dependencies (root)
run: composer install --no-interaction --prefer-dist --no-progress
- name: Start a Temporal dev server for the root suite
run: |
docker run -d --name temporal-dev -p 7333:7233 -p 7343:7243 temporalio/temporal:1.9.1 \
server start-dev --ip 0.0.0.0 --port 7233 --http-port 7243 --namespace durable-test \
--search-attribute DurableOrderId=Keyword --search-attribute DurableAmount=Int --search-attribute DurablePrice=Double \
--search-attribute DurableWorkflowName=Keyword --search-attribute DurableExecutionId=Keyword
timeout 60 bash -c 'until nc -z 127.0.0.1 7333; do sleep 1; done'
# --fail-on-skipped: a missing variable or server turns the job red instead of green.
# `temporal` only: the server-free `integration` suite already runs in `qa`, on every PHP.
# This is the ext-grpc transport's run; `temporal-transport` covers grpc-curl and Guzzle.
- name: PHPUnit — root Temporal suite (ext-grpc)
env:
DURABLE_TEMPORAL_ADDRESS: '127.0.0.1:7333'
DURABLE_TEMPORAL_HTTP_ADDRESS: '127.0.0.1:7343'
run: vendor/bin/phpunit --testsuite temporal --fail-on-skipped --display-skipped
- name: Install Composer dependencies (symfony/)
working-directory: symfony
run: composer install --no-interaction --prefer-dist --no-progress
# #664: the bench serves billing and calls stock, through its own workers, with the root's
# demo harness on the other side. On this dev server, not the compose stack: its
# auto-setup 1.25.2 answers "Nexus APIs are disabled". durable-test has the search
# attributes the bench's dev env writes on every start.
- name: PHPUnit — the bench serves and calls Nexus operations
working-directory: symfony
timeout-minutes: 10
env:
DURABLE_DSN: 'temporal://127.0.0.1:7333?namespace=durable-test&nexus_task_queue=symfony-bench-nexus&tls=0'
run: php bin/phpunit --testdox --fail-on-skipped --display-skipped --group nexus-bench tests/Integration/Temporal/
- name: Stop the dev server
if: always()
run: docker rm -f temporal-dev
# `symfony/.env` publishes the frontend on 7234, so it can sit next to a local
# Temporal on 7233. In CI there is nothing to sit next to: we publish on 7233,
# the port the DSN and the tests' docblocks announce. Without this steering,
# compose listened on 7234, the DSN aimed at 7233, and the nine tests that talk
# to the server were skipped — the job stayed green while testing nothing.
- name: Validate compose.yaml
working-directory: symfony
run: docker compose config --quiet
# temporal-ui rides along: the stack the docs tell users to start, UI included.
- name: Start PostgreSQL + Temporal + UI (symfony/compose.yaml)
working-directory: symfony
env:
TEMPORAL_FRONTEND_PORT: '7233'
run: docker compose up -d --wait --wait-timeout 300 database temporal temporal-ui
- name: The frontend responds, otherwise we test nothing
run: timeout 30 bash -c 'until nc -z 127.0.0.1 7233; do sleep 1; done'
- name: PHPUnit — Temporal integration tests (the whole directory)
working-directory: symfony
env:
DURABLE_DSN: 'temporal://127.0.0.1:7233?namespace=default&journal_task_queue=durable-journal&activity_task_queue=durable-activities&tls=0'
run: |
php bin/phpunit \
--testdox \
--fail-on-skipped \
--display-skipped \
--exclude-group nexus-bench \
tests/Integration/Temporal/
- name: Check Temporal UI (internal exec)
working-directory: symfony
run: |
set -e
for i in $(seq 1 20); do
if docker compose exec -T temporal-ui wget -qO- http://localhost:8080/ > /dev/null 2>&1; then
echo "Temporal UI OK (attempt $i)"
exit 0
fi
sleep 5
done
docker compose logs temporal-ui --tail 40
exit 1
# #353: the DSN's TLS options (#516) against a server that demands them. `start-dev` serves
# plain gRPC only, so nginx terminates TLS in front of it: it presents a certificate only
# `ca=` verifies, requires the client certificate `cert=`/`key=`, and answers 401 without
# the `api_key=` bearer. A green run proves the client sent all four. The CA, the
# certificates and the key are throwaway: generated here, valid one day, never stored.
- name: TLS — throwaway CA, server and client certificates
run: |
set -euo pipefail
d="$RUNNER_TEMP/durable-tls"; mkdir -p "$d"; cd "$d"
openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj '/CN=durable-ci-ca' -keyout ca.key -out ca.crt
openssl req -newkey rsa:2048 -nodes -subj '/CN=localhost' -keyout server.key -out server.csr
printf 'subjectAltName=DNS:localhost,IP:127.0.0.1\nextendedKeyUsage=serverAuth\n' > server.ext
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -days 1 -extfile server.ext -out server.crt
openssl req -newkey rsa:2048 -nodes -subj '/CN=durable-ci-client' -keyout client.key -out client.csr
printf 'extendedKeyUsage=clientAuth\n' > client.ext
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -days 1 -extfile client.ext -out client.crt
chmod 644 ./*.key
- name: TLS — a dev server behind an nginx gRPC terminator (mTLS + API key)
run: |
set -euo pipefail
d="$RUNNER_TEMP/durable-tls"
cat > "$d/nginx.conf" <<'NGINX'
events {}
http {
server {
listen 7443 ssl;
http2 on;
ssl_certificate /tls/server.crt;
ssl_certificate_key /tls/server.key;
ssl_client_certificate /tls/ca.crt;
ssl_verify_client on;
grpc_read_timeout 120s;
grpc_send_timeout 120s;
location / {
if ($http_authorization != "Bearer durable-ci-throwaway-key") {
return 401;
}
grpc_pass grpc://temporal-tls-dev:7233;
}
}
}
NGINX
docker network create durable-tls
docker run -d --name temporal-tls-dev --network durable-tls temporalio/temporal:1.9.1 \
server start-dev --ip 0.0.0.0 --port 7233 --headless
docker run -d --name temporal-tls-proxy --network durable-tls -p 7443:7443 \
-v "$d:/tls:ro" -v "$d/nginx.conf:/etc/nginx/nginx.conf:ro" nginx:1.27-alpine
timeout 60 bash -c 'until docker exec temporal-tls-dev temporal operator namespace describe default >/dev/null 2>&1; do sleep 1; done'
timeout 30 bash -c 'until nc -z 127.0.0.1 7443; do sleep 1; done'
- name: PHPUnit — the Temporal integration tests through a TLS DSN
working-directory: symfony
run: |
d="$RUNNER_TEMP/durable-tls"
export DURABLE_DSN="temporal://localhost:7443?namespace=default&journal_task_queue=durable-journal&activity_task_queue=durable-activities&tls=1&ca=$d/ca.crt&cert=$d/client.crt&key=$d/client.key&api_key=durable-ci-throwaway-key"
php bin/phpunit --testdox --fail-on-skipped --display-skipped --exclude-group nexus-bench tests/Integration/Temporal/
- name: Stop the TLS server
if: always()
run: |
docker logs --tail 40 temporal-tls-proxy || true
docker rm -f temporal-tls-proxy temporal-tls-dev || true
docker network rm durable-tls || true
- name: Stop the containers
if: always()
working-directory: symfony
run: docker compose down -v
temporal-oldest-server:
# The oldest Temporal server Durable supports (#523): 1.20, the first whose SQL visibility
# takes custom search attributes. Every other Temporal job runs a current server (the CLI
# dev server embeds 1.32), and a query only a newer server accepts passed them all (#508).
# On PostgreSQL, 1.20 needs the postgres12 plugin: the legacy `postgresql` one only offers
# standard visibility, which cannot filter on custom search attributes.
name: Temporal · oldest server (1.20)
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- uses: actions/checkout@v7
- name: Setup PHP (ext-grpc required)
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: mbstring, sqlite3, pdo_sqlite, grpc
coverage: none
- name: Cache Composer downloads
uses: actions/cache@v6
with:
path: ~/.cache/composer
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock', '**/composer.json') }}
restore-keys: composer-${{ runner.os }}-
- name: Install Composer dependencies
run: composer install --no-interaction --prefer-dist --no-progress
- name: Start PostgreSQL and Temporal 1.20
run: |
docker network create oldest
docker run -d --name oldest-pg --network oldest -e POSTGRES_USER=temporal -e POSTGRES_PASSWORD=temporal postgres:13
timeout 60 bash -c 'until docker exec oldest-pg pg_isready -U temporal >/dev/null 2>&1; do sleep 1; done'
# Every test registers a fresh namespace, and 1.20's operator service and visibility
# learn of it on the namespace cache's refresh, every 10 s by default: two refreshes
# per test, 18 of this job's 19 minutes. 1 s is not the server's default: it only
# shortens setup. The tests keep their waits and assert the same things; the lag is
# still there, just 1 s instead of 10.
printf 'system.namespaceCacheRefreshInterval:\n - value: 1s\n' > "$RUNNER_TEMP/temporal-dc.yaml"
docker run -d --name oldest-temporal --network oldest -p 7233:7233 \
-e DB=postgres12 -e DB_PORT=5432 -e POSTGRES_USER=temporal -e POSTGRES_PWD=temporal -e POSTGRES_SEEDS=oldest-pg \
-e DYNAMIC_CONFIG_FILE_PATH=/etc/temporal/config/dynamicconfig/ci.yaml \
-v "$RUNNER_TEMP/temporal-dc.yaml:/etc/temporal/config/dynamicconfig/ci.yaml:ro" \
temporalio/auto-setup:1.20
# Probe the server, not a log line: 1.20 does not always print "Search attributes have been
# added" (#650). The attributes Durable needs are registered, with a retry, by the next step.
timeout 300 bash -c 'until docker exec oldest-temporal tctl --address oldest-temporal:7233 cluster health 2>/dev/null | grep -q SERVING; do sleep 2; done'
# SearchAttributesTest runs in `durable-test`, which the dev server of the other jobs
# creates at start. tctl, because the `temporal` CLI shipped in this image ignores
# --address on its operator commands. 1.20 keeps these attributes cluster-wide, and
# its operator service learns of a new namespace late: hence the retry.
- name: The durable-test namespace and its search attributes
run: |
TCTL="docker exec oldest-temporal tctl --address oldest-temporal:7233 --namespace durable-test"
# Register until describe answers: a server that just turned SERVING may refuse the first try.
timeout 60 bash -c "until $TCTL namespace describe >/dev/null 2>&1; do $TCTL namespace register >/dev/null 2>&1 || true; sleep 1; done"
timeout 90 bash -c "until $TCTL admin cluster get-search-attributes 2>/dev/null | grep -q DurableAmount; do
docker exec oldest-temporal sh -c 'yes | tctl --address oldest-temporal:7233 --namespace durable-test admin cluster add-search-attributes --name DurableOrderId --type Keyword --name DurableAmount --type Int --name DurablePrice --type Double --name DurableWorkflowName --type Keyword --name DurableExecutionId --type Keyword' >/dev/null 2>&1 || true
sleep 2
done"
# --fail-on-skipped: a missing variable or server turns the job red instead of green.
# No WorkflowUpdateTest here: updates need Server 1.21 (#860). 1.20 drops the update's
# events when the same workflow task completes the workflow.
- name: PHPUnit — the run catalogue and the visibility queries on 1.20
env:
DURABLE_TEMPORAL_ADDRESS: '127.0.0.1:7233'
run: >-
vendor/bin/phpunit --fail-on-skipped --display-skipped
tests/integration/Temporal/TemporalWorkflowRunCatalogConformanceTest.php
tests/integration/Temporal/TemporalWorkflowRunCatalogWithoutSearchAttributesConformanceTest.php
tests/integration/Temporal/TemporalRunCatalogFailedFilterTest.php
tests/integration/Temporal/SearchAttributesTest.php
tests/integration/Temporal/ContinueAsNewKeepsTheExecutionIdTest.php
- name: Server logs
if: failure()
run: docker logs oldest-temporal 2>&1 | tail -200
- name: Stop the containers
if: always()
run: docker rm -f oldest-temporal oldest-pg
temporal-transport:
# The Temporal suite over the two transports `temporal-integration` does not take: gRPC over
# curl without ext-grpc, and Guzzle (GuzzleGrpcTransport) with it. The ext-grpc transport
# itself is `temporal-integration`'s, which runs the same suite over it.
#
# What the curl leg proves and no other job can: the Temporal bridge loads, installs and
# talks to the server **without** `ext-grpc`. Every other job installs the extension; without
# this leg, a regression that put `\Grpc\BaseStub` back on a collaborator's path would stay
# invisible until a user's first `composer install`. The tests that need the extension
# (`RequiresPhpExtension('grpc')`: Nexus, the message batch) skip there, hence no
# `--fail-on-skipped` on that leg.
name: Temporal · ${{ matrix.label }}
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- { transport: 'grpc-curl', label: 'grpc-curl, no ext-grpc', grpc: false, php-version: '8.4', extensions: 'mbstring, bcmath, sqlite3, pdo_sqlite, curl' }
- { transport: 'guzzle', label: 'Guzzle', grpc: true, php-version: '8.2', extensions: 'mbstring, bcmath, sqlite3, pdo_sqlite, curl, grpc' }
steps:
- uses: actions/checkout@v7
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
extensions: ${{ matrix.extensions }}
coverage: none
- name: ext-grpc is absent, otherwise this leg measures nothing
if: ${{ !matrix.grpc }}
run: |
if php -m | grep -qi '^grpc$'; then echo 'ext-grpc is loaded; this leg must run without it.' >&2; exit 1; fi
- name: Cache Composer downloads
uses: actions/cache@v6
with:
path: ~/.cache/composer
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock', '**/composer.json') }}
restore-keys: composer-${{ runner.os }}-
- name: Install Composer dependencies
run: composer install --no-interaction --prefer-dist --no-progress
- name: PHPUnit — unit suite without ext-grpc
if: ${{ !matrix.grpc }}
run: vendor/bin/phpunit --testsuite unit
# The same pinned server as `temporal-integration`, search attributes registered at start.
# Durable writes DurableWorkflowName and DurableExecutionId on every start (#558): the probe
# waits until a query on one of them is accepted.
- name: Start a Temporal dev server (gRPC 7233, HTTP 7243)
run: |
docker run -d --name temporal-dev -p 7233:7233 -p 7243:7243 temporalio/temporal:1.9.1 \
server start-dev --ip 0.0.0.0 --port 7233 --http-port 7243 --namespace durable-test --headless \
--search-attribute DurableOrderId=Keyword --search-attribute DurableAmount=Int --search-attribute DurablePrice=Double \
--search-attribute DurableWorkflowName=Keyword --search-attribute DurableExecutionId=Keyword
timeout 60 bash -c 'until nc -z 127.0.0.1 7233 && nc -z 127.0.0.1 7243; do sleep 1; done'
timeout 30 bash -c "until docker exec temporal-dev temporal workflow list --namespace durable-test --limit 1 --query \"DurableExecutionId = 'probe'\" >/dev/null 2>&1; do sleep 1; done"
- name: PHPUnit — Temporal suite over ${{ matrix.transport }}
env:
DURABLE_TEMPORAL_ADDRESS: '127.0.0.1:7233'
DURABLE_TEMPORAL_HTTP_ADDRESS: '127.0.0.1:7243'
DURABLE_TEMPORAL_TRANSPORT: ${{ matrix.transport }}
run: vendor/bin/phpunit --testsuite temporal --display-skipped ${{ matrix.grpc && '--fail-on-skipped' || '' }}
- name: PHPUnit — JSON gateway over a PSR-18 client (Guzzle)
if: ${{ !matrix.grpc }}
env:
DURABLE_TEMPORAL_HTTP_ADDRESS: '127.0.0.1:7243'
DURABLE_TEMPORAL_HTTP_CLIENT: 'psr18'
run: vendor/bin/phpunit tests/integration/Temporal/Http/JsonGatewayRoundTripTest.php --display-skipped --fail-on-skipped
- name: Stop the dev server
if: always()
run: docker rm -f temporal-dev
manifests:
# Every package declares what it imports (#346): a split package installs alone, and an
# import the monorepo happens to provide is a fatal error there.
name: Core · manifests (validate + require-checker)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
# 8.4: composer-require-checker 4.24 requires it.
php-version: '8.4'
extensions: mbstring, sqlite3, pdo_sqlite, grpc
coverage: none
# The root lock against the path packages' manifests (#640). No vendor needed, so it
# runs before the install and fails fast; its test runs on throwaway fixtures.
- name: The root composer.lock matches the path packages
run: bin/check-root-lock.sh && bin/check-root-lock-test.sh
# setup-php's `tools:` input fails on it without saying why: the release phar, pinned.
- name: Install composer-require-checker 4.24.0
run: |
phar="$RUNNER_TEMP/composer-require-checker"
curl -sSfL -o "$phar" \
https://github.com/maglnet/ComposerRequireChecker/releases/download/4.24.0/composer-require-checker.phar
echo "35d1dfdd7aa94a750f4b4c9b0b22dd5e76b5e9e35fb910c8901f3dafd2ad2ab9 $phar" | sha256sum -c
chmod +x "$phar"
echo "COMPOSER_REQUIRE_CHECKER=$phar" >> "$GITHUB_ENV"
- name: Cache Composer downloads
uses: actions/cache@v6
with:
path: ~/.cache/composer
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock', '**/composer.json') }}
restore-keys: composer-${{ runner.os }}-
- name: Install Composer dependencies
run: composer install --no-interaction --prefer-dist --no-progress
- name: Validate manifests and check imports
run: bin/check-manifests.sh
ci-ok:
# The one required status check on `main` (branch protection, 2026-09-16). It needs every
# job above, so a red matrix leg blocks a merge the way a red QA leg used to; the individual
# job names can move without touching the protection. A skipped job is not a failure.
name: ci-ok
if: always()
needs: [qa, symfony-matrix, laravel, laravel-bench, filament, sylius-shop, magento-matrix, magento-without-temporal, magento-boot, static-analysis, symfony-sample, conformance-sql, guide-follows, temporal-integration, temporal-oldest-server, temporal-transport, manifests]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Every job succeeded or was skipped
env:
RESULTS: ${{ toJSON(needs) }}
run: |
echo "$RESULTS" | jq -r 'to_entries[] | "\(.key): \(.value.result)"'
echo "$RESULTS" | jq -e 'all(.[]; .result == "success" or .result == "skipped")' >/dev/null